Add local UDP discovery and invitation delivery for chat clients

This commit is contained in:
evgeny committed 2026-10-18 14:46:04 +02:00
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()
@@ -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;