From 848247bde6f03318bf99b4822961c8d4d45f888c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 18:08:29 +0100 Subject: [PATCH] Exercise Android identity regeneration through JNI on devices --- .../com/utun/chat/viewmodel/ChatViewModel.kt | 4 +- .../tests/test_identity_lifecycle.kt | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tools/chatgui-android/tests/test_identity_lifecycle.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index d3a8d04f..11ac9f63 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -262,6 +262,7 @@ class ChatViewModel : ViewModel() { private val _generatingKeys = MutableStateFlow(false) val generatingKeys: StateFlow = _generatingKeys + private var keyGenerationJob: Job? = null private val _members = MutableStateFlow>(emptyList()) val members: StateFlow> = _members @@ -1082,8 +1083,9 @@ class ChatViewModel : ViewModel() { fun requestRegenerateKeys() { if (_generatingKeys.value || _databaseReset.value.running) return + keyGenerationJob?.cancel() _generatingKeys.value = true - viewModelScope.launch { + keyGenerationJob = viewModelScope.launch { try { HeadlessService.awaitStartup() val config = ChatApplication.instance.configProvider.buildConfigText().await() diff --git a/tools/chatgui-android/tests/test_identity_lifecycle.kt b/tools/chatgui-android/tests/test_identity_lifecycle.kt new file mode 100644 index 00000000..c0bbf72a --- /dev/null +++ b/tools/chatgui-android/tests/test_identity_lifecycle.kt @@ -0,0 +1,62 @@ +/* app_process + APK: JNI и настоящий worker без Activity, профиля приложения и внешних соединений. + * Аргумент — новый каталог внутри /data/local/tmp; libutun.so/зависимости должны быть в java.library.path. + * JNI restart вызывается напрямую: frontend-владельцы камеры/аудио в этом процессе не создаются. */ +import com.utun.chat.data.ChatEventCallback +import com.utun.chat.data.NativeLib +import java.io.File +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess + +private fun nativeCommand(name: String, config: String? = null): Any? { + val method = if (config == null) NativeLib::class.java.getDeclaredMethod(name) + else NativeLib::class.java.getDeclaredMethod(name, String::class.java) + method.isAccessible = true + return if (config == null) method.invoke(NativeLib) else method.invoke(NativeLib, config) +} + +fun main(args: Array) { + check(args.size == 1) + val directory = File(args.single()).canonicalFile + check(directory.path.startsWith("/data/local/tmp/utun-identity-audit-") && !directory.exists()) + check(directory.mkdirs()) + val events = LinkedBlockingQueue() + NativeLib.setEventCallback(object : ChatEventCallback { + override fun onEvent(type: Int, data: ByteArray?) { + if (type == 12 || type == 13) events.offer(type) + } + }) + fun awaitStarted(expectStop: Boolean) { + if (expectStop) check(events.poll(10, TimeUnit.SECONDS) == 13) { "old core did not stop" } + check(events.poll(10, TimeUnit.SECONDS) == 12 && NativeLib.isReady()) { "new core did not start" } + } + fun identity(): Pair { + val lines = File(directory, "keys.conf").readLines() + check(lines.size == 2 && lines[0].startsWith("public_key=") && lines[1].startsWith("private_key=")) + val keys = lines[0].removePrefix("public_key=") to lines[1].removePrefix("private_key=") + check(keys.first.length == 64 && keys.second.length == 64) + val public = keys.first.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + check(NativeLib.deriveNodeId(public) == NativeLib.getMyNodeId()) { "file/core identity mismatch" } + return keys + } + val config = "[global]\ndb_path=${directory.path}\nauto_sockets=no\n[ntp]\nenabled=no\n" + + "[stun]\nenabled=no\n[chatserver]\nstorage_autoload=0\n" + try { + check(NativeLib.start(config)) + awaitStarted(expectStop = false) + val original = identity() + val staleConfig = "[global]\nmy_public_key=${original.first}\nmy_private_key=${original.second}\n$config" + check(nativeCommand("nativeRegenerateKeys", staleConfig) == true) + awaitStarted(expectStop = true) + val generated = identity() + check(generated != original) { "key regeneration retained old pair" } + check(nativeCommand("nativeRestart", staleConfig) == true) + awaitStarted(expectStop = true) + check(identity() == generated) { "stale config reverted persisted identity" } + } finally { + nativeCommand("nativeStop") + } + check(!NativeLib.isReady()) + println("PASS: Android JNI start, full identity regeneration, persisted keys, stale-config restart and stop") + exitProcess(0) +}