Browse Source
- 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
23 changed files with 4376 additions and 0 deletions
Binary file not shown.
Binary file not shown.
@ -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 |
||||
} |
||||
} |
||||
@ -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 }) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -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() {} |
||||
} |
||||
@ -0,0 +1,6 @@
|
||||
package com.utun.chat.data |
||||
|
||||
interface ConfigCallback { |
||||
fun getString(key: String): String? |
||||
fun getInt(key: String): Int |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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}" |
||||
} |
||||
} |
||||
@ -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 ?: "") |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
) |
||||
@ -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) |
||||
} |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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") } |
||||
} |
||||
) |
||||
} |
||||
@ -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() |
||||
) |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -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") } |
||||
} |
||||
} |
||||
) |
||||
} |
||||
@ -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)) } |
||||
} |
||||
} |
||||
} |
||||
@ -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") } |
||||
} |
||||
) |
||||
} |
||||
@ -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 |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
@ -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 }) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue