Browse Source

android: restore Kotlin sources + fix build

- Restore chat Kotlin sources deleted in d773f17 (refactor STCP)
  from d773f17~1: ChatApplication, MainActivity, data/, headless/,
  viewmodel/, ui/screens/, ui/components/
- Add InviteLinkDialog.kt (new file, never committed)
- Add missing libcrypto.a for arm64-v8a and x86_64
  (cross-compiled OpenSSL 3.5.5 via NDK 29)
- Build and install successful
topo_upd
evgeny 2 months ago
parent
commit
3f5dd43109
  1. BIN
      tools/chatgui-android/app/src/main/cpp/openssl/arm64-v8a/lib/libcrypto.a
  2. BIN
      tools/chatgui-android/app/src/main/cpp/openssl/x86_64/lib/libcrypto.a
  3. 47
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  4. 149
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  5. 260
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt
  6. 326
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  7. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt
  8. 397
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  9. 156
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt
  10. 47
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt
  11. 178
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  12. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt
  13. 72
      tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt
  14. 436
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  15. 166
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  16. 184
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  17. 73
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt
  18. 142
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt
  19. 98
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt
  20. 374
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  21. 165
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt
  22. 579
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  23. 512
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

BIN
tools/chatgui-android/app/src/main/cpp/openssl/arm64-v8a/lib/libcrypto.a

Binary file not shown.

BIN
tools/chatgui-android/app/src/main/cpp/openssl/x86_64/lib/libcrypto.a

Binary file not shown.

47
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<Pair<Int, ByteArray?>>(replay = 16, extraBufferCapacity = 64)
val events: SharedFlow<Pair<Int, ByteArray?>> = _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
}
}

149
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<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteLinkText by remember { mutableStateOf<String?>(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 })
}
}
}
}

260
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
}
}

326
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<Float> = 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<MemberAddr> = 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<LinkStatus> = 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<Channel> {
val list = mutableListOf<Channel>()
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<Message> {
val list = mutableListOf<Message>()
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<Float>()
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<ChatMember> {
val list = mutableListOf<ChatMember>()
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<MemberAddr>()
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<LinkStatus>()
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<LocalSocketState> {
try {
val arr = JSONArray(NativeLib.getLocalSockets())
val list = mutableListOf<LocalSocketState>()
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() {}
}

6
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
}

397
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<String, String>? {
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<Map<String, String>> = 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<ServerEntry> {
val servers = mutableListOf<ServerEntry>()
val seen = mutableSetOf<String>()
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<ServerEntry> {
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<RawSrv>()
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<ServerEntry>) {
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<ServerEntry>): 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<Pair<String, String>> = runBlocking {
val list = mutableListOf<Pair<String, String>>()
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)
}
}

156
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<InviteAddr>
) {
/** 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<InviteAddr>()
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}"
}
}

47
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<LogEntry>(MAX_BUFFER)
private val _events = MutableSharedFlow<LogEntry>(replay = 0, extraBufferCapacity = 512)
val events: SharedFlow<LogEntry> = _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<LogEntry> {
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 ?: "")
}
}

178
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)
}

9
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
)

72
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)
}
}
}

436
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)
}

166
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<Channel>,
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") }
}
)
}

184
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<Message>,
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<Uri?>(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<String?>(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()
)
}
}

73
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
}
}

142
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<InviteData?>(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") }
}
}
)
}

98
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<LogEntry>() }
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)) }
}
}
}

374
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<ChatMember>,
selectedMember: MemberDetail?,
viewModel: ChatViewModel,
onBack: () -> Unit
) {
var showEditDialog by remember { mutableStateOf(false) }
var editMember by remember { mutableStateOf<ChatMember?>(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<String>()
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") }
}
)
}

165
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<ProcessCameraProvider?>(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
}
)
}
}
}

579
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<Map<String, String>>(emptyMap()) }
var servers by remember { mutableStateOf<List<ServerEntry>>(emptyList()) }
var availableIfaces by remember { mutableStateOf<List<Pair<String, String>>>(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<ServerEntry> -> 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<Float>,
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 })
}
}
}
}
}

512
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> = _playbackState
private val _channels = MutableStateFlow<List<Channel>>(emptyList())
val channels: StateFlow<List<Channel>> = _channels
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _currentChannel = MutableStateFlow<Channel?>(null)
val currentChannel: StateFlow<Channel?> = _currentChannel
private val _pubKey = MutableStateFlow("")
val pubKey: StateFlow<String> = _pubKey
private val _generatingKeys = MutableStateFlow(false)
val generatingKeys: StateFlow<Boolean> = _generatingKeys
private val _members = MutableStateFlow<List<ChatMember>>(emptyList())
val members: StateFlow<List<ChatMember>> = _members
private val _selectedMember = MutableStateFlow<MemberDetail?>(null)
val selectedMember: StateFlow<MemberDetail?> = _selectedMember
private val _memberLinks = MutableStateFlow<MemberLinks>(MemberLinks())
val memberLinks: StateFlow<MemberLinks> = _memberLinks
private val _isStuck = MutableStateFlow(false)
val isStuck: StateFlow<Boolean> = _isStuck
private val _nodeStatuses = MutableStateFlow<Map<Long, NodeStatus>>(emptyMap())
val nodeStatuses: StateFlow<Map<Long, NodeStatus>> = _nodeStatuses
private val _localSockets = MutableStateFlow<List<LocalSocketState>>(emptyList())
val localSockets: StateFlow<List<LocalSocketState>> = _localSockets
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording
private val _isRecordingLocked = MutableStateFlow(false)
val isRecordingLocked: StateFlow<Boolean> = _isRecordingLocked
private val _recordingDurationMs = MutableStateFlow(0)
val recordingDurationMs: StateFlow<Int> = _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 <T : ViewModel> create(modelClass: Class<T>): T = ChatViewModel() as T
}
}
Loading…
Cancel
Save