Add local UDP discovery and invitation delivery for chat clients
This commit is contained in:
1 parent
aad295c9d0
commit
28826e050c
43 files changed
+1934
-43
No files matched your search
@@ -31,6 +31,7 @@ object AppEventHandler : ChatEventCallback {
|
||||
val events: SharedFlow<Pair<Int, ByteArray?>> = _events
|
||||
|
||||
override fun onEvent(type: Int, data: ByteArray?) {
|
||||
if (type == 13 || type in 61..64) mainHandler.post { com.utun.chat.data.LocalDiscovery.onEvent(type, data) }
|
||||
if (type == 12) mainHandler.post { com.utun.chat.headless.HeadlessService.onCoreStarted() }
|
||||
if (type == 13 || type in 49..50) mainHandler.post { ChatApplication.instance.memberFiles.onEvent(type, data) }
|
||||
if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31) || type in 49..53)
|
||||
|
||||
@@ -522,6 +522,21 @@ class MainActivity : AppCompatActivity() {
|
||||
pmInvite?.let { state ->
|
||||
PmInviteDialog(state, vm::selectPmInviteGroup, vm::sendPmInvite, vm::dismissPmInvite)
|
||||
}
|
||||
val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState()
|
||||
localInvitations.firstOrNull()?.let { invitation ->
|
||||
AlertDialog(
|
||||
onDismissRequest = com.utun.chat.data.LocalDiscovery::dismissInvitation,
|
||||
title = { Text(stringResource(R.string.invite_link)) },
|
||||
text = { Text(stringResource(R.string.local_invite_received, invitation.device.name)) },
|
||||
confirmButton = { TextButton(onClick = {
|
||||
com.utun.chat.data.LocalDiscovery.dismissInvitation()
|
||||
vm.openInviteLink(invitation.link)
|
||||
}) { Text(stringResource(R.string.connect)) } },
|
||||
dismissButton = { TextButton(onClick = com.utun.chat.data.LocalDiscovery::dismissInvitation) {
|
||||
Text(stringResource(R.string.close))
|
||||
} }
|
||||
)
|
||||
}
|
||||
linkJoin?.let { state ->
|
||||
AlertDialog(
|
||||
onDismissRequest = vm::dismissLinkJoin,
|
||||
|
||||
@@ -78,6 +78,8 @@ class ConfigProvider(private val context: Context) {
|
||||
private val chatStandbyMinSleepSec = intPreferencesKey("chat_standby_min_sleep_sec")
|
||||
private val chatStandbyKeepaliveMs = intPreferencesKey("chat_standby_keepalive_ms")
|
||||
private val chatGroupAutoconnect = intPreferencesKey("chat_group_autoconnect")
|
||||
private val chatLocalInvitesEnabled = intPreferencesKey("chat_local_invites_enabled")
|
||||
private val chatLocalBroadcastsEnabled = intPreferencesKey("chat_local_broadcasts_enabled")
|
||||
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
|
||||
private val connectionModeKey = stringPreferencesKey("connection_mode")
|
||||
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
|
||||
@@ -230,6 +232,8 @@ class ConfigProvider(private val context: Context) {
|
||||
key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
|
||||
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
|
||||
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1
|
||||
key == "chat.local_invites_enabled" -> context.dataStore.data.first()[chatLocalInvitesEnabled] ?: 1
|
||||
key == "chat.local_broadcasts_enabled" -> context.dataStore.data.first()[chatLocalBroadcastsEnabled] ?: 1
|
||||
key == "radio.auto_enabled" -> context.dataStore.data.first()[radioAutoEnabledKey] ?: 0
|
||||
key == "radio.vad_threshold" -> context.dataStore.data.first()[radioVadThresholdKey] ?: 50
|
||||
key == "radio.auto_hangover_ms" -> context.dataStore.data.first()[radioAutoHangoverMsKey] ?: 200
|
||||
@@ -272,6 +276,8 @@ class ConfigProvider(private val context: Context) {
|
||||
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
|
||||
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
|
||||
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1
|
||||
"chat.local_invites_enabled" -> prefs[chatLocalInvitesEnabled] = value.toIntOrNull() ?: 1
|
||||
"chat.local_broadcasts_enabled" -> prefs[chatLocalBroadcastsEnabled] = value.toIntOrNull() ?: 1
|
||||
"radio.auto_enabled" -> prefs[radioAutoEnabledKey] = value.toIntOrNull() ?: 0
|
||||
"radio.vad_threshold" -> prefs[radioVadThresholdKey] = value.toIntOrNull() ?: 50
|
||||
"radio.auto_hangover_ms" -> prefs[radioAutoHangoverMsKey] = value.toIntOrNull() ?: 200
|
||||
@@ -736,6 +742,8 @@ class ConfigProvider(private val context: Context) {
|
||||
appendLine("standby_min_sleep_sec=${getInt("chat.standby_min_sleep_sec")}")
|
||||
appendLine("standby_keepalive_ms=${getInt("chat.standby_keepalive_ms")}")
|
||||
appendLine("group_autoconnect=${getInt("chat.group_autoconnect")}")
|
||||
appendLine("local_invites_enabled=${getInt("chat.local_invites_enabled")}")
|
||||
appendLine("local_broadcasts_enabled=${getInt("chat.local_broadcasts_enabled")}")
|
||||
appendLine()
|
||||
appendLine("[debug]")
|
||||
val dbgCat = getString("debug.categories") ?: ""
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import com.utun.chat.ChatApplication
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
data class LocalDevice(val nodeId: Long, val name: String) {
|
||||
val label: String get() = "$name [${nodeId.toULong().toString(16).takeLast(6)}]"
|
||||
}
|
||||
data class LocalInvitation(val device: LocalDevice, val link: String)
|
||||
data class LocalDelivery(val request: Long, val pending: Boolean, val received: Boolean = false, val forbidden: Boolean = false)
|
||||
|
||||
/** Состояние приложения переживает Activity и не повторяет приглашения из replay событий ViewModel.
|
||||
* Все методы вызываются на main; MulticastLock следует эффективной активности native-сервиса. */
|
||||
object LocalDiscovery {
|
||||
private val _devices = MutableStateFlow<List<LocalDevice>>(emptyList())
|
||||
val devices = _devices.asStateFlow()
|
||||
private val _invitations = MutableStateFlow<List<LocalInvitation>>(emptyList())
|
||||
val invitations = _invitations.asStateFlow()
|
||||
private val _delivery = MutableStateFlow<LocalDelivery?>(null)
|
||||
val delivery = _delivery.asStateFlow()
|
||||
private var nextRequest = 0L
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
|
||||
fun send(device: LocalDevice, link: String) {
|
||||
if (_delivery.value?.pending == true) return
|
||||
val request = ++nextRequest
|
||||
_delivery.value = LocalDelivery(request, pending = true)
|
||||
if (!NativeLib.sendLocalInvite(device.nodeId, request, link)) {
|
||||
LogManager.addLog("WARN", "discovery", "invitation enqueue failed peer=${device.nodeId}")
|
||||
_delivery.value = LocalDelivery(request, pending = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDelivery() { if (_delivery.value?.pending != true) _delivery.value = null }
|
||||
fun dismissInvitation() { _invitations.value = _invitations.value.drop(1) }
|
||||
|
||||
fun onEvent(type: Int, data: ByteArray?) {
|
||||
if (type == 13) {
|
||||
setActive(false); _devices.value = emptyList()
|
||||
_delivery.value?.takeIf { it.pending }?.let { _delivery.value = it.copy(pending = false) }
|
||||
return
|
||||
}
|
||||
if (data == null) { LogManager.addLog("WARN", "discovery", "empty event type=$type"); return }
|
||||
val input = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN)
|
||||
when (type) {
|
||||
61 -> {
|
||||
if (input.remaining() < 2) return invalid(type, data.size)
|
||||
val count = input.short.toInt() and 0xffff
|
||||
if (count > 128) return invalid(type, data.size)
|
||||
val devices = ArrayList<LocalDevice>(count)
|
||||
repeat(count) {
|
||||
if (input.remaining() < 9) return invalid(type, data.size)
|
||||
val id = input.long
|
||||
val size = input.get().toInt() and 0xff
|
||||
if (size !in 1..127 || input.remaining() < size) return invalid(type, data.size)
|
||||
val name = ByteArray(size).also { input.get(it) }.toString(Charsets.UTF_8)
|
||||
devices.add(LocalDevice(id, name))
|
||||
}
|
||||
if (input.hasRemaining()) return invalid(type, data.size)
|
||||
_devices.value = devices.sortedBy { it.label }
|
||||
}
|
||||
62 -> {
|
||||
if (input.remaining() < 10) return invalid(type, data.size)
|
||||
val id = input.long
|
||||
val size = input.get().toInt() and 0xff
|
||||
if (size !in 1..127 || input.remaining() <= size) return invalid(type, data.size)
|
||||
val name = ByteArray(size).also { input.get(it) }.toString(Charsets.UTF_8)
|
||||
val link = ByteArray(input.remaining()).also { input.get(it) }.toString(Charsets.UTF_8)
|
||||
if (!link.startsWith("utun://")) return invalid(type, data.size)
|
||||
if (_invitations.value.size >= 8) {
|
||||
LogManager.addLog("WARN", "discovery", "incoming invitation queue full"); return
|
||||
}
|
||||
_invitations.value += LocalInvitation(LocalDevice(id, name), link)
|
||||
}
|
||||
63 -> {
|
||||
if (data.size != 17 || data[16].toInt() !in 0..2) return invalid(type, data.size)
|
||||
val request = input.long
|
||||
if (_delivery.value?.request == request)
|
||||
_delivery.value = LocalDelivery(request, pending = false, received = data[16].toInt() == 1,
|
||||
forbidden = data[16].toInt() == 2)
|
||||
}
|
||||
64 -> {
|
||||
if (data.size != 1 || data[0].toInt() !in 0..1) return invalid(type, data.size)
|
||||
setActive(data[0].toInt() == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalid(type: Int, size: Int) { LogManager.addLog("WARN", "discovery", "invalid event type=$type bytes=$size") }
|
||||
|
||||
private fun setActive(active: Boolean) {
|
||||
try {
|
||||
if (active && multicastLock?.isHeld != true) {
|
||||
val wifi = ChatApplication.instance.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
if (wifi == null) { LogManager.addLog("WARN", "discovery", "Wi-Fi service unavailable"); return }
|
||||
multicastLock = wifi.createMulticastLock("utun-local-discovery").apply { setReferenceCounted(false); acquire() }
|
||||
LogManager.addLog("INFO", "discovery", "Wi-Fi multicast receive enabled")
|
||||
} else if (!active) {
|
||||
multicastLock?.let { if (it.isHeld) it.release() }
|
||||
multicastLock = null
|
||||
LogManager.addLog("INFO", "discovery", "Wi-Fi multicast receive disabled")
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
LogManager.addLog("ERROR", "discovery", "multicast lock failed active=$active: ${error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@ object NativeLib {
|
||||
|
||||
fun requestInviteCandidates(channelId: String) { nativeRequestInviteCandidates(channelId) }
|
||||
fun requestInviteLink(channelId: String, targetNodeId: Long, requestId: Long) { nativeRequestInviteLink(channelId, targetNodeId, requestId) }
|
||||
fun requestLocalDevices(): Boolean = nativeRequestLocalDevices()
|
||||
fun sendLocalInvite(peer: Long, request: Long, link: String): Boolean =
|
||||
nativeSendLocalInvite(peer, request, link.toByteArray(Charsets.UTF_8))
|
||||
|
||||
fun ping() { nativePing() }
|
||||
fun requestCpuLoad(): Boolean = nativeRequestCpuLoad()
|
||||
@@ -341,6 +344,8 @@ object NativeLib {
|
||||
private external fun nativeGetMyNodeId(): Long
|
||||
private external fun nativeRequestInviteCandidates(channelId: String)
|
||||
private external fun nativeRequestInviteLink(channelId: String, targetNodeId: Long, requestId: Long)
|
||||
private external fun nativeRequestLocalDevices(): Boolean
|
||||
private external fun nativeSendLocalInvite(peer: Long, request: Long, link: ByteArray): Boolean
|
||||
private external fun nativeRestart(configText: String): Boolean
|
||||
private external fun nativeUpdateSockets(configText: String): Boolean
|
||||
private external fun nativePing()
|
||||
|
||||
+59
-20
@@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
@@ -24,6 +25,8 @@ import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.utun.chat.data.InviteCandidate
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.LocalDiscovery
|
||||
import com.utun.chat.data.NativeLib
|
||||
|
||||
@Composable
|
||||
private fun candidateLabel(c: InviteCandidate): String =
|
||||
@@ -45,6 +48,12 @@ fun InviteLinkDialog(
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
var selectedNodeId by remember { mutableStateOf<Long?>(null) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val devices by LocalDiscovery.devices.collectAsState()
|
||||
val delivery by LocalDiscovery.delivery.collectAsState()
|
||||
var selectedDevice by remember { mutableStateOf<Long?>(null) }
|
||||
var devicesOpen by remember { mutableStateOf(false) }
|
||||
val device = devices.firstOrNull { it.nodeId == selectedDevice } ?: devices.firstOrNull()
|
||||
LaunchedEffect(Unit) { LocalDiscovery.clearDelivery(); NativeLib.requestLocalDevices() }
|
||||
|
||||
val effectiveSelected = remember(candidates, selectedNodeId) {
|
||||
candidates.firstOrNull { it.nodeId == selectedNodeId } ?: candidates.firstOrNull()
|
||||
@@ -58,27 +67,31 @@ fun InviteLinkDialog(
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(R.string.invite_lifetime), style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box {
|
||||
OutlinedButton(onClick = { menuOpen = true }) {
|
||||
Text(
|
||||
effectiveSelected?.let { candidateLabel(it) } ?: stringResource(R.string.select_node),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
candidates.forEach { c ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(candidateLabel(c)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
if (selectedNodeId != c.nodeId) {
|
||||
selectedNodeId = c.nodeId
|
||||
copied = false
|
||||
onSelectNode(c.nodeId)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.invite_connect_via))
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
OutlinedButton(onClick = { menuOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
effectiveSelected?.let { candidateLabel(it) } ?: stringResource(R.string.select_node),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
candidates.forEach { c ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(candidateLabel(c)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
if (selectedNodeId != c.nodeId) {
|
||||
selectedNodeId = c.nodeId
|
||||
copied = false
|
||||
onSelectNode(c.nodeId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -92,10 +105,36 @@ fun InviteLinkDialog(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(link, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
modifier = Modifier.padding(8.dp))
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp))
|
||||
}
|
||||
else -> Text(stringResource(R.string.invite_build_failed), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
if (devices.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(stringResource(R.string.local_invite_devices), style = MaterialTheme.typography.bodyMedium)
|
||||
Box {
|
||||
OutlinedButton(onClick = { devicesOpen = true }) { Text(device?.label.orEmpty(), maxLines = 1) }
|
||||
DropdownMenu(expanded = devicesOpen, onDismissRequest = { devicesOpen = false }) {
|
||||
devices.forEach { item ->
|
||||
DropdownMenuItem(text = { Text(item.label) }, onClick = {
|
||||
selectedDevice = item.nodeId; devicesOpen = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(enabled = link != null && !pending && device != null && delivery?.pending != true, onClick = {
|
||||
if (device != null && link != null) LocalDiscovery.send(device, link)
|
||||
}) { Text(stringResource(R.string.local_invite_send)) }
|
||||
}
|
||||
delivery?.let { status ->
|
||||
Text(stringResource(when {
|
||||
status.pending -> R.string.local_invite_sending
|
||||
status.forbidden -> R.string.local_invite_forbidden
|
||||
status.received -> R.string.local_invite_delivered
|
||||
else -> R.string.local_invite_failed
|
||||
}), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -172,6 +172,8 @@ private fun GeneralSettingsTab(onDirty: (Boolean) -> Unit) {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val scope = rememberCoroutineScope()
|
||||
var nodeName by remember { mutableStateOf("") }
|
||||
var localInvitesEnabled by remember { mutableStateOf(provider.getInt("chat.local_invites_enabled") != 0) }
|
||||
var localBroadcastsEnabled by remember { mutableStateOf(provider.getInt("chat.local_broadcasts_enabled") != 0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
|
||||
@@ -190,6 +192,36 @@ private fun GeneralSettingsTab(onDirty: (Boolean) -> Unit) {
|
||||
label = { Text(stringResource(R.string.node_name)) }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
|
||||
}
|
||||
item(key = "profile-avatar") { ProfileAvatarControls() }
|
||||
item(key = "local-network-title") {
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
Text(stringResource(R.string.local_network), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
item(key = "local-invites") {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.local_invites_enabled), Modifier.weight(1f))
|
||||
Switch(checked = localInvitesEnabled, onCheckedChange = { enabled ->
|
||||
localInvitesEnabled = enabled
|
||||
scope.launch {
|
||||
val value = if (enabled) "1" else "0"
|
||||
provider.setValue("chat.local_invites_enabled", value)
|
||||
if (NativeLib.isReady()) NativeLib.setChatSetting("local_invites_enabled", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
item(key = "local-broadcasts") {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.local_broadcasts_enabled), Modifier.weight(1f))
|
||||
Switch(checked = localBroadcastsEnabled, onCheckedChange = { enabled ->
|
||||
localBroadcastsEnabled = enabled
|
||||
scope.launch {
|
||||
val value = if (enabled) "1" else "0"
|
||||
provider.setValue("chat.local_broadcasts_enabled", value)
|
||||
if (NativeLib.isReady()) NativeLib.setChatSetting("local_broadcasts_enabled", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user