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
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<string name="candidate_self">" (свой узел)"</string>
|
||||
<string name="invite_link">"Ссылка-приглашение"</string>
|
||||
<string name="select_node">"Выбор узла…"</string>
|
||||
<string name="invite_connect_via">Подключим через</string>
|
||||
<string name="invite_generating">"Генерация ссылки…"</string>
|
||||
<string name="qr_code">"QR-код"</string>
|
||||
<string name="invite_build_failed">"Не удалось собрать ссылку"</string>
|
||||
@@ -506,4 +507,14 @@
|
||||
<string name="attachment_key_denied">Автор отказал в доступе</string>
|
||||
<string name="attachment_decrypting">Расшифровка…</string>
|
||||
<string name="attachment_decrypt_failed">Ошибка расшифровки. Нажмите для повтора</string>
|
||||
<string name="local_invite_devices">Устройства в локальной сети:</string>
|
||||
<string name="local_invite_send">Отправить на устройство</string>
|
||||
<string name="local_invite_sending">Отправка приглашения…</string>
|
||||
<string name="local_invite_delivered">Приглашение доставлено</string>
|
||||
<string name="local_invite_failed">Не удалось доставить приглашение</string>
|
||||
<string name="local_invite_received">%1$s прислал приглашение по локальной сети. Подключиться?</string>
|
||||
<string name="local_network">Локальная сеть</string>
|
||||
<string name="local_invites_enabled">Разрешить получать приглашения из локальной сети</string>
|
||||
<string name="local_broadcasts_enabled">Разрешить отправлять широковещательные объявления</string>
|
||||
<string name="local_invite_forbidden">Приём приглашений запрещён. Разрешите его в настройках устройства получателя.</string>
|
||||
</resources>
|
||||
@@ -104,6 +104,7 @@
|
||||
<string name="candidate_self">" (self)"</string>
|
||||
<string name="invite_link">"Invite link"</string>
|
||||
<string name="select_node">"Select node…"</string>
|
||||
<string name="invite_connect_via">Connect via</string>
|
||||
<string name="invite_generating">"Generating link…"</string>
|
||||
<string name="qr_code">"QR code"</string>
|
||||
<string name="invite_build_failed">"Could not build invite link"</string>
|
||||
@@ -505,4 +506,14 @@
|
||||
<string name="attachment_key_denied">Access denied by author</string>
|
||||
<string name="attachment_decrypting">Decrypting…</string>
|
||||
<string name="attachment_decrypt_failed">Decryption failed. Tap to retry</string>
|
||||
<string name="local_invite_devices">Devices on the local network:</string>
|
||||
<string name="local_invite_send">Send to device</string>
|
||||
<string name="local_invite_sending">Sending invitation…</string>
|
||||
<string name="local_invite_delivered">Invitation delivered</string>
|
||||
<string name="local_invite_failed">Could not deliver invitation</string>
|
||||
<string name="local_invite_received">%1$s sent an invitation over the local network. Connect?</string>
|
||||
<string name="local_network">Local network</string>
|
||||
<string name="local_invites_enabled">Receive invitations from the local network</string>
|
||||
<string name="local_broadcasts_enabled">Broadcast presence on the local network</string>
|
||||
<string name="local_invite_forbidden">Receiving invitations is forbidden. Enable it in the recipient device\'s settings.</string>
|
||||
</resources>
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "../../../src/chat/chat_core.h"
|
||||
#include "../../../src/media_async/encrypted_file.h"
|
||||
#include "../../../src/chat/chat_event.h"
|
||||
#include "../../../src/chat/chat_local.h"
|
||||
#include "../../../src/chat/chat_poll.h"
|
||||
#include "../../../lib/strbuf.h"
|
||||
#include "../../../src/chat/chat_member.h"
|
||||
@@ -1974,6 +1975,37 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRequestInviteLink
|
||||
|
||||
/* ── nativeSetEventCallback(eventCb) ── */
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRequestLocalDevices(JNIEnv* env, jobject thiz) {
|
||||
(void)env; (void)thiz;
|
||||
struct UASYNC* ua = instance_lite_get_uasync();
|
||||
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
|
||||
if (!inst || !uasync_is_running(ua)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "device list requested while core is stopped"); return JNI_FALSE;
|
||||
}
|
||||
uasync_post(ua, chat_local_peers_trampoline, inst);
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeSendLocalInvite(
|
||||
JNIEnv* env, jobject thiz, jlong peer, jlong request, jbyteArray link) {
|
||||
(void)thiz;
|
||||
struct UASYNC* ua = instance_lite_get_uasync();
|
||||
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
|
||||
jsize len = link ? (*env)->GetArrayLength(env, link) : 0;
|
||||
if (!inst || !uasync_is_running(ua) || !peer || !request || len <= 0 || len > LOCAL_TRANSPORT_DATA_MAX) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid/stopped local send request bytes=%d", (int)len); return JNI_FALSE;
|
||||
}
|
||||
struct chat_local_send_request* req = u_calloc(1, sizeof(*req));
|
||||
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "local send request allocation failed"); return JNI_FALSE; }
|
||||
req->inst = inst; req->peer_id = (uint64_t)peer; req->request_id = (uint64_t)request;
|
||||
(*env)->GetByteArrayRegion(env, link, 0, len, (jbyte*)req->link);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "copying local invitation from JNI failed"); u_free(req); return JNI_FALSE;
|
||||
}
|
||||
uasync_post(ua, chat_local_send_trampoline, req);
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback(
|
||||
JNIEnv* env, jobject thiz, jobject callback) {
|
||||
(void)thiz;
|
||||
|
||||
Reference in new issue
Block a user