diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 8abc2372..bfa7925b 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream 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.security.SecureRandom import java.time.OffsetDateTime @@ -331,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray { } } return outputStream.toByteArray() -} \ No newline at end of file +} + +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>( + { 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.16–31/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 Enumeration.toList(): List = Collections.list(this) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index b25240bc..0c3d4662 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString import com.futo.platformplayer.toInetAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.json.JSONObject @@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice { private var _mediaSessionId: Int? = null; private var _thread: 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, port: Int) : super() { this.name = name; @@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice { launchObject.put("appId", "CC1AD845"); launchObject.put("requestId", _requestId++); sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); + _lastLaunchTime_ms = System.currentTimeMillis() } private fun getStatus() { @@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice { _contentType = null; _streamType = null; _sessionId = null; + _launchRetries = 0 _transportId = null; } @@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice { _started = true; _sessionId = null; + _launchRetries = 0 _mediaSessionId = null; Logger.i(TAG, "Starting..."); @@ -393,7 +402,7 @@ class ChromecastCastingDevice : CastingDevice { try { val inputStream = _inputStream ?: break; - synchronized(_inputStreamLock) + val message = synchronized(_inputStreamLock) { Log.d(TAG, "Receiving next packet..."); val b1 = inputStream.readUnsignedByte(); @@ -405,7 +414,7 @@ class ChromecastCastingDevice : CastingDevice { if (size > buffer.size) { Logger.w(TAG, "Skipping packet that is too large $size bytes.") inputStream.skip(size.toLong()); - return@synchronized + return@synchronized null } 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? val messageBytes = buffer.sliceArray(IntRange(0, size - 1)); Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}."); - val message = ChromeCast.CastMessage.parseFrom(messageBytes); - if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { - Logger.i(TAG, "Received message: $message"); + val msg = ChromeCast.CastMessage.parseFrom(messageBytes); + if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { + Logger.i(TAG, "Received message: $msg"); } + return@synchronized msg + } + if (message != null) { try { handleMessage(message); } catch (e: Throwable) { Logger.w(TAG, "Failed to handle message.", e); + break } } } catch (e: java.net.SocketException) { @@ -512,6 +525,7 @@ class ChromecastCastingDevice : CastingDevice { if (_sessionId == null) { connectionState = CastConnectionState.CONNECTED; _sessionId = applicationUpdate.getString("sessionId"); + _launchRetries = 0 val transportId = applicationUpdate.getString("transportId"); connectMediaChannel(transportId); @@ -526,21 +540,40 @@ class ChromecastCastingDevice : CastingDevice { } if (!sessionIsRunning) { - _sessionId = null; - _mediaSessionId = null; - setTime(0.0); - _transportId = null; - Logger.w(TAG, "Session not found."); + if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) { + _sessionId = null + _mediaSessionId = null + setTime(0.0) + _transportId = null - if (_launching) { - Logger.i(TAG, "Player not found, launching."); - launchPlayer(); + if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) { + Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}") + _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 { - Logger.i(TAG, "Player not found, disconnecting."); //TODO: Add recovery from this scenario ? - stop(); + if (_retryJob == null) { + Logger.i(TAG, "Scheduled retry job over 5 seconds") + _retryJob = _scopeIO?.launch(Dispatchers.IO) { + delay(5000) + getStatus() + _retryJob = null + } + } } } else { - _launching = false; + _launching = false + _launchRetries = 0 } val volume = status.getJSONObject("volume"); @@ -582,6 +615,8 @@ class ChromecastCastingDevice : CastingDevice { if (message.sourceId == "receiver-0") { Logger.i(TAG, "Close received."); stop(); + } else if (_transportId == message.sourceId) { + throw Exception("Transport id closed.") } } } else { @@ -616,6 +651,9 @@ class ChromecastCastingDevice : CastingDevice { localAddress = null; _started = false; + _retryJob?.cancel() + _retryJob = null + val socket = _socket; val scopeIO = _scopeIO; diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index a1ece8b4..af897ea0 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -43,6 +43,7 @@ import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.findPreferredAddress import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS @@ -61,6 +62,7 @@ import java.net.Inet6Address import java.net.InetAddress import java.net.URLDecoder import java.net.URLEncoder +import java.util.Collections import java.util.UUID class StateCasting { @@ -1216,26 +1218,13 @@ class StateCasting { } } - private fun findFirstIPv4(): InetAddress? { - val interfaces = NetworkInterface.getNetworkInterfaces() - for (intf in interfaces) { - if (!intf.isUp || intf.isLoopback) continue - for (addr in intf.inetAddresses) { - if (addr is Inet4Address && !addr.isLoopbackAddress) { - return addr - } - } - } - return null - } - private fun getLocalUrl(ad: CastingDevice): String { var address = ad.localAddress!! if (address is Inet6Address && address.isLinkLocalAddress) { - address = findFirstIPv4() ?: address + address = findPreferredAddress() ?: address Logger.i(TAG, "Selected casting address: $address") } - return "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}"; } @OptIn(UnstableApi::class)