diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 61910915..176dc044 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,7 +56,7 @@ + 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/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 095ac521..b051c904 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -33,6 +33,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.whenStateAtLeast +import androidx.lifecycle.withStateAtLeast import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R @@ -203,7 +205,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } try { - runBlocking { + lifecycleScope.launch { handleUrlAll(content) } } catch (e: Throwable) { @@ -280,7 +282,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES 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 @@ -707,7 +713,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { "VIDEO" -> { val url = intent.getStringExtra("VIDEO"); - navigate(_fragVideoDetail, url); + navigateWhenReady(_fragVideoDetail, url); } "IMPORT_OPTIONS" -> { @@ -725,11 +731,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { "Sources" -> { runBlocking { StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. - navigate(_fragMainSources); + navigateWhenReady(_fragMainSources); } }; "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 -> StateApp.instance.contextOrNull?.let { if (it is MainActivity) { @@ -747,8 +753,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { try { if (targetData != null) { - runBlocking { - handleUrlAll(targetData) + lifecycleScope.launch(Dispatchers.Main) { + try { + handleUrlAll(targetData) + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in handleUrlAll", e) + } } } } catch (ex: Throwable) { @@ -776,10 +786,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { startActivity(intent); } else if (url.startsWith("grayjay://video/")) { val videoUrl = url.substring("grayjay://video/".length); - navigate(_fragVideoDetail, videoUrl); + navigateWhenReady(_fragVideoDetail, videoUrl); } else if (url.startsWith("grayjay://channel/")) { 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"); if (StatePlatform.instance.hasEnabledVideoClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found video client"); - lifecycleScope.launch(Dispatchers.Main) { + withContext(Dispatchers.Main) { if (position > 0) - navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); + navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); else - navigate(_fragVideoDetail, url); + navigateWhenReady(_fragVideoDetail, url); _fragVideoDetail.maximizeVideoDetail(true); } return@withContext true; } else if (StatePlatform.instance.hasEnabledChannelClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found channel client"); - lifecycleScope.launch(Dispatchers.Main) { - navigate(_fragMainChannel, url); + withContext(Dispatchers.Main) { + navigateWhenReady(_fragMainChannel, url); delay(100); _fragVideoDetail.minimizeVideoDetail(); }; return@withContext true; } else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found playlist client"); - lifecycleScope.launch(Dispatchers.Main) { - navigate(_fragMainRemotePlaylist, url); + withContext(Dispatchers.Main) { + navigateWhenReady(_fragMainRemotePlaylist, url); delay(100); _fragVideoDetail.minimizeVideoDetail(); }; @@ -1094,6 +1104,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { 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 * A parameter can be provided which becomes available in the onShow of said fragment diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index e8e95f66..0fff7b06 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -9,6 +9,7 @@ import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp @@ -100,12 +101,18 @@ class SyncHomeActivity : AppCompatActivity() { } } - StateSync.instance.confirmStarted(this, { - StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) - }, { + StateSync.instance.confirmStarted(this, onStarted = { + 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() - }, { - StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) }) } 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 32bee98d..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."); - 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 647aaae6..af897ea0 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -10,6 +10,8 @@ import android.os.Build import android.os.Looper import android.util.Base64 import android.util.Log +import java.net.NetworkInterface +import java.net.Inet4Address import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi 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.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 @@ -55,9 +58,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +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 { @@ -483,7 +488,7 @@ class StateCasting { } } else { val proxyStreams = Settings.instance.casting.alwaysProxyRequests; - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); if (videoSource is IVideoUrlSource) { @@ -578,7 +583,7 @@ class StateCasting { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val videoPath = "/video-${id}" val videoUrl = url + videoPath; @@ -597,7 +602,7 @@ class StateCasting { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val audioPath = "/audio-${id}" 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 { val ad = activeDevice ?: return listOf() - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" + val url = getLocalUrl(ad) val id = UUID.randomUUID() 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 { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -762,7 +767,7 @@ class StateCasting { val ad = activeDevice ?: return listOf(); 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 videoPath = "/video-${id}" @@ -827,7 +832,7 @@ class StateCasting { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); 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 { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val hlsPath = "/hls-${id}" @@ -1127,7 +1132,7 @@ class StateCasting { val ad = activeDevice ?: return listOf(); 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 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) private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); @@ -1220,7 +1234,7 @@ class StateCasting { cleanExecutors() _castServer.removeAllHandlers("castDashRaw") - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}" diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 14be4fdf..0939fbde 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -176,6 +176,11 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), } private fun updateSearchViewVisibility() { + if (subType != null) { + _searchView?.visibility = View.GONE + return + } + val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) } Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}") _searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 2988b217..e2155e8b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -412,24 +412,12 @@ class StateApp { } if (Settings.instance.synchronization.enabled) { - StateSync.instance.start(context, { - try { - UIDialogs.toast("Failed to start sync, port in use") - } catch (e: Throwable) { - //Ignored - } - }) + StateSync.instance.start(context) } 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 - } - }) + StateSync.instance.start(context) } else { StateSync.instance.stop() } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 81efece4..fd08165c 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -51,7 +51,7 @@ class StateSync { val deviceRemoved: Event1 = Event1() val deviceUpdatedOrAdded: Event2 = Event2() - fun start(context: Context, onServerBindFail: () -> Unit) { + fun start(context: Context) { if (syncService != null) { Logger.i(TAG, "Already started.") return @@ -150,24 +150,14 @@ class StateSync { } } - syncService?.start(context, onServerBindFail) + syncService?.start(context) } - fun showFailedToBindDialogIfNecessary(context: Context) { - 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) { + fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) { if (syncService == null) { UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", { Settings.instance.synchronization.enabled = true - start(context, onServerBindFail) + start(context) Settings.instance.save() onStarted.invoke() }, { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt index e6f9e6d8..5e5ad7de 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt @@ -72,6 +72,8 @@ class SyncService( private val _lastConnectTimesMdns: MutableMap = mutableMapOf() private val _lastConnectTimesIp: MutableMap = mutableMapOf() var serverSocketFailedToStart = false + var serverSocketStarted = false + var relayConnected = false //TODO: Should sync mdns and casting mdns be merged? //TODO: Decrease interval that devices are updated //TODO: Send less data @@ -212,7 +214,7 @@ class SyncService( var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null - fun start(context: Context, onServerBindFail: (() -> Unit)? = null) { + fun start(context: Context) { if (_started) { Logger.i(TAG, "Already started.") return @@ -273,10 +275,12 @@ class SyncService( Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)") + serverSocketStarted = false if (settings.bindListener) { - startListener(onServerBindFail) + startListener() } + relayConnected = false if (settings.relayEnabled) { startRelayLoop() } @@ -286,13 +290,15 @@ class SyncService( } } - private fun startListener(onServerBindFail: (() -> Unit)? = null) { + private fun startListener() { serverSocketFailedToStart = false + serverSocketStarted = false _thread = Thread { try { val serverSocket = ServerSocket(settings.listenerPort) _serverSocket = serverSocket + serverSocketStarted = true Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)") while (_started) { @@ -300,10 +306,12 @@ class SyncService( val session = createSocketSession(socket, true) session.startAsResponder() } + + serverSocketStarted = false } catch (e: Throwable) { Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e) serverSocketFailedToStart = true - onServerBindFail?.invoke() + serverSocketStarted = false } }.apply { start() } } @@ -386,121 +394,192 @@ class SyncService( } private fun startRelayLoop() { + relayConnected = false _threadRelay = Thread { - var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) - var backoffIndex = 0; + try { + var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) + var backoffIndex = 0; - while (_started) { - try { - Log.i(TAG, "Starting relay session...") + while (_started) { + try { + Log.i(TAG, "Starting relay session...") + relayConnected = false - var socketClosed = false; - val socket = Socket(relayServer, 9000) - _relaySession = SyncSocketSession( - (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, - keyPair!!, - socket, - isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, - onNewChannel = { _, c -> - val remotePublicKey = c.remotePublicKey - if (remotePublicKey == null) { - Log.e(TAG, "Remote public key should never be null in onNewChannel.") - return@SyncSocketSession - } - - Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") - - var session: SyncSession? - synchronized(_sessions) { - session = _sessions[remotePublicKey] - if (session == null) { - val remoteDeviceName = database.getDeviceName(remotePublicKey) - session = createNewSyncSession(remotePublicKey, remoteDeviceName) - _sessions[remotePublicKey] = session!! + var socketClosed = false; + val socket = Socket(relayServer, 9000) + _relaySession = SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + socket, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> + isHandshakeAllowed( + linkType, + syncSocketSession, + publicKey, + pairingCode, + appId + ) + }, + onNewChannel = { _, c -> + val remotePublicKey = c.remotePublicKey + if (remotePublicKey == null) { + Log.e( + TAG, + "Remote public key should never be null in onNewChannel." + ) + return@SyncSocketSession } - session!!.addChannel(c) - } - c.setDataHandler { _, channel, opcode, subOpcode, data -> - session?.handlePacket(opcode, subOpcode, data) - } - c.setCloseHandler { channel -> - session?.removeChannel(channel) - } - }, - onChannelEstablished = { _, channel, isResponder -> - handleAuthorization(channel, isResponder) - }, - onClose = { socketClosed = true }, - onHandshakeComplete = { relaySession -> - backoffIndex = 0 + Log.i( + TAG, + "New channel established from relay (pk: '$remotePublicKey')." + ) - Thread { - try { - while (_started && !socketClosed) { - val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf() - relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed) + var session: SyncSession? + synchronized(_sessions) { + session = _sessions[remotePublicKey] + if (session == null) { + val remoteDeviceName = + database.getDeviceName(remotePublicKey) + session = + createNewSyncSession(remotePublicKey, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(c) + } - Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information") - val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } - Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") + c.setDataHandler { _, channel, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + c.setCloseHandler { channel -> + session?.removeChannel(channel) + } + }, + onChannelEstablished = { _, channel, isResponder -> + handleAuthorization(channel, isResponder) + }, + onClose = { socketClosed = true }, + onHandshakeComplete = { relaySession -> + backoffIndex = 0 - for ((targetKey, connectionInfo) in connectionInfos) { - val potentialLocalAddresses = connectionInfo.ipv4Addresses - .filter { it != connectionInfo.remoteIp } - if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { - Thread { + Thread { + try { + while (_started && !socketClosed) { + val unconnectedAuthorizedDevices = + 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 { - Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") - connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null) + Logger.v( + TAG, + "Attempting relayed connection with '$targetKey'." + ) + runBlocking { + relaySession.startRelayedChannel( + targetKey, + appId, + null + ) + } } 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) } - - Thread.sleep(15000) + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in relay session.", e) + relaySession.stop() } - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in relay session.", e) - relaySession.stop() - } - }.start() + }.start() + } + ) + + _relaySession!!.authorizable = object : IAuthorizable { + override val isAuthorized: Boolean get() = true } - ) - _relaySession!!.authorizable = object : IAuthorizable { - override val isAuthorized: Boolean get() = true + relayConnected = 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() } } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index fb4e920f..cb67f934 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -529,7 +529,7 @@ class SyncSocketSession { val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true) if (!isAllowed) { 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.putInt(requestId) rp.rewind()