diff --git a/tools/chatgui-android/app/src/main/cpp/openssl/arm64-v8a/lib/libcrypto.a b/tools/chatgui-android/app/src/main/cpp/openssl/arm64-v8a/lib/libcrypto.a new file mode 100644 index 00000000..e945571a Binary files /dev/null and b/tools/chatgui-android/app/src/main/cpp/openssl/arm64-v8a/lib/libcrypto.a differ diff --git a/tools/chatgui-android/app/src/main/cpp/openssl/x86_64/lib/libcrypto.a b/tools/chatgui-android/app/src/main/cpp/openssl/x86_64/lib/libcrypto.a new file mode 100644 index 00000000..4c906300 Binary files /dev/null and b/tools/chatgui-android/app/src/main/cpp/openssl/x86_64/lib/libcrypto.a differ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt new file mode 100644 index 00000000..4bd6da21 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -0,0 +1,47 @@ +package com.utun.chat + +import android.app.Application +import android.util.Log +import com.utun.chat.data.LogManager +import com.utun.chat.data.ConfigProvider +import com.utun.chat.data.NativeLib +import com.utun.chat.data.ChatEventCallback +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +object AppEventHandler : ChatEventCallback { + private val _events = MutableSharedFlow>(replay = 16, extraBufferCapacity = 64) + val events: SharedFlow> = _events + + override fun onEvent(type: Int, data: ByteArray?) { + Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}") + _events.tryEmit(type to data) + } +} + +class ChatApplication : Application() { + lateinit var logManager: LogManager + lateinit var configProvider: ConfigProvider + + override fun onCreate() { + super.onCreate() + instance = this + logManager = LogManager + configProvider = ConfigProvider(this) + + /* One-time event callback registration — survives ViewModel recreation */ + NativeLib.setEventCallback(AppEventHandler) + + /* First launch config */ + if (configProvider.isFirstLaunch()) { + kotlinx.coroutines.runBlocking { + LogManager.addLog("INFO", "APP", "First launch — marking launched") + configProvider.markLaunched() + } + } + } + + companion object { + lateinit var instance: ChatApplication + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt new file mode 100644 index 00000000..dbbb8892 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -0,0 +1,149 @@ +package com.utun.chat + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.runtime.* +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.utun.chat.data.InviteData +import com.utun.chat.data.LogManager +import com.utun.chat.data.NativeLib +import com.utun.chat.headless.HeadlessService +import com.utun.chat.ui.screens.* +import com.utun.chat.viewmodel.ChatViewModel +import android.content.Intent + +class MainActivity : ComponentActivity() { + enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + /* Start uTun headless service (must be from foreground Activity on API 34+) */ + ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java)) + + setContent { + val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory()) + + var screen by remember { mutableStateOf(Screen.ChannelList) } + var qrResult by remember { mutableStateOf(null) } + var showJoinDialog by remember { mutableStateOf(false) } + var joinAutoLink by remember { mutableStateOf(null) } + var inviteLinkText by remember { mutableStateOf(null) } + + BackHandler(enabled = screen != Screen.ChannelList) { + when (screen) { + Screen.MemberList -> screen = Screen.Chat + else -> screen = Screen.ChannelList + } + } + + val connected = NativeLib.isReady() + + /* Subscribe native logs only when LogScreen is open */ + DisposableEffect(screen) { + if (screen == Screen.Logs) NativeLib.subscribeNativeLogs(true) + onDispose { NativeLib.subscribeNativeLogs(false) } + } + + when (screen) { + Screen.ChannelList -> { + val channels by vm.channels.collectAsState() + val stuck by vm.isStuck.collectAsState() + ChannelListScreen( + channels = channels, + connected = connected, + isStuck = stuck, + onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, + onCreateChannel = { vm.createChannel("general", "channel_1") }, + onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, + onInviteClick = { ch -> + val chId = ch.id.toLongOrNull() + if (chId == null) { + LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}") + } else { + val link = NativeLib.encodeInviteLink(chId) + if (link != null) inviteLinkText = link + else LogManager.addLog("ERROR", "INVITE", "encodeInviteLink returned null for ch=$chId") + } + }, + onSettings = { screen = Screen.Settings }, + onLogs = { screen = Screen.Logs }, + onRefresh = { vm.requestRefresh() } + ) + } + Screen.Chat -> { + val messages by vm.messages.collectAsState() + val channel = vm.currentChannel.collectAsState().value + if (channel != null) { + ChatScreen( + channel = channel, + messages = messages, + connected = connected, + viewModel = vm, + onSendMessage = { vm.sendMessage(it) }, + onMembersClick = { screen = Screen.MemberList }, + onBack = { screen = Screen.ChannelList } + ) + } + } + Screen.MemberList -> { + val members by vm.members.collectAsState() + val selectedMember by vm.selectedMember.collectAsState() + val channel = vm.currentChannel.collectAsState().value + if (channel != null) { + MemberListScreen( + channel = channel, + members = members, + selectedMember = selectedMember, + viewModel = vm, + onBack = { screen = Screen.Chat } + ) + } + } + Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() }) + Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList }) + Screen.QrScan -> { + QrScanScreen( + onResult = { link -> + qrResult = link + screen = Screen.ChannelList + }, + onBack = { screen = Screen.ChannelList } + ) + } + } + + /* Show join dialog after QR scan returns */ + LaunchedEffect(qrResult) { + qrResult?.let { link -> + joinAutoLink = link + showJoinDialog = true + qrResult = null + } + } + + if (showJoinDialog) { + JoinChannelDialog( + autoLink = joinAutoLink, + onScanQr = { + screen = Screen.QrScan + showJoinDialog = false + }, + onConnect = { data: InviteData -> + val addrs = data.serializeAddrs() + NativeLib.joinChannel(data.channelId, data.nodeId, data.pubkey, addrs, data.addrs.size) + showJoinDialog = false + }, + onDismiss = { showJoinDialog = false } + ) + } + + inviteLinkText?.let { link -> + InviteLinkDialog(link = link, onDismiss = { inviteLinkText = null }) + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt new file mode 100644 index 00000000..37608adf --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -0,0 +1,260 @@ +package com.utun.chat.data + +import android.Manifest +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaRecorder +import androidx.core.content.ContextCompat +import com.utun.chat.ChatApplication + +class AudioRecorderManager { + companion object { + const val SAMPLE_RATE = 48000 + const val CHANNELS = 1 + const val FRAME_MS = 20 + const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 + const val MIN_DURATION_MS = 500 + } + + private var audioRecord: AudioRecord? = null + private var recordingThread: Thread? = null + @Volatile private var isRecording = false + + private var testRecord: AudioRecord? = null + private var testThread: Thread? = null + @Volatile var isTesting = false + private set + + fun hasPermission(): Boolean { + val ctx = ChatApplication.instance + return ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + } + + fun startRecording(channelId: String): Boolean { + if (isRecording) return false + if (!hasPermission()) return false + + val ok = NativeLib.voiceStart(channelId) + if (!ok) { + LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed") + return false + } + + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ).coerceAtLeast(FRAME_SAMPLES * 2) + + audioRecord = try { + AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize * 2 + ) + } catch (e: Exception) { + LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord create failed: ${e.message}") + NativeLib.voiceCancel() + return false + } + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord not initialized") + audioRecord?.release() + audioRecord = null + NativeLib.voiceCancel() + return false + } + + isRecording = true + LogManager.addLog("INFO", "AudioRecorder", "started ch=$channelId bufferSize=$bufferSize") + audioRecord?.startRecording() + + recordingThread = Thread { + val buffer = ShortArray(FRAME_SAMPLES) + var totalRead = 0L + + while (isRecording && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + val read = audioRecord?.read(buffer, 0, FRAME_SAMPLES) ?: break + if (read <= 0) break + NativeLib.voiceFeed(buffer.copyOf(read)) + totalRead += read + } + LogManager.addLog("DEBUG", "AudioRecorder", "thread done totalSamples=$totalRead") + }.apply { + priority = Thread.MAX_PRIORITY + start() + } + + return true + } + + fun stopRecording(): Int { + if (!isRecording) return 0 + isRecording = false + + recordingThread?.join(1000) + recordingThread = null + + audioRecord?.apply { + try { stop() } catch (_: Exception) {} + release() + } + audioRecord = null + + val duration = NativeLib.voiceStop() + LogManager.addLog("INFO", "AudioRecorder", "stopped duration=${duration}ms") + return duration + } + + fun cancelRecording() { + if (!isRecording) return + isRecording = false + + recordingThread?.join(1000) + recordingThread = null + + audioRecord?.apply { + try { stop() } catch (_: Exception) {} + release() + } + audioRecord = null + + NativeLib.voiceCancel() + LogManager.addLog("INFO", "AudioRecorder", "cancelled") + } + + fun isActive(): Boolean = isRecording + + fun startMicTest(): Boolean { + if (isTesting) return true + if (!hasPermission()) return false + + NativeLib.voiceStart("__test__") + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ).coerceAtLeast(FRAME_SAMPLES * 2) + + testRecord = try { + AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize * 2 + ) + } catch (e: Exception) { + NativeLib.voiceCancel(); return false + } + if (testRecord?.state != AudioRecord.STATE_INITIALIZED) { + testRecord?.release(); testRecord = null; NativeLib.voiceCancel(); return false + } + + isTesting = true + testRecord?.startRecording() + + testThread = Thread { + val buffer = ShortArray(FRAME_SAMPLES) + while (isTesting && testRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + val read = testRecord?.read(buffer, 0, FRAME_SAMPLES) ?: break + if (read <= 0) break + NativeLib.voiceFeed(buffer.copyOf(read)) + } + }.apply { priority = Thread.MAX_PRIORITY; start() } + return true + } + + fun stopMicTest() { + isTesting = false + testThread?.join(1000); testThread = null + testRecord?.apply { try { stop() } catch (_: Exception) {}; release() } + testRecord = null + NativeLib.voiceCancel() + } + + @Volatile private var playing = false + private var playThread: Thread? = null + @Volatile private var playGeneration = 0 + + fun playVoiceFile(filePath: String, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { + playFromPosition(filePath, 0, onProgress, onComplete) + } + + fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { + stopPlayback() + playing = true + + playThread = Thread { + val myGen = playGeneration + try { + val info = IntArray(3) + val handle = NativeLib.voiceDecodeOpen(filePath, info) + if (handle == 0L) { + LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath") + playing = false; onComplete(); return@Thread + } + val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2] + + /* skip to startMs by decoding and discarding frames */ + val frameSamples = FRAME_SAMPLES * channels + val skipFrames = startMs / FRAME_MS + val skipBuf = ShortArray(frameSamples) + var skippedSamples = 0L + for (f in 0 until skipFrames) { + val dec = NativeLib.voiceDecodeRead(handle, skipBuf) + if (dec <= 0) break + skippedSamples += dec + } + + val bufSize = AudioTrack.getMinBufferSize(sampleRate, + if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT) + + val track = AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, + if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, bufSize, AudioTrack.MODE_STREAM) + track.play() + + val pcm = ShortArray(frameSamples) + var readTotal = 0L + var read: Int + while (playing) { + read = NativeLib.voiceDecodeRead(handle, pcm) + if (read <= 0) break + track.write(pcm, 0, read) + readTotal += read + val total = (skippedSamples + readTotal).toFloat() / (totalSamples * channels).coerceAtLeast(1).toFloat() + val eMs = startMs + (readTotal * FRAME_MS / frameSamples).toInt() + onProgress?.invoke(total, eMs) + } + val drainMs = (bufSize * 1000L) / (sampleRate.toLong() * channels * 2) + 100 + try { Thread.sleep(drainMs) } catch (_: InterruptedException) {} + track.stop(); track.release() + NativeLib.voiceDecodeClose(handle) + } catch (e: Exception) { + LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") + } + if (playGeneration == myGen) { + playing = false; onComplete() + } + }.apply { start() } + } + + fun pausePlayback() { + playGeneration++; playing = false + playThread?.join(500); playThread = null + } + + fun stopPlayback() { + playGeneration++; playing = false + playThread?.join(500); playThread = null + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt new file mode 100644 index 00000000..ca8c31e4 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -0,0 +1,326 @@ +package com.utun.chat.data + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.nio.ByteBuffer +import java.nio.ByteOrder + +const val EVT_NODEINFO_UPDATED = 16 +const val EVT_LINK_STATUS_CHANGED = 17 +const val EVT_LOCAL_SOCKETS = 19 + +data class NodeStatus( + val nodeId: Long = 0, + val directPresence: Boolean = false, + val directUp: Boolean = false, + val indirectPresence: Boolean = false, + val indirectUp: Boolean = false, + val bgpPresence: Boolean = false, + val bgpUp: Boolean = false, + val bestRtt: Int = 0, + val rttType: Int = 0 // 0=interface, 1=nat, 2=real +) { + val online: Boolean get() = (directPresence && directUp) || (indirectPresence && indirectUp) + val hasPresence: Boolean get() = directPresence || indirectPresence || bgpPresence +} + +fun parseNodeStatus(data: ByteArray): NodeStatus? { + if (data.size < 12) return null + val buf = ByteBuffer.wrap(data, 0, 12).order(ByteOrder.LITTLE_ENDIAN) + val nodeId = buf.getLong() + val presence = buf.get().toInt() and 0xFF + val up = buf.get().toInt() and 0xFF + val rttPacked = buf.getShort().toInt() and 0xFFFF + val bestRtt = rttPacked and 0x3FFF + val rttType = (rttPacked shr 14) and 3 + return NodeStatus( + nodeId = nodeId, + directPresence = (presence and 1) != 0, + directUp = (up and 1) != 0, + indirectPresence = (presence and 2) != 0, + indirectUp = (up and 2) != 0, + bgpPresence = (presence and 4) != 0, + bgpUp = (up and 4) != 0, + bestRtt = bestRtt, + rttType = rttType + ) +} + +data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "", + val peersOnline: Int = 0) +data class Message(val id: Long, val author: String, val text: String, val ts: Long, + val isOutgoing: Boolean = false, val contentType: String = "text/plain", + val filePath: String = "", val waveform: List = emptyList(), + val voiceDurationMs: Int = 0, val fileSize: Long = 0, + val downloadState: String = "" /* "", "downloading", "downloaded", "error" */) + +data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, + val isAdmin: Boolean = false, val isSuper: Boolean = false, + val isStorage: Boolean = false, val nodeType: Int = 0, + val rtt: Int = 0, val rttType: Int = 0, val isSelf: Boolean = false, + val directUp: Boolean = false, val indirectUp: Boolean = false, + val directPresence: Boolean = false, val indirectPresence: Boolean = false, + val bgpPresence: Boolean = false) + +data class MemberDetail(val nodeId: Long, val name: String = "", + val online: Boolean = false, val lastSeen: Long = 0, + val created: Long = 0, val addresses: List = emptyList(), + val bestRtt: Int = 0, val rttType: Int = 0, + val directPresence: Boolean = false, val directUp: Boolean = false, + val indirectPresence: Boolean = false, val indirectUp: Boolean = false, + val bgpPresence: Boolean = false) + +data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "", + val port: Int = 0, val socketId: Int = 0, val rtt: Int = 0) + +data class LinkStatus(val family: Int = 0, val address: String = "", val port: Int = 0, + val linkState: Int = 0, val linkStatus: Int = 0) + +data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false, + val linksUp: Int = 0, val links: List = emptyList()) + +data class LocalSocketState( + val sockId: Int = 0, val ifName: String = "", val bindIp: String = "", + val port: Int = 0, val protocol: String = "", val state: Int = 0, + val linksCount: Int = 0 +) { + val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" } +} + +class ChatRepository { + private val myNodeId: Long = NativeLib.getMyNodeId() + + fun getChannels(): List { + val list = mutableListOf() + try { + val arr = JSONArray(NativeLib.getChannels()) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + list.add(Channel( + id = obj.getString("id"), + name = obj.optString("name", obj.getString("id")), + lastMsgAt = obj.optLong("lastMsgAt", 0), + lastMsg = obj.optString("lastMsg", ""), + peersOnline = obj.optInt("peersOnline", 0) + )) + } + } catch (e: Exception) { Log.w("utun-gui", "getChannels error", e) } + return list + } + + fun getPeersOnline(channelId: String): Int { + try { + val arr = JSONArray(NativeLib.getChannels()) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + if (obj.getString("id") == channelId) return obj.optInt("peersOnline", 0) + } + } catch (e: Exception) { Log.w("utun-gui", "getPeersOnline error", e) } + return 0 + } + + fun getMessages(channelId: String, limit: Int = 50): List { + val list = mutableListOf() + try { + val arr = JSONArray(NativeLib.getMessages(channelId, limit)) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + val text = obj.optString("text", "") + val contentType = obj.optString("contentType", "text/plain") + val filePath = obj.optString("filePath", "") + + var displayText = text + var fileSize = 0L + + /* Parse download state from localAttrs */ + var downloadState = "" + val localAttrs = obj.optString("localAttrs", "") + if (filePath.isNotEmpty()) downloadState = "downloaded" + else if (localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded" + + /* Parse base64 waveform from voice message text (format: "wf=<100 chars A-Za-z0-9+/>;dur=1234;") */ + var waveform = emptyList() + var voiceDurationMs = 0 + if (contentType == "audio/opus" && text.startsWith("wf=")) { + try { + val wfEnd = text.indexOf(";dur=") + if (wfEnd > 0) { + val wfRaw = text.substring(3, wfEnd) // 100 base64 chars + waveform = if (wfRaw.length >= 50) { + wfRaw.map { c -> + val idx = when (c) { + in 'A'..'Z' -> c - 'A' + in 'a'..'z' -> c - 'a' + 26 + in '0'..'9' -> c - '0' + 52 + '+' -> 62; '/' -> 63; else -> 0 + } + (idx.toFloat() / 63f).coerceIn(0f, 1f) + } + } else { + /* old CSV format fallback */ + val parts = wfRaw.split(",") + if (parts.size >= 2) { + parts.map { s -> + val v = s.toFloatOrNull() ?: 0f + val db = if (v > 0.00001f) 20 * kotlin.math.log10(v.toDouble()).toFloat() else -100f + ((db + 60f) / 60f).coerceIn(0f, 1f) + } + } else emptyList() + } + val durPart = text.substring(wfEnd + 5).takeWhile { it != ';' } + voiceDurationMs = durPart.toIntOrNull() ?: 0 + } + } catch (_: Exception) {} + } + + /* Parse file attachment: "filename.ext|file_size|block_size|num_blocks|sigs" */ + if (contentType == "application/octet-stream") { + try { + val pipeIdx = text.indexOf('|') + if (pipeIdx > 0) { + displayText = text.substring(0, pipeIdx) + val metaParts = text.substring(pipeIdx + 1).split("|") + if (metaParts.isNotEmpty()) + fileSize = metaParts[0].toLongOrNull() ?: 0L + } + } catch (_: Exception) {} + } + + list.add(Message( + id = obj.getLong("id"), + author = obj.getString("author"), + text = displayText, + ts = obj.getLong("ts"), + isOutgoing = obj.optBoolean("isOutgoing", false), + contentType = contentType, + filePath = filePath, + waveform = waveform, + voiceDurationMs = voiceDurationMs, + fileSize = fileSize, + downloadState = downloadState + )) + } + } catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) } + return list + } + + fun sendMessage(channelId: String, text: String) = NativeLib.sendMessage(channelId, text) + fun sendAttachment(channelId: String, filePath: String) = NativeLib.attachmentSend(channelId, filePath) + fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId) + fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex) + fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) = + NativeLib.joinChannel(channelId, nodeId, pubkey, addrs, addrCount) + fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId) + fun isReady() = NativeLib.isReady() + + fun getMembers(channelId: String): List { + val list = mutableListOf() + try { + val arr = JSONArray(NativeLib.getChannelMembers(channelId)) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + list.add(ChatMember( + nodeId = obj.getLong("ni"), + name = obj.optString("n", ""), + online = obj.optInt("on", 0) != 0, + isAdmin = obj.optInt("ad", 0) != 0, + isSuper = obj.optInt("sp", 0) != 0, + isStorage = obj.optInt("st", 0) != 0, + nodeType = obj.optInt("tp", 0), + rtt = obj.optInt("rt", 0), + isSelf = obj.optInt("is", 0) != 0 + )) + } + } catch (e: Exception) { Log.w("utun-gui", "getMembers error", e) } + return list + } + + fun getMemberDetail(channelId: String, nodeId: Long): MemberDetail? { + try { + val obj = JSONObject(NativeLib.getMemberDetail(channelId, nodeId)) + if (obj.length() == 0) return null + val addrs = mutableListOf() + val arr = obj.optJSONArray("adr") + if (arr != null) { + for (i in 0 until arr.length()) { + val a = arr.getJSONObject(i) + addrs.add(MemberAddr( + family = a.optInt("fm", 0), + protocol = a.optString("pt", ""), + address = a.optString("ad", ""), + port = a.optInt("po", 0), + socketId = a.optInt("si", 0), + rtt = a.optInt("rt", 0) + )) + } + } + return MemberDetail( + nodeId = obj.getLong("ni"), + name = obj.optString("n", ""), + online = obj.optInt("on", 0) != 0, + lastSeen = obj.optLong("ls", 0), + created = obj.optLong("cr", 0), + addresses = addrs + ) + } catch (e: Exception) { Log.w("utun-gui", "getMemberDetail error", e) } + return null + } + + fun setMemberNick(channelId: String, nodeId: Long, nick: String) = + NativeLib.setMemberNick(channelId, nodeId, nick) + + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) = + NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, storage) + + fun getMemberLinks(nodeId: Long): MemberLinks { + try { + val obj = JSONObject(NativeLib.getMemberLinks(nodeId)) + if (!obj.optBoolean("cn", false)) return MemberLinks() + val links = mutableListOf() + val arr = obj.optJSONArray("lk") + if (arr != null) { + for (i in 0 until arr.length()) { + val a = arr.getJSONObject(i) + links.add(LinkStatus( + family = a.optInt("fm", 0), + address = a.optString("ad", ""), + port = a.optInt("po", 0), + linkState = a.optInt("st", 0), + linkStatus = a.optInt("ls", 0) + )) + } + } + return MemberLinks( + connected = obj.optBoolean("cn", false), + initialized = obj.optBoolean("in", false), + linksUp = obj.optInt("lu", 0), + links = links + ) + } catch (e: Exception) { Log.w("utun-gui", "getMemberLinks error", e) } + return MemberLinks() + } + + fun getLocalSockets(): List { + try { + val arr = JSONArray(NativeLib.getLocalSockets()) + val list = mutableListOf() + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + list.add(LocalSocketState( + sockId = obj.optInt("si", 0), + ifName = obj.optString("nm", ""), + bindIp = obj.optString("ad", ""), + port = obj.optInt("po", 0), + protocol = obj.optString("pt", ""), + state = obj.optInt("st", 0), + linksCount = obj.optInt("lk", 0) + )) + } + return list + } catch (e: Exception) { Log.w("utun-gui", "getLocalSockets error", e) } + return emptyList() + } + + fun close() {} +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt new file mode 100644 index 00000000..e43969b2 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt @@ -0,0 +1,6 @@ +package com.utun.chat.data + +interface ConfigCallback { + fun getString(key: String): String? + fun getInt(key: String): Int +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt new file mode 100644 index 00000000..0c24072b --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -0,0 +1,397 @@ +package com.utun.chat.data + +import android.content.Context +import android.os.Build +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.io.File +import java.util.UUID +import java.util.concurrent.ThreadLocalRandom + +private val Context.dataStore by preferencesDataStore("utun_config") + +class ConfigProvider(private val context: Context) { + private val nodePrivateKey = stringPreferencesKey("node_private_key") + private val nodePublicKey = stringPreferencesKey("node_public_key") + private val nodeName = stringPreferencesKey("node_name") + private val nodeListenPort = intPreferencesKey("node_listen_port") + private val controlPort = intPreferencesKey("control_port") + private val controlBindIp = stringPreferencesKey("control_bind_ip") + private val dbPath = stringPreferencesKey("db_path") + private val debugConsoleLevel = stringPreferencesKey("debug_console_level") + private val debugLogFile = stringPreferencesKey("debug_log_file") + private val debugCategories = stringPreferencesKey("debug_categories") + private val logUdpIp = stringPreferencesKey("log_udp_ip") + private val logUdpPort = intPreferencesKey("log_udp_port") + private val chatStorageAutoload = intPreferencesKey("chat_storage_autoload") + private val chatStorageAutoloadMaxsizeMb = intPreferencesKey("chat_storage_autoload_maxsize_mb") + private val chatStorageMaxsizeGb = intPreferencesKey("chat_storage_maxsize_gb") + private val chatOpusCodecPreset = intPreferencesKey("chat_opus_codec_preset") + private val chatCompressorEnabled = intPreferencesKey("chat_compressor_enabled") + private val chatCompressorMaxGainDb = intPreferencesKey("chat_compressor_max_gain_db") + private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate") + private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers") + private val firstLaunchDone = booleanPreferencesKey("first_launch_done") + private val connectionModeKey = stringPreferencesKey("connection_mode") + + private fun keysFilePath(): String { + val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath + return "$dir/keys.conf" + } + + fun deleteKeysFile() { + File(keysFilePath()).delete() + } + + private fun readKeysFromFile(): Pair? { + val f = File(keysFilePath()) + if (!f.exists()) return null + try { + val lines = f.readLines() + var pub = ""; var priv = "" + for (l in lines) { + when { + l.startsWith("public_key=") -> pub = l.removePrefix("public_key=").trim() + l.startsWith("private_key=") -> priv = l.removePrefix("private_key=").trim() + } + } + if (pub.length == 64 && priv.length == 64) return Pair(pub, priv) + } catch (_: Exception) {} + return null + } + + fun getDeviceName(): String = Build.MODEL.ifEmpty { "Android" } + + val allValues: Flow> = context.dataStore.data.map { prefs -> + mapOf( + "node.private_key" to (prefs[nodePrivateKey] ?: ""), + "node.public_key" to (prefs[nodePublicKey] ?: ""), + "node.name" to (prefs[nodeName] ?: getDeviceName()), + "node.listen_port" to (prefs[nodeListenPort]?.toString() ?: "12345"), + "control.port" to (prefs[controlPort]?.toString() ?: "9999"), + "db.path" to (prefs[dbPath] ?: context.filesDir.absolutePath), + "debug.console_level" to (prefs[debugConsoleLevel] ?: "info"), + "debug.log_file" to (prefs[debugLogFile] ?: "utun.log"), + "debug.categories" to (prefs[debugCategories] ?: ""), + "log_udp.ip" to (prefs[logUdpIp] ?: ""), + "log_udp.port" to (prefs[logUdpPort]?.toString() ?: "0") + ) + } + + fun getString(key: String): String? = runBlocking { + when { + key == "node.private_key" -> readKeysFromFile()?.second ?: context.dataStore.data.first()[nodePrivateKey] + key == "node.public_key" -> readKeysFromFile()?.first ?: context.dataStore.data.first()[nodePublicKey] + key == "node.name" -> context.dataStore.data.first()[nodeName] ?: getDeviceName() + key == "db.path" -> context.dataStore.data.first()[dbPath] ?: context.filesDir.absolutePath + key == "control.bind_ip" -> context.dataStore.data.first()[controlBindIp] ?: "127.0.0.1" + key == "debug.console_level" -> context.dataStore.data.first()[debugConsoleLevel] ?: "info" + key == "debug.log_file" -> context.dataStore.data.first()[debugLogFile] ?: "utun.log" + key == "debug.categories" -> context.dataStore.data.first()[debugCategories] ?: "" + key == "log_udp.ip" -> context.dataStore.data.first()[logUdpIp] ?: "192.168.40.250" + else -> null + } + } + + fun getInt(key: String): Int = runBlocking { + when { + key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345 + key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999 + key == "log_udp.port" -> context.dataStore.data.first()[logUdpPort] ?: 9999 + key == "chat.storage_autoload" -> context.dataStore.data.first()[chatStorageAutoload] ?: 1 + key == "chat.storage_autoload_maxsize_mb" -> context.dataStore.data.first()[chatStorageAutoloadMaxsizeMb] ?: 10 + key == "chat.storage_maxsize_gb" -> context.dataStore.data.first()[chatStorageMaxsizeGb] ?: 1 + key == "chat.opus_codec_preset" -> context.dataStore.data.first()[chatOpusCodecPreset] ?: 1 + key == "chat.compressor_enabled" -> context.dataStore.data.first()[chatCompressorEnabled] ?: 0 + key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 25 + key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 10 + key == "chat.media_download_max_peers" -> context.dataStore.data.first()[chatMediaDownloadMaxPeers] ?: 3 + else -> 0 + } + } + + suspend fun setValue(key: String, value: String) { + context.dataStore.edit { prefs -> + when (key) { + "node.private_key" -> prefs[nodePrivateKey] = value + "node.public_key" -> prefs[nodePublicKey] = value + "node.name" -> prefs[nodeName] = value + "node.listen_port" -> prefs[nodeListenPort] = value.toIntOrNull() ?: 12345 + "control.port" -> prefs[controlPort] = value.toIntOrNull() ?: 9999 + "control.bind_ip" -> prefs[controlBindIp] = value + "db.path" -> prefs[dbPath] = value + "debug.console_level" -> prefs[debugConsoleLevel] = value + "debug.log_file" -> prefs[debugLogFile] = value + "debug.categories" -> prefs[debugCategories] = value + "log_udp.ip" -> prefs[logUdpIp] = value + "log_udp.port" -> prefs[logUdpPort] = value.toIntOrNull() ?: 0 + "chat.storage_autoload" -> prefs[chatStorageAutoload] = value.toIntOrNull() ?: 1 + "chat.storage_autoload_maxsize_mb" -> prefs[chatStorageAutoloadMaxsizeMb] = value.toIntOrNull() ?: 10 + "chat.storage_maxsize_gb" -> prefs[chatStorageMaxsizeGb] = value.toIntOrNull() ?: 1 + "chat.opus_codec_preset" -> prefs[chatOpusCodecPreset] = value.toIntOrNull() ?: 1 + "chat.compressor_enabled" -> prefs[chatCompressorEnabled] = value.toIntOrNull() ?: 0 + "chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 25 + "chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 10 + "chat.media_download_max_peers" -> prefs[chatMediaDownloadMaxPeers] = value.toIntOrNull() ?: 3 + } + } + } + + suspend fun generateKeys() { + /* Keys are generated in C (instance_lite via ensure_keys). + * They are saved back via NativeLib.start() → nativeGetGeneratedKeys(). */ + } + + private companion object { + /** Accept only global unicast (2000::/3) and unique local (fc00::/7) IPv6. */ + fun isUsableV6(addr: Inet6Address): Boolean { + if (addr.isLoopbackAddress || addr.isLinkLocalAddress || addr.isMulticastAddress) return false + val b = addr.address // 16 bytes + val hi = ((b[0].toInt() and 0xFF) shl 8) or (b[1].toInt() and 0xFF) + // fc00::/7 → fc00-fdff, 2000::/3 → 2000-3fff + return hi in 0xFC00..0xFDFF || hi in 0x2000..0x3FFF + } + + // 0x20 = IFA_F_TEMPORARY в /proc/net/if_inet6 + fun isTemporaryV6(ifName: String, ip: String): Boolean { + val hexAddr = ip.split("%")[0].replace(":", "").uppercase().padStart(32, '0') + return try { + File("/proc/net/if_inet6").useLines { lines -> + lines.any { line -> + val parts = line.trim().split("\\s+".toRegex(), limit = 6) + parts.size >= 6 && parts[0] == hexAddr && parts[5] == ifName && + parts[4].toIntOrNull(16)?.let { (it and 0x20) != 0 } == true + } + } + } catch (_: Exception) { false } + } + } + + /* ── First launch ── */ + + fun isFirstLaunch(): Boolean = runBlocking { + !(context.dataStore.data.first()[firstLaunchDone] ?: false) + } + + suspend fun markLaunched() { + context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true } + } + + /* ── Connection mode ── */ + + fun getConnectionMode(): String = runBlocking { + context.dataStore.data.first()[connectionModeKey] ?: "manual" + } + + suspend fun setConnectionMode(mode: String) { + context.dataStore.edit { prefs -> prefs[connectionModeKey] = mode } + } + + /* ── Server entries ── */ + + suspend fun detectInterfaces(): List { + val servers = mutableListOf() + val seen = mutableSetOf() + try { + val nis = NetworkInterface.getNetworkInterfaces() + while (nis.hasMoreElements()) { + val ni = nis.nextElement() + if (!ni.isUp || ni.isLoopback) continue + for (a in ni.interfaceAddresses) { + val addr = a.address + val ip = when { + addr is Inet4Address -> addr.hostAddress + addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr.hostAddress ?: "") -> addr.hostAddress?.let { it.split("%")[0] } + else -> null + } ?: continue + val key = "${ni.name}/${if (ip.contains(":")) "v6" else "v4"}" + if (!seen.add(key)) continue + servers.add(ServerEntry( + id = UUID.randomUUID().toString(), + ifName = ni.name, + bindIp = ip, + protocol = "tcp", + port = ThreadLocalRandom.current().nextInt(2000, 10000) + )) + } + } + } catch (e: Exception) { + LogManager.addLog("ERROR", "CFG", "detectInterfaces failed: ${e.message}") + } + return servers + } + + /* ── Config file as single source of truth for servers ── */ + + fun configFilePath(): String { + val dir = getString("db.path") ?: context.filesDir.absolutePath + return "$dir/utun.cfg" + } + + fun loadServersFromConfig(): List { + val file = File(configFilePath()) + if (!file.exists()) return emptyList() + data class RawSrv(val ifName: String, val family: String, val port: Int, val protocol: String) + val raw = mutableListOf() + try { + val lines = file.readLines() + var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp"; var sectionOpen = false + for (line in lines) { + val trimmed = line.trim() + if (trimmed.startsWith("[server:") && trimmed.endsWith("]")) { + if (sectionOpen && ifName.isNotEmpty() && addrPort > 0) + raw.add(RawSrv(ifName, family, addrPort, proto)) + val section = trimmed.removeSurrounding("[", "]").removePrefix("server:") + val lastUnderscore = section.lastIndexOf('_') + if (lastUnderscore > 0) { + ifName = section.substring(0, lastUnderscore) + family = section.substring(lastUnderscore + 1) + } else { ifName = ""; family = "" } + addrPort = 0; proto = "tcp"; sectionOpen = true + } else if (trimmed.startsWith("addr=")) { + val addr = trimmed.removePrefix("addr=") + val colonIdx = addr.lastIndexOf(':') + if (colonIdx >= 0) addrPort = addr.substring(colonIdx + 1).toIntOrNull() ?: 0 + } else if (trimmed.startsWith("transport=")) { + proto = if (trimmed.removePrefix("transport=") == "udp") "udp" else "tcp" + } + } + if (sectionOpen && ifName.isNotEmpty() && addrPort > 0) + raw.add(RawSrv(ifName, family, addrPort, proto)) + } catch (_: Exception) { return emptyList() } + + val liveIps = getAvailableInterfaces() + return raw.map { rs -> + val isV6 = rs.family == "v6" + val realIp = liveIps.firstOrNull { (ifn, ip) -> + ifn == rs.ifName && (if (isV6) ip.contains(":") else !ip.contains(":")) + }?.second + ServerEntry(UUID.randomUUID().toString(), rs.ifName, + realIp ?: if (isV6) "::" else "0.0.0.0", rs.protocol, rs.port) + } + } + + fun saveServersToConfig(servers: List) { + val text = buildConfigFromServers(servers) + File(configFilePath()).writeText(text) + LogManager.addLog("INFO", "CFG", "saveServersToConfig: ${servers.size} servers written" + + servers.joinToString(", ") { "${it.ifName}=${it.protocol}" }) + } + + /** Build complete INI text from server list (without touching config file) */ + private fun buildConfigFromServers(servers: List): String { + val pubKey = getString("node.public_key") ?: "" + val privKey = getString("node.private_key") ?: "" + val nodeName = getString("node.name") ?: getDeviceName() + val dbDir = getString("db.path") ?: context.filesDir.absolutePath + return buildString { + appendLine("[global]") + if (nodeName.isNotBlank()) appendLine("my_node_name=$nodeName") + if (pubKey.length == 64) appendLine("my_public_key=$pubKey") + if (privKey.length == 64) appendLine("my_private_key=$privKey") + appendLine("db_path=$dbDir") + appendLine("db_sync_enabled=1") + appendLine("chatserver_enabled=1") + appendLine() + for (srv in servers) { + val family = if (srv.bindIp.contains(":")) "v6" else "v4" + appendLine("[server:${srv.ifName}_$family]") + appendLine("addr=${if (family == "v6") "[::]" else "0.0.0.0"}:${srv.port}") + appendLine("type=public") + appendLine("transport=${srv.protocol}") + if (family == "v6") appendLine("netif=${srv.ifName}") + appendLine() + } + appendLine("[allowed_keys]") + appendLine("allow_all=yes") + appendLine() + appendLine("[ntp]") + appendLine("enabled=no") + appendLine() + appendLine("[chat]") + val chatAutoload = getInt("chat.storage_autoload") + val chatAutoloadMaxMb = getInt("chat.storage_autoload_maxsize_mb") + val chatMaxGb = getInt("chat.storage_maxsize_gb") + val chatOpusPreset = getInt("chat.opus_codec_preset") + val chatCompEnabled = getInt("chat.compressor_enabled") + val chatCompMaxGain = getInt("chat.compressor_max_gain_db") + val chatCompRiseRate = getInt("chat.compressor_rise_rate") + val chatDlMaxPeers = getInt("chat.media_download_max_peers") + appendLine("storage_autoload=$chatAutoload") + appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb") + appendLine("storage_maxsize_gb=$chatMaxGb") + appendLine("opus_codec_preset=$chatOpusPreset") + appendLine("compressor_enabled=$chatCompEnabled") + appendLine("compressor_max_gain_db=$chatCompMaxGain") + appendLine("compressor_rise_rate=$chatCompRiseRate") + appendLine("media_download_max_peers=$chatDlMaxPeers") + appendLine() + appendLine("[debug]") + appendLine("console_level=info") + val udpIp = getString("log_udp.ip") ?: "" + val udpPort = getInt("log_udp.port") + if (udpIp.isNotEmpty() && udpPort > 0) { + appendLine() + appendLine("[log_udp]") + appendLine("ip=$udpIp") + appendLine("port=$udpPort") + } + } + } + + /** Return all currently available network interface names (for dropdown) */ + fun getAvailableInterfaces(): List> = runBlocking { + val list = mutableListOf>() + try { + val nis = NetworkInterface.getNetworkInterfaces() + while (nis.hasMoreElements()) { + val ni = nis.nextElement() + if (!ni.isUp || ni.isLoopback) continue + for (a in ni.interfaceAddresses) { + val addr = a.address + val ip = when { + addr is Inet4Address -> addr.hostAddress + addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr.hostAddress ?: "") -> addr.hostAddress?.let { it.split("%")[0] } + else -> null + } ?: continue + list.add(ni.name to ip) + } + } + } catch (_: Exception) {} + if (list.isEmpty()) list.add("lo" to "127.0.0.1") + list + } + + /** Build full INI config text for NativeLib.start() */ + fun buildConfigText(): String { + if (getConnectionMode() == "auto") { + val detected = runBlocking { detectInterfaces() } + LogManager.addLog("INFO", "CFG", "buildConfigText: auto mode, detected ${detected.size} interfaces" + + detected.joinToString(", ") { "${it.ifName}=${it.protocol}" }) + saveServersToConfig(detected) + return buildConfigFromServers(detected) + } + val cfgFile = File(configFilePath()) + val existing = if (cfgFile.exists()) loadServersFromConfig() else emptyList() + LogManager.addLog("INFO", "CFG", "buildConfigText: exists=${cfgFile.exists()} srv=${existing.size}" + + existing.joinToString(", ") { "${it.ifName}=${it.protocol}" }) + if (!cfgFile.exists() || existing.isEmpty()) { + val detected = runBlocking { detectInterfaces() } + LogManager.addLog("WARN", "CFG", "buildConfigText: auto-detecting ${detected.size} interfaces") + saveServersToConfig(detected) + return buildConfigFromServers(detected) + } + return buildConfigFromServers(existing) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt new file mode 100644 index 00000000..b46df4c0 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt @@ -0,0 +1,156 @@ +package com.utun.chat.data + +import android.util.Base64 +import com.utun.chat.data.LogManager +import java.security.MessageDigest + +const val INVITE_LINK_VERSION: Int = 0x01 +const val INVITE_PUBKEY_SIZE: Int = 32 + +const val INVITE_PROTO_UDP: Int = 0x01 +const val INVITE_PROTO_TCP: Int = 0x02 +const val INVITE_PROTO_BOTH: Int = 0x03 + +data class InviteAddr( + val family: Int, // 4 or 6 + val address: ByteArray, + val port: Int, + val socketId: Int, + val proto: Int // INVITE_PROTO_UDP/TCP/BOTH +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InviteAddr) return false + return family == other.family && address.contentEquals(other.address) && + port == other.port && socketId == other.socketId && proto == other.proto + } + override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId * 31 + proto +} + +data class InviteData( + val channelId: Long, + val pubkey: ByteArray, + val nodeId: Long, + val addrs: List +) { + /** Serialize addresses for C bridge: family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE) */ + fun serializeAddrs(): ByteArray { + val size = addrs.fold(0) { acc, a -> acc + if (a.family == 4) 9 else 21 } + val buf = ByteArray(size) + var pos = 0 + for (a in addrs) { + buf[pos] = a.family.toByte(); pos++ + buf[pos] = a.socketId.toByte(); pos++ + buf[pos] = a.proto.toByte(); pos++ + val ipLen = if (a.family == 4) 4 else 16 + System.arraycopy(a.address, 0, buf, pos, ipLen); pos += ipLen + buf[pos] = ((a.port shr 8) and 0xFF).toByte(); pos++ + buf[pos] = (a.port and 0xFF).toByte(); pos++ + } + return buf + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InviteData) return false + return channelId == other.channelId && pubkey.contentEquals(other.pubkey) && + nodeId == other.nodeId && addrs == other.addrs + } + override fun hashCode(): Int = channelId.hashCode() * 31 + pubkey.contentHashCode() +} + +object InviteLink { + private const val PREFIX = "utun://" + + fun decode(link: String): InviteData? { + LogManager.addLog("DEBUG", "INVITE", "decode: ${link.take(80)}") + if (!link.startsWith(PREFIX)) { + LogManager.addLog("WARN", "INVITE", "decode: missing utun:// prefix, len=${link.length}") + return null + } + val b64 = link.substring(PREFIX.length) + LogManager.addLog("DEBUG", "INVITE", "decode: base64 len=${b64.length}") + val raw: ByteArray + try { + raw = Base64.decode(b64, Base64.DEFAULT) + } catch (e: Exception) { + LogManager.addLog("ERROR", "INVITE", "decode: base64 failed: ${e.message}") + return null + } + LogManager.addLog("DEBUG", "INVITE", "decode: raw=${raw.size}B ver=${raw[0].toInt() and 0xFF}") + if (raw.size < 11) { + LogManager.addLog("ERROR", "INVITE", "decode: raw too short (${raw.size} < 11)") + return null + } + + var off = 0 + if ((raw[off].toInt() and 0xFF) != INVITE_LINK_VERSION) { + LogManager.addLog("ERROR", "INVITE", "decode: wrong version 0x%02x expected 0x%02x".format( + raw[off].toInt() and 0xFF, INVITE_LINK_VERSION)) + return null + } + off++ + + var chId = 0L + for (i in 0 until 8) { + if (off + i >= raw.size) return null + chId = (chId shl 8) or (raw[off + i].toLong() and 0xFF) + } + off += 8 + + LogManager.addLog("DEBUG", "INVITE", "decode: chId=0x%016x".format(chId)) + + var pubkey: ByteArray? = null + val addrs = mutableListOf() + var firstBlock = true + + while (off + 1 <= raw.size) { + val header = raw[off].toInt() and 0xFF; off++ + val cnt = (header and 0x03) + 1 + if (cnt < 1 || cnt > 4) return null + if (off + 32 > raw.size) return null + + if (firstBlock) { + pubkey = raw.copyOfRange(off, off + 32) + firstBlock = false + } + off += 32 + + for (j in 0 until cnt) { + val isV6 = ((header shr (2 + j)) and 1) != 0 + val ipLen = if (isV6) 16 else 4 + if (off + 2 + ipLen + 2 > raw.size) return null + val sockId = raw[off].toInt() and 0xFF; off++ + val proto = raw[off].toInt() and 0xFF; off++ + val ip = raw.copyOfRange(off, off + ipLen); off += ipLen + val port = ((raw[off].toInt() and 0xFF) shl 8) or (raw[off + 1].toInt() and 0xFF); off += 2 + val addr = InviteAddr(family = if (isV6) 6 else 4, address = ip, port = port, socketId = sockId, proto = proto) + addrs.add(addr) + LogManager.addLog("DEBUG", "INVITE", "decode: ${addrToString(addr)}") + } + } + + if (pubkey == null || pubkey.size != INVITE_PUBKEY_SIZE) return null + val nodeId = NativeLib.deriveNodeId(pubkey) + val pkHex = pubkey.take(8).joinToString("") { "%02x".format(it) } + LogManager.addLog("INFO", "INVITE", "decode: ch=0x%016x node=0x%016x pk=%s... addrs=%d".format( + chId, nodeId, pkHex, addrs.size)) + return InviteData(channelId = chId, pubkey = pubkey, nodeId = nodeId, addrs = addrs) + } + + fun addrToString(addr: InviteAddr): String { + val protoStr = when (addr.proto) { + INVITE_PROTO_UDP -> "UDP" + INVITE_PROTO_TCP -> "TCP" + INVITE_PROTO_BOTH -> "UDP+TCP" + else -> "?${addr.proto}" + } + val ip = if (addr.family == 4 && addr.address.size == 4) { + val b = addr.address + "${b[0].toInt() and 0xFF}.${b[1].toInt() and 0xFF}.${b[2].toInt() and 0xFF}.${b[3].toInt() and 0xFF}:${addr.port}" + } else { + "ipv6:${addr.address.joinToString("") { "%02x".format(it) }}:${addr.port}" + } + return "$protoStr $ip sock=${addr.socketId}" + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt new file mode 100644 index 00000000..937a44ca --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt @@ -0,0 +1,47 @@ +package com.utun.chat.data + +import android.util.Log +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +data class LogEntry( + val timestamp: Long = System.currentTimeMillis(), + val level: String, + val category: String, + val message: String +) + +object LogManager { + private const val MAX_BUFFER = 100000 + private val _buffer = ArrayDeque(MAX_BUFFER) + private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 512) + val events: SharedFlow = _events.asSharedFlow() + + @Synchronized + fun addLog(level: String, category: String, message: String) { + Log.d("utun-gui", "[$level] [$category] $message") + val entry = LogEntry(level = level, category = category, message = message) + if (_buffer.size >= MAX_BUFFER) _buffer.removeFirst() + _buffer.add(entry) + _events.tryEmit(entry) + } + + @Synchronized + fun getRecent(count: Int = 100): List { + return _buffer.takeLast(count) + } + + /** Called from JNI (via JNI config callback interface) */ + fun onNativeLog(level: Int, category: String?, message: String?) { + val lvl = when (level) { + 0 -> "TRACE" + 1 -> "DEBUG" + 2 -> "INFO" + 3 -> "WARN" + 4 -> "ERROR" + else -> "NONE" + } + addLog(lvl, category ?: "", message ?: "") + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt new file mode 100644 index 00000000..c5e074ea --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -0,0 +1,178 @@ +package com.utun.chat.data + +import com.utun.chat.ChatApplication + +interface ChatEventCallback { + fun onEvent(type: Int, data: ByteArray?) +} + +object NativeLib { + + init { + System.loadLibrary("utun") + } + + /** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */ + fun init(dbPath: String, controlPort: Int): Boolean { + return nativeInit( + dbPath, controlPort, + ChatApplication.instance.configProvider, + LogManager + ) + } + + /** Start full uTun instance from INI config text */ + fun start(configText: String): Boolean { + if (isReady()) return true + return nativeStart(configText) + } + + fun stop() { + if (!isReady()) return + nativeStop() + } + + fun setEventCallback(cb: ChatEventCallback) { + nativeSetEventCallback(object : ChatEventCallback { + override fun onEvent(type: Int, data: ByteArray?) { cb.onEvent(type, data) } + }) + } + + fun destroy() { nativeDestroy() } + + fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { + return nativeRestart(configText) + } + + fun isReady(): Boolean = nativeIsRunning() + + fun getMyNodeId(): Long = nativeGetMyNodeId() + + fun encodeInviteLink(channelId: Long): String? = nativeEncodeInviteLink(channelId) + + fun ping() { nativePing() } + fun isResponsive(): Boolean = nativeIsResponsive() + + fun sendMessage(channelId: String, text: String) { nativeSendMessage(channelId, text) } + fun createChannel(name: String, channelId: String) { nativeCreateChannel(name, channelId) } + fun connectNode(address: String, port: Int, pubkeyHex: String) { nativeConnectNode(address, port, pubkeyHex) } + fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { + LogManager.addLog("INFO", "JNI", "joinChannel: calling native") + nativeJoinChannel(channelId, nodeId, pubkey, addrs, addrCount) + LogManager.addLog("INFO", "JNI", "joinChannel: native returned") + } + fun deriveNodeId(pubkey: ByteArray): Long = nativeDeriveNodeId(pubkey) + fun connectChannel(channelId: String) { nativeConnectChannel(channelId) } + fun getChannels(): String = nativeGetChannels() ?: "[]" + fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]" + fun clearDatabase(): Boolean = nativeClearDatabase() + fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) } + + /* ── Chat settings (live update via C side) ── */ + fun setChatSetting(name: String, value: String) { nativeSetChatSetting(name, value) } + fun getChatSettingInt(name: String, def: Int = 0): Int = nativeGetChatSettingInt(name, def) + + /** Enable/disable forwarding native C logs to LogManager (expensive, off by default) */ + fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) } + fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) } + fun regenerateKeys() { nativeRegenerateKeys() } + + /* ── Channel members ── */ + fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]" + fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" + fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) { + nativeSetMemberFlags(channelId, nodeId, admin, supernode, storage) + } + fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}" + + /* ── Local sockets (for auto-mode display) ── */ + fun getLocalSockets(): String = nativeGetLocalSockets() ?: "[]" + + /* ── Voice recording ── */ + fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId) + fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples) + fun voiceStop(): Int = nativeVoiceStop() + fun voiceCancel() { nativeVoiceCancel() } + fun voiceIsActive(): Boolean = nativeVoiceIsActive() + fun voiceSetPreset(preset: Int) { nativeVoiceSetPreset(preset) } + fun voiceGetPreset(): Int = nativeVoiceGetPreset() + fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) } + fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor() + fun voiceGetPeakLevel(): Float = nativeVoiceGetPeakLevel() + fun voiceSetCompressorConfig(maxGainDb: Int, lookbackMs: Int, lookaheadMs: Int, + riseRate: Float, targetLevelDb: Float) { + nativeVoiceSetCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb) + } + + /* ── Attachment ── */ + fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath) + fun attachmentDownload(channelId: String, msgId: Long): Boolean = nativeAttachmentDownload(channelId, msgId) + + /* ── Voice playback ── */ + fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo) + fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf) + fun voiceDecodeClose(handle: Long) { nativeVoiceDecodeClose(handle) } + + private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean + private external fun nativeStart(configText: String): Boolean + private external fun nativeStop() + private external fun nativeSetEventCallback(cb: Any) + private external fun nativeDestroy() + private external fun nativeSendMessage(channelId: String, text: String) + private external fun nativeCreateChannel(name: String, channelId: String) + private external fun nativeConnectNode(address: String, port: Int, pubkeyHex: String) + private external fun nativeJoinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) + private external fun nativeDeriveNodeId(pubkey: ByteArray): Long + private external fun nativeConnectChannel(channelId: String) + private external fun nativeGetChannels(): String? + private external fun nativeGetMessages(channelId: String, limit: Int): String? + private external fun nativeSetDebugLevel(category: String, level: String) + private external fun nativeSetChatSetting(name: String, value: String) + private external fun nativeGetChatSettingInt(name: String, def: Int): Int + private external fun nativeSubscribeLogs(enabled: Boolean) + private external fun nativeSetUdpLogTarget(ip: String, port: Int) + private external fun nativeRegenerateKeys() + private external fun nativeClearDatabase(): Boolean + private external fun nativeGetMyNodeId(): Long + private external fun nativeEncodeInviteLink(channelId: Long): String? + private external fun nativeRestart(configText: String): Boolean + private external fun nativePing() + private external fun nativeIsResponsive(): Boolean + private external fun nativeIsRunning(): Boolean + + /* ── Channel members JNI ── */ + private external fun nativeGetChannelMembers(channelId: String): String? + private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String? + private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String) + private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) + private external fun nativeGetMemberLinks(nodeId: Long): String? + + /* ── Local sockets JNI ── */ + private external fun nativeGetLocalSockets(): String? + + /* ── Voice recording JNI ── */ + private external fun nativeVoiceStart(channelId: String): Boolean + private external fun nativeVoiceFeed(samples: ShortArray): Boolean + private external fun nativeVoiceStop(): Int + private external fun nativeVoiceCancel() + private external fun nativeVoiceIsActive(): Boolean + private external fun nativeVoiceSetPreset(preset: Int) + private external fun nativeVoiceGetPreset(): Int + private external fun nativeVoiceSetCompressor(enabled: Boolean) + private external fun nativeVoiceGetCompressor(): Boolean + private external fun nativeVoiceGetPeakLevel(): Float + private external fun nativeVoiceSetCompressorConfig( + maxGainDb: Int, lookbackMs: Int, lookaheadMs: Int, + riseRate: Float, targetLevelDb: Float + ) + + /* ── Attachment JNI ── */ + private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean + private external fun nativeAttachmentDownload(channelId: String, msgId: Long): Boolean + + /* ── Voice playback JNI ── */ + private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long + private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int + private external fun nativeVoiceDecodeClose(handle: Long) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt new file mode 100644 index 00000000..3a4e56a6 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt @@ -0,0 +1,9 @@ +package com.utun.chat.data + +data class ServerEntry( + val id: String, // UUID + val ifName: String, // wlan0, rmnet0, ... + val bindIp: String, // IPv4 address + val protocol: String, // "tcp" or "udp" + val port: Int +) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt new file mode 100644 index 00000000..b58800e4 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt @@ -0,0 +1,72 @@ +package com.utun.chat.headless + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.utun.chat.ChatApplication +import com.utun.chat.MainActivity +import com.utun.chat.data.LogManager +import com.utun.chat.data.NativeLib + +class HeadlessService : Service() { + override fun onCreate() { + super.onCreate() + createNotificationChannel() + + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + + val notification = NotificationCompat.Builder(this, "utun_channel") + .setContentTitle("uTun Chat") + .setContentText("Service running...") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + + startForeground(1, notification) + + val provider = ChatApplication.instance.configProvider + val dbDir = provider.getString("db.path") ?: filesDir.absolutePath + val listenPort = provider.getInt("node.listen_port") + + val configText = provider.buildConfigText() + + LogManager.addLog("INFO", "SRV", "uTun service starting, port=$listenPort db=$dbDir") + + NativeLib.init(dbDir, 9999) + + val ok = NativeLib.start(configText) + if (ok) { + LogManager.addLog("INFO", "SRV", "uTun core started") + } else { + LogManager.addLog("ERROR", "SRV", "uTun start failed") + } + } + + override fun onDestroy() { + LogManager.addLog("INFO", "SRV", "uTun service stopping") + NativeLib.stop() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + "utun_channel", "uTun Chat", + NotificationManager.IMPORTANCE_LOW + ).apply { description = "uTun Chat background service" } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt new file mode 100644 index 00000000..2da23a15 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -0,0 +1,436 @@ +package com.utun.chat.ui.components + +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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.utun.chat.data.Message +import com.utun.chat.data.NativeLib +import com.utun.chat.viewmodel.ChatViewModel +import kotlinx.coroutines.delay + +@Composable +fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), + onPlayVoice: (String, Int) -> Unit = { _, _ -> }, + onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> }, + onDownloadFile: (Message) -> Unit = {}, + onOpenFile: (Message) -> Unit = {}) { + val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) + val textColor = if (message.isOutgoing) Color.White else Color.Black + val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start + val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp + val voiceMaxWidth = (LocalConfiguration.current.screenWidthDp * 0.75f).dp + + val isVoice = message.contentType == "audio/opus" + val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain" + val isActive = isVoice && playbackState.filePath == message.filePath + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = alignment + ) { + Column( + modifier = Modifier + .widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp) + .clip(RoundedCornerShape(12.dp)) + .background(bgColor) + .padding(12.dp) + ) { + if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) + Spacer(Modifier.height(2.dp)) + + when { + isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice) + isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile) + else -> { + Text(message.text, fontSize = 17.sp, color = textColor) + } + } + + if (!isVoice) { + Spacer(Modifier.height(2.dp)) + Text( + formatTime(message.ts), + fontSize = 11.sp, + color = textColor.copy(alpha = 0.5f), + modifier = Modifier.align(Alignment.End) + ) + } + } + } +} +@Composable +private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, + ps: ChatViewModel.PlaybackState, + onPlay: (String, Int) -> Unit, + onSeek: (String, Int, Int) -> Unit) { + val wf = message.waveform + val wfCount = wf.size + val durMs = message.voiceDurationMs + val playing = isActive && ps.playing + val paused = isActive && ps.paused + val elapsedMs = ps.elapsedMs + val progress = ps.progress + + Column { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(32.dp).background(Color.White, CircleShape) + .clickable { if (message.filePath.isNotEmpty()) onPlay(message.filePath, durMs) }, + contentAlignment = Alignment.Center + ) { + Text(if (playing && !paused) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black) + } + Spacer(Modifier.width(8.dp)) + + if (wfCount >= 2) { + Box( + modifier = Modifier.weight(1f).height(28.dp) + .pointerInput(message.filePath) { + detectTapGestures { offset -> + if (message.filePath.isEmpty()) return@detectTapGestures + val seekFrac = (offset.x / size.width).coerceIn(0f, 1f) + val seekMs = (seekFrac * durMs).toInt() + onSeek(message.filePath, seekMs, durMs) + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val barCount = wfCount + val slotW = size.width / barCount + val barW = slotW * 0.75f + val ph = size.height + val showCursor = playing || paused + val playedIdx = if (showCursor && progress > 0f) (progress * barCount).toInt() else 0 + for (i in 0 until barCount) { + val level = wf[i].coerceIn(0f, 1f) + val barH = (2f + level * (ph - 2f)).coerceAtMost(ph) + val x = i * slotW + val y = ph - barH + val played = showCursor && i < playedIdx + drawRect( + color = when { played -> Color.White; else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) }, + topLeft = Offset(x, y), + size = Size(barW, barH) + ) + } + if (showCursor && progress > 0f && progress < 1f) { + val cursorX = progress * size.width + drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f) + } + } + } + } + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Text( + if (playing || paused) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" + else formatDurationMs(durMs), + fontSize = 12.sp, + color = textColor.copy(alpha = 0.55f) + ) + Text( + formatTime(message.ts), + fontSize = 11.sp, + color = textColor.copy(alpha = 0.4f), + modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp) + ) + } + } +} + +@Composable +private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit = {}, + onOpen: (Message) -> Unit = {}) { + val state = message.downloadState + val stateIcon = when { + state == "downloaded" -> "\u2705" + state == "downloading" -> "\u23F3" + else -> "\u2B07\uFE0F" + } + val stateText = when { + state == "downloaded" -> "Tap to save" + state == "downloading" -> "Downloading..." + else -> "Tap to download" + } + + Column( + modifier = Modifier.clickable { + when { + state == "downloaded" -> onOpen(message) + state.isEmpty() -> onDownload(message) + } + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stateIcon, fontSize = 18.sp) + Spacer(Modifier.width(6.dp)) + Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(2.dp)) + Text( + "${formatFileSize(message.fileSize)} · $stateText", + fontSize = 12.sp, + color = textColor.copy(alpha = 0.5f) + ) + } +} + +private fun formatFileSize(bytes: Long): String { + if (bytes <= 0) return "" + if (bytes < 1024) return "${bytes}B" + val kb = bytes / 1024.0 + if (kb < 1024.0) return if (kb < 10.0) "%.1fKB".format(kb) else "%.0fKB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024.0) return if (mb < 10.0) "%.1fMB".format(mb) else "%.0fMB".format(mb) + val gb = mb / 1024.0 + return "%.1fGB".format(gb) +} + +private fun formatDurationMs(ms: Int): String { + val secs = ms / 1000 + return "%d:%02d".format(secs / 60, secs % 60) +} + +private val LOCK_SWIPE_THRESHOLD_DP = 80f + +@Composable +fun InputBar( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + enabled: Boolean = true, + isRecording: Boolean = false, + isRecordingLocked: Boolean = false, + recordingDurationMs: Int = 0, + recordingLevel: Float = 0f, + onPttPressed: () -> Unit = {}, + onPttReleased: () -> Unit = {}, + onPttLock: () -> Unit = {}, + onVoiceSend: () -> Unit = {}, + onVoiceCancel: () -> Unit = {}, + onAttachClicked: () -> Unit = {}, + pendingAttachmentName: String = "", + pendingAttachmentSize: Long = 0L, + onClearAttachment: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val ctx = LocalContext.current + val density = LocalDensity.current + val lockThresholdPx = with(density) { LOCK_SWIPE_THRESHOLD_DP.dp.toPx() } + + var hasMicPerm by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + hasMicPerm = granted + } + + val hasAttachment = pendingAttachmentName.isNotEmpty() + + Column(modifier = modifier.fillMaxWidth().padding(8.dp)) { + /* Attachment preview bar */ + if (hasAttachment) { + Surface( + modifier = Modifier.fillMaxWidth().height(36.dp).padding(bottom = 4.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("\uD83D\uDCCE", fontSize = 14.sp) + Spacer(Modifier.width(6.dp)) + Text( + pendingAttachmentName, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (pendingAttachmentSize > 0) { + Text( + formatFileSize(pendingAttachmentSize), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) + ) + Spacer(Modifier.width(8.dp)) + } + IconButton( + onClick = onClearAttachment, + modifier = Modifier.size(24.dp) + ) { + Text("\u2715", fontSize = 14.sp) + } + } + } + } + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + /* Attach button */ + if (!isRecording) { + IconButton(onClick = onAttachClicked, enabled = enabled) { + Text("\uD83D\uDCCE", fontSize = 20.sp) + } + } else { + Spacer(Modifier.width(4.dp)) + } + + /* Recording overlay or text input */ + if (isRecording) { + Surface( + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(8.dp), + color = Color(0xFFE53935) + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = Modifier.weight(1f) + ) + val lvlPct = (recordingLevel * 40f).coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { lvlPct }, + modifier = Modifier.width(60.dp).height(4.dp).padding(end = 8.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + } + } + } else { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + enabled = enabled, + modifier = Modifier.weight(1f), + placeholder = { Text("Message...") }, + maxLines = 4 + ) + } + + Spacer(Modifier.width(4.dp)) + + if (isRecording && isRecordingLocked) { + /* Locked: cancel + send voice */ + IconButton(onClick = onVoiceCancel) { + Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935)) + } + IconButton(onClick = onVoiceSend) { + Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50)) + } + } else if (!isRecording && (text.isNotBlank() || hasAttachment)) { + /* Send button — round, primary color */ + val sendColor = MaterialTheme.colorScheme.primary + Box( + modifier = Modifier + .size(44.dp) + .background(sendColor, CircleShape) + .clickable { onSend() }, + contentAlignment = Alignment.Center + ) { + Text("\u27A4", fontSize = 22.sp, color = Color.White) + } + } else { + /* PTT Box — podcast/recording, same identity in idle and holding */ + val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent + Box( + modifier = Modifier + .size(48.dp) + .background(pttBg, RoundedCornerShape(4.dp)) + .pointerInput(hasMicPerm) { + awaitPointerEventScope { + while (true) { + val down = awaitPointerEvent() + val ch = down.changes.firstOrNull() ?: continue + if (!ch.pressed) continue + ch.consume() + + if (!hasMicPerm) { + permLauncher.launch(Manifest.permission.RECORD_AUDIO) + continue + } + + onPttPressed() + var locked = false + var totalDragY = 0f + var lastY = ch.position.y + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: break + if (change.pressed) { + val dy = change.position.y - lastY + lastY = change.position.y + totalDragY += dy + if (totalDragY < -lockThresholdPx && !locked) { locked = true; onPttLock() } + change.consume() + } else { + change.consume(); break + } + } + onPttReleased() + } + } + }, + contentAlignment = Alignment.Center + ) { + Text("\uD83C\uDFA4", fontSize = 20.sp, color = if (isRecording) Color.White else Color.Unspecified) + } + } + } + } +} + +private fun formatDuration(ms: Int): String { + val secs = ms / 1000 + return "%d:%02d".format(secs / 60, secs % 60) +} + +private fun formatTime(ts: Long): String { + if (ts == 0L) return "" + val s = ts / 1_000_000_000 + val h = (s / 3600) % 24 + val m = (s / 60) % 60 + return "%02d:%02d".format(h, m) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt new file mode 100644 index 00000000..f67879da --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -0,0 +1,166 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Build +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.data.Channel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChannelListScreen( + channels: List, + connected: Boolean, + isStuck: Boolean = false, + onSelectChannel: (Channel) -> Unit, + onCreateChannel: () -> Unit, + onJoinChannel: () -> Unit, + onInviteClick: (Channel) -> Unit, + onSettings: () -> Unit, + onLogs: () -> Unit, + onRefresh: () -> Unit = {} +) { + var showCreateDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { onRefresh() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("uTun Chat") }, + actions = { + Box( + Modifier.size(10.dp) + .background(if (isStuck) Color.Red else Color(0xFF4CAF50), CircleShape) + ) + Spacer(Modifier.width(4.dp)) + IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") } + IconButton(onClick = onLogs) { Icon(Icons.Default.Build, "Logs") } + IconButton(onClick = onSettings) { Icon(Icons.Default.Settings, "Settings") } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, "New channel") + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + if (channels.isEmpty()) { + Box(Modifier.fillMaxSize().weight(1f)) { + Text( + "No channels yet.\nTap + to create one.", + modifier = Modifier.padding(32.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn { + items(channels) { ch -> + val barColor = if (ch.peersOnline > 0) Color(0xFF4CAF50) else Color(0xFF9E9E9E) + val badgeText = when { + ch.peersOnline > 99 -> "99+" + ch.peersOnline > 0 -> ch.peersOnline.toString() + else -> "" + } + + Surface(modifier = Modifier.clickable { onSelectChannel(ch) }) { + Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) + Row(Modifier.fillMaxWidth().padding(12.dp)) { + Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { + Surface(shape = MaterialTheme.shapes.small, + modifier = Modifier.clickable { onInviteClick(ch) }) { + Text("#", fontSize = 22.sp, modifier = Modifier.padding(8.dp)) + } + if (badgeText.isNotEmpty()) { + val badgeSize = 18.dp + Box( + Modifier + .align(Alignment.TopStart) + .offset(x = (-4).dp, y = (-6).dp) + .size(badgeSize) + .clip(CircleShape) + .background(Color(0xFF4CAF50)), + contentAlignment = Alignment.Center + ) { + Text( + badgeText, fontSize = 9.sp, color = Color.White, + fontWeight = FontWeight.Bold, textAlign = TextAlign.Center + ) + } + } + } + Spacer(Modifier.width(10.dp)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + if (ch.lastMsg.isNotEmpty()) + Text(ch.lastMsg, fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + } + } + HorizontalDivider() + } + } + } + } + } + + if (showCreateDialog) { + CreateChannelDialog( + onDismiss = { showCreateDialog = false }, + onCreate = { name -> + showCreateDialog = false + onCreateChannel() + } + ) + } +} + +@Composable +fun CreateChannelDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New Channel") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Channel name") }, + singleLine = true + ) + }, + confirmButton = { + Button(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) { + Text("Create") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt new file mode 100644 index 00000000..8fd60dfb --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -0,0 +1,184 @@ +package com.utun.chat.ui.screens + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.People +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.utun.chat.data.Channel +import com.utun.chat.data.Message +import com.utun.chat.data.NativeLib +import com.utun.chat.ui.components.InputBar +import com.utun.chat.ui.components.MessageBubble +import com.utun.chat.viewmodel.ChatViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + channel: Channel, + messages: List, + connected: Boolean, + viewModel: ChatViewModel, + onSendMessage: (String) -> Unit, + onMembersClick: () -> Unit, + onBack: () -> Unit +) { + var text by remember { mutableStateOf("") } + val listState = rememberLazyListState() + val isRecording by viewModel.isRecording.collectAsState() + val isRecordingLocked by viewModel.isRecordingLocked.collectAsState() + val recordingDurationMs by viewModel.recordingDurationMs.collectAsState() + var recordingLevel by remember { mutableStateOf(0f) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + /* pending attachment state */ + var pendingAttachUri by remember { mutableStateOf(null) } + var pendingAttachName by remember { mutableStateOf("") } + var pendingAttachSize by remember { mutableStateOf(0L) } + + /* file picker */ + val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + val cr: ContentResolver = context.contentResolver + var name = "file" + var size = 0L + cr.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0) name = cursor.getString(nameIdx) ?: name + if (sizeIdx >= 0) size = cursor.getLong(sizeIdx) + } + } + pendingAttachUri = uri + pendingAttachName = name + pendingAttachSize = size + } + } + + /* save dialog for downloaded files */ + var pendingSaveFilePath by remember { mutableStateOf(null) } + val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri -> + if (saveUri != null) { + val filePath = pendingSaveFilePath + pendingSaveFilePath = null + if (filePath != null) { + scope.launch(Dispatchers.IO) { + viewModel.saveDownloadedFile(filePath, saveUri) + } + } + } + } + + /* Poll peak level during recording */ + LaunchedEffect(isRecording) { + if (isRecording) { + while (true) { + recordingLevel = NativeLib.voiceGetPeakLevel() + delay(80) + } + } + } + + val playbackState by viewModel.playbackState.collectAsState() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) + } + + Column( + modifier = Modifier.fillMaxSize().imePadding() + ) { + TopAppBar( + title = { Text(channel.name) }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + }, + actions = { + IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } + } + ) + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + state = listState + ) { + items(messages.size) { idx -> + MessageBubble(messages[idx], playbackState = playbackState, + onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) }, + onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) }, + onDownloadFile = { msg -> viewModel.downloadAttachment(msg) }, + onOpenFile = { msg -> + if (msg.filePath.isNotEmpty()) { + pendingSaveFilePath = msg.filePath + saveFileLauncher.launch(msg.text) /* suggest original filename */ + } + }) + } + item { Spacer(Modifier.height(8.dp)) } + } + InputBar( + text = text, + onTextChange = { text = it }, + onSend = { + val attachUri = pendingAttachUri + val attachName = pendingAttachName + pendingAttachUri = null + pendingAttachName = "" + pendingAttachSize = 0L + if (attachUri != null) { + scope.launch(Dispatchers.IO) { + val cachePath = viewModel.copyUriToCache(context, attachUri, attachName) + if (cachePath != null) { + viewModel.sendAttachment(cachePath) + } + } + } + if (text.isNotBlank()) { + onSendMessage(text) + text = "" + } + }, + enabled = true, + isRecording = isRecording, + isRecordingLocked = isRecordingLocked, + recordingDurationMs = recordingDurationMs, + recordingLevel = recordingLevel, + onPttPressed = { viewModel.startVoiceRecording() }, + onPttReleased = { + if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage() + }, + onPttLock = { viewModel.lockVoiceRecording() }, + onVoiceSend = { viewModel.sendVoiceMessage() }, + onVoiceCancel = { + viewModel.cancelVoiceRecording() + recordingLevel = 0f + }, + onAttachClicked = { filePickerLauncher.launch("*/*") }, + pendingAttachmentName = pendingAttachName, + pendingAttachmentSize = pendingAttachSize, + onClearAttachment = { + pendingAttachUri = null + pendingAttachName = "" + pendingAttachSize = 0L + }, + modifier = Modifier.navigationBarsPadding() + ) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt new file mode 100644 index 00000000..fcb9da4f --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt @@ -0,0 +1,73 @@ +package com.utun.chat.ui.screens + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color as AndroidColor +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.unit.dp +import androidx.compose.ui.unit.sp +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import com.google.zxing.common.BitMatrix +import com.utun.chat.data.LogManager + +@Composable +fun InviteLinkDialog(link: String, onDismiss: () -> Unit) { + val context = LocalContext.current + var copied by remember { mutableStateOf(false) } + val qrBitmap = remember(link) { generateQRCode(link) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Invite Link", fontWeight = FontWeight.Bold) }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + qrBitmap?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = "QR", + modifier = Modifier.size(200.dp)) + Spacer(modifier = Modifier.height(8.dp)) + } + Text(link, fontFamily = FontFamily.Monospace, fontSize = 11.sp, + modifier = Modifier.padding(8.dp)) + } + }, + confirmButton = { + Button(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("invite", link)) + copied = true + LogManager.addLog("INFO", "INVITE", "Link copied to clipboard") + }) { Text(if (copied) "Copied!" else "Copy Link") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) +} + +private fun generateQRCode(text: String): Bitmap? { + return try { + val writer = MultiFormatWriter() + val matrix: BitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, 400, 400) + val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888) + for (x in 0 until 400) { + for (y in 0 until 400) { + bitmap.setPixel(x, y, if (matrix[x, y]) AndroidColor.BLACK else AndroidColor.WHITE) + } + } + bitmap + } catch (e: Exception) { + null + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt new file mode 100644 index 00000000..78a21dce --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt @@ -0,0 +1,142 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.data.InviteData +import com.utun.chat.data.InviteLink +import com.utun.chat.data.LogManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JoinChannelDialog( + autoLink: String? = null, + onScanQr: () -> Unit, + onConnect: (InviteData) -> Unit, + onDismiss: () -> Unit +) { + var link by remember { mutableStateOf(autoLink ?: "") } + var decoded by remember { mutableStateOf(null) } + var status by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + var connecting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + if (!autoLink.isNullOrBlank()) { + LaunchedEffect(autoLink) { + connecting = true + isError = false + + status = "Decoding..." + delay(300) + + val d = InviteLink.decode(autoLink.trim()) + if (d != null) { + decoded = d + status = "Connecting to peer..." + delay(800) + + LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d" + .format(d.channelId, d.nodeId, d.addrs.size)) + onConnect(d) + } else { + connecting = false + status = "Invalid invite link" + isError = true + } + } + } + + AlertDialog( + onDismissRequest = { if (!connecting) onDismiss() }, + title = { Text("Join Channel") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (autoLink.isNullOrBlank()) { + OutlinedTextField( + value = link, + onValueChange = { newVal -> + link = newVal + status = "" + isError = false + scope.launch { + delay(400) + if (link == newVal) { + if (newVal.isBlank()) { + decoded = null + } else { + decoded = InviteLink.decode(newVal.trim()) + } + } + } + }, + label = { Text("Invite link") }, + placeholder = { Text("utun://...") }, + trailingIcon = { + IconButton(onClick = onScanQr) { + Icon(Icons.Default.QrCodeScanner, "Scan QR") + } + }, + singleLine = true, + enabled = !connecting, + modifier = Modifier.fillMaxWidth() + ) + } + + decoded?.let { d -> + val addrStr = d.addrs.joinToString(", ") { InviteLink.addrToString(it) } + Column(Modifier.fillMaxWidth()) { + Text("Node: 0x%016x".format(d.nodeId), fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Channel: ${d.channelId}", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (addrStr.isNotBlank()) + Text(addrStr, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + if (status.isNotBlank()) { + Text( + status, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontSize = 14.sp, fontWeight = FontWeight.Medium + ) + } + } + }, + confirmButton = { + Button( + onClick = { + connecting = true + isError = false + status = "Connecting to peer..." + val d = InviteLink.decode(link.trim()) + if (d != null) { + onConnect(d) + } else { + connecting = false + status = "Invalid invite link" + isError = true + } + }, + enabled = !connecting && decoded != null && (autoLink.isNullOrBlank()) + ) { + Text(if (connecting) "..." else "Connect") + } + }, + dismissButton = { + if (autoLink.isNullOrBlank()) { + TextButton(onClick = onDismiss, enabled = !connecting) { Text("Cancel") } + } + } + ) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt new file mode 100644 index 00000000..54c8e89b --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt @@ -0,0 +1,98 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.ChatApplication +import com.utun.chat.data.LogEntry +import com.utun.chat.data.LogManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogScreen(onBack: () -> Unit) { + val entries = remember { mutableStateListOf() } + val listState = rememberLazyListState() + var filterLevel by remember { mutableStateOf("") } + var autoScroll by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + /* Load history from in-memory buffer */ + val recent = LogManager.getRecent(500) + entries.addAll(recent) + + /* Live tail via SharedFlow */ + LogManager.events.collect { entry -> + entries.add(entry) + if (entries.size > 10000) entries.removeAt(0) + if (autoScroll) { + listState.animateScrollToItem(entries.size - 1) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Logs") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } }, + actions = { + for ((lvl, label) in arrayOf("" to "ALL", "ERROR" to "ERR", "WARN" to "WRN", "INFO" to "INF", "DEBUG" to "DBG", "TRACE" to "TRC")) { + val selected = filterLevel == lvl + Text( + label, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + modifier = Modifier + .clickable { filterLevel = lvl } + .background( + if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + MaterialTheme.shapes.extraSmall + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + TextButton(onClick = { autoScroll = !autoScroll }) { + Text(if (autoScroll) "|>" else "||", fontSize = 11.sp) + } + } + ) + } + ) { padding -> + val filtered = if (filterLevel.isEmpty()) entries.toList() + else entries.filter { it.level == filterLevel } + + LazyColumn(modifier = Modifier.padding(padding), state = listState) { + items(filtered.size) { idx -> // index-based to avoid recomposition on each new entry + val entry = filtered[idx] + val color = when (entry.level) { + "ERROR" -> Color(0xFFFF4444) + "WARN" -> Color(0xFFFFAA00) + "INFO" -> Color(0xFF4CAF50) + "DEBUG" -> Color(0xFF2196F3) + "TRACE" -> Color(0xFF9E9E9E) + else -> Color.Gray + } + Text( + text = "${entry.level} [${entry.category}] ${entry.message}", + color = color, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 1.dp) + ) + } + item { Spacer(Modifier.height(8.dp)) } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt new file mode 100644 index 00000000..f7825b4a --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -0,0 +1,374 @@ +package com.utun.chat.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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.utun.chat.data.Channel +import com.utun.chat.data.ChatMember +import com.utun.chat.data.MemberDetail +import com.utun.chat.data.MemberAddr +import com.utun.chat.data.MemberLinks +import com.utun.chat.data.LinkStatus +import com.utun.chat.viewmodel.ChatViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemberListScreen( + channel: Channel, + members: List, + selectedMember: MemberDetail?, + viewModel: ChatViewModel, + onBack: () -> Unit +) { + var showEditDialog by remember { mutableStateOf(false) } + var editMember by remember { mutableStateOf(null) } + + LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) } + + val memberLinks by viewModel.memberLinks.collectAsState() + + LaunchedEffect(selectedMember?.nodeId) { + val nodeId = selectedMember?.nodeId + if (nodeId == null) { viewModel.refreshMemberLinks(0); return@LaunchedEffect } + viewModel.refreshMemberLinks(nodeId) + } + + Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { + TopAppBar( + title = { Text("Members (${members.size})") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + } + ) + + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + items(members.size) { idx -> + val m = members[idx] + MemberListItem( + member = m, + isSelected = selectedMember?.nodeId == m.nodeId, + onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, + onEdit = { + editMember = m + showEditDialog = true + } + ) + } + } + + AnimatedVisibility( + visible = selectedMember != null, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + selectedMember?.let { detail -> + MemberDetailCard( + detail = detail, + memberLinks = memberLinks, + isSelf = members.any { it.nodeId == detail.nodeId && it.isSelf }, + onDismiss = { } + ) + } + } + } + + if (showEditDialog && editMember != null) { + MemberEditDialog( + member = editMember!!, + onDismiss = { showEditDialog = false }, + onSave = { nick, admin, supernode, storage -> + if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick) + if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || storage != editMember!!.isStorage) + viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, storage) + showEditDialog = false + } + ) + } +} + +@Composable +private fun MemberListItem( + member: ChatMember, + isSelected: Boolean = false, + onClick: () -> Unit, + onEdit: () -> Unit +) { + val onlineColor = when { + member.directUp || member.indirectUp -> Color(0xFF4CAF50) // green: up + member.directPresence || member.indirectPresence || member.bgpPresence -> Color(0xFFFFA726) // orange: presence but down + else -> Color(0xFF9E9E9E) // gray: offline + } + val connLabel = when { + member.directUp -> "direct" + member.indirectUp -> "indirect" + member.directPresence || member.indirectPresence || member.bgpPresence -> "offline" + else -> "" + } + val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = bgColor, + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (member.isSelf) { + Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50)) + } else { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(onlineColor) + ) + } + Spacer(Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = member.name, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified + ) + val parts = mutableListOf() + if (member.isAdmin) parts.add("Admin") + if (member.isSuper) parts.add("Super") + if (member.isStorage) parts.add("Storage") + if (member.isSelf) parts.add("self") + else if (member.directUp) parts.add("direct↑") + else if (member.indirectUp) parts.add("indirect↑") + else if (member.directPresence || member.indirectPresence || member.bgpPresence) parts.add("down") + if (member.rtt > 0) parts.add("${member.rtt}ms") + if (parts.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = parts.joinToString(" "), + fontSize = 11.sp, + color = if (member.isSelf || member.directUp || member.indirectUp) + Color(0xFF4CAF50) else Color.Gray + ) + } + } + } + + TextButton(onClick = onEdit) { Text("Edit") } + } + } +} + +@Composable +private fun MemberDetailCard( + detail: MemberDetail, + memberLinks: MemberLinks, + isSelf: Boolean = false, + onDismiss: () -> Unit +) { + fun linkDotColor(addr: MemberAddr): Color { + val lk = memberLinks.links.find { + it.family == addr.family && it.address == addr.address && it.port == addr.port + } ?: return Color(0xFF9E9E9E) + return if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val dotColor = if (detail.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E) + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(dotColor) + ) + Spacer(Modifier.width(8.dp)) + Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + + Spacer(Modifier.height(4.dp)) + Text( + text = "ID: 0x${detail.nodeId.toString(16).padStart(16, '0')}", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = Color.Gray + ) + + if (detail.lastSeen > 0) { + val sec = System.currentTimeMillis() / 1000 - detail.lastSeen + val ago = when { + sec < 60 -> "${sec}s ago" + sec < 3600 -> "${sec / 60}m ago" + sec < 86400 -> "${sec / 3600}h ago" + else -> "${sec / 86400}d ago" + } + Text("Last seen: $ago", fontSize = 12.sp, color = Color.Gray) + } + + if (detail.created > 0) { + val sec = System.currentTimeMillis() / 1000 - detail.created + val ago = when { + sec < 604800 -> "${sec / 86400}d ago" + else -> "${sec / 2592000}mo ago" + } + Text("Created: $ago", fontSize = 12.sp, color = Color.Gray) + } + + if (detail.addresses.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium) + detail.addresses.forEach { a -> + if (isSelf) { + Text( + text = "${a.protocol} ${a.address}:${a.port}" + + (if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = Color.DarkGray + ) + } else { + val dcol = linkDotColor(a) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(dcol) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "${a.protocol} ${a.address}:${a.port}" + + (if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = Color.DarkGray + ) + } + } + } + } + val rttTypeName = when (detail.rttType) { 0 -> "interface" 1 -> "nat" 2 -> "real" else -> "?" } + Spacer(Modifier.height(6.dp)) + Text("Connection:", fontSize = 13.sp, fontWeight = FontWeight.Medium) + if (isSelf) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50)) + Spacer(Modifier.width(4.dp)) + Text("self (local)", fontSize = 12.sp, color = Color(0xFF4CAF50)) + } + } else if (detail.directPresence) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape) + .background(if (detail.directUp) Color(0xFF4CAF50) else Color(0xFFE53935))) + Spacer(Modifier.width(6.dp)) + Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" + + (if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt}ms ($rttTypeName)" else ""), + fontSize = 12.sp, color = Color.DarkGray) + } + } + if (detail.indirectPresence) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape) + .background(if (detail.indirectUp) Color(0xFF4CAF50) else Color(0xFFE53935))) + Spacer(Modifier.width(6.dp)) + Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}", + fontSize = 12.sp, color = Color.DarkGray) + } + } + if (detail.bgpPresence) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF4CAF50))) + Spacer(Modifier.width(6.dp)) + Text("BGP: visible", fontSize = 12.sp, color = Color.DarkGray) + } + } + if (!isSelf && !detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) { + Text("offline", fontSize = 12.sp, color = Color.Gray) + } + } + } +} + +@Composable +private fun MemberEditDialog( + member: ChatMember, + onDismiss: () -> Unit, + onSave: (nick: String, admin: Boolean, supernode: Boolean, storage: Boolean) -> Unit +) { + var nick by remember { mutableStateOf(member.name) } + var admin by remember { mutableStateOf(member.isAdmin) } + var supernode by remember { mutableStateOf(member.isSuper) } + var storage by remember { mutableStateOf(member.isStorage) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit ${member.name}") }, + text = { + Column { + OutlinedTextField( + value = nick, + onValueChange = { nick = it }, + label = { Text("Nickname") }, + enabled = !member.isSelf, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = admin, onCheckedChange = { admin = it }) + Text("Admin", modifier = Modifier.clickable { admin = !admin }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = supernode, onCheckedChange = { supernode = it }) + Text("Supernode", modifier = Modifier.clickable { supernode = !supernode }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = storage, onCheckedChange = { storage = it }) + Text("Storage", modifier = Modifier.clickable { storage = !storage }) + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(nick, admin, supernode, storage) }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt new file mode 100644 index 00000000..4136ee74 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt @@ -0,0 +1,165 @@ +package com.utun.chat.ui.screens + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QrScanScreen( + onResult: (String) -> Unit, + onBack: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var hasPermission by remember { mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ) } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + LaunchedEffect(Unit) { + if (!hasPermission) launcher.launch(Manifest.permission.CAMERA) + } + + /* Camera lifecycle — unbind when leaving this screen */ + val cameraProviderRef = remember { mutableStateOf(null) } + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + val mainHandler = remember { Handler(Looper.getMainLooper()) } + + DisposableEffect(Unit) { + onDispose { + Log.d("QrScan", "disposing camera") + cameraProviderRef.value?.unbindAll() + cameraProviderRef.value = null + cameraExecutor.shutdown() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Scan QR Code") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } + } + ) + } + ) { padding -> + if (!hasPermission) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Camera permission required") + Spacer(Modifier.height(8.dp)) + Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) { + Text("Grant") + } + } + } + } else { + var scanned by remember { mutableStateOf(false) } + + AndroidView( + modifier = Modifier.fillMaxSize().padding(padding), + factory = { ctx -> + val previewView = PreviewView(ctx) + + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + cameraProviderRef.value = cameraProvider + + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val scanner = BarcodeScanning.getClient(options) + + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + analysis.setAnalyzer(cameraExecutor) { imageProxy -> + if (scanned) { + imageProxy.close() + return@setAnalyzer + } + val mediaImage = imageProxy.image + if (mediaImage != null) { + try { + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + val text = barcode.rawValue + if (text != null && text.startsWith("utun://")) { + scanned = true + val link = text + Log.d("QrScan", "detected: $link") + mainHandler.post { onResult(link) } + break + } + } + } + .addOnCompleteListener { imageProxy.close() } + } catch (e: Exception) { + Log.e("QrScan", "scan error", e) + imageProxy.close() + } + } else { + imageProxy.close() + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + analysis + ) + Log.d("QrScan", "camera bound ok") + } catch (e: Exception) { + Log.e("QrScan", "Camera bind failed", e) + } + }, ContextCompat.getMainExecutor(ctx)) + + previewView + } + ) + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt new file mode 100644 index 00000000..a786b027 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -0,0 +1,579 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.utun.chat.ChatApplication +import com.utun.chat.data.NativeLib +import com.utun.chat.data.ServerEntry +import com.utun.chat.data.LocalSocketState +import com.utun.chat.viewmodel.ChatViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID + +private val LEVELS = listOf("NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE") +private val CATEGORIES = listOf( + "UASYNC", "LL_QUEUE", "CONNECTION", "ETCP", "CRYPTO", "MEMORY", + "TIMING", "CONFIG", "TUN", "ROUTING", "TIMERS", "NORMALIZER", "BGP", + "SOCKET", "CONTROL", "DUMP", "TRAFFIC", "DEBUG", "GENERAL", "NAT", + "KEEPALIVE", "ETCPROUTE", "BBR", "ETCP_DUMP", "CONNECTIVITY", "DB_SYNC" +) +private val PROTOCOLS = listOf("tcp", "udp") + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {}) { + val provider = ChatApplication.instance.configProvider + val scope = rememberCoroutineScope() + var dirty by remember { mutableStateOf(false) } + + var tabIndex by remember { mutableStateOf(0) } + val tabs = listOf("Settings", "Audio", "Storage") + + val handleBack = { + if (dirty) { + scope.launch(Dispatchers.IO) { + val path = provider.getString("db.path") ?: "utun_chat.db" + val port = provider.getInt("control.port") + val configText = provider.buildConfigText() + NativeLib.restart(path, port, configText) + } + } + onBack() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + TabRow(selectedTabIndex = tabIndex) { + tabs.forEachIndexed { idx, name -> + Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(name) }) + } + } + when (tabIndex) { + 0 -> GeneralSettingsTab(vm, dirty, onDirty = { dirty = it }, onExit) + 1 -> AudioSettingsTab(vm) + 2 -> StorageSettingsTab() + } + } + } +} + +/* ── General Settings Tab ── */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Boolean) -> Unit, + onExit: () -> Unit) { + val provider = ChatApplication.instance.configProvider + val scope = rememberCoroutineScope() + + var nodeName by remember { mutableStateOf("") } + var listenPort by remember { mutableStateOf("12345") } + var controlPort by remember { mutableStateOf("9999") } + var controlBindIp by remember { mutableStateOf("127.0.0.1") } + var ctrlBindLabel by remember { mutableStateOf("lo (127.0.0.1)") } + var debugLevel by remember { mutableStateOf("info") } + var logFile by remember { mutableStateOf("") } + var logUdpIp by remember { mutableStateOf("") } + var logUdpPort by remember { mutableStateOf("") } + val pubKey by vm.pubKey.collectAsState() + val generatingKeys by vm.generatingKeys.collectAsState() + + var showDebugCategories by remember { mutableStateOf(false) } + var showOverwriteKeys by remember { mutableStateOf(false) } + var showClearDb by remember { mutableStateOf(false) } + var clearingDb by remember { mutableStateOf(false) } + var catLevels by remember { mutableStateOf>(emptyMap()) } + var servers by remember { mutableStateOf>(emptyList()) } + var availableIfaces by remember { mutableStateOf>>(emptyList()) } + var connectionMode by remember { mutableStateOf("manual") } + + val localSocketStates by vm.localSockets.collectAsState() + + LaunchedEffect(Unit) { + nodeName = provider.getString("node.name") ?: provider.getDeviceName() + listenPort = provider.getInt("node.listen_port").toString() + controlPort = provider.getInt("control.port").toString() + controlBindIp = provider.getString("control.bind_ip") ?: "127.0.0.1" + val allIface = listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() + ctrlBindLabel = allIface.firstOrNull { it.second == controlBindIp }?.let { "${it.first} (${it.second})" } ?: "lo (127.0.0.1)" + debugLevel = provider.getString("debug.console_level") ?: "info" + logFile = provider.getString("debug.log_file") ?: "utun.log" + logUdpIp = provider.getString("log_udp.ip") ?: "192.168.40.250" + logUdpPort = provider.getInt("log_udp.port").toString().let { if (it == "0") "9999" else it } + val port = logUdpPort.toIntOrNull() ?: 0 + if (logUdpIp.isNotEmpty() && port > 0) NativeLib.setUdpLogTarget(logUdpIp, port) + val stored = provider.getString("debug.categories") ?: "" + catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap() + servers = provider.loadServersFromConfig() + availableIfaces = provider.getAvailableInterfaces() + connectionMode = provider.getConnectionMode() + } + + val save = { s: List -> onDirty(true); servers = s; provider.saveServersToConfig(s) } + val switchMode = { mode: String -> + connectionMode = mode; onDirty(true) + scope.launch(Dispatchers.IO) { + provider.setConnectionMode(mode) + NativeLib.restart(provider.getString("db.path") ?: "", + provider.getInt("control.port"), provider.buildConfigText()) + } + } + val setDebug = { level: String -> + onDirty(true); NativeLib.setDebugLevel("", level) + scope.launch { provider.setValue("debug.console_level", level) }; debugLevel = level + } + val setCatLevel = { cat: String, lvl: String -> + onDirty(true) + val updated = catLevels.toMutableMap() + if (lvl == "") updated.remove(cat) else updated[cat] = lvl + catLevels = updated + val csv = updated.entries.joinToString(",") { "${it.key}=${it.value}" } + scope.launch { provider.setValue("debug.categories", csv) } + if (lvl.isEmpty()) NativeLib.setDebugLevel(cat, "NONE") else NativeLib.setDebugLevel(cat, lvl) + } + + Column( + modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + /* Node */ + Text("Node", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(nodeName, { v -> onDirty(true); nodeName = v; scope.launch { provider.setValue("node.name", v) } }, + label = { Text("Node Name") }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) + OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } }, + label = { Text("Listen Port") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) + + /* Control interface */ + var ctrlExpanded by remember { mutableStateOf(false) } + val ctrlIfaces = remember { listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() } + Row(verticalAlignment = Alignment.CenterVertically) { + ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) { + OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true, + label = { Text("Control Bind IP") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) { + ctrlIfaces.forEach { (n, ip) -> + DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = { + controlBindIp = ip; ctrlBindLabel = "$n ($ip)"; onDirty(true) + scope.launch { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false + }) + } + } + } + Spacer(Modifier.width(8.dp)) + OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } }, + label = { Text("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + } + + /* Keys */ + Text("Keys", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(pubKey, {}, label = { Text("Public Key") }, readOnly = true, singleLine = true) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { if (pubKey.length == 64) showOverwriteKeys = true else { onDirty(true); vm.requestRegenerateKeys() } }, + enabled = !generatingKeys) { Text(if (generatingKeys) "Generating..." else "Generate Keys") } + Button(onClick = { showClearDb = true }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Clear DB") + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + /* ── Connection Mode ── */ + Text("Network Interfaces", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") }, + label = { Text("Manual") }) + FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") }, + label = { Text("Auto") }) + } + + if (connectionMode == "auto") { + /* Auto mode: read-only live socket display */ + if (localSocketStates.isEmpty()) { + Text("No sockets detected", color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + localSocketStates.forEach { s -> + val stateColor = when (s.state) { 2 -> Color(0xFF27AE60); 1 -> Color(0xFFF1C40F); else -> Color(0xFFE53935) } + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(s.ifName, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(4.dp)) + Text("${s.bindIp}:${s.port}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.width(6.dp)) + Surface(shape = MaterialTheme.shapes.extraSmall, color = stateColor, + modifier = Modifier.size(10.dp)) {} + Spacer(Modifier.width(4.dp)) + Text(s.stateLabel, style = MaterialTheme.typography.labelSmall, color = stateColor) + Spacer(Modifier.width(6.dp)) + Text(s.protocol, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (s.linksCount > 0) { + Spacer(Modifier.width(4.dp)) + Text("${s.linksCount} links", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + } else { + /* Manual mode: existing editable server config */ + servers.forEachIndexed { idx, srv -> + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + var ifExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, modifier = Modifier.weight(1f)) { + OutlinedTextField(srv.ifName, {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) { + availableIfaces.forEach { (n, ip) -> + DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = { + save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = n, bindIp = ip) }); ifExpanded = false + }) + } + } + } + Spacer(Modifier.width(4.dp)) + Text(srv.bindIp, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, + maxLines = 1, modifier = Modifier.width(105.dp)) + IconButton(onClick = { save(servers.toMutableList().also { it.removeAt(idx) }) }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + var protoExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, modifier = Modifier.width(100.dp)) { + OutlinedTextField(srv.protocol.uppercase(), {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(protoExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(protoExpanded, onDismissRequest = { protoExpanded = false }) { + PROTOCOLS.forEach { p -> + DropdownMenuItem(text = { Text(p.uppercase()) }, onClick = { + save(servers.toMutableList().also { it[idx] = srv.copy(protocol = p) }); protoExpanded = false + }) + } + } + } + Spacer(Modifier.width(4.dp)) + var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) } + OutlinedTextField(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535) + save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) }, + singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + } + Spacer(Modifier.height(2.dp)) + } + TextButton(onClick = { + val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1") + val rndPort = (2000..9999).random() + save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort)) + }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Add interface") } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + /* Debug */ + Text("Debug", style = MaterialTheme.typography.titleMedium) + DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug) + OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } }, + label = { Text("Log File") }, singleLine = true) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(logUdpIp, { v -> + logUdpIp = v; scope.launch { provider.setValue("log_udp.ip", v) } + val p = logUdpPort.toIntOrNull() ?: 0 + if (v.isNotEmpty() && p > 0) NativeLib.setUdpLogTarget(v, p) else NativeLib.setUdpLogTarget("", 0) + }, label = { Text("UDP Log IP") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true) + OutlinedTextField(logUdpPort, { v -> + logUdpPort = v; val p = v.toIntOrNull() ?: 0; scope.launch { provider.setValue("log_udp.port", v) } + if (logUdpIp.isNotEmpty() && p > 0 && p <= 65535) NativeLib.setUdpLogTarget(logUdpIp, p) else NativeLib.setUdpLogTarget("", 0) + }, label = { Text("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + } + Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories }, + horizontalArrangement = Arrangement.SpaceBetween) { + Text("Categories", style = MaterialTheme.typography.titleMedium) + Icon(Icons.Default.ArrowDropDown, if (showDebugCategories) "Collapse" else "Expand", modifier = Modifier.padding(end = 4.dp)) + } + if (showDebugCategories) { + CATEGORIES.forEach { cat -> + val current = catLevels[cat] ?: "" + DebugLevelDropdown(label = cat, current = current.ifEmpty { "(global)" }, + onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true) + } + } + Spacer(Modifier.height(16.dp)) + + Button(onClick = { + scope.launch(Dispatchers.IO) { + val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") + NativeLib.restart(path, port, provider.buildConfigText()) + } + }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { + Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Restart instance") + } + Spacer(Modifier.height(8.dp)) + Button(onClick = { NativeLib.stop(); onExit() }, modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Stop All & Exit") + } + Spacer(Modifier.height(16.dp)) + } + + /* Dialogs */ + if (showOverwriteKeys) AlertDialog( + onDismissRequest = { showOverwriteKeys = false }, title = { Text("Overwrite Keys?") }, + text = { Text("Keys already exist. Generating new keys will invalidate all existing connections and channels.") }, + confirmButton = { Button(onClick = { showOverwriteKeys = false; onDirty(true); vm.requestRegenerateKeys() }) { Text("Overwrite") } }, + dismissButton = { TextButton(onClick = { showOverwriteKeys = false }) { Text("Cancel") } }) + + if (showClearDb) AlertDialog( + onDismissRequest = { showClearDb = false }, title = { Text("Clear Database?") }, + text = { Text("This will delete all channels, messages, and peers.\nThis cannot be undone.") }, + confirmButton = { Button(onClick = { + showClearDb = false; clearingDb = true + scope.launch(Dispatchers.IO) { + vm.clearState(); NativeLib.stop(); NativeLib.clearDatabase() + val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") + NativeLib.restart(path, port, provider.buildConfigText()) + withContext(Dispatchers.Main) { clearingDb = false } + } + }, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Delete All") } }, + dismissButton = { TextButton(onClick = { showClearDb = false }) { Text("Cancel") } }) +} + +/* ── Audio Settings Tab ── */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AudioSettingsTab(vm: ChatViewModel) { + val scope = rememberCoroutineScope() + val provider = ChatApplication.instance.configProvider + + /* Mic test state */ + var micTesting by remember { mutableStateOf(false) } + var micLevel by remember { mutableStateOf(0f) } + + LaunchedEffect(micTesting) { + if (micTesting) { + while (true) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) } + } + } + + /* Codec preset */ + val presets = listOf("Low (16 kbps)", "Standard (32 kbps)", "High (64 kbps)") + var preset by remember { mutableStateOf(vm.getVoicePreset()) } + var presetExpanded by remember { mutableStateOf(false) } + + /* Compressor params with defaults from desktop */ + var compressorOn by remember { mutableStateOf(vm.getVoiceCompressor()) } + var maxGainDb by remember { mutableStateOf(30) } + var lookbackMs by remember { mutableStateOf(200) } + var lookaheadMs by remember { mutableStateOf(100) } + var riseRateTenths by remember { mutableStateOf(20) } + var targetLevelDb by remember { mutableStateOf(-12) } + + val apply = { + vm.setVoicePreset(preset) + vm.setVoiceCompressor(compressorOn) + vm.setCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRateTenths / 10f, targetLevelDb.toFloat()) + scope.launch { + provider.setValue("chat.opus_codec_preset", preset.toString()) + provider.setValue("chat.compressor_enabled", if (compressorOn) "1" else "0") + provider.setValue("chat.compressor_max_gain_db", maxGainDb.toString()) + provider.setValue("chat.compressor_rise_rate", riseRateTenths.toString()) + } + NativeLib.setChatSetting("opus_codec_preset", preset.toString()) + NativeLib.setChatSetting("compressor_enabled", if (compressorOn) "1" else "0") + NativeLib.setChatSetting("compressor_max_gain_db", maxGainDb.toString()) + NativeLib.setChatSetting("compressor_rise_rate", riseRateTenths.toString()) + } + + Column( + modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + /* ── Microphone test ── */ + Text("Microphone test", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val lvlPct = micLevel.coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { lvlPct }, + modifier = Modifier.weight(1f).height(16.dp), + color = when { lvlPct < 0.6f -> Color(0xFF27AE60); lvlPct < 0.85f -> Color(0xFFF1C40F); else -> Color(0xFFE53935) }, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + Button(onClick = { + if (micTesting) { vm.audioRecorder.stopMicTest(); micTesting = false; micLevel = 0f } + else { + if (vm.audioRecorder.startMicTest()) { micTesting = true; micLevel = 0f } + } + apply() + }) { Text(if (micTesting) "Stop" else "Test") } + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + /* ── Voice codec quality ── */ + Text("Voice codec quality", style = MaterialTheme.typography.titleMedium) + ExposedDropdownMenuBox(expanded = presetExpanded, onExpandedChange = { presetExpanded = it }) { + OutlinedTextField(presets[preset], {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(presetExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(presetExpanded, onDismissRequest = { presetExpanded = false }) { + presets.forEachIndexed { i, n -> + DropdownMenuItem(text = { Text(n) }, onClick = { preset = i; apply(); presetExpanded = false }) + } + } + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + /* ── Voice Compressor ── */ + Text("Voice Compressor", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Enable compressor", modifier = Modifier.weight(1f)) + Switch(checked = compressorOn, onCheckedChange = { compressorOn = it; apply() }) + } + + LabeledSlider("Max gain:", maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB", + { maxGainDb = it.toInt(); apply() }) + LabeledSlider("Lookback time:", lookbackMs.toFloat(), 50f..500f, 45, "${lookbackMs} ms", + { lookbackMs = it.toInt(); apply() }) + LabeledSlider("Lookahead time:", lookaheadMs.toFloat(), 20f..500f, 48, "${lookaheadMs} ms", + { lookaheadMs = it.toInt(); apply() }) + LabeledSlider("Rise rate (x/500ms):", riseRateTenths.toFloat(), 11f..100f, 89, + String.format("%.1fx", riseRateTenths / 10f), + { riseRateTenths = it.toInt(); apply() }) + LabeledSlider("Target level:", targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS", + { targetLevelDb = it.toInt(); apply() }) + } +} + +/* ── Storage Settings Tab ── */ + +@Composable +private fun StorageSettingsTab() { + val provider = ChatApplication.instance.configProvider + val scope = rememberCoroutineScope() + + var autoload by remember { mutableStateOf(true) } + var autoloadMaxSizeMb by remember { mutableStateOf("10") } + var maxStorageGb by remember { mutableStateOf("1") } + + LaunchedEffect(Unit) { + autoload = provider.getInt("chat.storage_autoload") != 0 + autoloadMaxSizeMb = provider.getInt("chat.storage_autoload_maxsize_mb").toString() + maxStorageGb = provider.getInt("chat.storage_maxsize_gb").toString() + } + + Column( + modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Media Storage", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Autoload media", modifier = Modifier.weight(1f)) + Switch(checked = autoload, onCheckedChange = { + autoload = it + val s = if (it) "1" else "0" + scope.launch { provider.setValue("chat.storage_autoload", s) } + NativeLib.setChatSetting("storage_autoload", s) + }) + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + OutlinedTextField(autoloadMaxSizeMb, + { v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) { scope.launch { provider.setValue("chat.storage_autoload_maxsize_mb", v) }; NativeLib.setChatSetting("storage_autoload_maxsize_mb", v) } }, + label = { Text("Autoload max size (MB)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, modifier = Modifier.padding(top = 4.dp)) + + OutlinedTextField(maxStorageGb, + { v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) { scope.launch { provider.setValue("chat.storage_maxsize_gb", v) }; NativeLib.setChatSetting("storage_maxsize_gb", v) } }, + label = { Text("Max storage size (GB)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true) + } +} + +/* ── Labeled Slider ── */ + +@Composable +private fun LabeledSlider( + label: String, value: Float, range: ClosedFloatingPointRange, + steps: Int, displayValue: String, onValueChange: (Float) -> Unit +) { + Column { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(displayValue, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Slider( + value = value, onValueChange = onValueChange, + valueRange = range, steps = steps, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/* ── Debug Level Dropdown ── */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DebugLevelDropdown(label: String, current: String, onSelect: (String) -> Unit, compact: Boolean = false) { + var expanded by remember { mutableStateOf(false) } + if (compact) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, modifier = Modifier.width(120.dp), style = MaterialTheme.typography.labelMedium) + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField(current, {}, readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor().height(48.dp).weight(1f), singleLine = true) + ExposedDropdownMenu(expanded, onDismissRequest = { expanded = false }) { + LEVELS.forEach { lvl -> + DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false }) + } + } + } + } + } else { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField(current, {}, readOnly = true, label = { Text("$label level") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor(), singleLine = true) + ExposedDropdownMenu(expanded, onDismissRequest = { expanded = false }) { + LEVELS.forEach { lvl -> + DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false }) + } + } + } + } +} 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 new file mode 100644 index 00000000..27601f44 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -0,0 +1,512 @@ +package com.utun.chat.viewmodel + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.utun.chat.ChatApplication +import com.utun.chat.AppEventHandler +import com.utun.chat.data.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +class ChatViewModel : ViewModel() { + + data class PlaybackState( + val filePath: String? = null, + val playing: Boolean = false, + val paused: Boolean = false, + val elapsedMs: Int = 0, + val progress: Float = 0f, + val durMs: Int = 0 + ) + + private var repo: ChatRepository? = null + private var dbReady = false + + private val _playbackState = MutableStateFlow(PlaybackState()) + val playbackState: StateFlow = _playbackState + + private val _channels = MutableStateFlow>(emptyList()) + val channels: StateFlow> = _channels + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + private val _currentChannel = MutableStateFlow(null) + val currentChannel: StateFlow = _currentChannel + + private val _pubKey = MutableStateFlow("") + val pubKey: StateFlow = _pubKey + + private val _generatingKeys = MutableStateFlow(false) + val generatingKeys: StateFlow = _generatingKeys + + private val _members = MutableStateFlow>(emptyList()) + val members: StateFlow> = _members + + private val _selectedMember = MutableStateFlow(null) + val selectedMember: StateFlow = _selectedMember + + private val _memberLinks = MutableStateFlow(MemberLinks()) + val memberLinks: StateFlow = _memberLinks + + private val _isStuck = MutableStateFlow(false) + val isStuck: StateFlow = _isStuck + + private val _nodeStatuses = MutableStateFlow>(emptyMap()) + val nodeStatuses: StateFlow> = _nodeStatuses + + private val _localSockets = MutableStateFlow>(emptyList()) + val localSockets: StateFlow> = _localSockets + + val audioRecorder = AudioRecorderManager() + + private val _isRecording = MutableStateFlow(false) + val isRecording: StateFlow = _isRecording + + private val _isRecordingLocked = MutableStateFlow(false) + val isRecordingLocked: StateFlow = _isRecordingLocked + + private val _recordingDurationMs = MutableStateFlow(0) + val recordingDurationMs: StateFlow = _recordingDurationMs + + private var recordingStartTimeMs = 0L + + @Volatile private var realPlaybackElapsedMs = 0 + + init { + viewModelScope.launch { + AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } + } + viewModelScope.launch(Dispatchers.IO) { + val key = ChatApplication.instance.configProvider.getString("node.public_key") + if (key != null && key.length == 64) _pubKey.value = key + } + viewModelScope.launch { + delay(500) + if (!dbReady && NativeLib.isReady()) { + repo = ChatRepository() + dbReady = true + LogManager.addLog("INFO", "VM", "DB ready (proactive)") + refreshChannels() + } + } + viewModelScope.launch { + while (isActive) { + delay(3000) + if (!dbReady || !NativeLib.isReady()) { _isStuck.value = true; continue } + NativeLib.ping() + delay(100) + _isStuck.value = !NativeLib.isResponsive() + } + } + viewModelScope.launch { + while (isActive) { + if (_isRecording.value) { + _recordingDurationMs.value = (System.currentTimeMillis() - recordingStartTimeMs).toInt() + } + delay(80) + } + } + viewModelScope.launch { + while (isActive) { + delay(50) + val s = _playbackState.value + if (s.playing && !s.paused) { + val real = realPlaybackElapsedMs + val targetMs = if (real >= s.durMs && s.durMs > 0) s.durMs else real + val newMs = (s.elapsedMs + (targetMs - s.elapsedMs) * 0.3f).toInt().coerceIn(0, s.durMs) + val newProg = if (s.durMs > 0) newMs.toFloat() / s.durMs else 0f + + if (real >= s.durMs && s.durMs > 0 && newMs >= s.durMs - 30) { + _playbackState.value = PlaybackState() + realPlaybackElapsedMs = 0 + } else { + _playbackState.value = s.copy(elapsedMs = newMs, progress = newProg) + } + } + } + } + } + + private fun handleEvent(type: Int, data: ByteArray?) { + when (type) { + 9 -> { /* DB_READY */ + if (dbReady) return + repo = ChatRepository() + dbReady = true + LogManager.addLog("INFO", "VM", "DB ready") + refreshChannels() + refreshLocalSockets() + } + 11 -> { /* KEYS_GENERATED: [pub_hex:64] */ + if (data != null && data.size >= 64) { + _pubKey.value = String(data, 0, 64) + _generatingKeys.value = false + } + } + 1 -> { /* MSG_RECEIVED: [ch_id_len:1][ch_id:var][author:8] */ + if (repo == null || data == null || data.size < 2) return + val chLen = data[0].toInt() and 0xFF + if (data.size >= 1 + chLen) { + val chId = String(data, 1, chLen) + val cur = _currentChannel.value + if (cur != null && cur.id == chId) refreshMessages(chId) + refreshChannels() + } + } + 5 -> { /* MEMBERS_CHANGED: [ch_id_len:1][ch_id:var] */ + if (repo == null || data == null || data.size < 2) return + val chLen = data[0].toInt() and 0xFF + if (data.size >= 1 + chLen) { + val chId = String(data, 1, chLen) + val cur = _currentChannel.value + if (cur != null && cur.id == chId) refreshMembers(chId) + } + } + 8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */ + 14 -> { /* ATTACHMENT_DOWNLOADED: [ch_id_len:1][ch_id:var][msg_id:8] */ + if (repo == null || data == null || data.size < 2) return + val chLen = data[0].toInt() and 0xFF + if (data.size >= 1 + chLen + 8) { + val chId = String(data, 1, chLen) + val cur = _currentChannel.value + if (cur != null && cur.id == chId) refreshMessages(chId) + refreshChannels() + } + } + 2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */ + if (repo == null || data == null || data.size < 20) { LogManager.addLog("WARN", "VM", "CONNECT_RESULT skipped repo=${repo != null} data=${data != null}"); return } + val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val nodeId = buf.getLong() + val result = buf.getInt() + val chId = buf.getLong() + LogManager.addLog( + if (result == 0) "INFO" else "ERROR", + "JOIN", "connect to 0x%016x ch=$chId result=$result".format(nodeId)) + LogManager.addLog("INFO", "VM", "CONNECT_RESULT result=$result — refreshChannels") + if (result == 0) refreshChannels() + } + 4 -> { if (repo != null) { LogManager.addLog("INFO", "VM", "CHANNEL_UPDATED — refreshChannels"); refreshChannels() } else { LogManager.addLog("WARN", "VM", "CHANNEL_UPDATED skipped — repo is NULL") } } + 12 -> LogManager.addLog("INFO", "VM", "service started") + 13 -> LogManager.addLog("INFO", "VM", "service stopped") + 16 -> { /* NODEINFO_UPDATED: [node_id:8][presence:1][up:1][best_rtt:2] */ + if (data == null || data.size < 12) return + val ns = parseNodeStatus(data) ?: return + _nodeStatuses.value = _nodeStatuses.value + (ns.nodeId to ns) + val cur = _currentChannel.value + if (cur != null) refreshMembers(cur.id) + } + 17 -> { /* LINK_STATUS_CHANGED: [peer_id:8][link_id:1][state:1][status:1][links_up:1][initialized:1] */ + if (data == null || data.size < 13) return + val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val nodeId = buf.getLong() + val detail = _selectedMember.value + if (detail != null && detail.nodeId == nodeId) refreshMemberLinks(nodeId) + } + 19 -> { if (repo != null) refreshLocalSockets() } /* LOCAL_SOCKETS */ + } + } + + fun requestRegenerateKeys() { + _generatingKeys.value = true + NativeLib.regenerateKeys() + viewModelScope.launch { + delay(5000) + if (_generatingKeys.value) { + _generatingKeys.value = false + LogManager.addLog("ERROR", "KEYS", "key regeneration timeout") + } + } + } + + fun requestRefresh() { + if (dbReady && repo != null) refreshChannels() + } + + fun refreshChannels() { + val r = repo ?: return + viewModelScope.launch { + _channels.value = withContext(Dispatchers.IO) { r.getChannels() } + } + } + + fun refreshMessages(chId: String) { + val r = repo ?: return + viewModelScope.launch { + _messages.value = withContext(Dispatchers.IO) { r.getMessages(chId) } + } + } + + fun selectChannel(channel: Channel) { + val r = repo ?: return + _currentChannel.value = channel + r.connectChannel(channel.id) + refreshMessages(channel.id) + } + + fun refreshMembers(chId: String) { + val r = repo ?: return + val statuses = _nodeStatuses.value + viewModelScope.launch { + val raw = withContext(Dispatchers.IO) { r.getMembers(chId) } + _members.value = raw.map { m -> + val ns = statuses[m.nodeId] + if (ns != null) m.copy( + online = ns.online, rtt = ns.bestRtt, rttType = ns.rttType, + directUp = ns.directUp, indirectUp = ns.indirectUp, + directPresence = ns.directPresence, indirectPresence = ns.indirectPresence, + bgpPresence = ns.bgpPresence + ) else m + } + } + } + + fun showMemberDetail(channelId: String, nodeId: Long) { + val r = repo ?: return + val ns = _nodeStatuses.value[nodeId] + viewModelScope.launch { + val d = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) } + if (d != null && ns != null) _selectedMember.value = d.copy( + online = ns.online, bestRtt = ns.bestRtt, rttType = ns.rttType, + directPresence = ns.directPresence, directUp = ns.directUp, + indirectPresence = ns.indirectPresence, indirectUp = ns.indirectUp, + bgpPresence = ns.bgpPresence + ) + else _selectedMember.value = d + } + } + + fun setMemberNick(channelId: String, nodeId: Long, nick: String) { + val r = repo ?: return + r.setMemberNick(channelId, nodeId, nick) + refreshMembers(channelId) + } + + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) { + val r = repo ?: return + r.setMemberFlags(channelId, nodeId, admin, supernode, storage) + refreshMembers(channelId) + } + + fun refreshMemberLinks(nodeId: Long) { + if (nodeId == 0L) { _memberLinks.value = MemberLinks(); return } + val r = repo ?: return + viewModelScope.launch { + _memberLinks.value = withContext(Dispatchers.IO) { r.getMemberLinks(nodeId) } + } + } + + fun refreshLocalSockets() { + val r = repo ?: return + viewModelScope.launch { + _localSockets.value = withContext(Dispatchers.IO) { r.getLocalSockets() } + } + } + + fun sendMessage(text: String) { + val r = repo ?: return + val ch = _currentChannel.value ?: return + r.sendMessage(ch.id, text) + viewModelScope.launch { + _messages.value = withContext(Dispatchers.IO) { r.getMessages(ch.id) } + refreshChannels() + } + } + + fun createChannel(name: String, channelId: String) { + val r = repo ?: return + r.createChannel(name, channelId) + refreshChannels() + } + + fun connectNode(address: String, port: Int, pubkeyHex: String) { + val r = repo ?: return + r.connectNode(address, port, pubkeyHex) + } + + fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { + val r = repo + if (r == null) { LogManager.addLog("ERROR", "VM", "joinChannel: repo is NULL"); return } + LogManager.addLog("INFO", "VM", "joinChannel: ch=0x%016x node=0x%016x".format(channelId, nodeId)) + r.joinChannel(channelId, nodeId, pubkey, addrs, addrCount) + } + + fun startVoiceRecording() { + val ch = _currentChannel.value ?: return + if (!audioRecorder.startRecording(ch.id)) { + LogManager.addLog("ERROR", "VM", "voice recording failed to start") + return + } + _isRecording.value = true + _isRecordingLocked.value = false + recordingStartTimeMs = System.currentTimeMillis() + _recordingDurationMs.value = 0 + LogManager.addLog("INFO", "VM", "voice recording started ch=${ch.id}") + } + + fun lockVoiceRecording() { + _isRecordingLocked.value = true + LogManager.addLog("DEBUG", "VM", "voice recording locked") + } + + fun stopVoiceRecording(): Int { + if (!_isRecording.value) return 0 + val duration = audioRecorder.stopRecording() + _isRecording.value = false + _isRecordingLocked.value = false + _recordingDurationMs.value = 0 + val ch = _currentChannel.value + if (ch != null && duration >= 500) { + viewModelScope.launch { + delay(500) + refreshMessages(ch.id) + refreshChannels() + } + } + LogManager.addLog("INFO", "VM", "voice recording stopped dur=${duration}ms") + return duration + } + + fun cancelVoiceRecording() { + audioRecorder.cancelRecording() + _isRecording.value = false + _isRecordingLocked.value = false + _recordingDurationMs.value = 0 + LogManager.addLog("INFO", "VM", "voice recording cancelled") + } + + fun sendVoiceMessage() { + val duration = stopVoiceRecording() + if (duration < 1000) { + LogManager.addLog("INFO", "VM", "voice too short (${duration}ms), discarded") + } + } + + fun sendAttachment(filePath: String) { + val r = repo ?: return + val ch = _currentChannel.value ?: return + r.sendAttachment(ch.id, filePath) + viewModelScope.launch { + delay(500) + refreshMessages(ch.id) + refreshChannels() + } + } + + fun downloadAttachment(msg: Message) { + val ch = _currentChannel.value ?: return + NativeLib.attachmentDownload(ch.id, msg.id) + viewModelScope.launch { + delay(500) + refreshMessages(ch.id) + } + } + + fun getVoicePreset(): Int = NativeLib.voiceGetPreset() + fun setVoicePreset(preset: Int) { NativeLib.voiceSetPreset(preset) } + fun getVoiceCompressor(): Boolean = NativeLib.voiceGetCompressor() + fun setVoiceCompressor(enabled: Boolean) { NativeLib.voiceSetCompressor(enabled) } + fun setCompressorConfig(maxGainDb: Int, lookbackMs: Int, lookaheadMs: Int, + riseRate: Float, targetLevelDb: Float) { + NativeLib.voiceSetCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb) + } + + fun playVoiceMessage(filePath: String, durMs: Int) { + val cur = _playbackState.value + if (cur.filePath == filePath) { + if (cur.paused) { + realPlaybackElapsedMs = cur.elapsedMs + audioRecorder.playFromPosition(filePath, cur.elapsedMs, + onProgress = { _, elapsed -> realPlaybackElapsedMs = elapsed }, + onComplete = { realPlaybackElapsedMs = durMs }) + _playbackState.value = cur.copy(playing = true, paused = false) + } else { + audioRecorder.pausePlayback() + realPlaybackElapsedMs = 0 + _playbackState.value = cur.copy(playing = false, paused = true) + } + return + } + audioRecorder.stopPlayback() + realPlaybackElapsedMs = 0 + _playbackState.value = PlaybackState(filePath, playing = true, durMs = durMs) + audioRecorder.playVoiceFile(filePath, + onProgress = { _, elapsed -> realPlaybackElapsedMs = elapsed }, + onComplete = { + realPlaybackElapsedMs = 0 + _playbackState.value = PlaybackState() + }) + } + + fun seekVoice(filePath: String, seekMs: Int, durMs: Int) { + audioRecorder.stopPlayback() + realPlaybackElapsedMs = seekMs + _playbackState.value = PlaybackState(filePath, playing = true, durMs = durMs, elapsedMs = seekMs, + progress = if (durMs > 0) seekMs.toFloat() / durMs.toFloat() else 0f) + audioRecorder.playFromPosition(filePath, seekMs, + onProgress = { _, elapsed -> realPlaybackElapsedMs = elapsed }, + onComplete = { + realPlaybackElapsedMs = 0 + _playbackState.value = PlaybackState() + }) + } + + fun copyUriToCache(context: Context, uri: Uri, fileName: String): String? { + try { + val cacheDir = File(context.cacheDir, "attachments") + cacheDir.mkdirs() + val cacheFile = File(cacheDir, fileName) + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cacheFile).use { output -> input.copyTo(output) } + } ?: return null + LogManager.addLog("INFO", "VM", "copied attachment to cache: ${cacheFile.absolutePath}") + return cacheFile.absolutePath + } catch (e: Exception) { + LogManager.addLog("ERROR", "VM", "copyUriToCache failed: ${e.message}") + return null + } + } + + fun saveDownloadedFile(srcPath: String, saveUri: Uri) { + try { + val src = File(srcPath) + if (!src.exists()) { + LogManager.addLog("ERROR", "VM", "saveDownloadedFile: src not found $srcPath") + return + } + val ctx = ChatApplication.instance + ctx.contentResolver.openOutputStream(saveUri)?.use { output -> + src.inputStream().use { input -> input.copyTo(output) } + } + LogManager.addLog("INFO", "VM", "file saved: $srcPath -> $saveUri") + } catch (e: Exception) { + LogManager.addLog("ERROR", "VM", "saveDownloadedFile failed: ${e.message}") + } + } + + fun clearState() { + repo?.close() + repo = null + dbReady = false + _channels.value = emptyList() + _messages.value = emptyList() + _currentChannel.value = null + _generatingKeys.value = false + _localSockets.value = emptyList() + } + + class Factory : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ChatViewModel() as T + } +}