Compare commits

...

21 Commits

Author SHA1 Message Date
Koen J e0b5e7b808 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 16:55:51 +02:00
Koen J ac3a8da002 Various fixes for android to android pairing. 2025-05-06 16:54:58 +02:00
Kelvin 1aa45c2156 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 13:23:10 +02:00
Kelvin 3cf8abd409 Fix racecondition watchlater adds 2025-05-06 13:23:00 +02:00
Koen J db8426779c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 13:04:53 +02:00
Koen J b419e033f3 Casting device more clearly communicates when not ready. Implemented backoffs for SyncServer. 2025-05-06 13:04:42 +02:00
Kelvin d686fa327b Incorrect gzip compression 2025-05-06 12:40:24 +02:00
Kelvin a1ce5eda43 Fix synced ImageVariables showing black images 2025-05-06 11:53:30 +02:00
Koen J 1e790d1aa9 Added toggle to be able to disable local functionality for sync. Sync now automatically closes when pairing is successful. Pairing in progress layouts now properly show again. 2025-05-05 13:34:52 +02:00
Koen J d1d304b758 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-05 12:00:15 +02:00
Koen J e12b500144 Sync disabled by default. 2025-05-05 12:00:04 +02:00
Kelvin bd77651a1e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-05 11:18:57 +02:00
Kelvin 35dc186395 Login edgecase fix 2025-05-05 11:18:46 +02:00
Koen J 07e78e0d12 Fixed sending packets without data in sync protocol. 2025-05-05 10:41:21 +02:00
Koen J 5b8905c1d2 Made ipv6 casting URL fix. 2025-05-04 00:50:12 +02:00
Koen J 158a27cbae Casting fixes. 2025-05-03 21:20:19 +02:00
Koen J 5769b39d78 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-03 21:05:23 +02:00
Koen J 5c96262c75 Crashfix. 2025-05-03 21:05:12 +02:00
Koen J 766f57dc9d Crashfix. 2025-05-03 21:04:39 +02:00
Kelvin 9986078582 Fix sync crash and responsiveness for subs sync 2025-05-03 19:30:47 +02:00
Koen J e047ab5684 Crashfixes. 2025-05-03 17:35:17 +02:00
22 changed files with 365 additions and 204 deletions
@@ -399,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
fun String.getSubdomainWildcardQuery(): String {
val domainParts = this.split(".");
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase();
if(slds.contains(sldParts))
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
var wildcardDomain = if(domainParts.size > 2)
"." + domainParts.drop(1).joinToString(".")
else
return "." + domainParts.drop(domainParts.size - 2).joinToString(".");
"." + domainParts.joinToString(".");
if(slds.contains(wildcardDomain.lowercase()))
"." + domainParts.joinToString(".");
return wildcardDomain;
}
@@ -7,6 +7,9 @@ import java.net.InetAddress
import java.net.URI
import java.net.URISyntaxException
import java.net.URLEncoder
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
//Syntax sugaring
inline fun <reified T> Any.assume(): T?{
@@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
fun InetAddress?.toUrlAddress(): String {
return when (this) {
is Inet6Address -> {
"[${hostAddress}]"
val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
val index = hostAddr.indexOf('%')
if (index != -1) {
val addrPart = hostAddr.substring(0, index)
val scopeId = hostAddr.substring(index + 1)
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
} else {
"[$hostAddr]"
}
}
is Inet4Address -> {
hostAddress
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
}
else -> {
throw Exception("Invalid address type")
}
}
}
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
if (this == null || this < 0)
return OffsetDateTime.MIN
if(this > 4070912400)
return OffsetDateTime.MAX;
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
}
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
if (this == null || this < 0)
return OffsetDateTime.MIN
if(this > 4070912400)
return OffsetDateTime.MAX;
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
}
@@ -590,7 +590,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = false;
var allowIpv6: Boolean = true;
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -926,7 +926,7 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
class Synchronization {
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
var enabled: Boolean = true;
var enabled: Boolean = false;
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
var broadcast: Boolean = false;
@@ -948,6 +948,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
var connectLocalDirectThroughRelay: Boolean = true;
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -279,7 +279,7 @@ fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
}
}
if(newIndex < 0)
return originalArr.size;
return newArr.size;
else
return newIndex;
}
@@ -89,6 +89,14 @@ class SyncHomeActivity : AppCompatActivity() {
updateEmptyVisibility()
}
}
StateSync.instance.confirmStarted(this, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
}, {
finish()
}, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
})
}
override fun onDestroy() {
@@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
_layoutPairingSuccess.setOnClickListener {
_layoutPairingSuccess.visibility = View.GONE
finish()
}
_layoutPairingError.setOnClickListener {
_layoutPairingError.visibility = View.GONE
@@ -111,9 +112,15 @@ class SyncPairActivity : AppCompatActivity() {
try {
StateSync.instance.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) {
if (complete != null && complete) {
_layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
if (complete != null) {
if (complete) {
_layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
} else {
_textError.text = message
_layoutPairingError.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
}
} else {
_textPairingStatus.text = message
}
@@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
_textError.text = e.message
_layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e)
} finally {
_layoutPairing.visibility = View.GONE
}
}
@@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
}
}
@@ -322,6 +322,7 @@ class ChromecastCastingDevice : CastingDevice {
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
}
}
@@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
@@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
}
}
@@ -45,6 +45,8 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.states.StateSync.Companion
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toUrlAddress
@@ -179,7 +181,7 @@ class StateCasting {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
//Ignored
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
@@ -228,12 +230,20 @@ class StateCasting {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
_nsdManager?.stopServiceDiscovery(this)
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
_nsdManager?.stopServiceDiscovery(this)
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
@@ -29,7 +29,7 @@ data class ImageVariable(
Glide.with(imageView)
.load(bitmap)
.into(imageView)
} else if(resId != null) {
} else if(resId != null && resId > 0) {
Glide.with(imageView)
.load(resId)
.into(imageView)
@@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
//val domainParts = domain!!.split(".");
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
val cookieDomain = domain!!.getSubdomainWildcardQuery();
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) })
_authConfig.cookiesToFind?.let { cookiesToFind ->
val cookies = cookieString.split(";");
for(cookieStr in cookies) {
@@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
if(cookieString != null) {
//val domainParts = domain!!.split(".");
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) })
cookiesToFind?.let { cookiesToFind ->
val cookies = cookieString.split(";");
for(cookieStr in cookies) {
@@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker
@@ -411,7 +412,27 @@ class StateApp {
}
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context)
StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
}
settingsActivityClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
} else {
StateSync.instance.stop()
}
}
Logger.onLogSubmitted.subscribe {
@@ -707,6 +728,7 @@ class StateApp {
StatePlayer.instance.closeMediaSession();
StateCasting.instance.stop();
StateSync.instance.stop();
StatePlayer.dispose();
Companion.dispose();
_fileLogConsumer?.close();
@@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.sToOffsetDateTimeUTC
import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
import com.futo.platformplayer.stores.FragmentedStorage
@@ -85,7 +86,7 @@ class StatePlaylists {
if(value.isEmpty())
return OffsetDateTime.MIN;
val tryParse = value.toLongOrNull() ?: 0;
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC);
return tryParse.sToOffsetDateTimeUTC();
}
private fun setWatchLaterReorderTime() {
val now = OffsetDateTime.now(ZoneOffset.UTC);
@@ -7,6 +7,7 @@ import android.os.Build
import android.util.Log
import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
@@ -24,6 +25,7 @@ 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.Noise
import com.futo.platformplayer.sToOffsetDateTimeUTC
import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStringMapStorage
@@ -65,6 +67,7 @@ import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.Base64
import java.util.Locale
import kotlin.math.min
import kotlin.system.measureTimeMillis
class StateSync {
@@ -77,10 +80,11 @@ class StateSync {
private var _serverSocket: ServerSocket? = null
private var _thread: Thread? = null
private var _connectThread: Thread? = null
private var _started = false
@Volatile private var _started = false
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
private var _serverStarted = false
//TODO: Should sync mdns and casting mdns be merged?
//TODO: Decrease interval that devices are updated
//TODO: Send less data
@@ -91,6 +95,117 @@ class StateSync {
private var _threadRelay: Thread? = null
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private var _nsdManager: NsdManager? = null
private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
fun addOrUpdate(name: String, adrs: Array<InetAddress>, port: Int, attributes: Map<String, ByteArray>) {
if (!Settings.instance.synchronization.connectDiscovered) {
return
}
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
val authorized = isAuthorized(pkey)
if (authorized && !isConnected(pkey)) {
val now = System.currentTimeMillis()
val lastConnectTime = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0
}
//Connect once every 30 seconds, max
if (now - lastConnectTime > 30000) {
synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] = now
}
Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect")
try {
connect(syncDeviceInfo)
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to $pkey", e)
}
}
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
if(service.host != null)
arrayOf(service.host);
else
arrayOf();
}, service.port, service.attributes)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes)
}
})
}
}
}
private val _registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}")
@@ -122,7 +237,7 @@ class StateSync {
}
}
fun start(context: Context) {
fun start(context: Context, onServerBindFail: () -> Unit) {
if (_started) {
Logger.i(TAG, "Already started.")
return
@@ -132,108 +247,7 @@ class StateSync {
if (Settings.instance.synchronization.connectDiscovered) {
_nsdManager?.apply {
discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
_nsdManager?.stopServiceDiscovery(this)
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
_nsdManager?.stopServiceDiscovery(this)
}
fun addOrUpdate(name: String, adrs: Array<InetAddress>, port: Int, attributes: Map<String, ByteArray>) {
if (!Settings.instance.synchronization.connectDiscovered) {
return
}
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
val authorized = isAuthorized(pkey)
if (authorized && !isConnected(pkey)) {
val now = System.currentTimeMillis()
val lastConnectTime = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0
}
//Connect once every 30 seconds, max
if (now - lastConnectTime > 30000) {
synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] = now
}
Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect")
try {
connect(syncDeviceInfo)
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to $pkey", e)
}
}
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
if(service.host != null)
arrayOf(service.host);
else
arrayOf();
}, service.port, service.attributes)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes)
}
})
}
}
})
discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
}
}
@@ -284,23 +298,31 @@ class StateSync {
Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})")
_thread = Thread {
try {
val serverSocket = ServerSocket(PORT)
_serverSocket = serverSocket
if (Settings.instance.synchronization.localConnections) {
_serverStarted = true
_thread = Thread {
try {
val serverSocket = ServerSocket(PORT)
_serverSocket = serverSocket
Log.i(TAG, "Running on port ${PORT} (TCP)")
Log.i(TAG, "Running on port ${PORT} (TCP)")
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true)
session.startAsResponder()
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true)
session.startAsResponder()
}
} catch (e: Throwable) {
_serverStarted = false
Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
onServerBindFail.invoke()
}
} finally {
_serverStarted = false
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
UIDialogs.toast("Failed to start sync, port in use")
}
}.apply { start() }
}.apply { start() }
}
if (Settings.instance.synchronization.connectLast) {
_connectThread = Thread {
@@ -352,6 +374,9 @@ class StateSync {
if (Settings.instance.synchronization.discoverThroughRelay) {
_threadRelay = Thread {
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0;
while (_started) {
try {
Log.i(TAG, "Starting relay session...")
@@ -397,6 +422,8 @@ class StateSync {
},
onClose = { socketClosed = true },
onHandshakeComplete = { relaySession ->
backoffIndex = 0
Thread {
try {
while (_started && !socketClosed) {
@@ -464,13 +491,36 @@ class StateSync {
} finally {
_relaySession?.stop()
_relaySession = null
Thread.sleep(5000)
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
}
}
}.apply { start() }
}
}
fun showFailedToBindDialogIfNecessary(context: Context) {
if (!_serverStarted && Settings.instance.synchronization.localConnections) {
try {
UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use")
} catch (e: Throwable) {
//Ignored
}
}
}
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) {
if (!_started) {
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
Settings.instance.synchronization.enabled = true
StateSync.instance.start(context, onServerBindFail)
Settings.instance.save()
onStarted.invoke()
}, {
onNotStarted.invoke()
})
} else {
onStarted.invoke()
}
}
private fun getDeviceName(): String {
@@ -548,7 +598,7 @@ class StateSync {
added.map { it.channel.name }.joinToString("\n"));
if(pack.subscriptions.isNotEmpty()) {
if(pack.subscriptionRemovals.isNotEmpty()) {
for (subRemoved in pack.subscriptionRemovals) {
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals);
if(removed.size > 3) {
@@ -645,12 +695,14 @@ class StateSync {
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
handleSyncSubscriptionPackage(session, subPackage);
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
if(subPackage.subscriptions.size > 0) {
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
val sesData = getSyncSessionData(remotePublicKey);
if(newestSub > sesData.lastSubscription) {
sesData.lastSubscription = newestSub;
saveSyncSessionData(sesData);
val sesData = getSyncSessionData(remotePublicKey);
if (newestSub > sesData.lastSubscription) {
sesData.lastSubscription = newestSub;
saveSyncSessionData(sesData);
}
}
}
@@ -680,7 +732,7 @@ class StateSync {
}
for(removal in pack.groupRemovals) {
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
val removalTime = removal.value.sToOffsetDateTimeUTC();
if(creation != null && creation.creationTime < removalTime)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
}
@@ -708,7 +760,7 @@ class StateSync {
}
for(removal in pack.playlistRemovals) {
val creation = StatePlaylists.instance.getPlaylist(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
val removalTime = removal.value.sToOffsetDateTimeUTC();
if(creation != null && creation.dateCreation < removalTime)
StatePlaylists.instance.removePlaylist(creation, false);
@@ -726,9 +778,9 @@ class StateSync {
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) {
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) (pack.videoAdds[video.url] ?: 0).sToOffsetDateTimeUTC() else OffsetDateTime.MIN;
val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN;
if(existing == null && time > removalTime) {
StatePlaylists.instance.addToWatchLater(video, false);
if(time > OffsetDateTime.MIN)
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
@@ -737,12 +789,12 @@ class StateSync {
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);
val removalTime = removal.value.sToOffsetDateTimeUTC()
if(creation < removalTime)
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
}
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC);
val packReorderTime = pack.reorderTime.sToOffsetDateTimeUTC()
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
if(localReorderTime < packReorderTime && pack.ordering != null) {
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
@@ -779,22 +831,15 @@ class StateSync {
}
}
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 {
private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession {
val remotePublicKey = rpk.base64ToByteArray().toBase64()
return SyncSession(
remotePublicKey,
onAuthorized = { it, isNewlyAuthorized, isNewSession ->
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
}
if (!isNewSession) {
return@SyncSession
}
@@ -806,7 +851,6 @@ class StateSync {
}
Logger.i(TAG, "$remotePublicKey authorized (name: ${it.displayName})")
onAuthorized(remotePublicKey)
_authorizedDevices.addDistinct(remotePublicKey)
_authorizedDevices.save()
deviceUpdatedOrAdded.emit(it.remotePublicKey, it)
@@ -814,10 +858,12 @@ class StateSync {
checkForSync(it);
},
onUnauthorized = {
unauthorize(remotePublicKey)
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
}
unauthorize(remotePublicKey)
Logger.i(TAG, "$remotePublicKey unauthorized (name: ${it.displayName})")
onUnuthorized(remotePublicKey)
synchronized(_sessions) {
it.close()
@@ -1026,19 +1072,31 @@ class StateSync {
fun stop() {
_started = false
_nsdManager?.unregisterService(_registrationListener)
try {
_nsdManager?.stopServiceDiscovery(_discoveryListener)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop discovery listener", e)
}
try {
_nsdManager?.unregisterService(_registrationListener)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to unregister service", e)
}
_relaySession?.stop()
_serverSocket?.close()
_serverSocket = null
_thread?.interrupt()
_thread = null
_connectThread?.interrupt()
_connectThread = null
_threadRelay?.interrupt()
_threadRelay = null
synchronized(_sessions) {
_sessions.values.forEach { it.close() }
_sessions.clear()
}
_relaySession?.stop()
_thread = null
_connectThread = null
_threadRelay = null
_relaySession = null
}
@@ -1054,7 +1112,7 @@ class StateSync {
runBlocking {
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[deviceInfo.publicKey] = onStatusUpdate
_remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
relaySession.startRelayedChannel(deviceInfo.publicKey, APP_ID, deviceInfo.pairingCode)
@@ -1073,7 +1131,7 @@ class StateSync {
val session = createSocketSession(socket, false)
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[publicKey] = onStatusUpdate
_remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
@@ -129,9 +129,9 @@ class SyncSession : IAuthorizable {
fun close() {
synchronized(_channels) {
_channels.forEach { it.close() }
_channels.clear()
}
_channels.toTypedArray()
}.forEach { it.close() }
_onClose(this)
}
@@ -3,6 +3,7 @@ package com.futo.platformplayer.sync.internal
import android.os.Build
import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair
@@ -10,7 +11,10 @@ import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import kotlinx.coroutines.CompletableDeferred
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
@@ -24,6 +28,7 @@ import java.nio.ByteOrder
import java.util.Base64
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.math.min
import kotlin.system.measureTimeMillis
@@ -166,7 +171,7 @@ class SyncSocketSession {
var totalBytesReceived: Int = 0
while (totalBytesReceived < size) {
val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived)
if (bytesReceived == 0)
if (bytesReceived <= 0)
throw Exception("Socket disconnected")
totalBytesReceived += bytesReceived
}
@@ -288,7 +293,7 @@ class SyncSocketSession {
_cipherStatePair = initiator.split()
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64()
}
private fun handshakeAsResponder(): Boolean {
@@ -342,7 +347,7 @@ class SyncSocketSession {
_outputStream.write(responseBuffer, 0, 4 + responseLength)
_cipherStatePair = responder.split()
_remotePublicKey = remotePublicKey
_remotePublicKey = remotePublicKey.base64ToByteArray().toBase64()
return true
}
@@ -437,7 +442,7 @@ class SyncSocketSession {
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
_outputStream.write(_sendBufferEncrypted, 0, 4 + len)
}
//Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})")
Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})")
}
}
}
@@ -447,14 +452,14 @@ class SyncSocketSession {
ensureNotMainThread()
synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2)
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4)
_sendBuffer.asUByteArray()[4] = opcode
_sendBuffer.asUByteArray()[5] = subOpcode
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE)
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE)
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
@@ -837,12 +842,16 @@ class SyncSocketSession {
if (!isGzipSupported)
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
val compressedStream = ByteArrayOutputStream()
GZIPOutputStream(compressedStream).use { gzipStream ->
gzipStream.write(data.array(), data.position(), data.remaining())
gzipStream.finish()
val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining())
val outputStream = ByteArrayOutputStream()
GZIPInputStream(compressedStream).use { gzipStream ->
val buffer = ByteArray(8192) // 8KB buffer
var bytesRead: Int
while (gzipStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
data = ByteBuffer.wrap(compressedStream.toByteArray())
data = ByteBuffer.wrap(outputStream.toByteArray())
}
when (opcode) {
@@ -929,7 +938,7 @@ class SyncSocketSession {
throw Exception("After sync stream end, the stream must be complete")
}
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, contentEncoding, sourceChannel)
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel)
}
}
Opcode.DATA.value -> {
@@ -18,6 +18,7 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import androidx.core.view.isVisible
import com.futo.platformplayer.UIDialogs
class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout;
@@ -55,9 +56,17 @@ class DeviceViewHolder : ViewHolder {
val connect = {
device?.let { dev ->
StateCasting.instance.activeDevice?.stopCasting();
StateCasting.instance.connectDevice(dev);
onConnect.emit(dev);
if (dev.isReady) {
StateCasting.instance.activeDevice?.stopCasting()
StateCasting.instance.connectDevice(dev)
onConnect.emit(dev)
} else {
try {
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
} catch (e: Throwable) {
//Ignored
}
}
}
}
@@ -84,7 +93,7 @@ class DeviceViewHolder : ViewHolder {
}
_textName.text = d.name;
_imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
if (!d.isReady) {
_imageLoader.visibility = View.GONE;
@@ -37,9 +37,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
_onDatasetChanged = onDatasetChanged;
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() }
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() }
else
updateDataset(); }
updateDataset();
}
updateDataset();
}
+3 -3
View File
@@ -57,15 +57,15 @@
<ImageView
android:id="@+id/image_clear"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_width="36dp"
android:layout_height="36dp"
app:srcCompat="@drawable/ic_clear_16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginEnd="6dp"
android:layout_marginStart="6dp"
android:padding="2dp" />
android:padding="12dp" />
<TextView
android:id="@+id/text_name"
+2
View File
@@ -384,6 +384,8 @@
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</string>
<string name="connect_local_direct_through_relay">Connect direct through relay</string>
<string name="connect_local_direct_through_relay_description">Allow devices to be directly locally connected to through information discovered from the relay</string>
<string name="local_connections">Local connections</string>
<string name="local_connections_description">Allow device to be directly locally connected</string>
<string name="gesture_controls">Gesture controls</string>
<string name="volume_slider">Volume slider</string>
<string name="volume_slider_descr">Enable slide gesture to change volume</string>