Compare commits

..

No commits in common. "master" and "289" have entirely different histories.
master ... 289

69 changed files with 903 additions and 3172 deletions

View file

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

View file

@ -216,14 +216,9 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this); return InetAddress.getByAddress(this);
} }
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? { fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
val timeout = 2000 val timeout = 2000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
if (addresses.isEmpty()) { if (addresses.isEmpty()) {
return null; return null;
} }

View file

@ -218,8 +218,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4) @FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
var showHomeFilters: Boolean = true; var showHomeFilters: Boolean = true;
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
var showHomeFiltersPluginNames: Boolean = false;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true; var previewFeedItems: Boolean = true;
@ -583,15 +581,10 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true; var keepScreenOn: Boolean = true;
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3) @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var alwaysProxyRequests: Boolean = false; var alwaysProxyRequests: Boolean = false;
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = false;
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)
@ -936,15 +929,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)

View file

@ -5,7 +5,6 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Animatable
import android.net.Uri import android.net.Uri
import android.text.Layout import android.text.Layout
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
@ -200,21 +199,16 @@ class UIDialogs {
dialog.show(); dialog.show();
} }
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context); val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view); builder.setView(view);
builder.setCancelable(defaultCloseAction > -2);
val dialog = builder.create(); val dialog = builder.create();
registerDialogOpened(dialog); registerDialogOpened(dialog);
view.findViewById<ImageView>(R.id.dialog_icon).apply { view.findViewById<ImageView>(R.id.dialog_icon).apply {
this.setImageResource(icon); this.setImageResource(icon);
if(animated)
this.drawable.assume<Animatable, Unit> { it.start() };
} }
view.findViewById<TextView>(R.id.dialog_text).apply { view.findViewById<TextView>(R.id.dialog_text).apply {
this.text = text; this.text = text;
@ -281,7 +275,6 @@ class UIDialogs {
registerDialogClosed(dialog); registerDialogClosed(dialog);
} }
dialog.show(); dialog.show();
return dialog;
} }
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) { fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {

View file

@ -27,17 +27,14 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod import com.futo.platformplayer.others.PlatformLinkMovementMethod
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.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.util.* import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String { fun getRandomString(sizeOfRandomString: Int): String {
@ -282,46 +279,3 @@ fun ByteBuffer.toUtf8String(): String {
get(remainingBytes) get(remainingBytes)
return String(remainingBytes, Charsets.UTF_8) return String(remainingBytes, Charsets.UTF_8)
} }
fun generateReadablePassword(length: Int): String {
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
val secureRandom = SecureRandom()
val randomBytes = ByteArray(length)
secureRandom.nextBytes(randomBytes)
val sb = StringBuilder(length)
for (byte in randomBytes) {
val index = (byte.toInt() and 0xFF) % validChars.length
sb.append(validChars[index])
}
return sb.toString()
}
fun ByteArray.toGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val gzipTimeStart = OffsetDateTime.now();
val outputStream = ByteArrayOutputStream()
GZIPOutputStream(outputStream).use { gzip ->
gzip.write(this)
}
val result = outputStream.toByteArray();
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
return result;
}
fun ByteArray.fromGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val inputStream = ByteArrayInputStream(this)
val outputStream = ByteArrayOutputStream()
GZIPInputStream(inputStream).use { gzip ->
val buffer = ByteArray(1024)
var bytesRead: Int
while (gzip.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
return outputStream.toByteArray()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
}
} }

View file

@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage
@ -33,7 +32,6 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.math.BigInteger import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
@ -92,7 +90,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 +324,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 +402,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")

View file

@ -1,6 +1,5 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import android.app.AlertDialog
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@ -10,7 +9,6 @@ import android.util.Log
import android.util.Xml import android.util.Xml
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@ -241,9 +239,6 @@ class StateCasting {
Logger.i(TAG, "CastingService stopped.") Logger.i(TAG, "CastingService stopped.")
} }
private val _castingDialogLock = Any();
private var _currentDialog: AlertDialog? = null;
@Synchronized @Synchronized
fun connectDevice(device: CastingDevice) { fun connectDevice(device: CastingDevice) {
if (activeDevice == device) if (activeDevice == device)
@ -277,41 +272,10 @@ class StateCasting {
invokeInMainScopeIfRequired { invokeInMainScopeIfRequired {
StateApp.withContext(false) { context -> StateApp.withContext(false) { context ->
context.let { context.let {
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
when (castConnectionState) { when (castConnectionState) {
CastConnectionState.CONNECTED -> { CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device")
Logger.i(TAG, "Casting connected to [${device.name}]"); CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...")
UIDialogs.appToast("Connected to device") CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
CastConnectionState.CONNECTING -> {
Logger.i(TAG, "Casting connecting to [${device.name}]");
UIDialogs.toast(it, "Connecting to device...")
synchronized(_castingDialogLock) {
if(_currentDialog == null) {
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
"Connecting to [${device.name}]",
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
UIDialogs.Action("Disconnect", {
device.stop();
}));
}
}
}
CastConnectionState.DISCONNECTED -> {
UIDialogs.toast(it, "Disconnected from device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
} }
} }
}; };

View file

@ -73,11 +73,11 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
}; };
_rememberedAdapter.onConnect.subscribe { _ -> _rememberedAdapter.onConnect.subscribe { _ ->
dismiss() dismiss()
//UIDialogs.showCastingDialog(context) UIDialogs.showCastingDialog(context)
} }
_adapter.onConnect.subscribe { _ -> _adapter.onConnect.subscribe { _ ->
dismiss() dismiss()
//UIDialogs.showCastingDialog(context) UIDialogs.showCastingDialog(context)
} }
_recyclerRememberedDevices.adapter = _rememberedAdapter; _recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);

View file

@ -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 -> {};
} }
} }

View file

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

View file

@ -21,8 +21,6 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView
@ -105,15 +103,12 @@ class DownloadsFragment : MainFragment() {
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>; private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null; private var lastDownloads: List<VideoLocal>? = null;
private var ordering = FragmentedStorage.get<StringStorage>("downloads_ordering") private var ordering: String? = "nameAsc";
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this); inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag; _frag = frag;
if(ordering.value.isNullOrBlank())
ordering.value = "nameAsc";
_usageUsed = findViewById(R.id.downloads_usage_used); _usageUsed = findViewById(R.id.downloads_usage_used);
_usageAvailable = findViewById(R.id.downloads_usage_available); _usageAvailable = findViewById(R.id.downloads_usage_available);
_usageProgress = findViewById(R.id.downloads_usage_progress); _usageProgress = findViewById(R.id.downloads_usage_progress);
@ -137,23 +132,22 @@ class DownloadsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
}; };
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc"); spinnerSortBy.setSelection(0);
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) { when(pos) {
0 -> ordering.setAndSave("nameAsc") 0 -> ordering = "nameAsc"
1 -> ordering.setAndSave("nameDesc") 1 -> ordering = "nameDesc"
2 -> ordering.setAndSave("downloadDateAsc") 2 -> ordering = "downloadDateAsc"
3 -> ordering.setAndSave("downloadDateDesc") 3 -> ordering = "downloadDateDesc"
4 -> ordering.setAndSave("releasedAsc") 4 -> ordering = "releasedAsc"
5 -> ordering.setAndSave("releasedDesc") 5 -> ordering = "releasedDesc"
else -> ordering.setAndSave("") else -> ordering = null
} }
updateContentFilters() updateContentFilters()
} }
override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}; };
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded) _listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) { .asAnyWithTop(findViewById(R.id.downloads_top)) {
@ -236,8 +230,8 @@ class DownloadsFragment : MainFragment() {
var vidsToReturn = vids; var vidsToReturn = vids;
if(!_listDownloadSearch.text.isNullOrEmpty()) if(!_listDownloadSearch.text.isNullOrEmpty())
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) }; vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
if(!ordering.value.isNullOrEmpty()) { if(!ordering.isNullOrEmpty()) {
vidsToReturn = when(ordering.value){ vidsToReturn = when(ordering){
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN }; "downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() } "nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }

View file

@ -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
@ -196,12 +197,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
false; false;
} }
else if (firstVisibleItemView != null && height != null && rowsHeight < height) { else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) {
false; false;
} else { } else {
true; true;
@ -241,9 +240,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_automaticNextPageCounter = 0; _automaticNextPageCounter = 0;
} }
} }
fun resetAutomaticNextPageCounter(){
_automaticNextPageCounter = 0;
}
protected fun setTextCentered(text: String?) { protected fun setTextCentered(text: String?) {
_textCentered.text = text; _textCentered.text = text;

View file

@ -6,6 +6,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.allViews import androidx.core.view.allViews
import androidx.core.view.contains
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.futo.platformplayer.* import com.futo.platformplayer.*
@ -28,7 +29,6 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
@ -37,6 +37,7 @@ import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -48,12 +49,6 @@ class HomeFragment : MainFragment() {
private var _view: HomeView? = null; private var _view: HomeView? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null; private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private var _cachedLastPager: IReusablePager<IPlatformContent>? = null
private var _toggleRecent = false;
private var _toggleWatched = false;
private var _togglePluginsDisabled = mutableListOf<String>();
fun reloadFeed() { fun reloadFeed() {
_view?.reloadFeed() _view?.reloadFeed()
@ -79,7 +74,7 @@ class HomeFragment : MainFragment() {
} }
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager); val view = HomeView(this, inflater, _cachedRecyclerData);
_view = view; _view = view;
return view; return view;
} }
@ -97,7 +92,6 @@ class HomeFragment : MainFragment() {
val view = _view; val view = _view;
if (view != null) { if (view != null) {
_cachedRecyclerData = view.recyclerData; _cachedRecyclerData = view.recyclerData;
_cachedLastPager = view.lastPager;
view.cleanup(); view.cleanup();
_view = null; _view = null;
} }
@ -117,10 +111,9 @@ class HomeFragment : MainFragment() {
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>; private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
var lastPager: IReusablePager<IPlatformContent>? = null; private var _lastPager: IReusablePager<IPlatformContent>? = null;
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null, cachedLastPager: IReusablePager<IPlatformContent>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
lastPager = cachedLastPager
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, { _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
}) })
@ -129,8 +122,7 @@ class HomeFragment : MainFragment() {
ReusableRefreshPager(it); ReusableRefreshPager(it);
else else
ReusablePager(it); ReusablePager(it);
lastPager = wrappedPager; _lastPager = wrappedPager;
resetAutomaticNextPageCounter();
loadedResult(wrappedPager.getWindow()); loadedResult(wrappedPager.getWindow());
} }
.exception<ScriptCaptchaRequiredException> { } .exception<ScriptCaptchaRequiredException> { }
@ -235,6 +227,9 @@ class HomeFragment : MainFragment() {
} }
private val _filterLock = Object(); private val _filterLock = Object();
private var _toggleRecent = false;
private var _toggleWatched = false;
private var _togglePluginsDisabled = mutableListOf<String>();
private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles"); private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
fun initializeToolbarContent() { fun initializeToolbarContent() {
if(_toolbarContentView.allViews.any { it is ToggleBar }) if(_toolbarContentView.allViews.any { it is ToggleBar })
@ -250,53 +245,38 @@ class HomeFragment : MainFragment() {
layoutParams = layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
} }
_togglePluginsDisabled.clear();
synchronized(_filterLock) { synchronized(_filterLock) {
var buttonsPlugins: List<ToggleBar.Toggle> = listOf() val buttonsPlugins = (if (_togglesConfig.contains("plugins"))
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
(StatePlatform.instance.getEnabledClients() (StatePlatform.instance.getEnabledClients()
.filter { it is JSClient && it.enableInHome }
.map { plugin -> .map { plugin ->
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active -> ToggleBar.Toggle(plugin.name, plugin.icon, true, {
var dontSwap = false; if (it) {
if (active) { if (_togglePluginsDisabled.contains(plugin.id))
if (fragment._togglePluginsDisabled.contains(plugin.id)) _togglePluginsDisabled.remove(plugin.id);
fragment._togglePluginsDisabled.remove(plugin.id);
} else { } else {
if (!fragment._togglePluginsDisabled.contains(plugin.id)) { if (!_togglePluginsDisabled.contains(plugin.id))
val enabledClients = StatePlatform.instance.getEnabledClients(); _togglePluginsDisabled.add(plugin.id);
val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id };
if(availableAfterDisable > 0)
fragment._togglePluginsDisabled.add(plugin.id);
else {
UIDialogs.appToast("Home needs atleast 1 plugin active");
dontSwap = true;
}
}
}
if(!dontSwap)
reloadForFilters();
else {
view.setToggle(!active);
} }
reloadForFilters();
}).withTag("plugins") }).withTag("plugins")
}) })
else listOf()) else listOf())
val buttons = (listOf<ToggleBar.Toggle?>( val buttons = (listOf<ToggleBar.Toggle?>(
(if (_togglesConfig.contains("today")) (if (_togglesConfig.contains("today"))
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active -> ToggleBar.Toggle("Today", _toggleRecent) {
fragment._toggleRecent = active; reloadForFilters() _toggleRecent = it; reloadForFilters()
} }
.withTag("today") else null), .withTag("today") else null),
(if (_togglesConfig.contains("watched")) (if (_togglesConfig.contains("watched"))
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active -> ToggleBar.Toggle("Unwatched", _toggleWatched) {
fragment._toggleWatched = active; reloadForFilters() _toggleWatched = it; reloadForFilters()
} }
.withTag("watched") else null), .withTag("watched") else null),
).filterNotNull() + buttonsPlugins) ).filterNotNull() + buttonsPlugins)
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf() .sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active -> val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, {
showOrderOverlay(_overlayContainer, showOrderOverlay(_overlayContainer,
"Visible home filters", "Visible home filters",
listOf( listOf(
@ -322,7 +302,7 @@ class HomeFragment : MainFragment() {
} }
} }
fun reloadForFilters() { fun reloadForFilters() {
lastPager?.let { loadedResult(it.getWindow()) }; _lastPager?.let { loadedResult(it.getWindow()) };
} }
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> { override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
@ -332,11 +312,11 @@ class HomeFragment : MainFragment() {
if(StateMeta.instance.isCreatorHidden(it.author.url)) if(StateMeta.instance.isCreatorHidden(it.author.url))
return@filter false; return@filter false;
if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
return@filter false; return@filter false;
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
return@filter false; return@filter false;
if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) { if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) {
return@filter false; return@filter false;
} }

View file

@ -326,10 +326,6 @@ class PlaylistFragment : MainFragment() {
playlist.videos = ArrayList(playlist.videos.filter { it != video }); playlist.videos = ArrayList(playlist.videos.filter { it != video });
StatePlaylists.instance.createOrUpdatePlaylist(playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist);
} }
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) { override fun onVideoClicked(video: IPlatformVideo) {
val playlist = _playlist; val playlist = _playlist;
if (playlist != null) { if (playlist != null) {

View file

@ -26,8 +26,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.adapters.*
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@ -84,7 +82,7 @@ class PlaylistsFragment : MainFragment() {
private var _listPlaylistsSearch: EditText; private var _listPlaylistsSearch: EditText;
private var _ordering = FragmentedStorage.get<StringStorage>("playlists_ordering") private var _ordering: String? = null;
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
@ -147,25 +145,24 @@ class PlaylistsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
}; };
val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc"); spinnerSortBy.setSelection(0);
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) { when(pos) {
0 -> _ordering.setAndSave("nameAsc") 0 -> _ordering = "nameAsc"
1 -> _ordering.setAndSave("nameDesc") 1 -> _ordering = "nameDesc"
2 -> _ordering.setAndSave("dateEditAsc") 2 -> _ordering = "dateEditAsc"
3 -> _ordering.setAndSave("dateEditDesc") 3 -> _ordering = "dateEditDesc"
4 -> _ordering.setAndSave("dateCreateAsc") 4 -> _ordering = "dateCreateAsc"
5 -> _ordering.setAndSave("dateCreateDesc") 5 -> _ordering = "dateCreateDesc"
6 -> _ordering.setAndSave("datePlayAsc") 6 -> _ordering = "datePlayAsc"
7 -> _ordering.setAndSave("datePlayDesc") 7 -> _ordering = "datePlayDesc"
else -> _ordering.setAndSave("") else -> _ordering = null
} }
updatePlaylistsFiltering() updatePlaylistsFiltering()
} }
override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}; };
spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value)));
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); }; findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
@ -217,8 +214,8 @@ class PlaylistsFragment : MainFragment() {
var playlistsToReturn = pls; var playlistsToReturn = pls;
if(!_listPlaylistsSearch.text.isNullOrEmpty()) if(!_listPlaylistsSearch.text.isNullOrEmpty())
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
if(!_ordering.value.isNullOrEmpty()){ if(!_ordering.isNullOrEmpty()){
playlistsToReturn = when(_ordering.value){ playlistsToReturn = when(_ordering){
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX }; "dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };

View file

@ -18,7 +18,6 @@ import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
@ -57,9 +56,6 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _group: SubscriptionGroup? = null; private var _group: SubscriptionGroup? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null; private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack); super.onShownWithView(parameter, isBack);
_view?.onShown(); _view?.onShown();
@ -188,6 +184,8 @@ class SubscriptionsFeedFragment : MainFragment() {
return Json.encodeToString(this); return Json.encodeToString(this);
} }
} }
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
private var _bypassRateLimit = false; private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null; private val _lastExceptions: List<Throwable>? = null;
@ -286,18 +284,13 @@ class SubscriptionsFeedFragment : MainFragment() {
fragment.navigate<SubscriptionGroupFragment>(g); fragment.navigate<SubscriptionGroupFragment>(g);
}; };
synchronized(fragment._filterLock) { synchronized(_filterLock) {
_subscriptionBar?.setToggles( _subscriptionBar?.setToggles(
SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active -> SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); }, SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active -> SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
toggleFilterContentType(ContentType.POST, active); }, SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active -> SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active ->
fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active ->
fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); }
); );
} }
@ -308,13 +301,13 @@ class SubscriptionsFeedFragment : MainFragment() {
toggleFilterContentType(contentType, isTrue); toggleFilterContentType(contentType, isTrue);
} }
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) { private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
synchronized(fragment._filterLock) { synchronized(_filterLock) {
if(!isTrue) { if(!isTrue) {
fragment._filterSettings.allowContentTypes.remove(contentType); _filterSettings.allowContentTypes.remove(contentType);
} else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) { } else if(!_filterSettings.allowContentTypes.contains(contentType)) {
fragment._filterSettings.allowContentTypes.add(contentType) _filterSettings.allowContentTypes.add(contentType)
} }
fragment._filterSettings.save(); _filterSettings.save();
}; };
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
loadResults(false); loadResults(false);
@ -327,9 +320,9 @@ class SubscriptionsFeedFragment : MainFragment() {
val nowSoon = OffsetDateTime.now().plusMinutes(5); val nowSoon = OffsetDateTime.now().plusMinutes(5);
val filterGroup = subGroup; val filterGroup = subGroup;
return results.filter { return results.filter {
val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration)) if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
return@filter false; return@filter false;
//TODO: Check against a sub cache //TODO: Check against a sub cache
@ -338,11 +331,11 @@ class SubscriptionsFeedFragment : MainFragment() {
if(it.datetime?.isAfter(nowSoon) == true) { if(it.datetime?.isAfter(nowSoon) == true) {
if(!fragment._filterSettings.allowPlanned) if(!_filterSettings.allowPlanned)
return@filter false; return@filter false;
} }
if(fragment._filterSettings.allowLive) { //If allowLive, always show live if(_filterSettings.allowLive) { //If allowLive, always show live
if(it is IPlatformVideo && it.isLive) if(it is IPlatformVideo && it.isLive)
return@filter true; return@filter true;
} }

View file

@ -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() {

View file

@ -693,9 +693,6 @@ class VideoDetailView : ConstraintLayout {
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onOptions.subscribe {
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
}
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };

View file

@ -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,22 @@ 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.onVideoClicked.subscribe(::onVideoClicked);
videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
_videoListEditorView = videoListEditorView; _videoListEditorView = videoListEditorView;
} }
@ -121,7 +112,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;
@ -132,7 +122,6 @@ abstract class VideoListEditorView : LinearLayout {
open fun onShuffleClick() { } open fun onShuffleClick() { }
open fun onEditClick() { } open fun onEditClick() { }
open fun onVideoRemoved(video: IPlatformVideo) {} open fun onVideoRemoved(video: IPlatformVideo) {}
open fun onVideoOptions(video: IPlatformVideo) {}
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {} open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
open fun onVideoClicked(video: IPlatformVideo) { open fun onVideoClicked(video: IPlatformVideo) {
@ -154,7 +143,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 +152,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 +161,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);
} }

View file

@ -103,9 +103,6 @@ class WatchLaterFragment : MainFragment() {
StatePlaylists.instance.removeFromWatchLater(video, true); StatePlaylists.instance.removeFromWatchLater(video, true);
} }
} }
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) { override fun onVideoClicked(video: IPlatformVideo) {
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();

View file

@ -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) };

View file

@ -69,7 +69,7 @@ class StateSubscriptions {
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>(); val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
private val _subsExchangeServer = "https://exchange.grayjay.app/"; private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/";
private val _subscriptionKey = FragmentedStorage.get<StringStorage>("sub_exchange_key"); private val _subscriptionKey = FragmentedStorage.get<StringStorage>("sub_exchange_key");
init { init {

View file

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

View file

@ -15,14 +15,12 @@ import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.api.media.structures.PlatformContentPager import com.futo.platformplayer.api.media.structures.PlatformContentPager
import com.futo.platformplayer.debug.Stopwatch
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@ -34,8 +32,6 @@ import com.futo.platformplayer.subsexchange.ChannelRequest
import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResolve
import com.futo.platformplayer.subsexchange.ExchangeContract import com.futo.platformplayer.subsexchange.ExchangeContract
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
@ -86,30 +82,23 @@ abstract class SubscriptionsTaskFetchAlgorithm(
var providedTasks: MutableList<SubscriptionTask>? = null; var providedTasks: MutableList<SubscriptionTask>? = null;
try { try {
val contractingTime = measureTimeMillis { val contractableTasks =
val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; contract =
contract = if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map {
if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url)
ChannelRequest(it.url) }.toTypedArray()) else null;
}.toTypedArray()) else null; if (contract?.provided?.isNotEmpty() == true)
if (contract?.provided?.isNotEmpty() == true) Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); if (contract != null && contract.required.isNotEmpty()) {
if (contract != null && contract!!.required.isNotEmpty()) { providedTasks = mutableListOf()
providedTasks = mutableListOf() for (task in tasks.toList()) {
for (task in tasks.toList()) { if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) {
if (!task.fromCache && !task.fromPeek && contract!!.provided.contains(task.url)) { providedTasks.add(task);
providedTasks!!.add(task); tasks.remove(task);
tasks.remove(task);
}
} }
} }
} }
if(contract != null)
Logger.i(TAG, "Subscription Exchange contract received in ${contractingTime}ms");
else if(contractingTime > 100)
Logger.i(TAG, "Subscription Exchange contract failed to received in${contractingTime}ms");
} }
catch(ex: Throwable){ catch(ex: Throwable){
Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex); Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex);
@ -120,8 +109,6 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
val taskResults = arrayListOf<SubscriptionTaskResult>(); val taskResults = arrayListOf<SubscriptionTaskResult>();
var resolveCount = 0;
var resolveTime = 0L;
val timeTotal = measureTimeMillis { val timeTotal = measureTimeMillis {
for(task in forkTasks) { for(task in forkTasks) {
try { try {
@ -150,82 +137,51 @@ abstract class SubscriptionsTaskFetchAlgorithm(
} }
}; };
} }
}
//Resolve Subscription Exchange //Resolve Subscription Exchange
if(contract != null) { if(contract != null) {
fun resolve() { try {
try { val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map {
resolveTime = measureTimeMillis { ChannelResolve(
val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { it.task.url,
ChannelResolve( it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
it.task.url, )
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } }.toTypedArray()
) val resolve = subsExchangeClient?.resolveContract(
}.toTypedArray() contract,
*resolves
val resolveRequestStart = OffsetDateTime.now(); );
if (resolve != null) {
val resolve = subsExchangeClient?.resolveContract( val invalids = resolve.filter { it.content.any { it.datetime == null } };
contract!!, UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})")
*resolves for(result in resolve){
); val task = providedTasks?.find { it.url == result.channelUrl };
if(task != null) {
Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms"); taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null));
providedTasks?.remove(task);
if (resolve != null) {
resolveCount = resolves.size;
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}")
for(result in resolve){
val task = providedTasks?.find { it.url == result.channelUrl };
if(task != null) {
taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null));
providedTasks?.remove(task);
}
}
}
if (providedTasks != null) {
for(task in providedTasks!!) {
taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange")));
}
}
} }
Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms");
}
catch(ex: Throwable) {
//TODO: fetch remainder after all?
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
} }
} }
if(providedTasks?.size ?: 0 == 0) if (providedTasks != null) {
scope.launch(Dispatchers.IO) { for(task in providedTasks) {
resolve(); taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange")));
} }
else }
resolve(); }
catch(ex: Throwable) {
//TODO: fetch remainder after all?
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
} }
} }
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms"); Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
if(resolveCount > 0) {
val selfFetchTime = timeTotal - resolveTime;
val selfFetchCount = tasks.count { !it.fromPeek && !it.fromCache };
if(selfFetchCount > 0) {
val selfResolvePercentage = resolveCount.toDouble() / selfFetchCount;
val estimateSelfFetchTime = selfFetchTime + selfFetchTime * selfResolvePercentage;
val selfFetchDelta = timeTotal - estimateSelfFetchTime;
if(selfFetchDelta > 0)
UIDialogs.appToast("Subscription Exchange lost ${selfFetchDelta}ms (out of ${timeTotal}ms)", true);
else
UIDialogs.appToast("Subscription Exchange saved ${(selfFetchDelta * -1).toInt()}ms (out of ${timeTotal}ms)", true);
}
}
//Cache pagers grouped by channel //Cache pagers grouped by channel
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url } val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
.map { entry -> .map { entry ->
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null; val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
val liveTasks = entry.value.filter { !it.task.fromCache && it.pager != null }; val liveTasks = entry.value.filter { !it.task.fromCache };
val cachedTasks = entry.value.filter { it.task.fromCache }; val cachedTasks = entry.value.filter { it.task.fromCache };
val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) { val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) {
onNewCacheHit.emit(sub!!, it); onNewCacheHit.emit(sub!!, it);

View file

@ -1,14 +1,10 @@
import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm.Companion.TAG
import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelRequest
import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResolve
import com.futo.platformplayer.subsexchange.ChannelResult import com.futo.platformplayer.subsexchange.ChannelResult
import com.futo.platformplayer.subsexchange.ExchangeContract import com.futo.platformplayer.subsexchange.ExchangeContract
import com.futo.platformplayer.subsexchange.ExchangeContractResolve import com.futo.platformplayer.subsexchange.ExchangeContractResolve
import com.futo.platformplayer.toGzip
import com.futo.platformplayer.toHumanBytesSize
import kotlinx.serialization.* import kotlinx.serialization.*
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -30,10 +26,9 @@ import java.nio.charset.StandardCharsets
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPublicKeySpec import java.security.spec.RSAPublicKeySpec
import java.time.OffsetDateTime
class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) { class SubsExchangeClient(private val server: String, private val privateKey: String) {
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -45,27 +40,24 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
// Endpoint: Contract // Endpoint: Contract
fun requestContract(vararg channels: ChannelRequest): ExchangeContract { fun requestContract(vararg channels: ChannelRequest): ExchangeContract {
val data = post("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json", contractTimeout) val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json")
return Json.decodeFromString(data) return Json.decodeFromString(data)
} }
suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract {
val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json") val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json")
return Json.decodeFromString(data) return Json.decodeFromString(data)
} }
// Endpoint: Resolve // Endpoint: Resolve
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> { fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves) val contractResolve = convertResolves(*resolves)
val contractResolveJson = Serializer.json.encodeToString(contractResolve); val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
val contractResolveTimeStart = OffsetDateTime.now(); Logger.v("SubsExchangeClient", "Resolve:" + result);
val result = post("/api/Channel/Resolve?contractId=${contract.id}", contractResolveJson.toByteArray(Charsets.UTF_8), "application/json", 0, true)
val contractResolveTime = contractResolveTimeStart.getNowDiffMiliseconds();
Logger.v("SubsExchangeClient", "Subscription Exchange Resolve Request [${contractResolveTime}ms]:" + result);
return Serializer.json.decodeFromString(result) return Serializer.json.decodeFromString(result)
} }
suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> { suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves) val contractResolve = convertResolves(*resolves)
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve).toByteArray(Charsets.UTF_8), "application/json", true) val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
return Serializer.json.decodeFromString(result) return Serializer.json.decodeFromString(result)
} }
@ -82,24 +74,13 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
} }
// IO methods // IO methods
private fun post(query: String, body: ByteArray, contentType: String, timeout: Int = 0, gzip: Boolean = false): String { private fun post(query: String, body: String, contentType: String): String {
val url = URL("${server.trim('/')}$query") val url = URL("${server.trim('/')}$query")
with(url.openConnection() as HttpURLConnection) { with(url.openConnection() as HttpURLConnection) {
if(timeout > 0)
this.connectTimeout = timeout
requestMethod = "POST" requestMethod = "POST"
setRequestProperty("Content-Type", contentType) setRequestProperty("Content-Type", contentType)
doOutput = true doOutput = true
OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() }
if(gzip) {
val gzipData = body.toGzip();
setRequestProperty("Content-Encoding", "gzip");
outputStream.write(gzipData);
Logger.i("SubsExchangeClient", "SubsExchange using gzip (${body.size.toHumanBytesSize()} => ${gzipData.size.toHumanBytesSize()}");
}
else
outputStream.write(body);
val status = responseCode; val status = responseCode;
Logger.i("SubsExchangeClient", "POST [${url}]: ${status}"); Logger.i("SubsExchangeClient", "POST [${url}]: ${status}");
@ -122,9 +103,9 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
} }
} }
} }
private suspend fun postAsync(query: String, body: ByteArray, contentType: String, gzip: Boolean = false): String { private suspend fun postAsync(query: String, body: String, contentType: String): String {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
post(query, body, contentType, 0, gzip) post(query, body, contentType)
} }
} }

View file

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

View file

@ -2,6 +2,6 @@ package com.futo.platformplayer.sync.internal;
public enum LinkType { public enum LinkType {
None, None,
Direct, Local,
Relayed Proxied
} }

View file

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

View file

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

View file

@ -1,6 +0,0 @@
package com.futo.platformplayer.sync.internal
enum class SyncErrorCode(val value: Int) {
ConnectionClosed(1),
NotFound(2)
}

View file

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

View file

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

View file

@ -53,7 +53,7 @@ class ToggleBar : LinearLayout {
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
else else
this.setInfo(button.name, button.isActive, button.isButton); this.setInfo(button.name, button.isActive, button.isButton);
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); }); this.onClick.subscribe { button.action(it); };
}); });
} }
} }
@ -62,27 +62,27 @@ class ToggleBar : LinearLayout {
val name: String; val name: String;
val icon: Int; val icon: Int;
val iconVariable: ImageVariable?; val iconVariable: ImageVariable?;
val action: (ToggleTagView, Boolean)->Unit; val action: (Boolean)->Unit;
val isActive: Boolean; val isActive: Boolean;
var isButton: Boolean = false var isButton: Boolean = false
private set; private set;
var tag: String? = null; var tag: String? = null;
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) {
this.name = name; this.name = name;
this.icon = 0; this.icon = 0;
this.iconVariable = icon; this.iconVariable = icon;
this.action = action; this.action = action;
this.isActive = isActive; this.isActive = isActive;
} }
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
this.name = name; this.name = name;
this.icon = icon; this.icon = icon;
this.iconVariable = null; this.iconVariable = null;
this.action = action; this.action = action;
this.isActive = isActive; this.isActive = isActive;
} }
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
this.name = name; this.name = name;
this.icon = 0; this.icon = 0;
this.iconVariable = null; this.iconVariable = null;

View file

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

View file

@ -14,7 +14,6 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
val onClick = Event1<IPlatformVideo>(); val onClick = Event1<IPlatformVideo>();
val onRemove = Event1<IPlatformVideo>(); val onRemove = Event1<IPlatformVideo>();
val onOptions = Event1<IPlatformVideo>();
var canEdit = false var canEdit = false
private set; private set;
@ -29,7 +28,6 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
val holder = VideoListEditorViewHolder(view, _touchHelper); val holder = VideoListEditorViewHolder(view, _touchHelper);
holder.onRemove.subscribe { v -> onRemove.emit(v); }; holder.onRemove.subscribe { v -> onRemove.emit(v); };
holder.onOptions.subscribe { v -> onOptions.emit(v); };
holder.onClick.subscribe { v -> onClick.emit(v); }; holder.onClick.subscribe { v -> onClick.emit(v); };
return holder; return holder;

View file

@ -32,7 +32,6 @@ class VideoListEditorViewHolder : ViewHolder {
private val _containerDuration: LinearLayout; private val _containerDuration: LinearLayout;
private val _containerLive: LinearLayout; private val _containerLive: LinearLayout;
private val _imageRemove: ImageButton; private val _imageRemove: ImageButton;
private val _imageOptions: ImageButton;
private val _imageDragDrop: ImageButton; private val _imageDragDrop: ImageButton;
private val _platformIndicator: PlatformIndicator; private val _platformIndicator: PlatformIndicator;
private val _layoutDownloaded: FrameLayout; private val _layoutDownloaded: FrameLayout;
@ -42,7 +41,6 @@ class VideoListEditorViewHolder : ViewHolder {
val onClick = Event1<IPlatformVideo>(); val onClick = Event1<IPlatformVideo>();
val onRemove = Event1<IPlatformVideo>(); val onRemove = Event1<IPlatformVideo>();
val onOptions = Event1<IPlatformVideo>();
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
@ -56,7 +54,6 @@ class VideoListEditorViewHolder : ViewHolder {
_containerDuration = view.findViewById(R.id.thumbnail_duration_container); _containerDuration = view.findViewById(R.id.thumbnail_duration_container);
_containerLive = view.findViewById(R.id.thumbnail_live_container); _containerLive = view.findViewById(R.id.thumbnail_live_container);
_imageRemove = view.findViewById(R.id.image_trash); _imageRemove = view.findViewById(R.id.image_trash);
_imageOptions = view.findViewById(R.id.image_settings);
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop); _imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
_platformIndicator = view.findViewById(R.id.thumbnail_platform); _platformIndicator = view.findViewById(R.id.thumbnail_platform);
_layoutDownloaded = view.findViewById(R.id.layout_downloaded); _layoutDownloaded = view.findViewById(R.id.layout_downloaded);
@ -77,10 +74,6 @@ class VideoListEditorViewHolder : ViewHolder {
val v = video ?: return@setOnClickListener; val v = video ?: return@setOnClickListener;
onRemove.emit(v); onRemove.emit(v);
}; };
_imageOptions?.setOnClickListener {
val v = video ?: return@setOnClickListener;
onOptions.emit(v);
}
} }
fun bind(v: IPlatformVideo, canEdit: Boolean) { fun bind(v: IPlatformVideo, canEdit: Boolean) {

View file

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

View file

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

View file

@ -8,7 +8,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
@ -23,7 +22,6 @@ class VideoListEditorView : FrameLayout {
val onVideoOrderChanged = Event1<List<IPlatformVideo>>() val onVideoOrderChanged = Event1<List<IPlatformVideo>>()
val onVideoRemoved = Event1<IPlatformVideo>(); val onVideoRemoved = Event1<IPlatformVideo>();
val onVideoOptions = Event1<IPlatformVideo>();
val onVideoClicked = Event1<IPlatformVideo>(); val onVideoClicked = Event1<IPlatformVideo>();
val isEmpty get() = _videos.isEmpty(); val isEmpty get() = _videos.isEmpty();
@ -56,9 +54,6 @@ class VideoListEditorView : FrameLayout {
} }
}; };
adapterVideos.onOptions.subscribe { v ->
onVideoOptions?.emit(v);
}
adapterVideos.onRemove.subscribe { v -> adapterVideos.onRemove.subscribe { v ->
val executeDelete = { val executeDelete = {
synchronized(_videos) { synchronized(_videos) {

View file

@ -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);
} }
} }

View file

@ -12,10 +12,8 @@ import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
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.constructs.Event2
import com.futo.platformplayer.images.GlideHelper import com.futo.platformplayer.images.GlideHelper
import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.views.ToggleBar
class ToggleTagView : LinearLayout { class ToggleTagView : LinearLayout {
private val _root: FrameLayout; private val _root: FrameLayout;
@ -28,7 +26,7 @@ class ToggleTagView : LinearLayout {
var isButton: Boolean = false var isButton: Boolean = false
private set; private set;
var onClick = Event2<ToggleTagView, Boolean>(); var onClick = Event1<Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
@ -38,7 +36,7 @@ class ToggleTagView : LinearLayout {
_root.setOnClickListener { _root.setOnClickListener {
if(!isButton) if(!isButton)
setToggle(!isActive); setToggle(!isActive);
onClick.emit(this, isActive); onClick.emit(isActive);
} }
} }
@ -54,31 +52,12 @@ class ToggleTagView : LinearLayout {
} }
} }
fun setInfo(toggle: ToggleBar.Toggle){
_text = toggle.name;
_textTag.text = toggle.name;
setToggle(toggle.isActive);
if(toggle.iconVariable != null) {
toggle.iconVariable.setImageView(_image, R.drawable.ic_error_pred);
_image.visibility = View.GONE;
}
else if(toggle.icon > 0) {
_image.setImageResource(toggle.icon);
_image.visibility = View.GONE;
}
else
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
}
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
_text = text; _text = text;
_textTag.text = text; _textTag.text = text;
setToggle(isActive); setToggle(isActive);
_image.setImageResource(imageResource); _image.setImageResource(imageResource);
_image.visibility = View.VISIBLE; _image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton; this.isButton = isButton;
} }
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
@ -87,14 +66,12 @@ class ToggleTagView : LinearLayout {
setToggle(isActive); setToggle(isActive);
image.setImageView(_image, R.drawable.ic_error_pred); image.setImageView(_image, R.drawable.ic_error_pred);
_image.visibility = View.VISIBLE; _image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton; this.isButton = isButton;
} }
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
_image.visibility = View.GONE; _image.visibility = View.GONE;
_text = text; _text = text;
_textTag.text = text; _textTag.text = text;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
setToggle(isActive); setToggle(isActive);
this.isButton = isButton; this.isButton = isButton;
} }

View file

@ -8,9 +8,7 @@ import android.widget.LinearLayout
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@ -25,7 +23,6 @@ class QueueEditorOverlay : LinearLayout {
private val _overlayContainer: FrameLayout; private val _overlayContainer: FrameLayout;
val onOptions = Event1<IPlatformVideo>();
val onClose = Event0(); val onClose = Event0();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@ -38,9 +35,6 @@ class QueueEditorOverlay : LinearLayout {
_topbar.onClose.subscribe(this, onClose::emit); _topbar.onClose.subscribe(this, onClose::emit);
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
_editor.onVideoOptions.subscribe { v ->
onOptions?.emit(v);
}
_editor.onVideoRemoved.subscribe { v -> _editor.onVideoRemoved.subscribe { v ->
StatePlayer.instance.removeFromQueue(v); StatePlayer.instance.removeFromQueue(v);
_topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos)); _topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos));

View file

@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout {
for(button in buttons) { for(button in buttons) {
_tagsContainer.addView(ToggleTagView(context).apply { _tagsContainer.addView(ToggleTagView(context).apply {
this.setInfo(button.name, button.isActive); this.setInfo(button.name, button.isActive);
this.onClick.subscribe({ view, value -> button.action(view, value); }); this.onClick.subscribe { button.action(it); };
}); });
} }
} }
@ -166,16 +166,16 @@ class SubscriptionBar : LinearLayout {
class Toggle { class Toggle {
val name: String; val name: String;
val icon: Int; val icon: Int;
val action: (ToggleTagView, Boolean)->Unit; val action: (Boolean)->Unit;
val isActive: Boolean; val isActive: Boolean;
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
this.name = name; this.name = name;
this.icon = icon; this.icon = icon;
this.action = action; this.action = action;
this.isActive = isActive; this.isActive = isActive;
} }
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
this.name = name; this.name = name;
this.icon = 0; this.icon = 0;
this.action = action; this.action = action;

View file

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

View file

@ -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.." />

View file

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

View file

@ -135,7 +135,7 @@
android:ellipsize="end" android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintRight_toLeftOf="@id/image_trash"
app:layout_constraintBottom_toTopOf="@id/text_author" app:layout_constraintBottom_toTopOf="@id/text_author"
android:layout_marginStart="10dp" /> android:layout_marginStart="10dp" />
@ -152,7 +152,7 @@
android:ellipsize="end" android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_video_name" app:layout_constraintTop_toBottomOf="@id/text_video_name"
app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintRight_toLeftOf="@id/image_trash"
app:layout_constraintBottom_toTopOf="@id/text_video_metadata" app:layout_constraintBottom_toTopOf="@id/text_video_metadata"
android:layout_marginStart="10dp" /> android:layout_marginStart="10dp" />
@ -169,35 +169,19 @@
android:ellipsize="end" android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_author" app:layout_constraintTop_toBottomOf="@id/text_author"
app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintRight_toLeftOf="@id/image_trash"
android:layout_marginStart="10dp" /> android:layout_marginStart="10dp" />
<LinearLayout <ImageButton
android:id="@+id/buttons" android:id="@+id/image_trash"
android:layout_width="wrap_content" android:layout_width="40dp"
android:layout_height="wrap_content" android:layout_height="40dp"
android:orientation="vertical" android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash_18dp"
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail" app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail"
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" > app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" />
<ImageButton
android:id="@+id/image_trash"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash_18dp"
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp"/>
<ImageButton
android:id="@+id/image_settings"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_settings"
app:srcCompat="@drawable/ic_settings"
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -3,8 +3,8 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="32dp" android:layout_height="32dp"
android:paddingStart="12dp" android:paddingStart="15dp"
android:paddingEnd="12dp" android:paddingEnd="15dp"
android:background="@drawable/background_pill" android:background="@drawable/background_pill"
android:layout_marginEnd="6dp" android:layout_marginEnd="6dp"
android:layout_marginTop="17dp" android:layout_marginTop="17dp"
@ -19,15 +19,12 @@
android:visibility="gone" android:visibility="gone"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:layout_gravity="center" android:layout_marginRight="5dp"
android:layout_marginLeft="2.5dp" android:layout_marginTop="4dp" />
android:layout_marginRight="2.5dp" />
<TextView <TextView
android:id="@+id/text_tag" android:id="@+id/text_tag"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginLeft="2.5dp"
android:layout_marginRight="2.5dp"
android:textColor="@color/white" android:textColor="@color/white"
android:layout_gravity="center" android:layout_gravity="center"
android:gravity="center" android:gravity="center"

View file

@ -72,8 +72,6 @@
<string name="keep_screen_on_while_casting">Keep screen on while casting</string> <string name="keep_screen_on_while_casting">Keep screen on while casting</string>
<string name="always_proxy_requests">Always proxy requests</string> <string name="always_proxy_requests">Always proxy requests</string>
<string name="always_proxy_requests_description">Always proxy requests when casting data through the device.</string> <string name="always_proxy_requests_description">Always proxy requests when casting data through the device.</string>
<string name="allow_ipv6">Allow IPV6</string>
<string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string>
<string name="discover">Discover</string> <string name="discover">Discover</string>
<string name="find_new_video_sources_to_add">Find new video sources to add</string> <string name="find_new_video_sources_to_add">Find new video sources to add</string>
<string name="these_sources_have_been_disabled">These sources have been disabled</string> <string name="these_sources_have_been_disabled">These sources have been disabled</string>
@ -372,12 +370,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>
@ -427,8 +419,6 @@
<string name="preview_feed_items_description">When the preview feedstyle is used, if items should auto-preview when scrolling over them</string> <string name="preview_feed_items_description">When the preview feedstyle is used, if items should auto-preview when scrolling over them</string>
<string name="show_home_filters">Show Home Filters</string> <string name="show_home_filters">Show Home Filters</string>
<string name="show_home_filters_description">If the home filters should be shown above home</string> <string name="show_home_filters_description">If the home filters should be shown above home</string>
<string name="show_home_filters_plugin_names">Home filter Plugin Names</string>
<string name="show_home_filters_plugin_names_description">If home filters should show full plugin names or just icons</string>
<string name="log_level">Log Level</string> <string name="log_level">Log Level</string>
<string name="logging">Logging</string> <string name="logging">Logging</string>
<string name="sync_grayjay">Sync Grayjay</string> <string name="sync_grayjay">Sync Grayjay</string>

@ -1 +1 @@
Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0

@ -1 +1 @@
Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d

@ -1 +1 @@
Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed Subproject commit 331dd929293614875af80e3ab4cb162dc6183410

@ -1 +1 @@
Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48

@ -1 +1 @@
Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0

@ -1 +1 @@
Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d

@ -1 +1 @@
Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed Subproject commit 331dd929293614875af80e3ab4cb162dc6183410

@ -1 +1 @@
Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48