mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-24 21:44:49 +00:00
Compare commits
No commits in common. "master" and "292" have entirely different histories.
39 changed files with 733 additions and 2730 deletions
|
@ -1,266 +0,0 @@
|
||||||
package com.futo.platformplayer
|
|
||||||
|
|
||||||
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.net.Socket
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class SyncServerTests {
|
|
||||||
|
|
||||||
//private val relayHost = "relay.grayjay.app"
|
|
||||||
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
|
|
||||||
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
|
|
||||||
private val relayHost = "192.168.1.175"
|
|
||||||
private val 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: ((SyncSocketSession, String, String?) -> Boolean)? = 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()
|
|
||||||
socketSession.startAsInitiator(relayKey)
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AlwaysAuthorized : IAuthorizable {
|
|
||||||
override val isAuthorized: Boolean get() = true
|
|
||||||
}
|
|
|
@ -936,15 +936,6 @@ 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)
|
||||||
|
|
|
@ -28,11 +28,12 @@ import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.security.SecureRandom
|
import java.nio.ByteOrder
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
@ -283,18 +284,6 @@ fun ByteBuffer.toUtf8String(): String {
|
||||||
return String(remainingBytes, Charsets.UTF_8)
|
return String(remainingBytes, Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateReadablePassword(length: Int): String {
|
|
||||||
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
|
|
||||||
val secureRandom = SecureRandom()
|
|
||||||
val randomBytes = ByteArray(length)
|
|
||||||
secureRandom.nextBytes(randomBytes)
|
|
||||||
val sb = StringBuilder(length)
|
|
||||||
for (byte in randomBytes) {
|
|
||||||
val index = (byte.toInt() and 0xFF) % validChars.length
|
|
||||||
sb.append(validChars[index])
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ByteArray.toGzip(): ByteArray {
|
fun ByteArray.toGzip(): ByteArray {
|
||||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||||
|
|
|
@ -100,8 +100,7 @@ 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)
|
||||||
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
|
||||||
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||||
//TODO: also display public key?
|
//TODO: also display public key?
|
||||||
.setStatus(if (connected) "Connected" else "Disconnected")
|
.setStatus(if (connected) "Connected" else "Disconnected")
|
||||||
|
|
|
@ -109,9 +109,9 @@ class SyncPairActivity : AppCompatActivity() {
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StateSync.instance.connect(deviceInfo) { complete, message ->
|
StateSync.instance.connect(deviceInfo) { session, complete, message ->
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (complete != null && complete) {
|
if (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, StateSync.instance.pairingCode)
|
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT)
|
||||||
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,6 +1,5 @@
|
||||||
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
|
||||||
|
@ -67,11 +66,6 @@ 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,10 +2,7 @@ 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
|
||||||
|
|
||||||
|
@ -45,21 +42,4 @@ 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,7 +12,6 @@ enum class ContentType(val value: Int) {
|
||||||
URL(9),
|
URL(9),
|
||||||
|
|
||||||
NESTED_VIDEO(11),
|
NESTED_VIDEO(11),
|
||||||
CHANNEL(60),
|
|
||||||
|
|
||||||
LOCKED(70),
|
LOCKED(70),
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
|
@ -32,7 +31,6 @@ 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
|
||||||
|
@ -363,10 +361,6 @@ 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,7 +1,6 @@
|
||||||
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
|
||||||
|
@ -27,7 +26,6 @@ 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,6 +5,7 @@ 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.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
||||||
if(datetimeInt == null || datetimeInt == 0.toLong())
|
if(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,7 +2,6 @@ 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,14 +15,4 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
||||||
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
||||||
return IJSContent.fromV8(plugin, obj);
|
return IJSContent.fromV8(plugin, obj);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
|
||||||
override val sourceConfig: SourcePluginConfig get() = config;
|
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
|
||||||
|
|
||||||
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
|
||||||
return JSChannelContent(config, obj);
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -92,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
|
||||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
private var _lastPongTime = -1L
|
||||||
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() {
|
||||||
|
@ -326,9 +326,9 @@ class FCastCastingDevice : CastingDevice {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
localAddress = _socket?.localAddress
|
localAddress = _socket?.localAddress;
|
||||||
_lastPongTime = System.currentTimeMillis()
|
connectionState = CastConnectionState.CONNECTED;
|
||||||
connectionState = CastConnectionState.CONNECTED
|
_lastPongTime = -1L
|
||||||
|
|
||||||
val buffer = ByteArray(4096);
|
val buffer = ByteArray(4096);
|
||||||
|
|
||||||
|
@ -404,32 +404,36 @@ 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 {
|
||||||
|
send(Opcode.Ping)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "Failed to send ping.")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
send(Opcode.Ping)
|
_socket?.close()
|
||||||
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
_inputStream?.close()
|
||||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
_outputStream?.close()
|
||||||
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 close socket.", e)
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Thread.sleep(5000)
|
|
||||||
|
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
|
||||||
|
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
|
||||||
|
|
||||||
|
try {
|
||||||
|
_socket?.close()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "Failed to close socket.", e)
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
|
||||||
|
Thread.sleep(2000)
|
||||||
}
|
}
|
||||||
Logger.i(TAG, "Stopped ping loop.")
|
|
||||||
|
Logger.i(TAG, "Stopped ping loop.");
|
||||||
}.apply { start() }
|
}.apply { start() }
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "Thread was still alive, not restarted")
|
Log.i(TAG, "Thread was still alive, not restarted")
|
||||||
|
|
|
@ -294,9 +294,7 @@ class StateCasting {
|
||||||
UIDialogs.toast(it, "Connecting to device...")
|
UIDialogs.toast(it, "Connecting to device...")
|
||||||
synchronized(_castingDialogLock) {
|
synchronized(_castingDialogLock) {
|
||||||
if(_currentDialog == null) {
|
if(_currentDialog == null) {
|
||||||
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
|
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2,
|
||||||
"Connecting to [${device.name}]",
|
|
||||||
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
|
|
||||||
UIDialogs.Action("Disconnect", {
|
UIDialogs.Action("Disconnect", {
|
||||||
device.stop();
|
device.stop();
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -201,12 +201,11 @@ 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 -> {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
|
@ -19,7 +18,6 @@ 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
|
||||||
|
@ -86,7 +84,6 @@ 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
|
||||||
|
@ -98,13 +95,7 @@ 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 {
|
||||||
when (_searchType)
|
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||||
{
|
|
||||||
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> { }
|
||||||
|
@ -125,7 +116,6 @@ 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) {
|
||||||
|
@ -268,15 +258,6 @@ 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;
|
||||||
|
|
||||||
|
|
|
@ -136,6 +136,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||||
|
|
||||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||||
|
|
||||||
if (it is IAsyncPager<*>)
|
if (it is IAsyncPager<*>)
|
||||||
it.nextPageAsync();
|
it.nextPageAsync();
|
||||||
else
|
else
|
||||||
|
|
|
@ -18,8 +18,6 @@ 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);
|
||||||
|
|
||||||
|
@ -30,7 +28,6 @@ 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;
|
||||||
|
@ -52,7 +49,14 @@ 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);
|
||||||
|
@ -76,15 +80,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
@ -115,27 +110,31 @@ 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(it.isHttpUrl()) {
|
if (_searchType == SearchType.CREATOR) {
|
||||||
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
navigate<CreatorSearchResultsFragment>(it);
|
||||||
navigate<RemotePlaylistFragment>(it);
|
} else if (_searchType == SearchType.PLAYLIST) {
|
||||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
navigate<PlaylistSearchResultsFragment>(it);
|
||||||
navigate<ChannelFragment>(it);
|
} else {
|
||||||
else {
|
if(it.isHttpUrl()) {
|
||||||
val url = it;
|
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
||||||
activity?.let {
|
navigate<RemotePlaylistFragment>(it);
|
||||||
close()
|
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||||
if(it is MainActivity)
|
navigate<ChannelFragment>(it);
|
||||||
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
else {
|
||||||
|
val url = it;
|
||||||
|
activity?.let {
|
||||||
|
close()
|
||||||
|
if(it is MainActivity)
|
||||||
|
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||||
}
|
}
|
||||||
else
|
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onTextChange.subscribe(this) {
|
onTextChange.subscribe(this) {
|
||||||
|
@ -197,7 +196,6 @@ 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() {
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
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
|
||||||
|
@ -50,11 +48,6 @@ abstract class VideoListEditorView : LinearLayout {
|
||||||
private var _loadedVideos: List<IPlatformVideo>? = null;
|
private var _loadedVideos: List<IPlatformVideo>? = null;
|
||||||
private var _loadedVideosCanEdit: Boolean = false;
|
private var _loadedVideosCanEdit: Boolean = false;
|
||||||
|
|
||||||
fun hideSearchKeyboard() {
|
|
||||||
(context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0)
|
|
||||||
_search.textSearch.clearFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(inflater: LayoutInflater) : super(inflater.context) {
|
constructor(inflater: LayoutInflater) : super(inflater.context) {
|
||||||
inflater.inflate(R.layout.fragment_video_list_editor, this);
|
inflater.inflate(R.layout.fragment_video_list_editor, this);
|
||||||
|
|
||||||
|
@ -86,7 +79,6 @@ abstract class VideoListEditorView : LinearLayout {
|
||||||
_search.textSearch.text = "";
|
_search.textSearch.text = "";
|
||||||
updateVideoFilters();
|
updateVideoFilters();
|
||||||
_buttonSearch.setImageResource(R.drawable.ic_search);
|
_buttonSearch.setImageResource(R.drawable.ic_search);
|
||||||
hideSearchKeyboard();
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_search.visibility = View.VISIBLE;
|
_search.visibility = View.VISIBLE;
|
||||||
|
@ -97,23 +89,23 @@ abstract class VideoListEditorView : LinearLayout {
|
||||||
_buttonShare = findViewById(R.id.button_share);
|
_buttonShare = findViewById(R.id.button_share);
|
||||||
val onShare = _onShare;
|
val onShare = _onShare;
|
||||||
if(onShare != null) {
|
if(onShare != null) {
|
||||||
_buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() };
|
_buttonShare.setOnClickListener { onShare.invoke() };
|
||||||
_buttonShare.visibility = View.VISIBLE;
|
_buttonShare.visibility = View.VISIBLE;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
_buttonShare.visibility = View.GONE;
|
_buttonShare.visibility = View.GONE;
|
||||||
|
|
||||||
buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); };
|
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
|
||||||
buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); };
|
buttonShuffle.setOnClickListener { onShuffleClick(); };
|
||||||
|
|
||||||
_buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); };
|
_buttonEdit.setOnClickListener { onEditClick(); };
|
||||||
setButtonExportVisible(false);
|
setButtonExportVisible(false);
|
||||||
setButtonDownloadVisible(canEdit());
|
setButtonDownloadVisible(canEdit());
|
||||||
|
|
||||||
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
||||||
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
||||||
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
|
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
|
||||||
videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
|
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
|
||||||
|
|
||||||
_videoListEditorView = videoListEditorView;
|
_videoListEditorView = videoListEditorView;
|
||||||
}
|
}
|
||||||
|
@ -121,7 +113,6 @@ 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;
|
||||||
|
@ -154,7 +145,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 { hideSearchKeyboard();
|
_buttonDownload.setOnClickListener {
|
||||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||||
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
||||||
});
|
});
|
||||||
|
@ -163,7 +154,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 { hideSearchKeyboard();
|
_buttonDownload.setOnClickListener {
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
@ -172,7 +163,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 { hideSearchKeyboard();
|
_buttonDownload.setOnClickListener {
|
||||||
onDownload();
|
onDownload();
|
||||||
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
||||||
}
|
}
|
||||||
|
|
|
@ -632,27 +632,6 @@ 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) };
|
||||||
|
|
|
@ -6,60 +6,38 @@ import com.futo.platformplayer.LittleEndianDataInputStream
|
||||||
import com.futo.platformplayer.LittleEndianDataOutputStream
|
import com.futo.platformplayer.LittleEndianDataOutputStream
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
|
||||||
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity
|
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity
|
||||||
import com.futo.platformplayer.api.media.Serializer
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.encryption.GEncryptionProvider
|
import com.futo.platformplayer.encryption.GEncryptionProvider
|
||||||
import com.futo.platformplayer.generateReadablePassword
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
import com.futo.platformplayer.getConnectedSocket
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.mdns.DnsService
|
import com.futo.platformplayer.mdns.DnsService
|
||||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
||||||
import com.futo.platformplayer.models.HistoryVideo
|
|
||||||
import com.futo.platformplayer.models.Subscription
|
|
||||||
import com.futo.platformplayer.noise.protocol.DHState
|
import com.futo.platformplayer.noise.protocol.DHState
|
||||||
import com.futo.platformplayer.noise.protocol.Noise
|
import com.futo.platformplayer.noise.protocol.Noise
|
||||||
import com.futo.platformplayer.smartMerge
|
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringStringMapStorage
|
import com.futo.platformplayer.stores.StringStringMapStorage
|
||||||
import com.futo.platformplayer.stores.StringArrayStorage
|
import com.futo.platformplayer.stores.StringArrayStorage
|
||||||
import com.futo.platformplayer.stores.StringStorage
|
import com.futo.platformplayer.stores.StringStorage
|
||||||
import com.futo.platformplayer.stores.StringTMapStorage
|
import com.futo.platformplayer.stores.StringTMapStorage
|
||||||
import com.futo.platformplayer.sync.SyncSessionData
|
import com.futo.platformplayer.sync.SyncSessionData
|
||||||
import com.futo.platformplayer.sync.internal.ChannelSocket
|
|
||||||
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
|
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
|
||||||
import com.futo.platformplayer.sync.internal.IAuthorizable
|
|
||||||
import com.futo.platformplayer.sync.internal.IChannel
|
|
||||||
import com.futo.platformplayer.sync.internal.LinkType
|
|
||||||
import com.futo.platformplayer.sync.internal.Opcode
|
|
||||||
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
|
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
|
||||||
import com.futo.platformplayer.sync.internal.SyncKeyPair
|
import com.futo.platformplayer.sync.internal.SyncKeyPair
|
||||||
import com.futo.platformplayer.sync.internal.SyncSession
|
import com.futo.platformplayer.sync.internal.SyncSession
|
||||||
import com.futo.platformplayer.sync.internal.SyncSocketSession
|
import com.futo.platformplayer.sync.internal.SyncSocketSession
|
||||||
import com.futo.platformplayer.sync.models.SendToDevicePackage
|
|
||||||
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
|
|
||||||
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
|
||||||
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
|
||||||
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
|
|
||||||
import com.futo.polycentric.core.base64ToByteArray
|
import com.futo.polycentric.core.base64ToByteArray
|
||||||
import com.futo.polycentric.core.toBase64
|
import com.futo.polycentric.core.toBase64
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.ServerSocket
|
import java.net.ServerSocket
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
@ -81,20 +59,13 @@ class StateSync {
|
||||||
//TODO: Should sync mdns and casting mdns be merged?
|
//TODO: Should sync mdns and casting mdns be merged?
|
||||||
//TODO: Decrease interval that devices are updated
|
//TODO: Decrease interval that devices are updated
|
||||||
//TODO: Send less data
|
//TODO: Send less data
|
||||||
private val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) }
|
val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) }
|
||||||
private val _pairingCode: String? = generateReadablePassword(8)
|
|
||||||
val pairingCode: String? get() = _pairingCode
|
|
||||||
private var _relaySession: SyncSocketSession? = null
|
|
||||||
private var _threadRelay: Thread? = null
|
|
||||||
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
|
|
||||||
|
|
||||||
var keyPair: DHState? = null
|
var keyPair: DHState? = null
|
||||||
var publicKey: String? = null
|
var publicKey: String? = null
|
||||||
val deviceRemoved: Event1<String> = Event1()
|
val deviceRemoved: Event1<String> = Event1()
|
||||||
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
|
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
|
||||||
|
|
||||||
//TODO: Should authorize acknowledge be implemented?
|
|
||||||
|
|
||||||
fun hasAuthorizedDevice(): Boolean {
|
fun hasAuthorizedDevice(): Boolean {
|
||||||
synchronized(_sessions) {
|
synchronized(_sessions) {
|
||||||
return _sessions.any{ it.value.connected && it.value.isAuthorized };
|
return _sessions.any{ it.value.connected && it.value.isAuthorized };
|
||||||
|
@ -156,7 +127,10 @@ class StateSync {
|
||||||
|
|
||||||
while (_started) {
|
while (_started) {
|
||||||
val socket = serverSocket.accept()
|
val socket = serverSocket.accept()
|
||||||
val session = createSocketSession(socket, true)
|
val session = createSocketSession(socket, true) { session, socketSession ->
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
session.startAsResponder()
|
session.startAsResponder()
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
@ -190,6 +164,8 @@ class StateSync {
|
||||||
|
|
||||||
for (connectPair in addressesToConnect) {
|
for (connectPair in addressesToConnect) {
|
||||||
try {
|
try {
|
||||||
|
val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT)
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val lastConnectTime = synchronized(_lastConnectTimesIp) {
|
val lastConnectTime = synchronized(_lastConnectTimesIp) {
|
||||||
_lastConnectTimesIp[connectPair.first] ?: 0
|
_lastConnectTimesIp[connectPair.first] ?: 0
|
||||||
|
@ -202,7 +178,7 @@ class StateSync {
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
|
Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
|
||||||
connect(arrayOf(connectPair.second), PORT, connectPair.first, null)
|
connect(syncDeviceInfo)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
|
Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
|
||||||
|
@ -212,125 +188,6 @@ class StateSync {
|
||||||
}
|
}
|
||||||
}.apply { start() }
|
}.apply { start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Settings.instance.synchronization.discoverThroughRelay) {
|
|
||||||
_threadRelay = Thread {
|
|
||||||
while (_started) {
|
|
||||||
try {
|
|
||||||
Log.i(TAG, "Starting relay session...")
|
|
||||||
|
|
||||||
var socketClosed = false;
|
|
||||||
val socket = Socket(RELAY_SERVER, 9000)
|
|
||||||
_relaySession = SyncSocketSession(
|
|
||||||
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
|
|
||||||
keyPair!!,
|
|
||||||
LittleEndianDataInputStream(socket.getInputStream()),
|
|
||||||
LittleEndianDataOutputStream(socket.getOutputStream()),
|
|
||||||
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode) },
|
|
||||||
onNewChannel = { _, c ->
|
|
||||||
val remotePublicKey = c.remotePublicKey
|
|
||||||
if (remotePublicKey == null) {
|
|
||||||
Log.e(TAG, "Remote public key should never be null in onNewChannel.")
|
|
||||||
return@SyncSocketSession
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').")
|
|
||||||
|
|
||||||
var session: SyncSession?
|
|
||||||
synchronized(_sessions) {
|
|
||||||
session = _sessions[remotePublicKey]
|
|
||||||
if (session == null) {
|
|
||||||
val remoteDeviceName = synchronized(_nameStorage) {
|
|
||||||
_nameStorage.get(remotePublicKey)
|
|
||||||
}
|
|
||||||
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
|
|
||||||
_sessions[remotePublicKey] = session!!
|
|
||||||
}
|
|
||||||
session!!.addChannel(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setDataHandler { _, channel, opcode, subOpcode, data ->
|
|
||||||
session?.handlePacket(opcode, subOpcode, data)
|
|
||||||
}
|
|
||||||
c.setCloseHandler { channel ->
|
|
||||||
session?.removeChannel(channel)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onChannelEstablished = { _, channel, isResponder ->
|
|
||||||
handleAuthorization(channel, isResponder)
|
|
||||||
},
|
|
||||||
onClose = { socketClosed = true },
|
|
||||||
onHandshakeComplete = { relaySession ->
|
|
||||||
Thread {
|
|
||||||
try {
|
|
||||||
while (_started && !socketClosed) {
|
|
||||||
val unconnectedAuthorizedDevices = synchronized(_authorizedDevices) {
|
|
||||||
_authorizedDevices.values.filter { !isConnected(it) }.toTypedArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, Settings.instance.synchronization.discoverThroughRelay, false, false, Settings.instance.synchronization.discoverThroughRelay && Settings.instance.synchronization.connectThroughRelay)
|
|
||||||
|
|
||||||
val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) }
|
|
||||||
|
|
||||||
for ((targetKey, connectionInfo) in connectionInfos) {
|
|
||||||
val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses)
|
|
||||||
.filter { it != connectionInfo.remoteIp }
|
|
||||||
if (connectionInfo.allowLocalDirect) {
|
|
||||||
Thread {
|
|
||||||
try {
|
|
||||||
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.")
|
|
||||||
connect(potentialLocalAddresses.map { it }.toTypedArray(), PORT, targetKey, null)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e)
|
|
||||||
}
|
|
||||||
}.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectionInfo.allowRemoteDirect) {
|
|
||||||
// TODO: Implement direct remote connection if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectionInfo.allowRemoteHolePunched) {
|
|
||||||
// TODO: Implement hole punching if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
|
|
||||||
try {
|
|
||||||
Log.v(TAG, "Attempting relayed connection with '$targetKey'.")
|
|
||||||
runBlocking { relaySession.startRelayedChannel(targetKey, null) }
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(TAG, "Failed to start relayed channel with $targetKey.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread.sleep(15000)
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(TAG, "Unhandled exception in relay session.", e)
|
|
||||||
relaySession.stop()
|
|
||||||
}
|
|
||||||
}.start()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_relaySession!!.authorizable = object : IAuthorizable {
|
|
||||||
override val isAuthorized: Boolean get() = true
|
|
||||||
}
|
|
||||||
|
|
||||||
_relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, null)
|
|
||||||
|
|
||||||
Log.i(TAG, "Started relay session.")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(TAG, "Relay session failed.", e)
|
|
||||||
Thread.sleep(5000)
|
|
||||||
} finally {
|
|
||||||
_relaySession?.stop()
|
|
||||||
_relaySession = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.apply { start() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDeviceName(): String {
|
private fun getDeviceName(): String {
|
||||||
|
@ -362,14 +219,14 @@ class StateSync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun getSessions(): List<SyncSession> {
|
fun getSessions(): List<SyncSession> {
|
||||||
synchronized(_sessions) {
|
return synchronized(_sessions) {
|
||||||
return _sessions.values.toList()
|
return _sessions.values.toList()
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
fun getAuthorizedSessions(): List<SyncSession> {
|
fun getAuthorizedSessions(): List<SyncSession> {
|
||||||
synchronized(_sessions) {
|
return synchronized(_sessions) {
|
||||||
return _sessions.values.filter { it.isAuthorized }.toList()
|
return _sessions.values.filter { it.isAuthorized }.toList()
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSyncSessionData(key: String): SyncSessionData {
|
fun getSyncSessionData(key: String): SyncSessionData {
|
||||||
|
@ -396,7 +253,7 @@ class StateSync {
|
||||||
val urlSafePkey = s.texts.firstOrNull { it.startsWith("pk=") }?.substring("pk=".length) ?: continue
|
val urlSafePkey = s.texts.firstOrNull { it.startsWith("pk=") }?.substring("pk=".length) ?: continue
|
||||||
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
|
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
|
||||||
|
|
||||||
val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port, null)
|
val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port)
|
||||||
val authorized = isAuthorized(pkey)
|
val authorized = isAuthorized(pkey)
|
||||||
|
|
||||||
if (authorized && !isConnected(pkey)) {
|
if (authorized && !isConnected(pkey)) {
|
||||||
|
@ -431,342 +288,12 @@ class StateSync {
|
||||||
deviceRemoved.emit(remotePublicKey)
|
deviceRemoved.emit(remotePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession, socketSession: SyncSocketSession) -> Unit): SyncSocketSession {
|
||||||
private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) {
|
|
||||||
val added = mutableListOf<Subscription>()
|
|
||||||
for(sub in pack.subscriptions) {
|
|
||||||
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
|
|
||||||
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
|
|
||||||
if(sub.creationTime > removalTime) {
|
|
||||||
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
|
|
||||||
added.add(newSub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(added.size > 3)
|
|
||||||
UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
|
|
||||||
else if(added.size > 0)
|
|
||||||
UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
|
|
||||||
added.map { it.channel.name }.joinToString("\n"));
|
|
||||||
|
|
||||||
|
|
||||||
if(pack.subscriptions.isNotEmpty()) {
|
|
||||||
for (subRemoved in pack.subscriptionRemovals) {
|
|
||||||
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals);
|
|
||||||
if(removed.size > 3) {
|
|
||||||
UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}");
|
|
||||||
} else if(removed.isNotEmpty()) {
|
|
||||||
UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}:\n" + removed.map { it.channel.name }.joinToString("\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
|
||||||
val remotePublicKey = session.remotePublicKey
|
|
||||||
when (subOpcode) {
|
|
||||||
GJSyncOpcodes.sendToDevices -> {
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
|
||||||
val context = StateApp.instance.contextOrNull;
|
|
||||||
if (context != null && context is MainActivity) {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
val remainder = data.remaining();
|
|
||||||
data.get(dataBody, 0, remainder);
|
|
||||||
val json = String(dataBody, Charsets.UTF_8);
|
|
||||||
val obj = Json.decodeFromString<SendToDevicePackage>(json);
|
|
||||||
UIDialogs.appToast("Received url from device [${session.remotePublicKey}]:\n{${obj.url}");
|
|
||||||
context.handleUrl(obj.url, obj.position);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
GJSyncOpcodes.syncStateExchange -> {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
data.get(dataBody);
|
|
||||||
val json = String(dataBody, Charsets.UTF_8);
|
|
||||||
val syncSessionData = Serializer.json.decodeFromString<SyncSessionData>(json);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Received SyncSessionData from $remotePublicKey");
|
|
||||||
|
|
||||||
|
|
||||||
session.sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
|
|
||||||
session.sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString());
|
|
||||||
session.sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString())
|
|
||||||
|
|
||||||
session.sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)));
|
|
||||||
|
|
||||||
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
|
|
||||||
if(recentHistory.isNotEmpty())
|
|
||||||
session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
GJSyncOpcodes.syncExport -> {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining());
|
|
||||||
bytesStr.use { bytesStrBytes ->
|
|
||||||
val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStrBytes);
|
|
||||||
for (store in exportStruct.stores) {
|
|
||||||
if (store.key.equals("subscriptions", true)) {
|
|
||||||
val subStore =
|
|
||||||
StateSubscriptions.instance.getUnderlyingSubscriptionsStore();
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
|
||||||
val pack = SyncSubscriptionsPackage(
|
|
||||||
store.value.map {
|
|
||||||
subStore.fromReconstruction(it, exportStruct.cache)
|
|
||||||
},
|
|
||||||
StateSubscriptions.instance.getSubscriptionRemovals()
|
|
||||||
);
|
|
||||||
handleSyncSubscriptionPackage(session, pack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GJSyncOpcodes.syncSubscriptions -> {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
data.get(dataBody);
|
|
||||||
val json = String(dataBody, Charsets.UTF_8);
|
|
||||||
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
|
|
||||||
handleSyncSubscriptionPackage(session, subPackage);
|
|
||||||
|
|
||||||
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
|
|
||||||
|
|
||||||
val sesData = getSyncSessionData(remotePublicKey);
|
|
||||||
if(newestSub > sesData.lastSubscription) {
|
|
||||||
sesData.lastSubscription = newestSub;
|
|
||||||
saveSyncSessionData(sesData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GJSyncOpcodes.syncSubscriptionGroups -> {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
data.get(dataBody);
|
|
||||||
val json = String(dataBody, Charsets.UTF_8);
|
|
||||||
val pack = Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(json);
|
|
||||||
|
|
||||||
var lastSubgroupChange = OffsetDateTime.MIN;
|
|
||||||
for(group in pack.groups){
|
|
||||||
if(group.lastChange > lastSubgroupChange)
|
|
||||||
lastSubgroupChange = group.lastChange;
|
|
||||||
|
|
||||||
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
|
||||||
|
|
||||||
if(existing == null)
|
|
||||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
|
|
||||||
else if(existing.lastChange < group.lastChange) {
|
|
||||||
existing.name = group.name;
|
|
||||||
existing.urls = group.urls;
|
|
||||||
existing.image = group.image;
|
|
||||||
existing.priority = group.priority;
|
|
||||||
existing.lastChange = group.lastChange;
|
|
||||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(removal in pack.groupRemovals) {
|
|
||||||
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
|
|
||||||
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
|
|
||||||
if(creation != null && creation.creationTime < removalTime)
|
|
||||||
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GJSyncOpcodes.syncPlaylists -> {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
data.get(dataBody);
|
|
||||||
val json = String(dataBody, Charsets.UTF_8);
|
|
||||||
val pack = Serializer.json.decodeFromString<SyncPlaylistsPackage>(json);
|
|
||||||
|
|
||||||
for(playlist in pack.playlists) {
|
|
||||||
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
|
|
||||||
|
|
||||||
if(existing == null)
|
|
||||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
|
|
||||||
else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) {
|
|
||||||
existing.dateUpdate = playlist.dateUpdate;
|
|
||||||
existing.name = playlist.name;
|
|
||||||
existing.videos = playlist.videos;
|
|
||||||
existing.dateCreation = playlist.dateCreation;
|
|
||||||
existing.datePlayed = playlist.datePlayed;
|
|
||||||
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(removal in pack.playlistRemovals) {
|
|
||||||
val creation = StatePlaylists.instance.getPlaylist(removal.key);
|
|
||||||
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
|
|
||||||
if(creation != null && creation.dateCreation < removalTime)
|
|
||||||
StatePlaylists.instance.removePlaylist(creation, false);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GJSyncOpcodes.syncWatchLater -> {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
data.get(dataBody);
|
|
||||||
val json = String(dataBody, Charsets.UTF_8);
|
|
||||||
val pack = Serializer.json.decodeFromString<SyncWatchLaterPackage>(json);
|
|
||||||
|
|
||||||
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
|
|
||||||
|
|
||||||
val allExisting = StatePlaylists.instance.getWatchLater();
|
|
||||||
for(video in pack.videos) {
|
|
||||||
val existing = allExisting.firstOrNull { it.url == video.url };
|
|
||||||
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
|
|
||||||
|
|
||||||
if(existing == null) {
|
|
||||||
StatePlaylists.instance.addToWatchLater(video, false);
|
|
||||||
if(time > OffsetDateTime.MIN)
|
|
||||||
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(removal in pack.videoRemovals) {
|
|
||||||
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
|
|
||||||
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
|
|
||||||
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC);
|
|
||||||
if(creation < removalTime)
|
|
||||||
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC);
|
|
||||||
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
|
|
||||||
if(localReorderTime < packReorderTime && pack.ordering != null) {
|
|
||||||
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GJSyncOpcodes.syncHistory -> {
|
|
||||||
val dataBody = ByteArray(data.remaining());
|
|
||||||
data.get(dataBody);
|
|
||||||
val json = String(dataBody, Charsets.UTF_8);
|
|
||||||
val history = Serializer.json.decodeFromString<List<HistoryVideo>>(json);
|
|
||||||
Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}");
|
|
||||||
|
|
||||||
var lastHistory = OffsetDateTime.MIN;
|
|
||||||
for(video in history){
|
|
||||||
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
|
|
||||||
if(hist != null)
|
|
||||||
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
|
|
||||||
if(lastHistory < video.date)
|
|
||||||
lastHistory = video.date;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(lastHistory != OffsetDateTime.MIN && history.size > 1) {
|
|
||||||
val sesData = getSyncSessionData(remotePublicKey);
|
|
||||||
if (lastHistory > sesData.lastHistory) {
|
|
||||||
sesData.lastHistory = lastHistory;
|
|
||||||
saveSyncSessionData(sesData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onAuthorized(remotePublicKey: String) {
|
|
||||||
synchronized(_remotePendingStatusUpdate) {
|
|
||||||
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onUnuthorized(remotePublicKey: String) {
|
|
||||||
synchronized(_remotePendingStatusUpdate) {
|
|
||||||
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNewSyncSession(remotePublicKey: String, remoteDeviceName: String?): SyncSession {
|
|
||||||
return SyncSession(
|
|
||||||
remotePublicKey,
|
|
||||||
onAuthorized = { it, isNewlyAuthorized, isNewSession ->
|
|
||||||
if (!isNewSession) {
|
|
||||||
return@SyncSession
|
|
||||||
}
|
|
||||||
|
|
||||||
it.remoteDeviceName?.let { remoteDeviceName ->
|
|
||||||
synchronized(_nameStorage) {
|
|
||||||
_nameStorage.setAndSave(remotePublicKey, remoteDeviceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "$remotePublicKey authorized (name: ${it.displayName})")
|
|
||||||
onAuthorized(remotePublicKey)
|
|
||||||
_authorizedDevices.addDistinct(remotePublicKey)
|
|
||||||
_authorizedDevices.save()
|
|
||||||
deviceUpdatedOrAdded.emit(it.remotePublicKey, it)
|
|
||||||
|
|
||||||
checkForSync(it);
|
|
||||||
},
|
|
||||||
onUnauthorized = {
|
|
||||||
unauthorize(remotePublicKey)
|
|
||||||
|
|
||||||
Logger.i(TAG, "$remotePublicKey unauthorized (name: ${it.displayName})")
|
|
||||||
onUnuthorized(remotePublicKey)
|
|
||||||
|
|
||||||
synchronized(_sessions) {
|
|
||||||
it.close()
|
|
||||||
_sessions.remove(remotePublicKey)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onConnectedChanged = { it, connected ->
|
|
||||||
Logger.i(TAG, "$remotePublicKey connected: $connected")
|
|
||||||
deviceUpdatedOrAdded.emit(it.remotePublicKey, it)
|
|
||||||
},
|
|
||||||
onClose = {
|
|
||||||
Logger.i(TAG, "$remotePublicKey closed")
|
|
||||||
|
|
||||||
synchronized(_sessions)
|
|
||||||
{
|
|
||||||
_sessions.remove(it.remotePublicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceRemoved.emit(it.remotePublicKey)
|
|
||||||
|
|
||||||
synchronized(_remotePendingStatusUpdate) {
|
|
||||||
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dataHandler = { it, opcode, subOpcode, data ->
|
|
||||||
handleData(it, opcode, subOpcode, data)
|
|
||||||
},
|
|
||||||
remoteDeviceName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?): Boolean {
|
|
||||||
Log.v(TAG, "Check if handshake allowed from '$publicKey'.")
|
|
||||||
if (publicKey == RELAY_PUBLIC_KEY)
|
|
||||||
return true
|
|
||||||
|
|
||||||
synchronized(_authorizedDevices) {
|
|
||||||
if (_authorizedDevices.values.contains(publicKey)) {
|
|
||||||
if (linkType == LinkType.Relayed && !Settings.instance.synchronization.connectThroughRelay)
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode'.")
|
|
||||||
if (_pairingCode == null || pairingCode.isNullOrEmpty())
|
|
||||||
return false
|
|
||||||
|
|
||||||
if (linkType == LinkType.Relayed && !Settings.instance.synchronization.pairThroughRelay)
|
|
||||||
return false
|
|
||||||
|
|
||||||
return _pairingCode == pairingCode
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession {
|
|
||||||
var session: SyncSession? = null
|
var session: SyncSession? = null
|
||||||
var channelSocket: ChannelSocket? = null
|
return SyncSocketSession((socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, keyPair!!, LittleEndianDataInputStream(socket.getInputStream()), LittleEndianDataOutputStream(socket.getOutputStream()),
|
||||||
return SyncSocketSession(
|
|
||||||
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
|
|
||||||
keyPair!!,
|
|
||||||
LittleEndianDataInputStream(socket.getInputStream()),
|
|
||||||
LittleEndianDataOutputStream(socket.getOutputStream()),
|
|
||||||
onClose = { s ->
|
onClose = { s ->
|
||||||
if (channelSocket != null)
|
session?.removeSocketSession(s)
|
||||||
session?.removeChannel(channelSocket!!)
|
|
||||||
},
|
},
|
||||||
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode) },
|
|
||||||
onHandshakeComplete = { s ->
|
onHandshakeComplete = { s ->
|
||||||
val remotePublicKey = s.remotePublicKey
|
val remotePublicKey = s.remotePublicKey
|
||||||
if (remotePublicKey == null) {
|
if (remotePublicKey == null) {
|
||||||
|
@ -776,8 +303,6 @@ class StateSync {
|
||||||
|
|
||||||
Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})")
|
Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})")
|
||||||
|
|
||||||
channelSocket = ChannelSocket(s)
|
|
||||||
|
|
||||||
synchronized(_sessions) {
|
synchronized(_sessions) {
|
||||||
session = _sessions[s.remotePublicKey]
|
session = _sessions[s.remotePublicKey]
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
|
@ -785,99 +310,126 @@ class StateSync {
|
||||||
_nameStorage.get(remotePublicKey)
|
_nameStorage.get(remotePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized(_lastAddressStorage) {
|
session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession ->
|
||||||
_lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress)
|
if (!isNewSession) {
|
||||||
}
|
return@SyncSession
|
||||||
|
}
|
||||||
|
|
||||||
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
|
it.remoteDeviceName?.let { remoteDeviceName ->
|
||||||
_sessions[remotePublicKey] = session!!
|
synchronized(_nameStorage) {
|
||||||
}
|
_nameStorage.setAndSave(remotePublicKey, remoteDeviceName)
|
||||||
session!!.addChannel(channelSocket!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAuthorization(channelSocket!!, isResponder)
|
|
||||||
},
|
|
||||||
onData = { s, opcode, subOpcode, data ->
|
|
||||||
session?.handlePacket(opcode, subOpcode, data)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAuthorization(channel: IChannel, isResponder: Boolean) {
|
|
||||||
val syncSession = channel.syncSession!!
|
|
||||||
val remotePublicKey = channel.remotePublicKey!!
|
|
||||||
|
|
||||||
if (isResponder) {
|
|
||||||
val isAuthorized = synchronized(_authorizedDevices) {
|
|
||||||
_authorizedDevices.values.contains(remotePublicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthorized) {
|
|
||||||
val scope = StateApp.instance.scopeOrNull
|
|
||||||
val activity = SyncShowPairingCodeActivity.activity
|
|
||||||
|
|
||||||
if (scope != null && activity != null) {
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?",
|
|
||||||
action = {
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
syncSession.authorize()
|
|
||||||
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to send authorize", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancelAction = {
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
unauthorize(remotePublicKey)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to send unauthorize", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncSession.close()
|
|
||||||
synchronized(_sessions) {
|
|
||||||
_sessions.remove(remotePublicKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})")
|
||||||
|
synchronized(_lastAddressStorage) {
|
||||||
|
_lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
onAuthorized(it, s)
|
||||||
|
_authorizedDevices.addDistinct(remotePublicKey)
|
||||||
|
_authorizedDevices.save()
|
||||||
|
deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!)
|
||||||
|
|
||||||
|
checkForSync(it);
|
||||||
|
}, onUnauthorized = {
|
||||||
|
unauthorize(remotePublicKey)
|
||||||
|
|
||||||
|
synchronized(_sessions) {
|
||||||
|
session?.close()
|
||||||
|
_sessions.remove(remotePublicKey)
|
||||||
|
}
|
||||||
|
}, onConnectedChanged = { it, connected ->
|
||||||
|
Logger.i(TAG, "${s.remotePublicKey} connected: " + connected)
|
||||||
|
deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!)
|
||||||
|
}, onClose = {
|
||||||
|
Logger.i(TAG, "${s.remotePublicKey} closed")
|
||||||
|
|
||||||
|
synchronized(_sessions)
|
||||||
|
{
|
||||||
|
_sessions.remove(it.remotePublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceRemoved.emit(it.remotePublicKey)
|
||||||
|
|
||||||
|
}, remoteDeviceName)
|
||||||
|
_sessions[remotePublicKey] = session!!
|
||||||
|
}
|
||||||
|
session!!.addSocketSession(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResponder) {
|
||||||
|
val isAuthorized = synchronized(_authorizedDevices) {
|
||||||
|
_authorizedDevices.values.contains(remotePublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
val scope = StateApp.instance.scopeOrNull
|
||||||
|
val activity = SyncShowPairingCodeActivity.activity
|
||||||
|
|
||||||
|
if (scope != null && activity != null) {
|
||||||
|
scope.launch(Dispatchers.Main) {
|
||||||
|
UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
session!!.authorize(s)
|
||||||
|
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to send authorize", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, cancelAction = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
unauthorize(remotePublicKey)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to send unauthorize", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(_sessions) {
|
||||||
|
session?.close()
|
||||||
|
_sessions.remove(remotePublicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val publicKey = session!!.remotePublicKey
|
||||||
|
session!!.unauthorize(s)
|
||||||
|
session!!.close()
|
||||||
|
|
||||||
|
synchronized(_sessions) {
|
||||||
|
_sessions.remove(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Connection unauthorized for ${remotePublicKey} because not authorized and not on pairing activity to ask")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//Responder does not need to check because already approved
|
||||||
|
session!!.authorize(s)
|
||||||
|
Logger.i(TAG, "Connection authorized for ${remotePublicKey} because already authorized")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val publicKey = syncSession.remotePublicKey
|
//Initiator does not need to check because the manual action of scanning the QR counts as approval
|
||||||
syncSession.unauthorize()
|
session!!.authorize(s)
|
||||||
syncSession.close()
|
Logger.i(TAG, "Connection authorized for ${remotePublicKey} because initiator")
|
||||||
|
|
||||||
synchronized(_sessions) {
|
|
||||||
_sessions.remove(publicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Connection unauthorized for $remotePublicKey because not authorized and not on pairing activity to ask")
|
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
//Responder does not need to check because already approved
|
onData = { s, opcode, subOpcode, data ->
|
||||||
syncSession.authorize()
|
session?.handlePacket(s, opcode, subOpcode, data)
|
||||||
Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized")
|
})
|
||||||
}
|
|
||||||
} else {
|
|
||||||
//Initiator does not need to check because the manual action of scanning the QR counts as approval
|
|
||||||
syncSession.authorize()
|
|
||||||
Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <reified T> broadcastJsonData(subOpcode: UByte, data: T) {
|
inline fun <reified T> broadcastJsonData(subOpcode: UByte, data: T) {
|
||||||
broadcast(Opcode.DATA.value, subOpcode, Json.encodeToString(data));
|
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data));
|
||||||
}
|
}
|
||||||
fun broadcastData(subOpcode: UByte, data: String) {
|
fun broadcastData(subOpcode: UByte, data: String) {
|
||||||
broadcast(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)));
|
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8));
|
||||||
}
|
}
|
||||||
fun broadcast(opcode: UByte, subOpcode: UByte, data: String) {
|
fun broadcast(opcode: UByte, subOpcode: UByte, data: String) {
|
||||||
broadcast(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)));
|
broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
|
||||||
}
|
}
|
||||||
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) {
|
||||||
for(session in getAuthorizedSessions()) {
|
for(session in getAuthorizedSessions()) {
|
||||||
try {
|
try {
|
||||||
session.send(opcode, subOpcode, data);
|
session.send(opcode, subOpcode, data);
|
||||||
|
@ -904,53 +456,21 @@ class StateSync {
|
||||||
_serverSocket?.close()
|
_serverSocket?.close()
|
||||||
_serverSocket = null
|
_serverSocket = null
|
||||||
|
|
||||||
_thread?.interrupt()
|
//_thread?.join()
|
||||||
_thread = null
|
_thread = null
|
||||||
_connectThread?.interrupt()
|
|
||||||
_connectThread = null
|
_connectThread = null
|
||||||
_threadRelay?.interrupt()
|
|
||||||
_threadRelay = null
|
|
||||||
|
|
||||||
_relaySession?.stop()
|
|
||||||
_relaySession = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
|
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((session: SyncSocketSession?, complete: Boolean, message: String) -> Unit)? = null): SyncSocketSession {
|
||||||
try {
|
onStatusUpdate?.invoke(null, false, "Connecting...")
|
||||||
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate)
|
val socket = getConnectedSocket(deviceInfo.addresses.map { InetAddress.getByName(it) }, deviceInfo.port) ?: throw Exception("Failed to connect")
|
||||||
} catch (e: Throwable) {
|
onStatusUpdate?.invoke(null, false, "Handshaking...")
|
||||||
Logger.e(TAG, "Failed to connect directly", e)
|
|
||||||
val relaySession = _relaySession
|
|
||||||
if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) {
|
|
||||||
onStatusUpdate?.invoke(null, "Connecting via relay...")
|
|
||||||
|
|
||||||
runBlocking {
|
val session = createSocketSession(socket, false) { _, ss ->
|
||||||
if (onStatusUpdate != null) {
|
onStatusUpdate?.invoke(ss, true, "Handshake complete")
|
||||||
synchronized(_remotePendingStatusUpdate) {
|
|
||||||
_remotePendingStatusUpdate[deviceInfo.publicKey] = onStatusUpdate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
relaySession.startRelayedChannel(deviceInfo.publicKey, deviceInfo.pairingCode)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession {
|
|
||||||
onStatusUpdate?.invoke(null, "Connecting directly...")
|
|
||||||
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect")
|
|
||||||
onStatusUpdate?.invoke(null, "Handshaking...")
|
|
||||||
|
|
||||||
val session = createSocketSession(socket, false)
|
|
||||||
if (onStatusUpdate != null) {
|
|
||||||
synchronized(_remotePendingStatusUpdate) {
|
|
||||||
_remotePendingStatusUpdate[publicKey] = onStatusUpdate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session.startAsInitiator(publicKey, pairingCode)
|
session.startAsInitiator(deviceInfo.publicKey)
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1006,8 +526,6 @@ class StateSync {
|
||||||
val hash = "BLAKE2b"
|
val hash = "BLAKE2b"
|
||||||
var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}"
|
var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}"
|
||||||
val version = 1
|
val version = 1
|
||||||
val RELAY_SERVER = "relay.grayjay.app"
|
|
||||||
val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
|
|
||||||
|
|
||||||
private const val TAG = "StateSync"
|
private const val TAG = "StateSync"
|
||||||
const val PORT = 12315
|
const val PORT = 12315
|
||||||
|
|
|
@ -1,335 +0,0 @@
|
||||||
package com.futo.platformplayer.sync.internal
|
|
||||||
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
|
||||||
import com.futo.platformplayer.noise.protocol.DHState
|
|
||||||
import com.futo.platformplayer.noise.protocol.HandshakeState
|
|
||||||
import com.futo.platformplayer.states.StateSync
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
interface IChannel : AutoCloseable {
|
|
||||||
val remotePublicKey: String?
|
|
||||||
val remoteVersion: Int?
|
|
||||||
var authorizable: IAuthorizable?
|
|
||||||
var syncSession: SyncSession?
|
|
||||||
fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?)
|
|
||||||
fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null)
|
|
||||||
fun setCloseHandler(onClose: ((IChannel) -> Unit)?)
|
|
||||||
val linkType: LinkType
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChannelSocket(private val session: SyncSocketSession) : IChannel {
|
|
||||||
override val remotePublicKey: String? get() = session.remotePublicKey
|
|
||||||
override val remoteVersion: Int? get() = session.remoteVersion
|
|
||||||
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
|
|
||||||
private var onClose: ((IChannel) -> Unit)? = null
|
|
||||||
override val linkType: LinkType get() = LinkType.Direct
|
|
||||||
|
|
||||||
override var authorizable: IAuthorizable?
|
|
||||||
get() = session.authorizable
|
|
||||||
set(value) { session.authorizable = value }
|
|
||||||
override var syncSession: SyncSession? = null
|
|
||||||
|
|
||||||
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
|
|
||||||
this.onData = onData
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) {
|
|
||||||
this.onClose = onClose
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
session.stop()
|
|
||||||
onClose?.invoke(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
|
||||||
onData?.invoke(session, this, opcode, subOpcode, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) {
|
|
||||||
if (data != null) {
|
|
||||||
session.send(opcode, subOpcode, data)
|
|
||||||
} else {
|
|
||||||
session.send(opcode, subOpcode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChannelRelayed(
|
|
||||||
private val session: SyncSocketSession,
|
|
||||||
private val localKeyPair: DHState,
|
|
||||||
private val publicKey: String,
|
|
||||||
private val initiator: Boolean
|
|
||||||
) : IChannel {
|
|
||||||
private val sendLock = Object()
|
|
||||||
private val decryptLock = Object()
|
|
||||||
private var handshakeState: HandshakeState? = if (initiator) {
|
|
||||||
HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply {
|
|
||||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
|
||||||
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply {
|
|
||||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var transport: CipherStatePair? = null
|
|
||||||
override var authorizable: IAuthorizable? = null
|
|
||||||
val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false
|
|
||||||
var connectionId: Long = 0L
|
|
||||||
override var remotePublicKey: String? = publicKey
|
|
||||||
private set
|
|
||||||
override var remoteVersion: Int? = null
|
|
||||||
private set
|
|
||||||
override var syncSession: SyncSession? = null
|
|
||||||
override val linkType: LinkType get() = LinkType.Relayed
|
|
||||||
|
|
||||||
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
|
|
||||||
private var onClose: ((IChannel) -> Unit)? = null
|
|
||||||
private var disposed = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
handshakeState?.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
|
|
||||||
this.onData = onData
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) {
|
|
||||||
this.onClose = onClose
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
disposed = true
|
|
||||||
|
|
||||||
if (connectionId != 0L) {
|
|
||||||
Thread {
|
|
||||||
try {
|
|
||||||
session.sendRelayError(connectionId, SyncErrorCode.ConnectionClosed)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e("ChannelRelayed", "Exception while sending relay error", e)
|
|
||||||
}
|
|
||||||
}.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
transport?.sender?.destroy()
|
|
||||||
transport?.receiver?.destroy()
|
|
||||||
transport = null
|
|
||||||
handshakeState?.destroy()
|
|
||||||
handshakeState = null
|
|
||||||
|
|
||||||
onClose?.invoke(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun throwIfDisposed() {
|
|
||||||
if (disposed) throw IllegalStateException("ChannelRelayed is disposed")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
|
||||||
onData?.invoke(session, this, opcode, subOpcode, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun completeHandshake(remoteVersion: Int, transport: CipherStatePair) {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
this.remoteVersion = remoteVersion
|
|
||||||
val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength)
|
|
||||||
handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
|
||||||
this.remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
|
|
||||||
handshakeState?.destroy()
|
|
||||||
handshakeState = null
|
|
||||||
this.transport = transport
|
|
||||||
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendPacket(packet: ByteArray) {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
synchronized(sendLock) {
|
|
||||||
val encryptedPayload = ByteArray(packet.size + 16)
|
|
||||||
val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size)
|
|
||||||
|
|
||||||
val relayedPacket = ByteArray(8 + encryptedLength)
|
|
||||||
ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putLong(connectionId)
|
|
||||||
put(encryptedPayload, 0, encryptedLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.send(Opcode.RELAY.value, RelayOpcode.DATA.value, ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendError(errorCode: SyncErrorCode) {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
synchronized(sendLock) {
|
|
||||||
val packet = ByteArray(4)
|
|
||||||
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(errorCode.value)
|
|
||||||
|
|
||||||
val encryptedPayload = ByteArray(4 + 16)
|
|
||||||
val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size)
|
|
||||||
|
|
||||||
val relayedPacket = ByteArray(8 + encryptedLength)
|
|
||||||
ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putLong(connectionId)
|
|
||||||
put(encryptedPayload, 0, encryptedLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.send(Opcode.RELAY.value, RelayOpcode.ERROR.value, ByteBuffer.wrap(relayedPacket))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
val actualCount = data?.remaining() ?: 0
|
|
||||||
val ENCRYPTION_OVERHEAD = 16
|
|
||||||
val CONNECTION_ID_SIZE = 8
|
|
||||||
val HEADER_SIZE = 6
|
|
||||||
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16
|
|
||||||
|
|
||||||
if (actualCount > MAX_DATA_PER_PACKET && data != null) {
|
|
||||||
val streamId = session.generateStreamId()
|
|
||||||
val totalSize = actualCount
|
|
||||||
var sendOffset = 0
|
|
||||||
|
|
||||||
while (sendOffset < totalSize) {
|
|
||||||
val bytesRemaining = totalSize - sendOffset
|
|
||||||
val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - 2, bytesRemaining)
|
|
||||||
|
|
||||||
val streamData: ByteArray
|
|
||||||
val streamOpcode: StreamOpcode
|
|
||||||
if (sendOffset == 0) {
|
|
||||||
streamOpcode = StreamOpcode.START
|
|
||||||
streamData = ByteArray(4 + 4 + 1 + 1 + bytesToSend)
|
|
||||||
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putInt(streamId)
|
|
||||||
putInt(totalSize)
|
|
||||||
put(opcode.toByte())
|
|
||||||
put(subOpcode.toByte())
|
|
||||||
put(data.array(), data.position() + sendOffset, bytesToSend)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
streamData = ByteArray(4 + 4 + bytesToSend)
|
|
||||||
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putInt(streamId)
|
|
||||||
putInt(sendOffset)
|
|
||||||
put(data.array(), data.position() + sendOffset, bytesToSend)
|
|
||||||
}
|
|
||||||
streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END
|
|
||||||
}
|
|
||||||
|
|
||||||
val fullPacket = ByteArray(HEADER_SIZE + streamData.size)
|
|
||||||
ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putInt(streamData.size + 2)
|
|
||||||
put(Opcode.STREAM.value.toByte())
|
|
||||||
put(streamOpcode.value.toByte())
|
|
||||||
put(streamData)
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPacket(fullPacket)
|
|
||||||
sendOffset += bytesToSend
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val packet = ByteArray(HEADER_SIZE + actualCount)
|
|
||||||
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putInt(actualCount + 2)
|
|
||||||
put(opcode.toByte())
|
|
||||||
put(subOpcode.toByte())
|
|
||||||
if (actualCount > 0 && data != null) put(data.array(), data.position(), actualCount)
|
|
||||||
}
|
|
||||||
sendPacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendRequestTransport(requestId: Int, publicKey: String, pairingCode: String? = null) {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
synchronized(sendLock) {
|
|
||||||
val channelMessage = ByteArray(1024)
|
|
||||||
val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0)
|
|
||||||
|
|
||||||
val publicKeyBytes = Base64.getDecoder().decode(publicKey)
|
|
||||||
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
|
|
||||||
|
|
||||||
val (pairingMessageLength, pairingMessage) = if (pairingCode != null) {
|
|
||||||
val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply {
|
|
||||||
remotePublicKey.setPublicKey(publicKeyBytes, 0)
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8)
|
|
||||||
if (pairingCodeBytes.size > 32) throw IllegalArgumentException("Pairing code must not exceed 32 bytes")
|
|
||||||
val pairingMessageBuffer = ByteArray(1024)
|
|
||||||
val bytesWritten = pairingHandshake.writeMessage(pairingMessageBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size)
|
|
||||||
bytesWritten to pairingMessageBuffer.copyOf(bytesWritten)
|
|
||||||
} else {
|
|
||||||
0 to ByteArray(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
val packetSize = 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten
|
|
||||||
val packet = ByteArray(packetSize)
|
|
||||||
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putInt(requestId)
|
|
||||||
put(publicKeyBytes)
|
|
||||||
putInt(pairingMessageLength)
|
|
||||||
if (pairingMessageLength > 0) put(pairingMessage)
|
|
||||||
putInt(channelBytesWritten)
|
|
||||||
put(channelMessage, 0, channelBytesWritten)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.send(Opcode.REQUEST.value, RequestOpcode.TRANSPORT.value, ByteBuffer.wrap(packet))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
synchronized(sendLock) {
|
|
||||||
val message = ByteArray(1024)
|
|
||||||
val plaintext = ByteArray(1024)
|
|
||||||
handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0)
|
|
||||||
val bytesWritten = handshakeState!!.writeMessage(message, 0, null, 0, 0)
|
|
||||||
val transport = handshakeState!!.split()
|
|
||||||
|
|
||||||
val responsePacket = ByteArray(20 + bytesWritten)
|
|
||||||
ByteBuffer.wrap(responsePacket).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
||||||
putInt(0) // Status code
|
|
||||||
putLong(connectionId)
|
|
||||||
putInt(requestId)
|
|
||||||
putInt(bytesWritten)
|
|
||||||
put(message, 0, bytesWritten)
|
|
||||||
}
|
|
||||||
|
|
||||||
completeHandshake(remoteVersion, transport)
|
|
||||||
session.send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, ByteBuffer.wrap(responsePacket))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decrypt(encryptedPayload: ByteBuffer): ByteBuffer {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
synchronized(decryptLock) {
|
|
||||||
val encryptedBytes = ByteArray(encryptedPayload.remaining()).also { encryptedPayload.get(it) }
|
|
||||||
val decryptedPayload = ByteArray(encryptedBytes.size - 16)
|
|
||||||
val plen = transport!!.receiver.decryptWithAd(null, encryptedBytes, 0, decryptedPayload, 0, encryptedBytes.size)
|
|
||||||
if (plen != decryptedPayload.size) throw IllegalStateException("Expected decrypted payload length to be $plen")
|
|
||||||
return ByteBuffer.wrap(decryptedPayload).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleTransportRelayed(remoteVersion: Int, connectionId: Long, handshakeMessage: ByteArray) {
|
|
||||||
throwIfDisposed()
|
|
||||||
|
|
||||||
synchronized(decryptLock) {
|
|
||||||
this.connectionId = connectionId
|
|
||||||
val plaintext = ByteArray(1024)
|
|
||||||
val plen = handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0)
|
|
||||||
val transport = handshakeState!!.split()
|
|
||||||
completeHandshake(remoteVersion, transport)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,6 +2,6 @@ package com.futo.platformplayer.sync.internal;
|
||||||
|
|
||||||
public enum LinkType {
|
public enum LinkType {
|
||||||
None,
|
None,
|
||||||
Direct,
|
Local,
|
||||||
Relayed
|
Proxied
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
package com.futo.platformplayer.sync.internal
|
|
||||||
|
|
||||||
enum class Opcode(val value: UByte) {
|
|
||||||
PING(0u),
|
|
||||||
PONG(1u),
|
|
||||||
NOTIFY(2u),
|
|
||||||
STREAM(3u),
|
|
||||||
DATA(4u),
|
|
||||||
REQUEST(5u),
|
|
||||||
RESPONSE(6u),
|
|
||||||
RELAY(7u)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class NotifyOpcode(val value: UByte) {
|
|
||||||
AUTHORIZED(0u),
|
|
||||||
UNAUTHORIZED(1u),
|
|
||||||
CONNECTION_INFO(2u)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class StreamOpcode(val value: UByte) {
|
|
||||||
START(0u),
|
|
||||||
DATA(1u),
|
|
||||||
END(2u)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class RequestOpcode(val value: UByte) {
|
|
||||||
CONNECTION_INFO(0u),
|
|
||||||
TRANSPORT(1u),
|
|
||||||
TRANSPORT_RELAYED(2u),
|
|
||||||
PUBLISH_RECORD(3u),
|
|
||||||
DELETE_RECORD(4u),
|
|
||||||
LIST_RECORD_KEYS(5u),
|
|
||||||
GET_RECORD(6u),
|
|
||||||
BULK_PUBLISH_RECORD(7u),
|
|
||||||
BULK_GET_RECORD(8u),
|
|
||||||
BULK_CONNECTION_INFO(9u),
|
|
||||||
BULK_DELETE_RECORD(10u)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ResponseOpcode(val value: UByte) {
|
|
||||||
CONNECTION_INFO(0u),
|
|
||||||
TRANSPORT(1u),
|
|
||||||
TRANSPORT_RELAYED(2u), //TODO: Server errors also included in this one, disentangle?
|
|
||||||
PUBLISH_RECORD(3u),
|
|
||||||
DELETE_RECORD(4u),
|
|
||||||
LIST_RECORD_KEYS(5u),
|
|
||||||
GET_RECORD(6u),
|
|
||||||
BULK_PUBLISH_RECORD(7u),
|
|
||||||
BULK_GET_RECORD(8u),
|
|
||||||
BULK_CONNECTION_INFO(9u),
|
|
||||||
BULK_DELETE_RECORD(10u)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class RelayOpcode(val value: UByte) {
|
|
||||||
DATA(0u),
|
|
||||||
RELAYED_DATA(1u),
|
|
||||||
ERROR(2u),
|
|
||||||
RELAYED_ERROR(3u),
|
|
||||||
RELAY_ERROR(4u)
|
|
||||||
}
|
|
|
@ -5,12 +5,10 @@ class SyncDeviceInfo {
|
||||||
var publicKey: String
|
var publicKey: String
|
||||||
var addresses: Array<String>
|
var addresses: Array<String>
|
||||||
var port: Int
|
var port: Int
|
||||||
var pairingCode: String?
|
|
||||||
|
|
||||||
constructor(publicKey: String, addresses: Array<String>, port: Int, pairingCode: String?) {
|
constructor(publicKey: String, addresses: Array<String>, port: Int) {
|
||||||
this.publicKey = publicKey
|
this.publicKey = publicKey
|
||||||
this.addresses = addresses
|
this.addresses = addresses
|
||||||
this.port = port
|
this.port = port
|
||||||
this.pairingCode = pairingCode
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,6 +0,0 @@
|
||||||
package com.futo.platformplayer.sync.internal
|
|
||||||
|
|
||||||
enum class SyncErrorCode(val value: Int) {
|
|
||||||
ConnectionClosed(1),
|
|
||||||
NotFound(2)
|
|
||||||
}
|
|
|
@ -1,13 +1,37 @@
|
||||||
package com.futo.platformplayer.sync.internal
|
package com.futo.platformplayer.sync.internal
|
||||||
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.api.media.Serializer
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
|
import com.futo.platformplayer.smartMerge
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateBackup
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
|
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
|
import com.futo.platformplayer.states.StateSync
|
||||||
|
import com.futo.platformplayer.sync.SyncSessionData
|
||||||
|
import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode
|
||||||
|
import com.futo.platformplayer.sync.models.SendToDevicePackage
|
||||||
|
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
|
||||||
|
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
||||||
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
||||||
|
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
|
||||||
|
import com.futo.platformplayer.toUtf8String
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
interface IAuthorizable {
|
interface IAuthorizable {
|
||||||
|
@ -15,14 +39,13 @@ interface IAuthorizable {
|
||||||
}
|
}
|
||||||
|
|
||||||
class SyncSession : IAuthorizable {
|
class SyncSession : IAuthorizable {
|
||||||
private val _channels: MutableList<IChannel> = mutableListOf()
|
private val _socketSessions: MutableList<SyncSocketSession> = mutableListOf()
|
||||||
private var _authorized: Boolean = false
|
private var _authorized: Boolean = false
|
||||||
private var _remoteAuthorized: Boolean = false
|
private var _remoteAuthorized: Boolean = false
|
||||||
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
||||||
private val _onUnauthorized: (session: SyncSession) -> Unit
|
private val _onUnauthorized: (session: SyncSession) -> Unit
|
||||||
private val _onClose: (session: SyncSession) -> Unit
|
private val _onClose: (session: SyncSession) -> Unit
|
||||||
private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit
|
private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit
|
||||||
private val _dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit
|
|
||||||
val remotePublicKey: String
|
val remotePublicKey: String
|
||||||
override val isAuthorized get() = _authorized && _remoteAuthorized
|
override val isAuthorized get() = _authorized && _remoteAuthorized
|
||||||
private var _wasAuthorized = false
|
private var _wasAuthorized = false
|
||||||
|
@ -33,143 +56,140 @@ class SyncSession : IAuthorizable {
|
||||||
private set
|
private set
|
||||||
val displayName: String get() = remoteDeviceName ?: remotePublicKey
|
val displayName: String get() = remoteDeviceName ?: remotePublicKey
|
||||||
|
|
||||||
val linkType: LinkType get()
|
|
||||||
{
|
|
||||||
var linkType = LinkType.None
|
|
||||||
synchronized(_channels)
|
|
||||||
{
|
|
||||||
for (channel in _channels)
|
|
||||||
{
|
|
||||||
if (channel.linkType == LinkType.Direct)
|
|
||||||
return LinkType.Direct
|
|
||||||
if (channel.linkType == LinkType.Relayed)
|
|
||||||
linkType = LinkType.Relayed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return linkType
|
|
||||||
}
|
|
||||||
|
|
||||||
var connected: Boolean = false
|
var connected: Boolean = false
|
||||||
private set(v) {
|
private set(v) {
|
||||||
if (field != v) {
|
if (field != v) {
|
||||||
field = v
|
field = v
|
||||||
this._onConnectedChanged(this, v)
|
this._onConnectedChanged(this, v)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) {
|
||||||
remotePublicKey: String,
|
|
||||||
onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit,
|
|
||||||
onUnauthorized: (session: SyncSession) -> Unit,
|
|
||||||
onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit,
|
|
||||||
onClose: (session: SyncSession) -> Unit,
|
|
||||||
dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit,
|
|
||||||
remoteDeviceName: String? = null
|
|
||||||
) {
|
|
||||||
this.remotePublicKey = remotePublicKey
|
this.remotePublicKey = remotePublicKey
|
||||||
this.remoteDeviceName = remoteDeviceName
|
|
||||||
_onAuthorized = onAuthorized
|
_onAuthorized = onAuthorized
|
||||||
_onUnauthorized = onUnauthorized
|
_onUnauthorized = onUnauthorized
|
||||||
_onConnectedChanged = onConnectedChanged
|
_onConnectedChanged = onConnectedChanged
|
||||||
_onClose = onClose
|
_onClose = onClose
|
||||||
_dataHandler = dataHandler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addChannel(channel: IChannel) {
|
fun addSocketSession(socketSession: SyncSocketSession) {
|
||||||
if (channel.remotePublicKey != remotePublicKey) {
|
if (socketSession.remotePublicKey != remotePublicKey) {
|
||||||
throw Exception("Public key of session must match public key of channel")
|
throw Exception("Public key of session must match public key of socket session")
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized(_channels) {
|
synchronized(_socketSessions) {
|
||||||
_channels.add(channel)
|
_socketSessions.add(socketSession)
|
||||||
connected = _channels.isNotEmpty()
|
connected = _socketSessions.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.authorizable = this
|
socketSession.authorizable = this
|
||||||
channel.syncSession = this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authorize() {
|
fun authorize(socketSession: SyncSocketSession) {
|
||||||
Logger.i(TAG, "Sent AUTHORIZED with session id $_id")
|
Logger.i(TAG, "Sent AUTHORIZED with session id $_id")
|
||||||
val idString = _id.toString()
|
|
||||||
val idBytes = idString.toByteArray(Charsets.UTF_8)
|
if (socketSession.remoteVersion >= 3) {
|
||||||
val name = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}"
|
val idStringBytes = _id.toString().toByteArray()
|
||||||
val nameBytes = name.toByteArray(Charsets.UTF_8)
|
val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray()
|
||||||
val buffer = ByteArray(1 + idBytes.size + 1 + nameBytes.size)
|
val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size)
|
||||||
buffer[0] = idBytes.size.toByte()
|
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||||
System.arraycopy(idBytes, 0, buffer, 1, idBytes.size)
|
put(idStringBytes.size.toByte())
|
||||||
buffer[1 + idBytes.size] = nameBytes.size.toByte()
|
put(idStringBytes)
|
||||||
System.arraycopy(nameBytes, 0, buffer, 2 + idBytes.size, nameBytes.size)
|
put(nameBytes.size.toByte())
|
||||||
send(Opcode.NOTIFY.value, NotifyOpcode.AUTHORIZED.value, ByteBuffer.wrap(buffer))
|
put(nameBytes)
|
||||||
|
}.apply { flip() })
|
||||||
|
} else {
|
||||||
|
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
|
||||||
|
}
|
||||||
_authorized = true
|
_authorized = true
|
||||||
checkAuthorized()
|
checkAuthorized()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unauthorize() {
|
fun unauthorize(socketSession: SyncSocketSession? = null) {
|
||||||
send(Opcode.NOTIFY.value, NotifyOpcode.UNAUTHORIZED.value)
|
if (socketSession != null)
|
||||||
|
socketSession.send(Opcode.NOTIFY_UNAUTHORIZED.value)
|
||||||
|
else {
|
||||||
|
val ss = synchronized(_socketSessions) {
|
||||||
|
_socketSessions.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
ss.send(Opcode.NOTIFY_UNAUTHORIZED.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAuthorized() {
|
private fun checkAuthorized() {
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
val isNewlyAuthorized = !_wasAuthorized
|
val isNewlyAuthorized = !_wasAuthorized;
|
||||||
val isNewSession = _lastAuthorizedRemoteId != _remoteId
|
val isNewSession = _lastAuthorizedRemoteId != _remoteId;
|
||||||
Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)")
|
Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)");
|
||||||
_onAuthorized(this, isNewlyAuthorized, isNewSession)
|
_onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId)
|
||||||
_wasAuthorized = true
|
_wasAuthorized = true
|
||||||
_lastAuthorizedRemoteId = _remoteId
|
_lastAuthorizedRemoteId = _remoteId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeChannel(channel: IChannel) {
|
fun removeSocketSession(socketSession: SyncSocketSession) {
|
||||||
synchronized(_channels) {
|
synchronized(_socketSessions) {
|
||||||
_channels.remove(channel)
|
_socketSessions.remove(socketSession)
|
||||||
connected = _channels.isNotEmpty()
|
connected = _socketSessions.isNotEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
synchronized(_channels) {
|
synchronized(_socketSessions) {
|
||||||
_channels.forEach { it.close() }
|
for (socketSession in _socketSessions) {
|
||||||
_channels.clear()
|
socketSession.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
_socketSessions.clear()
|
||||||
}
|
}
|
||||||
_onClose(this)
|
|
||||||
|
_onClose.invoke(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Handle packet (opcode: $opcode, subOpcode: $subOpcode, data.length: ${data.remaining()})")
|
Logger.i(TAG, "Handle packet (opcode: ${opcode}, subOpcode: ${subOpcode}, data.length: ${data.remaining()})")
|
||||||
|
|
||||||
when (opcode) {
|
when (opcode) {
|
||||||
Opcode.NOTIFY.value -> when (subOpcode) {
|
Opcode.NOTIFY_AUTHORIZED.value -> {
|
||||||
NotifyOpcode.AUTHORIZED.value -> {
|
if (socketSession.remoteVersion >= 3) {
|
||||||
val idByteCount = data.get().toInt()
|
val idByteCount = data.get().toInt()
|
||||||
if (idByteCount > 64)
|
if (idByteCount > 64)
|
||||||
throw Exception("Id should always be smaller than 64 bytes")
|
throw Exception("Id should always be smaller than 64 bytes")
|
||||||
|
|
||||||
val idBytes = ByteArray(idByteCount)
|
val idBytes = ByteArray(idByteCount)
|
||||||
data.get(idBytes)
|
data.get(idBytes)
|
||||||
|
|
||||||
val nameByteCount = data.get().toInt()
|
val nameByteCount = data.get().toInt()
|
||||||
if (nameByteCount > 64)
|
if (nameByteCount > 64)
|
||||||
throw Exception("Name should always be smaller than 64 bytes")
|
throw Exception("Name should always be smaller than 64 bytes")
|
||||||
|
|
||||||
val nameBytes = ByteArray(nameByteCount)
|
val nameBytes = ByteArray(nameByteCount)
|
||||||
data.get(nameBytes)
|
data.get(nameBytes)
|
||||||
|
|
||||||
_remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8))
|
_remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8))
|
||||||
remoteDeviceName = nameBytes.toString(Charsets.UTF_8)
|
remoteDeviceName = nameBytes.toString(Charsets.UTF_8)
|
||||||
_remoteAuthorized = true
|
} else {
|
||||||
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')")
|
val str = data.toUtf8String()
|
||||||
checkAuthorized()
|
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000")
|
||||||
return
|
|
||||||
}
|
|
||||||
NotifyOpcode.UNAUTHORIZED.value -> {
|
|
||||||
_remoteAuthorized = false
|
|
||||||
_remoteId = null
|
|
||||||
remoteDeviceName = null
|
remoteDeviceName = null
|
||||||
_lastAuthorizedRemoteId = null
|
|
||||||
_onUnauthorized(this)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_remoteAuthorized = true
|
||||||
|
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')")
|
||||||
|
checkAuthorized()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
Opcode.NOTIFY_UNAUTHORIZED.value -> {
|
||||||
|
_remoteId = null
|
||||||
|
remoteDeviceName = null
|
||||||
|
_lastAuthorizedRemoteId = null
|
||||||
|
_remoteAuthorized = false
|
||||||
|
_onUnauthorized(this)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//TODO: Handle any kind of packet (that is not necessarily authorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
|
@ -177,57 +197,282 @@ class SyncSession : IAuthorizable {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opcode != Opcode.DATA.value) {
|
if (opcode != Opcode.DATA.value) {
|
||||||
Logger.w(TAG, "Unknown opcode received: (opcode = $opcode, subOpcode = $subOpcode)")
|
Logger.w(TAG, "Unknown opcode received: (opcode = ${opcode}, subOpcode = ${subOpcode})}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "Received (opcode = $opcode, subOpcode = $subOpcode) (${data.remaining()} bytes)")
|
Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)")
|
||||||
_dataHandler.invoke(this, opcode, subOpcode, data)
|
//TODO: Abstract this out
|
||||||
} catch (ex: Exception) {
|
when (subOpcode) {
|
||||||
Logger.w(TAG, "Failed to handle sync package $opcode: ${ex.message}", ex)
|
GJSyncOpcodes.sendToDevices -> {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
val context = StateApp.instance.contextOrNull;
|
||||||
|
if (context != null && context is MainActivity) {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
val remainder = data.remaining();
|
||||||
|
data.get(dataBody, 0, remainder);
|
||||||
|
val json = String(dataBody, Charsets.UTF_8);
|
||||||
|
val obj = Json.decodeFromString<SendToDevicePackage>(json);
|
||||||
|
UIDialogs.appToast("Received url from device [${socketSession.remotePublicKey}]:\n{${obj.url}");
|
||||||
|
context.handleUrl(obj.url, obj.position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
GJSyncOpcodes.syncStateExchange -> {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
data.get(dataBody);
|
||||||
|
val json = String(dataBody, Charsets.UTF_8);
|
||||||
|
val syncSessionData = Serializer.json.decodeFromString<SyncSessionData>(json);
|
||||||
|
|
||||||
|
Logger.i(TAG, "Received SyncSessionData from " + remotePublicKey);
|
||||||
|
|
||||||
|
|
||||||
|
sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
|
||||||
|
sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString());
|
||||||
|
sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString())
|
||||||
|
|
||||||
|
sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)));
|
||||||
|
|
||||||
|
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
|
||||||
|
if(recentHistory.size > 0)
|
||||||
|
sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
GJSyncOpcodes.syncExport -> {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining());
|
||||||
|
try {
|
||||||
|
val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStr);
|
||||||
|
for (store in exportStruct.stores) {
|
||||||
|
if (store.key.equals("subscriptions", true)) {
|
||||||
|
val subStore =
|
||||||
|
StateSubscriptions.instance.getUnderlyingSubscriptionsStore();
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
val pack = SyncSubscriptionsPackage(
|
||||||
|
store.value.map {
|
||||||
|
subStore.fromReconstruction(it, exportStruct.cache)
|
||||||
|
},
|
||||||
|
StateSubscriptions.instance.getSubscriptionRemovals()
|
||||||
|
);
|
||||||
|
handleSyncSubscriptionPackage(this@SyncSession, pack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bytesStr.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GJSyncOpcodes.syncSubscriptions -> {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
data.get(dataBody);
|
||||||
|
val json = String(dataBody, Charsets.UTF_8);
|
||||||
|
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
|
||||||
|
handleSyncSubscriptionPackage(this, subPackage);
|
||||||
|
|
||||||
|
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
|
||||||
|
|
||||||
|
val sesData = StateSync.instance.getSyncSessionData(remotePublicKey);
|
||||||
|
if(newestSub > sesData.lastSubscription) {
|
||||||
|
sesData.lastSubscription = newestSub;
|
||||||
|
StateSync.instance.saveSyncSessionData(sesData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GJSyncOpcodes.syncSubscriptionGroups -> {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
data.get(dataBody);
|
||||||
|
val json = String(dataBody, Charsets.UTF_8);
|
||||||
|
val pack = Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(json);
|
||||||
|
|
||||||
|
var lastSubgroupChange = OffsetDateTime.MIN;
|
||||||
|
for(group in pack.groups){
|
||||||
|
if(group.lastChange > lastSubgroupChange)
|
||||||
|
lastSubgroupChange = group.lastChange;
|
||||||
|
|
||||||
|
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
||||||
|
|
||||||
|
if(existing == null)
|
||||||
|
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
|
||||||
|
else if(existing.lastChange < group.lastChange) {
|
||||||
|
existing.name = group.name;
|
||||||
|
existing.urls = group.urls;
|
||||||
|
existing.image = group.image;
|
||||||
|
existing.priority = group.priority;
|
||||||
|
existing.lastChange = group.lastChange;
|
||||||
|
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(removal in pack.groupRemovals) {
|
||||||
|
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
|
||||||
|
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
|
||||||
|
if(creation != null && creation.creationTime < removalTime)
|
||||||
|
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GJSyncOpcodes.syncPlaylists -> {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
data.get(dataBody);
|
||||||
|
val json = String(dataBody, Charsets.UTF_8);
|
||||||
|
val pack = Serializer.json.decodeFromString<SyncPlaylistsPackage>(json);
|
||||||
|
|
||||||
|
for(playlist in pack.playlists) {
|
||||||
|
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
|
||||||
|
|
||||||
|
if(existing == null)
|
||||||
|
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
|
||||||
|
else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) {
|
||||||
|
existing.dateUpdate = playlist.dateUpdate;
|
||||||
|
existing.name = playlist.name;
|
||||||
|
existing.videos = playlist.videos;
|
||||||
|
existing.dateCreation = playlist.dateCreation;
|
||||||
|
existing.datePlayed = playlist.datePlayed;
|
||||||
|
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(removal in pack.playlistRemovals) {
|
||||||
|
val creation = StatePlaylists.instance.getPlaylist(removal.key);
|
||||||
|
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
|
||||||
|
if(creation != null && creation.dateCreation < removalTime)
|
||||||
|
StatePlaylists.instance.removePlaylist(creation, false);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GJSyncOpcodes.syncWatchLater -> {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
data.get(dataBody);
|
||||||
|
val json = String(dataBody, Charsets.UTF_8);
|
||||||
|
val pack = Serializer.json.decodeFromString<SyncWatchLaterPackage>(json);
|
||||||
|
|
||||||
|
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
|
||||||
|
|
||||||
|
val allExisting = StatePlaylists.instance.getWatchLater();
|
||||||
|
for(video in pack.videos) {
|
||||||
|
val existing = allExisting.firstOrNull { it.url == video.url };
|
||||||
|
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
|
||||||
|
|
||||||
|
if(existing == null) {
|
||||||
|
StatePlaylists.instance.addToWatchLater(video, false);
|
||||||
|
if(time > OffsetDateTime.MIN)
|
||||||
|
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(removal in pack.videoRemovals) {
|
||||||
|
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
|
||||||
|
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
|
||||||
|
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC);
|
||||||
|
if(creation < removalTime)
|
||||||
|
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC);
|
||||||
|
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
|
||||||
|
if(localReorderTime < packReorderTime && pack.ordering != null) {
|
||||||
|
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GJSyncOpcodes.syncHistory -> {
|
||||||
|
val dataBody = ByteArray(data.remaining());
|
||||||
|
data.get(dataBody);
|
||||||
|
val json = String(dataBody, Charsets.UTF_8);
|
||||||
|
val history = Serializer.json.decodeFromString<List<HistoryVideo>>(json);
|
||||||
|
Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}");
|
||||||
|
|
||||||
|
var lastHistory = OffsetDateTime.MIN;
|
||||||
|
for(video in history){
|
||||||
|
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
|
||||||
|
if(hist != null)
|
||||||
|
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
|
||||||
|
if(lastHistory < video.date)
|
||||||
|
lastHistory = video.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(lastHistory != OffsetDateTime.MIN && history.size > 1) {
|
||||||
|
val sesData = StateSync.instance.getSyncSessionData(remotePublicKey);
|
||||||
|
if (lastHistory > sesData.lastHistory) {
|
||||||
|
sesData.lastHistory = lastHistory;
|
||||||
|
StateSync.instance.saveSyncSessionData(sesData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Exception) {
|
catch(ex: Exception) {
|
||||||
Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex);
|
Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) {
|
||||||
|
val added = mutableListOf<Subscription>()
|
||||||
|
for(sub in pack.subscriptions) {
|
||||||
|
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
|
||||||
|
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
|
||||||
|
if(sub.creationTime > removalTime) {
|
||||||
|
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
|
||||||
|
added.add(newSub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(added.size > 3)
|
||||||
|
UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
|
||||||
|
else if(added.size > 0)
|
||||||
|
UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
|
||||||
|
added.map { it.channel.name }.joinToString("\n"));
|
||||||
|
|
||||||
|
|
||||||
|
if(pack.subscriptions != null && pack.subscriptions.size > 0) {
|
||||||
|
for (subRemoved in pack.subscriptionRemovals) {
|
||||||
|
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals);
|
||||||
|
if(removed.size > 3)
|
||||||
|
UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
|
||||||
|
else if(removed.size > 0)
|
||||||
|
UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
|
||||||
|
removed.map { it.channel.name }.joinToString("\n"));
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
||||||
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
|
send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendData(subOpcode: UByte, data: String) {
|
fun sendData(subOpcode: UByte, data: String) {
|
||||||
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)))
|
send(Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8));
|
||||||
}
|
}
|
||||||
|
|
||||||
fun send(opcode: UByte, subOpcode: UByte, data: String) {
|
fun send(opcode: UByte, subOpcode: UByte, data: String) {
|
||||||
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)))
|
send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
|
||||||
}
|
}
|
||||||
|
fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) {
|
||||||
|
val socketSessions = synchronized(_socketSessions) {
|
||||||
|
_socketSessions.toList()
|
||||||
|
}
|
||||||
|
|
||||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null) {
|
if (socketSessions.isEmpty()) {
|
||||||
val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() }
|
Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets")
|
||||||
if (channels.isEmpty()) {
|
|
||||||
//TODO: Should this throw?
|
|
||||||
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var sent = false
|
var sent = false
|
||||||
for (channel in channels) {
|
for (socketSession in socketSessions) {
|
||||||
try {
|
try {
|
||||||
channel.send(opcode, subOpcode, data)
|
socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data))
|
||||||
sent = true
|
sent = true
|
||||||
break
|
break
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode)", e)
|
Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
throw Exception("Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to send errors and no remaining candidates")
|
throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private const val TAG = "SyncSession"
|
const val TAG = "SyncSession"
|
||||||
}
|
}
|
||||||
}
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,9 +1,7 @@
|
||||||
package com.futo.platformplayer.views
|
package com.futo.platformplayer.views
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
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
|
||||||
|
@ -20,9 +18,6 @@ class SearchView : FrameLayout {
|
||||||
val buttonClear: ImageButton;
|
val buttonClear: ImageButton;
|
||||||
|
|
||||||
var onSearchChanged = Event1<String>();
|
var onSearchChanged = Event1<String>();
|
||||||
var onEnter = Event1<String>();
|
|
||||||
|
|
||||||
val text: String get() = textSearch.text.toString();
|
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||||
inflate(context, R.layout.view_search_bar, this);
|
inflate(context, R.layout.view_search_bar, this);
|
||||||
|
|
|
@ -1,88 +0,0 @@
|
||||||
package com.futo.platformplayer.views.adapters
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
import com.futo.platformplayer.R
|
|
||||||
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.toHumanNumber
|
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
|
||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
|
||||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
|
||||||
|
|
||||||
|
|
||||||
open class ChannelView : LinearLayout {
|
|
||||||
protected val _feedStyle : FeedStyle;
|
|
||||||
protected val _tiny: Boolean
|
|
||||||
|
|
||||||
private val _textName: TextView;
|
|
||||||
private val _creatorThumbnail: CreatorThumbnail;
|
|
||||||
private val _textMetadata: TextView;
|
|
||||||
private val _buttonSubscribe: SubscribeButton;
|
|
||||||
private val _platformIndicator: PlatformIndicator;
|
|
||||||
|
|
||||||
val onClick = Event1<IPlatformChannelContent>();
|
|
||||||
|
|
||||||
var currentChannel: IPlatformChannelContent? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
val content: IPlatformContent? get() = currentChannel;
|
|
||||||
|
|
||||||
constructor(context: Context, feedStyle: FeedStyle, tiny: Boolean) : super(context) {
|
|
||||||
inflate(feedStyle);
|
|
||||||
_feedStyle = feedStyle;
|
|
||||||
_tiny = tiny
|
|
||||||
|
|
||||||
_textName = findViewById(R.id.text_channel_name);
|
|
||||||
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
|
|
||||||
_textMetadata = findViewById(R.id.text_channel_metadata);
|
|
||||||
_buttonSubscribe = findViewById(R.id.button_subscribe);
|
|
||||||
_platformIndicator = findViewById(R.id.platform_indicator);
|
|
||||||
|
|
||||||
if (_tiny) {
|
|
||||||
_buttonSubscribe.visibility = View.GONE;
|
|
||||||
_textMetadata.visibility = View.GONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
findViewById<ConstraintLayout>(R.id.root).setOnClickListener {
|
|
||||||
val s = currentChannel ?: return@setOnClickListener;
|
|
||||||
onClick.emit(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected open fun inflate(feedStyle: FeedStyle) {
|
|
||||||
inflate(context, when(feedStyle) {
|
|
||||||
FeedStyle.PREVIEW -> R.layout.list_creator
|
|
||||||
else -> R.layout.list_creator
|
|
||||||
}, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun bind(content: IPlatformContent) {
|
|
||||||
isClickable = true;
|
|
||||||
|
|
||||||
if(content !is IPlatformChannelContent)
|
|
||||||
return
|
|
||||||
|
|
||||||
_creatorThumbnail.setThumbnail(content.thumbnail, false);
|
|
||||||
_textName.text = content.name;
|
|
||||||
|
|
||||||
if(content.subscribers == null || (content.subscribers ?: 0) <= 0L)
|
|
||||||
_textMetadata.visibility = View.GONE;
|
|
||||||
else {
|
|
||||||
_textMetadata.text = if((content.subscribers ?: 0) > 0) content.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
|
|
||||||
_textMetadata.visibility = View.VISIBLE;
|
|
||||||
}
|
|
||||||
_buttonSubscribe.setSubscribeChannel(content.url);
|
|
||||||
_platformIndicator.setPlatformFromClientID(content.id.pluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "ChannelView"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
package com.futo.platformplayer.views.adapters.feedtypes
|
|
||||||
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorFeedView
|
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
|
||||||
import com.futo.platformplayer.views.adapters.ChannelView
|
|
||||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
|
||||||
import com.futo.platformplayer.views.adapters.PlaylistView
|
|
||||||
|
|
||||||
|
|
||||||
class PreviewChannelViewHolder : ContentPreviewViewHolder {
|
|
||||||
val onClick = Event1<IPlatformChannelContent>();
|
|
||||||
|
|
||||||
val currentChannel: IPlatformChannelContent? get() = view.currentChannel;
|
|
||||||
|
|
||||||
override val content: IPlatformContent? get() = currentChannel;
|
|
||||||
|
|
||||||
private val view: ChannelView get() = itemView as ChannelView;
|
|
||||||
|
|
||||||
constructor(viewGroup: ViewGroup, feedStyle: FeedStyle, tiny: Boolean): super(ChannelView(viewGroup.context, feedStyle, tiny)) {
|
|
||||||
view.onClick.subscribe(onClick::emit);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
|
||||||
override fun stopPreview() = Unit;
|
|
||||||
override fun pausePreview() = Unit;
|
|
||||||
override fun resumePreview() = Unit;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "PreviewChannelViewHolder"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -23,7 +23,6 @@ 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.EmptyPreviewViewHolder
|
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import okhttp3.internal.platform.Platform
|
|
||||||
|
|
||||||
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
|
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
|
||||||
private var _initialPlay = true;
|
private var _initialPlay = true;
|
||||||
|
@ -83,7 +82,6 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||||
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
||||||
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
||||||
ContentType.LOCKED -> createLockedViewHolder(viewGroup);
|
ContentType.LOCKED -> createLockedViewHolder(viewGroup);
|
||||||
ContentType.CHANNEL -> createChannelViewHolder(viewGroup)
|
|
||||||
else -> EmptyPreviewViewHolder(viewGroup)
|
else -> EmptyPreviewViewHolder(viewGroup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,10 +115,6 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||||
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
|
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
|
||||||
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
|
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
|
||||||
};
|
};
|
||||||
private fun createChannelViewHolder(viewGroup: ViewGroup): PreviewChannelViewHolder = PreviewChannelViewHolder(viewGroup, _feedStyle, false).apply {
|
|
||||||
//TODO: Maybe PlatformAuthorLink as is needs to be phased out?
|
|
||||||
this.onClick.subscribe { this@PreviewContentListAdapter.onChannelClicked.emit(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail, it.subscribers)) };
|
|
||||||
};
|
|
||||||
|
|
||||||
override fun bindChild(holder: ContentPreviewViewHolder, pos: Int) {
|
override fun bindChild(holder: ContentPreviewViewHolder, pos: Int) {
|
||||||
val value = _dataSet[pos];
|
val value = _dataSet[pos];
|
||||||
|
|
|
@ -9,7 +9,6 @@ import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.getDataLinkFromUrl
|
import com.futo.platformplayer.getDataLinkFromUrl
|
||||||
|
@ -82,14 +81,12 @@ class CreatorThumbnail : ConstraintLayout {
|
||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
.load(url)
|
.load(url)
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageChannelThumbnail);
|
.into(_imageChannelThumbnail);
|
||||||
} else {
|
} else {
|
||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
.load(url)
|
.load(url)
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
|
||||||
.into(_imageChannelThumbnail);
|
.into(_imageChannelThumbnail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,13 +43,13 @@ class SyncDeviceView : ConstraintLayout {
|
||||||
|
|
||||||
_layoutLinkType.visibility = View.VISIBLE
|
_layoutLinkType.visibility = View.VISIBLE
|
||||||
_imageLinkType.setImageResource(when (linkType) {
|
_imageLinkType.setImageResource(when (linkType) {
|
||||||
LinkType.Relayed -> R.drawable.ic_internet
|
LinkType.Proxied -> R.drawable.ic_internet
|
||||||
LinkType.Direct -> R.drawable.ic_lan
|
LinkType.Local -> R.drawable.ic_lan
|
||||||
else -> 0
|
else -> 0
|
||||||
})
|
})
|
||||||
_textLinkType.text = when(linkType) {
|
_textLinkType.text = when(linkType) {
|
||||||
LinkType.Relayed -> "Relayed"
|
LinkType.Proxied -> "Proxied"
|
||||||
LinkType.Direct -> "Direct"
|
LinkType.Local -> "Local"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -144,9 +144,6 @@
|
||||||
android:layout_marginTop="10dp"
|
android:layout_marginTop="10dp"
|
||||||
android:layout_marginLeft="15dp"
|
android:layout_marginLeft="15dp"
|
||||||
android:layout_marginRight="15dp"
|
android:layout_marginRight="15dp"
|
||||||
android:inputType="text"
|
|
||||||
android:imeOptions="actionDone"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:background="@drawable/background_button_round"
|
android:background="@drawable/background_button_round"
|
||||||
android:hint="Search.." />
|
android:hint="Search.." />
|
||||||
|
|
||||||
|
|
|
@ -1,58 +1,14 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
|
||||||
tools:context=".fragment.mainactivity.main.SuggestionsFragment">
|
tools:context=".fragment.mainactivity.main.SuggestionsFragment">
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
|
||||||
android:id="@+id/app_bar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="@color/transparent"
|
|
||||||
app:elevation="0dp">
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.Toolbar
|
|
||||||
android:id="@+id/toolbar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minHeight="0dp"
|
|
||||||
app:layout_scrollFlags="scroll"
|
|
||||||
app:contentInsetStart="0dp"
|
|
||||||
app:contentInsetEnd="0dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/container_toolbar_content"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<com.futo.platformplayer.views.announcements.AnnouncementView
|
|
||||||
android:id="@+id/announcement_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<com.futo.platformplayer.views.others.RadioGroupView
|
|
||||||
android:id="@+id/radio_group"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingTop="8dp"
|
|
||||||
android:paddingBottom="8dp"
|
|
||||||
android:paddingStart="8dp"
|
|
||||||
android:paddingEnd="8dp" />
|
|
||||||
</LinearLayout>
|
|
||||||
</androidx.appcompat.widget.Toolbar>
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/list_suggestions"
|
android:id="@+id/list_suggestions"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical" />
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</FrameLayout>
|
|
@ -372,12 +372,6 @@
|
||||||
<string name="connect_discovered_description">Allow device to search for and initiate connection with known paired devices</string>
|
<string name="connect_discovered_description">Allow device to search for and initiate connection with known paired devices</string>
|
||||||
<string name="connect_last">Try connect last</string>
|
<string name="connect_last">Try connect last</string>
|
||||||
<string name="connect_last_description">Allow device to automatically connect to last known</string>
|
<string name="connect_last_description">Allow device to automatically connect to last known</string>
|
||||||
<string name="discover_through_relay">Discover through relay</string>
|
|
||||||
<string name="discover_through_relay_description">Allow paired devices to be discovered and connected to through the relay</string>
|
|
||||||
<string name="pair_through_relay">Pair through relay</string>
|
|
||||||
<string name="pair_through_relay_description">Allow devices to be paired through the relay</string>
|
|
||||||
<string name="connect_through_relay">Connection through relay</string>
|
|
||||||
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</string>
|
|
||||||
<string name="gesture_controls">Gesture controls</string>
|
<string name="gesture_controls">Gesture controls</string>
|
||||||
<string name="volume_slider">Volume slider</string>
|
<string name="volume_slider">Volume slider</string>
|
||||||
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
|
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
|
||||||
|
|
Loading…
Add table
Reference in a new issue