mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-28 02:33:03 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aa45c2156 | |||
| 3cf8abd409 | |||
| db8426779c | |||
| b419e033f3 | |||
| d686fa327b | |||
| a1ce5eda43 | |||
| 1e790d1aa9 | |||
| d1d304b758 | |||
| e12b500144 | |||
| bd77651a1e | |||
| 35dc186395 | |||
| 07e78e0d12 | |||
| 5b8905c1d2 |
@@ -399,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
|
|||||||
|
|
||||||
fun String.getSubdomainWildcardQuery(): String {
|
fun String.getSubdomainWildcardQuery(): String {
|
||||||
val domainParts = this.split(".");
|
val domainParts = this.split(".");
|
||||||
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase();
|
var wildcardDomain = if(domainParts.size > 2)
|
||||||
if(slds.contains(sldParts))
|
"." + domainParts.drop(1).joinToString(".")
|
||||||
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
|
|
||||||
else
|
else
|
||||||
return "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
"." + domainParts.joinToString(".");
|
||||||
|
if(slds.contains(wildcardDomain.lowercase()))
|
||||||
|
"." + domainParts.joinToString(".");
|
||||||
|
return wildcardDomain;
|
||||||
}
|
}
|
||||||
@@ -33,10 +33,18 @@ fun Boolean?.toYesNo(): String {
|
|||||||
fun InetAddress?.toUrlAddress(): String {
|
fun InetAddress?.toUrlAddress(): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is Inet6Address -> {
|
is Inet6Address -> {
|
||||||
"[${hostAddress}]"
|
val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||||
|
val index = hostAddr.indexOf('%')
|
||||||
|
if (index != -1) {
|
||||||
|
val addrPart = hostAddr.substring(0, index)
|
||||||
|
val scopeId = hostAddr.substring(index + 1)
|
||||||
|
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
|
||||||
|
} else {
|
||||||
|
"[$hostAddr]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Inet4Address -> {
|
is Inet4Address -> {
|
||||||
hostAddress
|
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
throw Exception("Invalid address type")
|
throw Exception("Invalid address type")
|
||||||
|
|||||||
@@ -926,7 +926,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable
|
@Serializable
|
||||||
class Synchronization {
|
class Synchronization {
|
||||||
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
||||||
var enabled: Boolean = true;
|
var enabled: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
||||||
var broadcast: Boolean = false;
|
var broadcast: Boolean = false;
|
||||||
@@ -948,6 +948,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
||||||
var connectLocalDirectThroughRelay: Boolean = true;
|
var connectLocalDirectThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||||
|
var localConnections: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||||||
updateEmptyVisibility()
|
updateEmptyVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StateSync.instance.confirmStarted(this, {
|
||||||
|
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||||
|
}, {
|
||||||
|
finish()
|
||||||
|
}, {
|
||||||
|
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
_layoutPairingSuccess.setOnClickListener {
|
_layoutPairingSuccess.setOnClickListener {
|
||||||
_layoutPairingSuccess.visibility = View.GONE
|
_layoutPairingSuccess.visibility = View.GONE
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
_layoutPairingError.setOnClickListener {
|
_layoutPairingError.setOnClickListener {
|
||||||
_layoutPairingError.visibility = View.GONE
|
_layoutPairingError.visibility = View.GONE
|
||||||
@@ -111,9 +112,15 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
try {
|
try {
|
||||||
StateSync.instance.connect(deviceInfo) { complete, message ->
|
StateSync.instance.connect(deviceInfo) { complete, message ->
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (complete != null && complete) {
|
if (complete != null) {
|
||||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
if (complete) {
|
||||||
_layoutPairing.visibility = View.GONE
|
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
_textError.text = message
|
||||||
|
_layoutPairingError.visibility = View.VISIBLE
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_textPairingStatus.text = message
|
_textPairingStatus.text = message
|
||||||
}
|
}
|
||||||
@@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
_textError.text = e.message
|
_textError.text = e.message
|
||||||
_layoutPairing.visibility = View.GONE
|
_layoutPairing.visibility = View.GONE
|
||||||
Logger.e(TAG, "Failed to pair", e)
|
Logger.e(TAG, "Failed to pair", e)
|
||||||
} finally {
|
|
||||||
_layoutPairing.visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ data class ImageVariable(
|
|||||||
Glide.with(imageView)
|
Glide.with(imageView)
|
||||||
.load(bitmap)
|
.load(bitmap)
|
||||||
.into(imageView)
|
.into(imageView)
|
||||||
} else if(resId != null) {
|
} else if(resId != null && resId > 0) {
|
||||||
Glide.with(imageView)
|
Glide.with(imageView)
|
||||||
.load(resId)
|
.load(resId)
|
||||||
.into(imageView)
|
.into(imageView)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
|
|||||||
//val domainParts = domain!!.split(".");
|
//val domainParts = domain!!.split(".");
|
||||||
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||||
val cookieDomain = domain!!.getSubdomainWildcardQuery();
|
val cookieDomain = domain!!.getSubdomainWildcardQuery();
|
||||||
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||||
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
||||||
val cookies = cookieString.split(";");
|
val cookies = cookieString.split(";");
|
||||||
for(cookieStr in cookies) {
|
for(cookieStr in cookies) {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
|
|||||||
if(cookieString != null) {
|
if(cookieString != null) {
|
||||||
//val domainParts = domain!!.split(".");
|
//val domainParts = domain!!.split(".");
|
||||||
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||||
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||||
cookiesToFind?.let { cookiesToFind ->
|
cookiesToFind?.let { cookiesToFind ->
|
||||||
val cookies = cookieString.split(";");
|
val cookies = cookieString.split(";");
|
||||||
for(cookieStr in cookies) {
|
for(cookieStr in cookies) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity
|
|||||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
import com.futo.platformplayer.activities.SettingsActivity
|
||||||
|
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
|
||||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
@@ -411,7 +412,27 @@ 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 {
|
||||||
|
if (Settings.instance.synchronization.enabled) {
|
||||||
|
StateSync.instance.start(context, {
|
||||||
|
try {
|
||||||
|
UIDialogs.toast("Failed to start sync, port in use")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
//Ignored
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
StateSync.instance.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.onLogSubmitted.subscribe {
|
Logger.onLogSubmitted.subscribe {
|
||||||
@@ -707,6 +728,7 @@ class StateApp {
|
|||||||
|
|
||||||
StatePlayer.instance.closeMediaSession();
|
StatePlayer.instance.closeMediaSession();
|
||||||
StateCasting.instance.stop();
|
StateCasting.instance.stop();
|
||||||
|
StateSync.instance.stop();
|
||||||
StatePlayer.dispose();
|
StatePlayer.dispose();
|
||||||
Companion.dispose();
|
Companion.dispose();
|
||||||
_fileLogConsumer?.close();
|
_fileLogConsumer?.close();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.os.Build
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.futo.platformplayer.LittleEndianDataInputStream
|
import com.futo.platformplayer.LittleEndianDataInputStream
|
||||||
import com.futo.platformplayer.LittleEndianDataOutputStream
|
import com.futo.platformplayer.LittleEndianDataOutputStream
|
||||||
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
@@ -65,6 +66,7 @@ import java.time.OffsetDateTime
|
|||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.min
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
class StateSync {
|
class StateSync {
|
||||||
@@ -77,10 +79,11 @@ class StateSync {
|
|||||||
private var _serverSocket: ServerSocket? = null
|
private var _serverSocket: ServerSocket? = null
|
||||||
private var _thread: Thread? = null
|
private var _thread: Thread? = null
|
||||||
private var _connectThread: Thread? = null
|
private var _connectThread: Thread? = null
|
||||||
private var _started = false
|
@Volatile private var _started = false
|
||||||
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
|
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
|
||||||
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()
|
||||||
|
private var _serverStarted = 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
|
||||||
@@ -91,6 +94,117 @@ class StateSync {
|
|||||||
private var _threadRelay: Thread? = null
|
private var _threadRelay: Thread? = null
|
||||||
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
|
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
|
||||||
private var _nsdManager: NsdManager? = null
|
private var _nsdManager: NsdManager? = null
|
||||||
|
private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onDiscoveryStarted(regType: String) {
|
||||||
|
Log.d(TAG, "Service discovery started for $regType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDiscoveryStopped(serviceType: String) {
|
||||||
|
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost(service: NsdServiceInfo) {
|
||||||
|
Log.e(TAG, "service lost: $service")
|
||||||
|
// TODO: Handle service lost, e.g., remove device
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||||
|
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||||
|
try {
|
||||||
|
_nsdManager?.stopServiceDiscovery(this)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||||
|
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||||
|
try {
|
||||||
|
_nsdManager?.stopServiceDiscovery(this)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addOrUpdate(name: String, adrs: Array<InetAddress>, port: Int, attributes: Map<String, ByteArray>) {
|
||||||
|
if (!Settings.instance.synchronization.connectDiscovered) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
|
||||||
|
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
|
||||||
|
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
|
||||||
|
val authorized = isAuthorized(pkey)
|
||||||
|
|
||||||
|
if (authorized && !isConnected(pkey)) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastConnectTime = synchronized(_lastConnectTimesMdns) {
|
||||||
|
_lastConnectTimesMdns[pkey] ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//Connect once every 30 seconds, max
|
||||||
|
if (now - lastConnectTime > 30000) {
|
||||||
|
synchronized(_lastConnectTimesMdns) {
|
||||||
|
_lastConnectTimesMdns[pkey] = now
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect")
|
||||||
|
|
||||||
|
try {
|
||||||
|
connect(syncDeviceInfo)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.i(TAG, "Failed to connect to $pkey", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceFound(service: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||||
|
addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
service.hostAddresses.toTypedArray()
|
||||||
|
} else {
|
||||||
|
if(service.host != null)
|
||||||
|
arrayOf(service.host);
|
||||||
|
else
|
||||||
|
arrayOf();
|
||||||
|
}, service.port, service.attributes)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
|
||||||
|
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||||
|
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost() {
|
||||||
|
Log.v(TAG, "onServiceLost: $service")
|
||||||
|
// TODO: Handle service lost
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||||
|
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceInfoCallbackUnregistered() {
|
||||||
|
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||||
|
Log.v(TAG, "Resolve failed: $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||||
|
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val _registrationListener = object : NsdManager.RegistrationListener {
|
private val _registrationListener = object : NsdManager.RegistrationListener {
|
||||||
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
|
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
|
||||||
Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}")
|
Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}")
|
||||||
@@ -122,7 +236,7 @@ class StateSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context, onServerBindFail: () -> Unit) {
|
||||||
if (_started) {
|
if (_started) {
|
||||||
Logger.i(TAG, "Already started.")
|
Logger.i(TAG, "Already started.")
|
||||||
return
|
return
|
||||||
@@ -132,116 +246,7 @@ class StateSync {
|
|||||||
|
|
||||||
if (Settings.instance.synchronization.connectDiscovered) {
|
if (Settings.instance.synchronization.connectDiscovered) {
|
||||||
_nsdManager?.apply {
|
_nsdManager?.apply {
|
||||||
discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, object : NsdManager.DiscoveryListener {
|
discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
|
||||||
override fun onDiscoveryStarted(regType: String) {
|
|
||||||
Log.d(TAG, "Service discovery started for $regType")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDiscoveryStopped(serviceType: String) {
|
|
||||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceLost(service: NsdServiceInfo) {
|
|
||||||
Log.e(TAG, "service lost: $service")
|
|
||||||
// TODO: Handle service lost, e.g., remove device
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
||||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
|
||||||
try {
|
|
||||||
_nsdManager?.stopServiceDiscovery(this)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
||||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
|
||||||
try {
|
|
||||||
_nsdManager?.stopServiceDiscovery(this)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addOrUpdate(name: String, adrs: Array<InetAddress>, port: Int, attributes: Map<String, ByteArray>) {
|
|
||||||
if (!Settings.instance.synchronization.connectDiscovered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
|
|
||||||
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
|
|
||||||
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
|
|
||||||
val authorized = isAuthorized(pkey)
|
|
||||||
|
|
||||||
if (authorized && !isConnected(pkey)) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val lastConnectTime = synchronized(_lastConnectTimesMdns) {
|
|
||||||
_lastConnectTimesMdns[pkey] ?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
//Connect once every 30 seconds, max
|
|
||||||
if (now - lastConnectTime > 30000) {
|
|
||||||
synchronized(_lastConnectTimesMdns) {
|
|
||||||
_lastConnectTimesMdns[pkey] = now
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect")
|
|
||||||
|
|
||||||
try {
|
|
||||||
connect(syncDeviceInfo)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Failed to connect to $pkey", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceFound(service: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
|
||||||
addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
service.hostAddresses.toTypedArray()
|
|
||||||
} else {
|
|
||||||
if(service.host != null)
|
|
||||||
arrayOf(service.host);
|
|
||||||
else
|
|
||||||
arrayOf();
|
|
||||||
}, service.port, service.attributes)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
|
|
||||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
|
||||||
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceLost() {
|
|
||||||
Log.v(TAG, "onServiceLost: $service")
|
|
||||||
// TODO: Handle service lost
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
|
||||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceInfoCallbackUnregistered() {
|
|
||||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
|
||||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
|
||||||
Log.v(TAG, "Resolve failed: $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
|
||||||
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,23 +297,31 @@ class StateSync {
|
|||||||
|
|
||||||
Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})")
|
Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})")
|
||||||
|
|
||||||
_thread = Thread {
|
if (Settings.instance.synchronization.localConnections) {
|
||||||
try {
|
_serverStarted = true
|
||||||
val serverSocket = ServerSocket(PORT)
|
_thread = Thread {
|
||||||
_serverSocket = serverSocket
|
try {
|
||||||
|
val serverSocket = ServerSocket(PORT)
|
||||||
|
_serverSocket = serverSocket
|
||||||
|
|
||||||
Log.i(TAG, "Running on port ${PORT} (TCP)")
|
Log.i(TAG, "Running on port ${PORT} (TCP)")
|
||||||
|
|
||||||
while (_started) {
|
while (_started) {
|
||||||
val socket = serverSocket.accept()
|
val socket = serverSocket.accept()
|
||||||
val session = createSocketSession(socket, true)
|
val session = createSocketSession(socket, true)
|
||||||
session.startAsResponder()
|
session.startAsResponder()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
_serverStarted = false
|
||||||
|
Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
onServerBindFail.invoke()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_serverStarted = false
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
}.apply { start() }
|
||||||
Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
|
}
|
||||||
UIDialogs.toast("Failed to start sync, port in use")
|
|
||||||
}
|
|
||||||
}.apply { start() }
|
|
||||||
|
|
||||||
if (Settings.instance.synchronization.connectLast) {
|
if (Settings.instance.synchronization.connectLast) {
|
||||||
_connectThread = Thread {
|
_connectThread = Thread {
|
||||||
@@ -360,6 +373,9 @@ class StateSync {
|
|||||||
|
|
||||||
if (Settings.instance.synchronization.discoverThroughRelay) {
|
if (Settings.instance.synchronization.discoverThroughRelay) {
|
||||||
_threadRelay = Thread {
|
_threadRelay = Thread {
|
||||||
|
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...")
|
||||||
@@ -405,6 +421,8 @@ class StateSync {
|
|||||||
},
|
},
|
||||||
onClose = { socketClosed = true },
|
onClose = { socketClosed = true },
|
||||||
onHandshakeComplete = { relaySession ->
|
onHandshakeComplete = { relaySession ->
|
||||||
|
backoffIndex = 0
|
||||||
|
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
while (_started && !socketClosed) {
|
while (_started && !socketClosed) {
|
||||||
@@ -472,13 +490,36 @@ class StateSync {
|
|||||||
} finally {
|
} finally {
|
||||||
_relaySession?.stop()
|
_relaySession?.stop()
|
||||||
_relaySession = null
|
_relaySession = null
|
||||||
Thread.sleep(5000)
|
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.apply { start() }
|
}.apply { start() }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFailedToBindDialogIfNecessary(context: Context) {
|
||||||
|
if (!_serverStarted && Settings.instance.synchronization.localConnections) {
|
||||||
|
try {
|
||||||
|
UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
//Ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) {
|
||||||
|
if (!_started) {
|
||||||
|
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
|
||||||
|
Settings.instance.synchronization.enabled = true
|
||||||
|
StateSync.instance.start(context, onServerBindFail)
|
||||||
|
Settings.instance.save()
|
||||||
|
onStarted.invoke()
|
||||||
|
}, {
|
||||||
|
onNotStarted.invoke()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onStarted.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDeviceName(): String {
|
private fun getDeviceName(): String {
|
||||||
@@ -737,8 +778,8 @@ class StateSync {
|
|||||||
for(video in pack.videos) {
|
for(video in pack.videos) {
|
||||||
val existing = allExisting.firstOrNull { it.url == video.url };
|
val existing = allExisting.firstOrNull { it.url == video.url };
|
||||||
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
|
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
|
||||||
|
val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN;
|
||||||
if(existing == null) {
|
if(existing == null && time > removalTime) {
|
||||||
StatePlaylists.instance.addToWatchLater(video, false);
|
StatePlaylists.instance.addToWatchLater(video, false);
|
||||||
if(time > OffsetDateTime.MIN)
|
if(time > OffsetDateTime.MIN)
|
||||||
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
|
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
|
||||||
@@ -1036,19 +1077,31 @@ class StateSync {
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
_started = false
|
_started = false
|
||||||
_nsdManager?.unregisterService(_registrationListener)
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
_nsdManager?.stopServiceDiscovery(_discoveryListener)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to stop discovery listener", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_nsdManager?.unregisterService(_registrationListener)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to unregister service", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
_relaySession?.stop()
|
||||||
_serverSocket?.close()
|
_serverSocket?.close()
|
||||||
_serverSocket = null
|
_serverSocket = null
|
||||||
|
|
||||||
_thread?.interrupt()
|
synchronized(_sessions) {
|
||||||
_thread = null
|
_sessions.values.forEach { it.close() }
|
||||||
_connectThread?.interrupt()
|
_sessions.clear()
|
||||||
_connectThread = null
|
}
|
||||||
_threadRelay?.interrupt()
|
|
||||||
_threadRelay = null
|
|
||||||
|
|
||||||
_relaySession?.stop()
|
_thread = null
|
||||||
|
_connectThread = null
|
||||||
|
_threadRelay = null
|
||||||
_relaySession = null
|
_relaySession = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.sync.internal
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.futo.platformplayer.LittleEndianDataInputStream
|
import com.futo.platformplayer.LittleEndianDataInputStream
|
||||||
import com.futo.platformplayer.LittleEndianDataOutputStream
|
import com.futo.platformplayer.LittleEndianDataOutputStream
|
||||||
|
import com.futo.platformplayer.copyToOutputStream
|
||||||
import com.futo.platformplayer.ensureNotMainThread
|
import com.futo.platformplayer.ensureNotMainThread
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
||||||
@@ -11,6 +12,7 @@ import com.futo.platformplayer.noise.protocol.HandshakeState
|
|||||||
import com.futo.platformplayer.states.StateSync
|
import com.futo.platformplayer.states.StateSync
|
||||||
import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion
|
import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
@@ -24,6 +26,7 @@ import java.nio.ByteOrder
|
|||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
@@ -447,14 +450,14 @@ class SyncSocketSession {
|
|||||||
ensureNotMainThread()
|
ensureNotMainThread()
|
||||||
|
|
||||||
synchronized(_sendLockObject) {
|
synchronized(_sendLockObject) {
|
||||||
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2)
|
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4)
|
||||||
_sendBuffer.asUByteArray()[4] = opcode
|
_sendBuffer.asUByteArray()[4] = opcode
|
||||||
_sendBuffer.asUByteArray()[5] = subOpcode
|
_sendBuffer.asUByteArray()[5] = subOpcode
|
||||||
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
|
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
|
||||||
|
|
||||||
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
|
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
|
||||||
|
|
||||||
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE)
|
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE)
|
||||||
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
|
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
|
||||||
|
|
||||||
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
||||||
@@ -837,12 +840,13 @@ class SyncSocketSession {
|
|||||||
if (!isGzipSupported)
|
if (!isGzipSupported)
|
||||||
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
|
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
|
||||||
|
|
||||||
val compressedStream = ByteArrayOutputStream()
|
val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining());
|
||||||
GZIPOutputStream(compressedStream).use { gzipStream ->
|
var outputStream = ByteArrayOutputStream();
|
||||||
gzipStream.write(data.array(), data.position(), data.remaining())
|
GZIPInputStream(compressedStream).use { gzipStream ->
|
||||||
gzipStream.finish()
|
gzipStream.copyToOutputStream(outputStream);
|
||||||
|
gzipStream.close();
|
||||||
}
|
}
|
||||||
data = ByteBuffer.wrap(compressedStream.toByteArray())
|
data = ByteBuffer.wrap(outputStream.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
when (opcode) {
|
when (opcode) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.casting.StateCasting
|
|||||||
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 androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
|
||||||
class DeviceViewHolder : ViewHolder {
|
class DeviceViewHolder : ViewHolder {
|
||||||
private val _layoutDevice: FrameLayout;
|
private val _layoutDevice: FrameLayout;
|
||||||
@@ -55,9 +56,17 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
|
|
||||||
val connect = {
|
val connect = {
|
||||||
device?.let { dev ->
|
device?.let { dev ->
|
||||||
StateCasting.instance.activeDevice?.stopCasting();
|
if (dev.isReady) {
|
||||||
StateCasting.instance.connectDevice(dev);
|
StateCasting.instance.activeDevice?.stopCasting()
|
||||||
onConnect.emit(dev);
|
StateCasting.instance.connectDevice(dev)
|
||||||
|
onConnect.emit(dev)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
//Ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +93,7 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_textName.text = d.name;
|
_textName.text = d.name;
|
||||||
_imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE
|
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
if (!d.isReady) {
|
if (!d.isReady) {
|
||||||
_imageLoader.visibility = View.GONE;
|
_imageLoader.visibility = View.GONE;
|
||||||
|
|||||||
@@ -57,15 +57,15 @@
|
|||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/image_clear"
|
android:id="@+id/image_clear"
|
||||||
android:layout_width="16dp"
|
android:layout_width="36dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="36dp"
|
||||||
app:srcCompat="@drawable/ic_clear_16dp"
|
app:srcCompat="@drawable/ic_clear_16dp"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:layout_marginEnd="6dp"
|
android:layout_marginEnd="6dp"
|
||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
android:padding="2dp" />
|
android:padding="12dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_name"
|
android:id="@+id/text_name"
|
||||||
|
|||||||
@@ -384,6 +384,8 @@
|
|||||||
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</string>
|
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</string>
|
||||||
<string name="connect_local_direct_through_relay">Connect direct through relay</string>
|
<string name="connect_local_direct_through_relay">Connect direct through relay</string>
|
||||||
<string name="connect_local_direct_through_relay_description">Allow devices to be directly locally connected to through information discovered from the relay</string>
|
<string name="connect_local_direct_through_relay_description">Allow devices to be directly locally connected to through information discovered from the relay</string>
|
||||||
|
<string name="local_connections">Local connections</string>
|
||||||
|
<string name="local_connections_description">Allow device to be directly locally connected</string>
|
||||||
<string name="gesture_controls">Gesture controls</string>
|
<string name="gesture_controls">Gesture controls</string>
|
||||||
<string name="volume_slider">Volume slider</string>
|
<string name="volume_slider">Volume slider</string>
|
||||||
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
|
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user