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
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:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -51,7 +51,7 @@ class StateSync {
val deviceRemoved: Event1<String> = Event1()
val deviceUpdatedOrAdded: Event2<String, SyncSession> = 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()
}, {

View file

@ -72,6 +72,8 @@ class SyncService(
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = 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<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0;
try {
var backoffs: Array<Long> = 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() }
}

View file

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