Merge remote-tracking branch 'origin/master'

This commit is contained in:
Kai 2025-05-29 10:24:51 -05:00
commit 75e97ed008
No known key found for this signature in database
11 changed files with 421 additions and 178 deletions

View file

@ -56,7 +56,7 @@
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance" android:launchMode="singleInstance"

View file

@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InterfaceAddress
import java.net.NetworkInterface
import java.net.SocketException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.security.SecureRandom import java.security.SecureRandom
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -332,3 +338,97 @@ fun ByteArray.fromGzip(): ByteArray {
} }
return outputStream.toByteArray() return outputStream.toByteArray()
} }
fun findPreferredAddress(): InetAddress? {
val candidates = NetworkInterface.getNetworkInterfaces()
.toList()
.asSequence()
.filter(::isUsableInterface)
.flatMap { nif ->
nif.interfaceAddresses
.asSequence()
.mapNotNull { ia ->
ia.address.takeIf(::isUsableAddress)?.let { addr ->
nif to ia
}
}
}
.toList()
return candidates
.minWithOrNull(
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
{ addressScore(it.second.address) },
{ interfaceScore(it.first) },
{ -it.second.networkPrefixLength.toInt() },
{ -it.first.mtu }
)
)?.second?.address
}
private fun isUsableInterface(nif: NetworkInterface): Boolean {
val name = nif.name.lowercase()
return try {
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
nif.isUp
&& !nif.isLoopback
&& !nif.isPointToPoint
&& !nif.isVirtual
&& !name.startsWith("docker")
&& !name.startsWith("veth")
&& !name.startsWith("br-")
&& !name.startsWith("virbr")
&& !name.startsWith("vmnet")
&& !name.startsWith("tun")
&& !name.startsWith("tap")
} catch (e: SocketException) {
false
}
}
private fun isUsableAddress(addr: InetAddress): Boolean {
return when {
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
addr.isLoopbackAddress -> false
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
addr.isMulticastAddress -> false
else -> true
}
}
private fun interfaceScore(nif: NetworkInterface): Int {
val name = nif.name.lowercase()
return when {
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
name.startsWith("eth") || name.contains("ethernet") -> 0
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
name.contains("wi-fi") || name.contains("wifi") -> 1
else -> 2
}
}
private fun addressScore(addr: InetAddress): Int {
return when (addr) {
is Inet4Address -> {
val octets = addr.address.map { it.toInt() and 0xFF }
when {
octets[0] == 10 -> 0 // 10/8
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.1631/12
else -> 1 // public IPv4
}
}
is Inet6Address -> {
// ULA (fc00::/7) vs global vs others
val b0 = addr.address[0].toInt() and 0xFF
when {
(b0 and 0xFE) == 0xFC -> 2 // ULA
(b0 and 0xE0) == 0x20 -> 3 // global
else -> 4
}
}
else -> Int.MAX_VALUE
}
}
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)

View file

@ -33,6 +33,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
@ -203,7 +205,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
try { try {
runBlocking { lifecycleScope.launch {
handleUrlAll(content) handleUrlAll(content)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -280,7 +282,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking { runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity); try {
StatePlatform.instance.updateAvailableClients(this@MainActivity);
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
}
} }
//Preload common files to memory //Preload common files to memory
@ -707,7 +713,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"VIDEO" -> { "VIDEO" -> {
val url = intent.getStringExtra("VIDEO"); val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url); navigateWhenReady(_fragVideoDetail, url);
} }
"IMPORT_OPTIONS" -> { "IMPORT_OPTIONS" -> {
@ -725,11 +731,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"Sources" -> { "Sources" -> {
runBlocking { runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
navigate(_fragMainSources); navigateWhenReady(_fragMainSources);
} }
}; };
"BROWSE_PLUGINS" -> { "BROWSE_PLUGINS" -> {
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req -> Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
if (it is MainActivity) { if (it is MainActivity) {
@ -747,8 +753,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try { try {
if (targetData != null) { if (targetData != null) {
runBlocking { lifecycleScope.launch(Dispatchers.Main) {
handleUrlAll(targetData) try {
handleUrlAll(targetData)
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
}
} }
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
@ -776,10 +786,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(intent); startActivity(intent);
} else if (url.startsWith("grayjay://video/")) { } else if (url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length); val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl); navigateWhenReady(_fragVideoDetail, videoUrl);
} else if (url.startsWith("grayjay://channel/")) { } else if (url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length); val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl); navigateWhenReady(_fragMainChannel, channelUrl);
} }
} }
@ -847,27 +857,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleUrl(url=$url) on IO"); Logger.i(TAG, "handleUrl(url=$url) on IO");
if (StatePlatform.instance.hasEnabledVideoClient(url)) { if (StatePlatform.instance.hasEnabledVideoClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found video client"); Logger.i(TAG, "handleUrl(url=$url) found video client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (position > 0) if (position > 0)
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
else else
navigate(_fragVideoDetail, url); navigateWhenReady(_fragVideoDetail, url);
_fragVideoDetail.maximizeVideoDetail(true); _fragVideoDetail.maximizeVideoDetail(true);
} }
return@withContext true; return@withContext true;
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) { } else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found channel client"); Logger.i(TAG, "handleUrl(url=$url) found channel client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
navigate(_fragMainChannel, url); navigateWhenReady(_fragMainChannel, url);
delay(100); delay(100);
_fragVideoDetail.minimizeVideoDetail(); _fragVideoDetail.minimizeVideoDetail();
}; };
return@withContext true; return@withContext true;
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) { } else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client"); Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
navigate(_fragMainRemotePlaylist, url); navigateWhenReady(_fragMainRemotePlaylist, url);
delay(100); delay(100);
_fragVideoDetail.minimizeVideoDetail(); _fragVideoDetail.minimizeVideoDetail();
}; };
@ -1094,6 +1104,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return fragCurrent is T; return fragCurrent is T;
} }
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
navigate(segment, parameter, withHistory, isBack)
} else {
lifecycleScope.launch {
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
navigate(segment, parameter, withHistory, isBack)
}
}
}
}
/** /**
* Navigate takes a MainFragment, and makes them the current main visible view * Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment * A parameter can be provided which becomes available in the onShow of said fragment

View file

@ -9,6 +9,7 @@ import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@ -100,12 +101,18 @@ class SyncHomeActivity : AppCompatActivity() {
} }
} }
StateSync.instance.confirmStarted(this, { StateSync.instance.confirmStarted(this, onStarted = {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
}, { UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
}
if (StateSync.instance.syncService?.relayConnected == false) {
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
}
if (StateSync.instance.syncService?.serverSocketStarted == false) {
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
}
}, onNotStarted = {
finish() finish()
}, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
}) })
} }

View file

@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
private var _mediaSessionId: Int? = null; private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null; private var _thread: Thread? = null;
private var _pingThread: Thread? = null; private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name; this.name = name;
@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
launchObject.put("appId", "CC1AD845"); launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++); launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
} }
private fun getStatus() { private fun getStatus() {
@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
_contentType = null; _contentType = null;
_streamType = null; _streamType = null;
_sessionId = null; _sessionId = null;
_launchRetries = 0
_transportId = null; _transportId = null;
} }
@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
_started = true; _started = true;
_sessionId = null; _sessionId = null;
_launchRetries = 0
_mediaSessionId = null; _mediaSessionId = null;
Logger.i(TAG, "Starting..."); Logger.i(TAG, "Starting...");
@ -393,7 +402,7 @@ class ChromecastCastingDevice : CastingDevice {
try { try {
val inputStream = _inputStream ?: break; val inputStream = _inputStream ?: break;
synchronized(_inputStreamLock) val message = synchronized(_inputStreamLock)
{ {
Log.d(TAG, "Receiving next packet..."); Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte(); val b1 = inputStream.readUnsignedByte();
@ -405,7 +414,7 @@ class ChromecastCastingDevice : CastingDevice {
if (size > buffer.size) { if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.") Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong()); inputStream.skip(size.toLong());
return@synchronized return@synchronized null
} }
Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
@ -414,15 +423,19 @@ class ChromecastCastingDevice : CastingDevice {
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end? //TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1)); val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}."); Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = ChromeCast.CastMessage.parseFrom(messageBytes); val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message"); Logger.i(TAG, "Received message: $msg");
} }
return@synchronized msg
}
if (message != null) {
try { try {
handleMessage(message); handleMessage(message);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e); Logger.w(TAG, "Failed to handle message.", e);
break
} }
} }
} catch (e: java.net.SocketException) { } catch (e: java.net.SocketException) {
@ -512,6 +525,7 @@ class ChromecastCastingDevice : CastingDevice {
if (_sessionId == null) { if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED; connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId"); _sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId"); val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId); connectMediaChannel(transportId);
@ -526,21 +540,40 @@ class ChromecastCastingDevice : CastingDevice {
} }
if (!sessionIsRunning) { if (!sessionIsRunning) {
_sessionId = null; if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_mediaSessionId = null; _sessionId = null
setTime(0.0); _mediaSessionId = null
_transportId = null; setTime(0.0)
Logger.w(TAG, "Session not found."); _transportId = null
if (_launching) { if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "Player not found, launching."); Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
launchPlayer(); _launchRetries++
launchPlayer()
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
} else {
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else { } else {
Logger.i(TAG, "Player not found, disconnecting."); if (_retryJob == null) {
stop(); Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
} }
} else { } else {
_launching = false; _launching = false
_launchRetries = 0
} }
val volume = status.getJSONObject("volume"); val volume = status.getJSONObject("volume");
@ -582,6 +615,8 @@ class ChromecastCastingDevice : CastingDevice {
if (message.sourceId == "receiver-0") { if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received."); Logger.i(TAG, "Close received.");
stop(); stop();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
} }
} }
} else { } else {
@ -616,6 +651,9 @@ class ChromecastCastingDevice : CastingDevice {
localAddress = null; localAddress = null;
_started = false; _started = false;
_retryJob?.cancel()
_retryJob = null
val socket = _socket; val socket = _socket;
val scopeIO = _scopeIO; val scopeIO = _scopeIO;

View file

@ -10,6 +10,8 @@ import android.os.Build
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import java.net.NetworkInterface
import java.net.Inet4Address
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.R
@ -41,6 +43,7 @@ import com.futo.platformplayer.builders.DashBuilder
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.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.findPreferredAddress
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
@ -55,9 +58,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Collections
import java.util.UUID import java.util.UUID
class StateCasting { class StateCasting {
@ -483,7 +488,7 @@ class StateCasting {
} }
} else { } else {
val proxyStreams = Settings.instance.casting.alwaysProxyRequests; val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
if (videoSource is IVideoUrlSource) { if (videoSource is IVideoUrlSource) {
@ -578,7 +583,7 @@ class StateCasting {
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = url + videoPath; val videoUrl = url + videoPath;
@ -597,7 +602,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = url + audioPath; val audioUrl = url + audioPath;
@ -616,7 +621,7 @@ class StateCasting {
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
val ad = activeDevice ?: return listOf() val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" val url = getLocalUrl(ad)
val id = UUID.randomUUID() val id = UUID.randomUUID()
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -712,7 +717,7 @@ class StateCasting {
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@ -762,7 +767,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
@ -827,7 +832,7 @@ class StateCasting {
_castServer.removeAllHandlers("castProxiedHlsMaster") _castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -997,7 +1002,7 @@ class StateCasting {
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -1127,7 +1132,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@ -1213,6 +1218,15 @@ class StateCasting {
} }
} }
private fun getLocalUrl(ad: CastingDevice): String {
var address = ad.localAddress!!
if (address is Inet6Address && address.isLinkLocalAddress) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@ -1220,7 +1234,7 @@ class StateCasting {
cleanExecutors() cleanExecutors()
_castServer.removeAllHandlers("castDashRaw") _castServer.removeAllHandlers("castDashRaw")
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"

View file

@ -176,6 +176,11 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
} }
private fun updateSearchViewVisibility() { private fun updateSearchViewVisibility() {
if (subType != null) {
_searchView?.visibility = View.GONE
return
}
val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) } val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) }
Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}") Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}")
_searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE _searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE

View file

@ -412,24 +412,12 @@ class StateApp {
} }
if (Settings.instance.synchronization.enabled) { 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 { settingsActivityClosed.subscribe {
if (Settings.instance.synchronization.enabled) { 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
}
})
} else { } else {
StateSync.instance.stop() StateSync.instance.stop()
} }

View file

@ -51,7 +51,7 @@ class StateSync {
val deviceRemoved: Event1<String> = Event1() val deviceRemoved: Event1<String> = Event1()
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2() val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun start(context: Context, onServerBindFail: () -> Unit) { fun start(context: Context) {
if (syncService != null) { if (syncService != null) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
return return
@ -150,24 +150,14 @@ class StateSync {
} }
} }
syncService?.start(context, onServerBindFail) syncService?.start(context)
} }
fun showFailedToBindDialogIfNecessary(context: Context) { fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) {
if (syncService?.serverSocketFailedToStart == true && 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 (syncService == null) { if (syncService == null) {
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", { UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
Settings.instance.synchronization.enabled = true Settings.instance.synchronization.enabled = true
start(context, onServerBindFail) start(context)
Settings.instance.save() Settings.instance.save()
onStarted.invoke() onStarted.invoke()
}, { }, {

View file

@ -72,6 +72,8 @@ class SyncService(
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
var serverSocketFailedToStart = false var serverSocketFailedToStart = false
var serverSocketStarted = false
var relayConnected = false
//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
@ -212,7 +214,7 @@ class SyncService(
var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
fun start(context: Context, onServerBindFail: (() -> Unit)? = null) { fun start(context: Context) {
if (_started) { if (_started) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
return return
@ -273,10 +275,12 @@ class SyncService(
Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)") Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)")
serverSocketStarted = false
if (settings.bindListener) { if (settings.bindListener) {
startListener(onServerBindFail) startListener()
} }
relayConnected = false
if (settings.relayEnabled) { if (settings.relayEnabled) {
startRelayLoop() startRelayLoop()
} }
@ -286,13 +290,15 @@ class SyncService(
} }
} }
private fun startListener(onServerBindFail: (() -> Unit)? = null) { private fun startListener() {
serverSocketFailedToStart = false serverSocketFailedToStart = false
serverSocketStarted = false
_thread = Thread { _thread = Thread {
try { try {
val serverSocket = ServerSocket(settings.listenerPort) val serverSocket = ServerSocket(settings.listenerPort)
_serverSocket = serverSocket _serverSocket = serverSocket
serverSocketStarted = true
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)") Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
while (_started) { while (_started) {
@ -300,10 +306,12 @@ class SyncService(
val session = createSocketSession(socket, true) val session = createSocketSession(socket, true)
session.startAsResponder() session.startAsResponder()
} }
serverSocketStarted = false
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e) Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
serverSocketFailedToStart = true serverSocketFailedToStart = true
onServerBindFail?.invoke() serverSocketStarted = false
} }
}.apply { start() } }.apply { start() }
} }
@ -386,121 +394,192 @@ class SyncService(
} }
private fun startRelayLoop() { private fun startRelayLoop() {
relayConnected = false
_threadRelay = Thread { _threadRelay = Thread {
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000) try {
var backoffIndex = 0; var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0;
while (_started) { while (_started) {
try { try {
Log.i(TAG, "Starting relay session...") Log.i(TAG, "Starting relay session...")
relayConnected = false
var socketClosed = false; var socketClosed = false;
val socket = Socket(relayServer, 9000) val socket = Socket(relayServer, 9000)
_relaySession = SyncSocketSession( _relaySession = SyncSocketSession(
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
keyPair!!, keyPair!!,
socket, socket,
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId ->
onNewChannel = { _, c -> isHandshakeAllowed(
val remotePublicKey = c.remotePublicKey linkType,
if (remotePublicKey == null) { syncSocketSession,
Log.e(TAG, "Remote public key should never be null in onNewChannel.") publicKey,
return@SyncSocketSession pairingCode,
} appId
)
Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") },
onNewChannel = { _, c ->
var session: SyncSession? val remotePublicKey = c.remotePublicKey
synchronized(_sessions) { if (remotePublicKey == null) {
session = _sessions[remotePublicKey] Log.e(
if (session == null) { TAG,
val remoteDeviceName = database.getDeviceName(remotePublicKey) "Remote public key should never be null in onNewChannel."
session = createNewSyncSession(remotePublicKey, remoteDeviceName) )
_sessions[remotePublicKey] = session!! return@SyncSocketSession
} }
session!!.addChannel(c)
}
c.setDataHandler { _, channel, opcode, subOpcode, data -> Log.i(
session?.handlePacket(opcode, subOpcode, data) TAG,
} "New channel established from relay (pk: '$remotePublicKey')."
c.setCloseHandler { channel -> )
session?.removeChannel(channel)
}
},
onChannelEstablished = { _, channel, isResponder ->
handleAuthorization(channel, isResponder)
},
onClose = { socketClosed = true },
onHandshakeComplete = { relaySession ->
backoffIndex = 0
Thread { var session: SyncSession?
try { synchronized(_sessions) {
while (_started && !socketClosed) { session = _sessions[remotePublicKey]
val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf() if (session == null) {
relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed) val remoteDeviceName =
database.getDeviceName(remotePublicKey)
session =
createNewSyncSession(remotePublicKey, remoteDeviceName)
_sessions[remotePublicKey] = session!!
}
session!!.addChannel(c)
}
Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information") c.setDataHandler { _, channel, opcode, subOpcode, data ->
val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } session?.handlePacket(opcode, subOpcode, data)
Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") }
c.setCloseHandler { channel ->
session?.removeChannel(channel)
}
},
onChannelEstablished = { _, channel, isResponder ->
handleAuthorization(channel, isResponder)
},
onClose = { socketClosed = true },
onHandshakeComplete = { relaySession ->
backoffIndex = 0
for ((targetKey, connectionInfo) in connectionInfos) { Thread {
val potentialLocalAddresses = connectionInfo.ipv4Addresses try {
.filter { it != connectionInfo.remoteIp } while (_started && !socketClosed) {
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { val unconnectedAuthorizedDevices =
Thread { database.getAllAuthorizedDevices()
?.filter { !isConnected(it) }?.toTypedArray()
?: arrayOf()
relaySession.publishConnectionInformation(
unconnectedAuthorizedDevices,
settings.listenerPort,
settings.relayConnectDirect,
false,
false,
settings.relayConnectRelayed
)
Logger.v(
TAG,
"Requesting ${unconnectedAuthorizedDevices.size} devices connection information"
)
val connectionInfos = runBlocking {
relaySession.requestBulkConnectionInfo(
unconnectedAuthorizedDevices
)
}
Logger.v(
TAG,
"Received ${connectionInfos.size} devices connection information"
)
for ((targetKey, connectionInfo) in connectionInfos) {
val potentialLocalAddresses =
connectionInfo.ipv4Addresses
.filter { it != connectionInfo.remoteIp }
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
Thread {
try {
Log.v(
TAG,
"Attempting to connect directly, locally to '$targetKey'."
)
connect(
potentialLocalAddresses.map { it }
.toTypedArray(),
settings.listenerPort,
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 { try {
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") Logger.v(
connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null) TAG,
"Attempting relayed connection with '$targetKey'."
)
runBlocking {
relaySession.startRelayedChannel(
targetKey,
appId,
null
)
}
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) Logger.e(
TAG,
"Failed to start relayed channel 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 {
Logger.v(TAG, "Attempting relayed connection with '$targetKey'.")
runBlocking { relaySession.startRelayedChannel(targetKey, appId, null) }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e)
} }
} }
Thread.sleep(15000)
} }
} catch (e: Throwable) {
Thread.sleep(15000) Logger.e(TAG, "Unhandled exception in relay session.", e)
relaySession.stop()
} }
} catch (e: Throwable) { }.start()
Logger.e(TAG, "Unhandled exception in relay session.", e) }
relaySession.stop() )
}
}.start() _relaySession!!.authorizable = object : IAuthorizable {
override val isAuthorized: Boolean get() = true
} }
)
_relaySession!!.authorizable = object : IAuthorizable { relayConnected = true
override val isAuthorized: Boolean get() = true _relaySession!!.runAsInitiator(relayPublicKey, appId, null)
Log.i(TAG, "Started relay session.")
} catch (e: Throwable) {
Log.e(TAG, "Relay session failed.", e)
} finally {
relayConnected = false
_relaySession?.stop()
_relaySession = null
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
} }
_relaySession!!.runAsInitiator(relayPublicKey, appId, null)
Log.i(TAG, "Started relay session.")
} catch (e: Throwable) {
Log.e(TAG, "Relay session failed.", e)
} finally {
_relaySession?.stop()
_relaySession = null
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
} }
} catch (ex: Throwable) {
Log.i(TAG, "Unhandled exception in relay loop.", ex)
} }
}.apply { start() } }.apply { start() }
} }

View file

@ -529,7 +529,7 @@ class SyncSocketSession {
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true) val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
if (!isAllowed) { if (!isAllowed) {
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
rp.putInt(2) // Status code for not allowed rp.putInt(7) // Status code for not allowed
rp.putLong(connectionId) rp.putLong(connectionId)
rp.putInt(requestId) rp.putInt(requestId)
rp.rewind() rp.rewind()