diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 0000000..4ebcea7 --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,72 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman + while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman", + "use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers + when token efficiency is requested. +--- + +# Caveman Mode + +## Core Rule + +Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance. + +## Grammar + +- Drop articles (a, an, the) +- Drop filler (just, really, basically, actually, simply) +- Drop pleasantries (sure, certainly, of course, happy to) +- Short synonyms (big not extensive, fix not "implement a solution for") +- No hedging (skip "it might be worth considering") +- Fragments fine. No need full sentence +- Technical terms stay exact. "Polymorphism" stays "polymorphism" +- Code blocks unchanged. Caveman speak around code, not in code +- Error messages quoted exact. Caveman only for explanation + +## Pattern + +``` +[thing] [action] [reason]. [next step]. +``` + +Not: +> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by... + +Yes: +> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix: + +## Examples + +**User:** Why is my React component re-rendering? + +**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object." + +**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." + +--- + +**User:** How do I set up a PostgreSQL connection pool? + +**Caveman:** +``` +Use `pg` pool: +``` +```js +const pool = new Pool({ + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}) +``` +``` +max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn. +``` + +## Boundaries + +- Code: write normal. Caveman English only +- Git commits: normal +- PR descriptions: normal +- User say "stop caveman" or "normal mode": revert immediately diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt index f51821b..0cfb675 100644 --- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -1,6 +1,7 @@ package com.wzp.ui.call import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,6 +22,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.IconButtonDefaults @@ -29,10 +31,10 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,9 +50,20 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wzp.engine.CallStats -import com.wzp.ui.call.LockStatus +import com.wzp.ui.components.CopyableFingerprint +import com.wzp.ui.components.Identicon import kotlin.math.roundToInt +// Desktop-style dark theme colors +private val DarkBg = Color(0xFF0F0F1A) +private val DarkSurface = Color(0xFF1A1A2E) +private val DarkSurface2 = Color(0xFF222244) +private val Accent = Color(0xFFE94560) +private val Green = Color(0xFF4ADE80) +private val Yellow = Color(0xFFFACC15) +private val Red = Color(0xFFEF4444) +private val TextDim = Color(0xFF777777) + @OptIn(ExperimentalLayoutApi::class) @Composable fun InCallScreen( @@ -67,237 +80,220 @@ fun InCallScreen( val roomName by viewModel.roomName.collectAsState() val selectedServer by viewModel.selectedServer.collectAsState() val servers by viewModel.servers.collectAsState() - val preferIPv6 by viewModel.preferIPv6.collectAsState() - val playoutGainDb by viewModel.playoutGainDb.collectAsState() - val captureGainDb by viewModel.captureGainDb.collectAsState() + val aecEnabled by viewModel.aecEnabled.collectAsState() val debugReportAvailable by viewModel.debugReportAvailable.collectAsState() val debugReportStatus by viewModel.debugReportStatus.collectAsState() + val seedHex by viewModel.seedHex.collectAsState() + val alias by viewModel.alias.collectAsState() + val recentRooms by viewModel.recentRooms.collectAsState() + val pingResults by viewModel.pingResults.collectAsState() - var showAddServerDialog by remember { mutableStateOf(false) } + var showManageRelays by remember { mutableStateOf(false) } + + // Don't auto-ping — loading the native .so triggers jemalloc init + // which crashes on Android 16 MTE. Let user click "Ping All" manually. Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = DarkBg ) { Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(horizontal = 24.dp, vertical = 16.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - // Settings button (top-right) - if (callState == 0) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onOpenSettings) { - Text("Settings") - } - } - } - - Spacer(modifier = Modifier.height(if (callState == 0) 16.dp else 48.dp)) - - Text( - text = "WZ Phone", - style = MaterialTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.Bold - ), - color = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(8.dp)) - - CallStateLabel(callState) - if (callState == 0) { + // ── IDLE / CONNECT SCREEN ── Spacer(modifier = Modifier.height(32.dp)) - // Server selector Text( - text = "Server", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "WarzonePhone", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + color = Color.White ) - Spacer(modifier = Modifier.height(4.dp)) - val pingResults by viewModel.pingResults.collectAsState() - - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - servers.forEachIndexed { idx, entry -> - val isSelected = selectedServer == idx - val ping = pingResults[entry.address] - val lockStatus = viewModel.lockStatus(entry.address) - val lockIcon = when (lockStatus) { - LockStatus.VERIFIED -> "\uD83D\uDD12" // 🔒 - LockStatus.NEW -> "\uD83D\uDD13" // 🔓 - LockStatus.CHANGED -> "⚠\uFE0F" // ⚠️ - LockStatus.OFFLINE -> "\uD83D\uDD34" // 🔴 - LockStatus.UNKNOWN -> "" - } - val rttText = ping?.let { "${it.rttMs}ms" } ?: "" - - FilledTonalIconButton( - onClick = { viewModel.selectServer(idx) }, - modifier = Modifier - .padding(2.dp) - .height(40.dp) - .width(160.dp), - shape = RoundedCornerShape(8.dp), - colors = if (isSelected) { - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - } else { - IconButtonDefaults.filledTonalIconButtonColors() - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (lockIcon.isNotEmpty()) { - Text(text = lockIcon, fontSize = 12.sp) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = entry.label, - style = MaterialTheme.typography.labelSmall, - maxLines = 1 - ) - if (rttText.isNotEmpty()) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = rttText, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp), - color = when { - (ping?.rttMs ?: 0) > 200 -> Color(0xFFFACC15) // yellow - else -> Color(0xFF4ADE80) // green - } - ) - } - } - } - } - // + Add button - OutlinedButton( - onClick = { showAddServerDialog = true }, - modifier = Modifier - .padding(2.dp) - .height(40.dp), - shape = RoundedCornerShape(8.dp) - ) { - Text("+", style = MaterialTheme.typography.labelMedium) - } - } - - // Ping button - TextButton(onClick = { viewModel.pingAllServers() }) { - Text("Ping All", style = MaterialTheme.typography.labelSmall) - } - - // IPv4/IPv6 preference - Spacer(modifier = Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - text = "IPv4", - style = MaterialTheme.typography.labelSmall, - color = if (!preferIPv6) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - Switch( - checked = preferIPv6, - onCheckedChange = { viewModel.setPreferIPv6(it) }, - modifier = Modifier.padding(horizontal = 8.dp) - ) - Text( - text = "IPv6", - style = MaterialTheme.typography.labelSmall, - color = if (preferIPv6) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Selected server address - Spacer(modifier = Modifier.height(4.dp)) Text( - text = servers.getOrNull(selectedServer)?.address ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "ENCRYPTED VOICE", + style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp), + color = TextDim ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = roomName, - onValueChange = { viewModel.setRoomName(it) }, - label = { Text("Room") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(0.6f) - ) - - // Recent rooms - val recentRooms by viewModel.recentRooms.collectAsState() - if (recentRooms.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - recentRooms.forEach { recent -> - Surface( - onClick = { - viewModel.setRoomName(recent.room) - // Select matching server - val idx = servers.indexOfFirst { it.address == recent.relay } - if (idx >= 0) viewModel.selectServer(idx) - }, - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.padding(2.dp) - ) { - Text( - text = recent.room, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - } - } - } - Spacer(modifier = Modifier.height(24.dp)) + // Relay selector button + val selServer = servers.getOrNull(selectedServer) + val selPing = selServer?.let { pingResults[it.address] } + val selLock = selServer?.let { viewModel.lockStatus(it.address) } ?: LockStatus.UNKNOWN + val lockEmoji = when (selLock) { + LockStatus.VERIFIED -> "\uD83D\uDD12" + LockStatus.NEW -> "\uD83D\uDD13" + LockStatus.CHANGED -> "\u26A0\uFE0F" + LockStatus.OFFLINE -> "\uD83D\uDD34" + LockStatus.UNKNOWN -> "\u26AA" + } + + SectionLabel("RELAY") + Surface( + onClick = { showManageRelays = true }, + shape = RoundedCornerShape(8.dp), + color = DarkSurface, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(12.dp) + ) { + Text(text = lockEmoji, fontSize = 16.sp) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = selServer?.let { "${it.label} (${it.address})" } ?: "No relay", + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + selPing?.let { + Text( + text = "${it.rttMs}ms", + color = if (it.rttMs > 200) Yellow else Green, + style = MaterialTheme.typography.labelSmall + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "\u2699", color = TextDim, fontSize = 16.sp) // ⚙ + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Room + SectionLabel("ROOM") + OutlinedTextField( + value = roomName, + onValueChange = { viewModel.setRoomName(it) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Alias + SectionLabel("ALIAS") + OutlinedTextField( + value = alias, + onValueChange = { viewModel.setAlias(it) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // AEC + Settings + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = aecEnabled, + onCheckedChange = { viewModel.setAecEnabled(it) } + ) + Text("OS ECHO CANCEL", color = TextDim, style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.weight(1f)) + Surface( + onClick = onOpenSettings, + shape = RoundedCornerShape(8.dp), + color = Color.Transparent, + modifier = Modifier.size(36.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text("\u2699", fontSize = 18.sp, color = TextDim) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Connect button Button( onClick = { viewModel.startCall() }, - modifier = Modifier - .size(120.dp) - .clip(CircleShape), - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF4CAF50) - ) + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Accent) ) { Text( - text = "CALL", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold - ), + "Connect", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = Color.White ) } errorMessage?.let { err -> - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = err, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = err, color = Red, style = MaterialTheme.typography.bodySmall) } - // Debug report card — shown after call ends + Spacer(modifier = Modifier.height(20.dp)) + + // Identity + val fp = if (seedHex.length >= 16) seedHex.take(16) else "" + Row(verticalAlignment = Alignment.CenterVertically) { + if (fp.isNotEmpty()) { + Identicon(fingerprint = seedHex, size = 28.dp) + Spacer(modifier = Modifier.width(8.dp)) + CopyableFingerprint( + fingerprint = fp.chunked(4).joinToString(":"), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = TextDim + ) + } + } + + // Recent rooms — grouped by server + if (recentRooms.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + val grouped = recentRooms.groupBy { it.relay } + val serverColors = listOf( + Color(0xFF0F3460), Color(0xFF3D0F60), Color(0xFF0F6034), + Color(0xFF60300F), Color(0xFF0F4D60) + ) + grouped.entries.forEachIndexed { sIdx, (relay, rooms) -> + val serverLabel = servers.find { it.address == relay }?.label ?: relay + val bgColor = serverColors[sIdx % serverColors.size] + Column(modifier = Modifier.fillMaxWidth()) { + rooms.forEach { recent -> + Surface( + onClick = { + viewModel.setRoomName(recent.room) + val idx = servers.indexOfFirst { it.address == recent.relay } + if (idx >= 0) viewModel.selectServer(idx) + }, + shape = RoundedCornerShape(16.dp), + color = bgColor, + modifier = Modifier.padding(vertical = 2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = recent.room, + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = serverLabel, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp), + color = Color.White.copy(alpha = 0.5f) + ) + } + } + } + } + } + } + + // Debug report card if (debugReportAvailable || debugReportStatus != null) { Spacer(modifier = Modifier.height(24.dp)) DebugReportCard( @@ -307,282 +303,333 @@ fun InCallScreen( onDismiss = { viewModel.dismissDebugReport() } ) } + } else { - // In-call UI - Spacer(modifier = Modifier.height(16.dp)) - - DurationDisplay(stats.durationSecs) - + // ── IN-CALL SCREEN ── Spacer(modifier = Modifier.height(24.dp)) - QualityIndicator(qualityTier, stats.qualityLabel) - - if (stats.roomParticipantCount > 0) { - // Dedup by fingerprint — same key = same person, even if - // relay hasn't cleaned up stale entries yet. - val unique = stats.roomParticipants - .distinctBy { it.fingerprint.ifEmpty { it.displayName } } - Spacer(modifier = Modifier.height(8.dp)) + // Room name + settings gear + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "${unique.size} in room", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = roomName, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = Color.White ) - unique.forEach { member -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 2.dp) - ) { - com.wzp.ui.components.Identicon( - fingerprint = member.fingerprint.ifEmpty { member.displayName }, - size = 28.dp, - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = member.displayName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (member.fingerprint.isNotEmpty()) { - com.wzp.ui.components.CopyableFingerprint( - fingerprint = member.fingerprint.take(16), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 9.sp, - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - } + Spacer(modifier = Modifier.width(8.dp)) + Surface( + onClick = onOpenSettings, + shape = RoundedCornerShape(8.dp), + color = Color.Transparent, + modifier = Modifier.size(28.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text("\u2699", fontSize = 14.sp, color = TextDim) } } } - Spacer(modifier = Modifier.height(32.dp)) + // Green dot + timer + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Green) + ) + Spacer(modifier = Modifier.width(8.dp)) + DurationDisplay(stats.durationSecs) + } + Spacer(modifier = Modifier.height(12.dp)) + + // Audio level meter AudioLevelBar(stats.audioLevel) Spacer(modifier = Modifier.height(16.dp)) - // Gain sliders - GainSlider( - label = "Voice Volume", - gainDb = playoutGainDb, - onGainChange = { viewModel.setPlayoutGainDb(it) } - ) - Spacer(modifier = Modifier.height(4.dp)) - GainSlider( - label = "Mic Gain", - gainDb = captureGainDb, - onGainChange = { viewModel.setCaptureGainDb(it) } - ) + // Participants card + Surface( + shape = RoundedCornerShape(12.dp), + color = DarkSurface, + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .height(280.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (stats.roomParticipantCount > 0) { + val unique = stats.roomParticipants + .distinctBy { it.fingerprint.ifEmpty { it.displayName } } + unique.forEach { member -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Identicon( + fingerprint = member.fingerprint.ifEmpty { member.displayName }, + size = 40.dp, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = member.displayName, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = Color.White + ) + if (member.fingerprint.isNotEmpty()) { + CopyableFingerprint( + fingerprint = member.fingerprint.take(16), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ), + color = TextDim, + ) + } + } + } + } + } else { + Text( + text = "Waiting for participants...", + color = TextDim, + style = MaterialTheme.typography.bodySmall + ) + } + } + } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(16.dp)) + // Controls: Mic / End / Spk ControlRow( isMuted = isMuted, isSpeaker = isSpeaker, onToggleMute = viewModel::toggleMute, onToggleSpeaker = viewModel::toggleSpeaker, - onHangUp = { - viewModel.stopCall() - } + onHangUp = { viewModel.stopCall() } ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(12.dp)) - StatsOverlay(stats) + // Stats + Text( + text = "TX: ${stats.framesEncoded} | RX: ${stats.framesDecoded}", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = TextDim + ) Spacer(modifier = Modifier.height(16.dp)) } } } - if (showAddServerDialog) { - AddServerDialog( - onDismiss = { showAddServerDialog = false }, - onAdd = { host, port, label -> - viewModel.addServer("$host:$port", label) - showAddServerDialog = false - } + // ── Manage Relays Dialog ── + if (showManageRelays) { + ManageRelaysDialog( + servers = servers, + selectedServer = selectedServer, + pingResults = pingResults, + viewModel = viewModel, + onSelect = { idx -> viewModel.selectServer(idx) }, + onDelete = { idx -> viewModel.removeServer(idx) }, + onAdd = { addr, label -> viewModel.addServer(addr, label) }, + onDismiss = { showManageRelays = false } ) } } +// ── Section label ── @Composable -private fun AddServerDialog( - onDismiss: () -> Unit, - onAdd: (host: String, port: String, label: String) -> Unit +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), + color = TextDim, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + ) +} + +// ── Manage Relays Dialog ── +@Composable +private fun ManageRelaysDialog( + servers: List, + selectedServer: Int, + pingResults: Map, + viewModel: CallViewModel, + onSelect: (Int) -> Unit, + onDelete: (Int) -> Unit, + onAdd: (String, String) -> Unit, + onDismiss: () -> Unit ) { - var host by remember { mutableStateOf("") } - var port by remember { mutableStateOf("4433") } - var label by remember { mutableStateOf("") } + var addName by remember { mutableStateOf("") } + var addAddr by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, - title = { Text("Add Server") }, - text = { - Column { - OutlinedTextField( - value = host, - onValueChange = { host = it }, - label = { Text("Host (IP or domain)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = port, - onValueChange = { port = it }, - label = { Text("Port") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = label, - onValueChange = { label = it }, - label = { Text("Label (optional)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - TextButton( - onClick = { - if (host.isNotBlank()) { - val displayLabel = label.ifBlank { host } - onAdd(host.trim(), port.trim(), displayLabel) + containerColor = DarkBg, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Manage Relays", color = Color.White, fontWeight = FontWeight.Bold) + Surface( + onClick = onDismiss, + shape = RoundedCornerShape(8.dp), + color = DarkSurface2, + modifier = Modifier.size(32.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text("\u00D7", color = TextDim, fontSize = 18.sp) } } - ) { Text("Add") } + } }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - } - ) -} - -@Composable -private fun CallStateLabel(state: Int) { - val label = when (state) { - 0 -> "Ready to connect" - 1 -> "Connecting..." - 2 -> "Active" - 3 -> "Reconnecting..." - 4 -> "Call Ended" - else -> "Unknown" - } - val color = when (state) { - 2 -> Color(0xFF4CAF50) - 1, 3 -> Color(0xFFFFC107) - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - color = color + text = { + Column { + servers.forEachIndexed { idx, entry -> + val isSelected = idx == selectedServer + val ping = pingResults[entry.address] + val lock = viewModel.lockStatus(entry.address) + val lockEmoji = when (lock) { + LockStatus.VERIFIED -> "\uD83D\uDD12" + LockStatus.NEW -> "\uD83D\uDD13" + LockStatus.CHANGED -> "\u26A0\uFE0F" + LockStatus.OFFLINE -> "\uD83D\uDD34" + LockStatus.UNKNOWN -> "" + } + + Surface( + onClick = { onSelect(idx) }, + shape = RoundedCornerShape(8.dp), + color = if (isSelected) Color(0xFF0F3460) else DarkSurface, + border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Accent) else null, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(10.dp) + ) { + Identicon( + fingerprint = ping?.serverFingerprint ?: entry.address, + size = 36.dp, + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(entry.label, color = Color.White, fontWeight = FontWeight.Medium) + Text( + entry.address, + color = TextDim, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (lockEmoji.isNotEmpty()) Text(lockEmoji, fontSize = 14.sp) + ping?.let { + Text( + "${it.rttMs}ms", + color = if (it.rttMs > 200) Yellow else Green, + style = MaterialTheme.typography.labelSmall + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + "\u00D7", + color = TextDim, + fontSize = 18.sp, + modifier = Modifier.clickable { onDelete(idx) } + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Add relay inputs + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = addName, + onValueChange = { addName = it }, + placeholder = { Text("Name", color = TextDim) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = addAddr, + onValueChange = { addAddr = it }, + placeholder = { Text("host:port", color = TextDim) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + if (addAddr.isNotBlank()) { + onAdd(addAddr.trim(), addName.ifBlank { addAddr }.trim()) + addName = ""; addAddr = "" + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Accent) + ) { + Text("Add Relay", color = Color.White, fontWeight = FontWeight.Bold) + } + } + }, + confirmButton = {} ) } +// ── Duration display ── @Composable private fun DurationDisplay(durationSecs: Double) { val totalSeconds = durationSecs.roundToInt() val minutes = totalSeconds / 60 val seconds = totalSeconds % 60 Text( - text = "%02d:%02d".format(minutes, seconds), - style = MaterialTheme.typography.displayLarge.copy( - fontWeight = FontWeight.Light, - letterSpacing = 4.sp - ), - color = MaterialTheme.colorScheme.onBackground + text = "%d:%02d".format(minutes, seconds), + style = MaterialTheme.typography.bodyMedium, + color = TextDim ) } -@Composable -private fun QualityIndicator(tier: Int, label: String) { - val dotColor = when (tier) { - 0 -> Color(0xFF4CAF50) - 1 -> Color(0xFFFFC107) - 2 -> Color(0xFFF44336) - else -> Color.Gray - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(dotColor) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - +// ── Audio level bar ── @Composable private fun AudioLevelBar(audioLevel: Int) { val level = if (audioLevel > 0) { - (audioLevel.toFloat() / 8000f).coerceIn(0.02f, 1f) - } else { - 0f - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "Audio Level", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) + (kotlin.math.ln(audioLevel.toFloat()) / kotlin.math.ln(32767f)).coerceIn(0f, 1f) + } else 0f + + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(DarkSurface) + ) { Box( modifier = Modifier - .fillMaxWidth(0.6f) - .height(6.dp) - .clip(RoundedCornerShape(3.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - Box( - modifier = Modifier - .fillMaxWidth(level) - .height(6.dp) - .background(MaterialTheme.colorScheme.primary) - ) - } - } -} - -@Composable -private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) { - Column( - modifier = Modifier.fillMaxWidth(0.8f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val sign = if (gainDb >= 0) "+" else "" - Text( - text = "$label: ${sign}${"%.0f".format(gainDb)} dB", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Slider( - value = gainDb, - onValueChange = { onGainChange(Math.round(it).toFloat()) }, - valueRange = -20f..20f, - steps = 0, - modifier = Modifier.fillMaxWidth() + .fillMaxWidth(level) + .height(4.dp) + .background( + brush = androidx.compose.ui.graphics.Brush.horizontalGradient( + colors = listOf(Green, Yellow, Red) + ) + ) ) } } +// ── Control row: Mic / End / Spk ── @Composable private fun ControlRow( isMuted: Boolean, @@ -596,57 +643,56 @@ private fun ControlRow( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { + // Mic FilledTonalIconButton( onClick = onToggleMute, modifier = Modifier.size(56.dp), colors = if (isMuted) { IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer + containerColor = Red, contentColor = Color.White ) } else { - IconButtonDefaults.filledTonalIconButtonColors() + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = DarkSurface2, contentColor = Color.White + ) } ) { Text( - text = if (isMuted) "MIC\nOFF" else "MIC", + text = if (isMuted) "Mic\nOff" else "Mic", textAlign = TextAlign.Center, style = MaterialTheme.typography.labelSmall, lineHeight = 12.sp ) } + // End FilledIconButton( onClick = onHangUp, - modifier = Modifier.size(72.dp), + modifier = Modifier.size(64.dp), shape = CircleShape, colors = IconButtonDefaults.filledIconButtonColors( - containerColor = Color(0xFFF44336), - contentColor = Color.White + containerColor = Accent, contentColor = Color.White ) ) { - Text( - text = "END", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold - ) - ) + Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)) } + // Speaker FilledTonalIconButton( onClick = onToggleSpeaker, modifier = Modifier.size(56.dp), colors = if (isSpeaker) { IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + containerColor = Color(0xFF0F3460), contentColor = Color.White ) } else { - IconButtonDefaults.filledTonalIconButtonColors() + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = DarkSurface2, contentColor = Color.White + ) } ) { Text( - text = if (isSpeaker) "SPK\nON" else "SPK", + text = if (isSpeaker) "Spk\nOn" else "Spk", textAlign = TextAlign.Center, style = MaterialTheme.typography.labelSmall, lineHeight = 12.sp @@ -655,60 +701,7 @@ private fun ControlRow( } } -@Composable -private fun StatsOverlay(stats: CallStats) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Stats", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - StatItem("Loss", "%.1f%%".format(stats.lossPct)) - StatItem("RTT", "${stats.rttMs}ms") - StatItem("Jitter", "${stats.jitterMs}ms") - } - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - StatItem("Sent", "${stats.framesEncoded}") - StatItem("Recv", "${stats.framesDecoded}") - StatItem("FEC", "${stats.fecRecovered}") - } - } - } -} - -@Composable -private fun StatItem(label: String, value: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = value, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - +// ── Debug report card ── @Composable private fun DebugReportCard( available: Boolean, @@ -718,7 +711,7 @@ private fun DebugReportCard( ) { Surface( modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), + color = DarkSurface, shape = RoundedCornerShape(12.dp) ) { Column( @@ -728,25 +721,19 @@ private fun DebugReportCard( Text( text = "Debug Report", style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSurface + color = Color.White ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "Email call recordings, logs & stats for analysis", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TextDim, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(12.dp)) - when { status != null && status.startsWith("Error") -> { - Text( - text = status, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) + Text(text = status, style = MaterialTheme.typography.bodySmall, color = Red) Spacer(modifier = Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton(onClick = onSend) { Text("Retry") } @@ -754,21 +741,15 @@ private fun DebugReportCard( } } status != null && status != "ready" -> { - // Preparing zip... - Text( - text = status, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text(text = status, style = MaterialTheme.typography.bodySmall, color = TextDim) } available -> { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onSend) { - Text("Email Report") - } - TextButton(onClick = onDismiss) { - Text("Skip") - } + Button( + onClick = onSend, + colors = ButtonDefaults.buttonColors(containerColor = Accent) + ) { Text("Email Report") } + TextButton(onClick = onDismiss) { Text("Skip") } } } } diff --git a/scripts/Dockerfile.android-builder b/scripts/Dockerfile.android-builder new file mode 100644 index 0000000..54caaff --- /dev/null +++ b/scripts/Dockerfile.android-builder @@ -0,0 +1,74 @@ +# ============================================================================= +# WZ Phone — Android build environment (Debian 12 / Bookworm) +# +# Matches the bare-metal build-android.sh environment: +# - Debian 12 (cmake 3.25, no Android cross-compilation bugs) +# - JDK 17 (Gradle 8.5 + AGP 8.2.0 compatible) +# - NDK 26.1 (last stable before scudo/MTE crash on NDK 27+) +# - Rust stable with aarch64-linux-android target + cargo-ndk +# +# Build: docker build -t wzp-android-builder -f Dockerfile.android-builder . +# ============================================================================= +FROM debian:bookworm + +ARG NDK_VERSION=26.1.10909125 +ARG ANDROID_API=34 + +ENV DEBIAN_FRONTEND=noninteractive \ + ANDROID_HOME=/opt/android-sdk \ + JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 + +ENV ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION \ + ANDROID_NDK=$ANDROID_HOME/ndk/$NDK_VERSION + +# ── System packages ────────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + curl \ + git \ + libssl-dev \ + pkg-config \ + unzip \ + wget \ + zip \ + openjdk-17-jdk-headless \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# ── Android SDK + NDK 26.1 ────────────────────────────────────────────────── +RUN mkdir -p $ANDROID_HOME/cmdline-tools \ + && cd /tmp \ + && wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip \ + && unzip -qo cmdtools.zip -d $ANDROID_HOME/cmdline-tools \ + && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \ + && rm cmdtools.zip + +RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1 \ + && $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install \ + "platforms;android-${ANDROID_API}" \ + "build-tools;${ANDROID_API}.0.0" \ + "ndk;${NDK_VERSION}" \ + "platform-tools" \ + 2>&1 | grep -v '^\[' > /dev/null + +# Make SDK world-readable so builder user can access it +RUN chmod -R a+rX $ANDROID_HOME + +# ── Builder user (1000:1000) ───────────────────────────────────────────────── +RUN groupadd -g 1000 builder \ + && useradd -m -u 1000 -g 1000 -s /bin/bash builder + +USER builder +WORKDIR /home/builder + +# ── Rust toolchain ─────────────────────────────────────────────────────────── +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable \ + && . $HOME/.cargo/env \ + && rustup target add aarch64-linux-android \ + && cargo install cargo-ndk + +ENV PATH="/home/builder/.cargo/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$JAVA_HOME/bin:$PATH" + +WORKDIR /build/source diff --git a/scripts/build-android-docker.sh b/scripts/build-android-docker.sh new file mode 100755 index 0000000..bea4c1a --- /dev/null +++ b/scripts/build-android-docker.sh @@ -0,0 +1,416 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# WZ Phone — Android APK build via Docker on remote host +# +# Replaces Hetzner Cloud VMs with a Docker container on SepehrHomeserverdk. +# Persistent storage at /mnt/storage/manBuilder/data/{source,cache,keystore}. +# Uploads APKs to rustypaste, then SCPs them back locally. +# +# Prerequisites: +# - SSH config has "SepehrHomeserverdk" host entry +# - SSH agent running with keys for both remote host and git.manko.yoga +# - Docker installed on remote host +# - /mnt/storage/manBuilder/.env with rusty_address and rusty_auth_token +# +# Usage: +# ./scripts/build-android-docker.sh Full: prepare+pull+build+upload+transfer +# ./scripts/build-android-docker.sh --prepare Build Docker image + sync keystores +# ./scripts/build-android-docker.sh --pull Clone/update source from Gitea +# ./scripts/build-android-docker.sh --build Build debug APK inside Docker +# ./scripts/build-android-docker.sh --upload Upload APKs to rustypaste +# ./scripts/build-android-docker.sh --transfer SCP APKs back to local machine +# ./scripts/build-android-docker.sh --all pull+build+upload+transfer (image ready) +# +# Add --release to also build release APK: +# ./scripts/build-android-docker.sh --build --release +# ./scripts/build-android-docker.sh --all --release +# ./scripts/build-android-docker.sh --release (full pipeline, debug+release) +# +# Environment variables (all optional): +# WZP_BRANCH Branch to build (default: feat/android-voip-client) +# ============================================================================= + +REMOTE_HOST="SepehrHomeserverdk" +BASE_DIR="/mnt/storage/manBuilder" +REPO_URL="ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git" +BRANCH="${WZP_BRANCH:-feat/android-voip-client}" +DOCKER_IMAGE="wzp-android-builder" +LOCAL_OUTPUT_DIR="target/android-apk" +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +LOCAL_KEYSTORE_DIR="$PROJECT_DIR/android/keystore" + +SSH_OPTS="-o ConnectTimeout=10 -o LogLevel=ERROR" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { echo -e "\n\033[1;36m>>> $*\033[0m"; } +err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; } + +ssh_cmd() { + ssh -A $SSH_OPTS "$REMOTE_HOST" "$@" +} + +push_reminder() { + echo "" + echo " ┌──────────────────────────────────────────────────────────────────┐" + echo " │ IMPORTANT: Push your changes to origin (Gitea) before build! │" + echo " │ │" + echo " │ The build fetches from: │" + echo " │ ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git │" + echo " │ │" + echo " │ Run: git push origin $BRANCH" + echo " └──────────────────────────────────────────────────────────────────┘" + echo "" + read -r -p "Press Enter to continue (Ctrl-C to abort)... " +} + +# --------------------------------------------------------------------------- +# --prepare: Create remote dirs, build Docker image, sync keystores +# --------------------------------------------------------------------------- +do_prepare() { + log "Preparing remote environment..." + ssh_cmd "mkdir -p $BASE_DIR/data/{source,cache/cargo-registry,cache/cargo-git,cache/target,cache/gradle,keystore}" + + # Sync keystores (gitignored — won't exist after clone) + REMOTE_HAS_KEYSTORE=$(ssh_cmd "[ -f $BASE_DIR/data/keystore/wzp-debug.jks ] && echo yes || echo no") + if [ "$REMOTE_HAS_KEYSTORE" = "no" ]; then + if [ -f "$LOCAL_KEYSTORE_DIR/wzp-debug.jks" ]; then + log "Uploading keystores to remote persistent storage..." + scp $SSH_OPTS \ + "$LOCAL_KEYSTORE_DIR/wzp-debug.jks" \ + "$LOCAL_KEYSTORE_DIR/wzp-release.jks" \ + "$REMOTE_HOST:$BASE_DIR/data/keystore/" + echo " Keystores uploaded to $BASE_DIR/data/keystore/" + else + err "No keystores found locally at $LOCAL_KEYSTORE_DIR/" + err "Build will generate a temporary debug keystore instead." + fi + else + echo " Keystores already on remote." + fi + + # Upload Dockerfile from local (always use local version — no git dependency) + log "Uploading Dockerfile to remote..." + ssh_cmd "mkdir -p $BASE_DIR/data/source/scripts" + scp $SSH_OPTS \ + "$PROJECT_DIR/scripts/Dockerfile.android-builder" \ + "$REMOTE_HOST:$BASE_DIR/data/source/scripts/Dockerfile.android-builder" + + # Build Docker image + log "Building Docker image (Debian 12 + Rust + Android SDK/NDK)..." + ssh_cmd bash </dev/null || git checkout -b "$BRANCH" "origin/$BRANCH" + git reset --hard "origin/$BRANCH" +else + echo " Cloning repo..." + cd "$BASE_DIR/data" + rm -rf source + git clone --branch "$BRANCH" "$REPO_URL" source + cd source +fi +git submodule update --init || true +echo " HEAD: \$(git log --oneline -1)" +echo " Branch: \$(git branch --show-current)" +PULL_EOF + + # Inject keystores into source tree + log "Injecting keystores into source tree..." + ssh_cmd bash </dev/null | \ + xargs -r chown 1000:1000 2>/dev/null || true + +docker run --rm \ + --user 1000:1000 \ + -e BUILD_RELEASE="$build_release" \ + -v "$BASE_DIR/data/source:/build/source" \ + -v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \ + -v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \ + -v "$BASE_DIR/data/cache/target:/build/source/target" \ + -v "$BASE_DIR/data/cache/gradle:/home/builder/.gradle" \ + "$DOCKER_IMAGE" \ + bash -c ' +set -euo pipefail +cd /build/source + +echo ">>> Building Rust native library (arm64-v8a, release)..." + +# Clean stale jniLibs so cargo-ndk re-copies libc++_shared.so +rm -rf android/app/src/main/jniLibs/arm64-v8a + +cargo ndk -t arm64-v8a \ + -o android/app/src/main/jniLibs \ + build --release -p wzp-android 2>&1 | tail -10 + +[ -f android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ] || { + echo "ERROR: libwzp_android.so not found after build"; exit 1; +} +echo " .so size: \$(du -h android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so | cut -f1)" + +# Verify keystores exist (should have been injected by --pull) +if [ -f android/keystore/wzp-debug.jks ] && [ -f android/keystore/wzp-release.jks ]; then + echo " Keystores: wzp-debug.jks + wzp-release.jks (from persistent storage)" +else + echo "WARNING: Keystores missing — generating temporary debug keystore..." + mkdir -p android/keystore + keytool -genkey -v \ + -keystore android/keystore/wzp-debug.jks \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -alias wzp-debug -storepass android -keypass android \ + -dname "CN=WZP Debug" 2>&1 | tail -1 + cp android/keystore/wzp-debug.jks android/keystore/wzp-release.jks +fi + +cd android +chmod +x ./gradlew + +echo ">>> Building debug APK..." +./gradlew assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -5 + +if [ "\${BUILD_RELEASE}" = "1" ]; then + echo ">>> Building release APK..." + ./gradlew assembleRelease --no-daemon --warning-mode=none 2>&1 | tail -5 || \ + echo " (release build failed — debug APK still available)" +fi + +echo "" +echo ">>> Build artifacts:" +find . -name "*.apk" -path "*/outputs/apk/*" -exec ls -lh {} \; +' +BUILD_EOF +} + +# --------------------------------------------------------------------------- +# --upload: Upload APKs to rustypaste +# --------------------------------------------------------------------------- +do_upload() { + log "Uploading APKs to rustypaste..." + + UPLOAD_RESULT=$(ssh_cmd bash <<'UPLOAD_EOF' +set -euo pipefail + +BASE_DIR="/mnt/storage/manBuilder" +ENV_FILE="$BASE_DIR/.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: $ENV_FILE not found — create it with rusty_address and rusty_auth_token" >&2 + exit 1 +fi + +source "$ENV_FILE" + +if [ -z "${rusty_address:-}" ] || [ -z "${rusty_auth_token:-}" ]; then + echo "ERROR: rusty_address or rusty_auth_token not set in $ENV_FILE" >&2 + exit 1 +fi + +upload_apk() { + local apk="$1" label="$2" + if [ -f "$apk" ]; then + local url + url=$(curl -s -F "file=@$apk" -H "Authorization: $rusty_auth_token" "$rusty_address") + echo "$label: $url" + fi +} + +DEBUG_APK=$(find "$BASE_DIR/data/source/android" -name "app-debug*.apk" -path "*/outputs/apk/*" 2>/dev/null | head -1) +RELEASE_APK=$(find "$BASE_DIR/data/source/android" -name "app-release*.apk" -path "*/outputs/apk/*" 2>/dev/null | head -1) + +upload_apk "${DEBUG_APK:-}" "debug" +upload_apk "${RELEASE_APK:-}" "release" +UPLOAD_EOF + ) + + echo "$UPLOAD_RESULT" +} + +# --------------------------------------------------------------------------- +# --transfer: SCP APKs back to local machine +# --------------------------------------------------------------------------- +do_transfer() { + log "Downloading APKs to local machine..." + + mkdir -p "$LOCAL_OUTPUT_DIR" + + # Debug APK + DEBUG_REMOTE=$(ssh_cmd "find $BASE_DIR/data/source/android -name 'app-debug*.apk' -path '*/outputs/apk/*' 2>/dev/null | head -1" || true) + if [ -n "$DEBUG_REMOTE" ]; then + scp $SSH_OPTS "$REMOTE_HOST:$DEBUG_REMOTE" "$LOCAL_OUTPUT_DIR/wzp-debug.apk" + echo " debug: $LOCAL_OUTPUT_DIR/wzp-debug.apk ($(du -h "$LOCAL_OUTPUT_DIR/wzp-debug.apk" | cut -f1))" + fi + + # Release APK + RELEASE_REMOTE=$(ssh_cmd "find $BASE_DIR/data/source/android -name 'app-release*.apk' -path '*/outputs/apk/*' 2>/dev/null | head -1" || true) + if [ -n "$RELEASE_REMOTE" ]; then + scp $SSH_OPTS "$REMOTE_HOST:$RELEASE_REMOTE" "$LOCAL_OUTPUT_DIR/wzp-release.apk" + echo " release: $LOCAL_OUTPUT_DIR/wzp-release.apk ($(du -h "$LOCAL_OUTPUT_DIR/wzp-release.apk" | cut -f1))" + fi + + # Also grab the .so + scp $SSH_OPTS "$REMOTE_HOST:$BASE_DIR/data/source/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so" \ + "$LOCAL_OUTPUT_DIR/libwzp_android.so" 2>/dev/null \ + && echo " .so: $LOCAL_OUTPUT_DIR/libwzp_android.so" || true +} + +# --------------------------------------------------------------------------- +# Summary banner +# --------------------------------------------------------------------------- +show_summary() { + log "All done!" + echo "" + echo " ┌──────────────────────────────────────────────────────────────┐" + [ -f "$LOCAL_OUTPUT_DIR/wzp-debug.apk" ] && \ + echo " │ Debug APK: $LOCAL_OUTPUT_DIR/wzp-debug.apk" + [ -f "$LOCAL_OUTPUT_DIR/wzp-release.apk" ] && \ + echo " │ Release APK: $LOCAL_OUTPUT_DIR/wzp-release.apk" + echo " │" + if [ -n "${UPLOAD_RESULT:-}" ]; then + echo " │ Rustypaste:" + echo "$UPLOAD_RESULT" | while read -r line; do + echo " │ $line" + done + echo " │" + fi + echo " │ Install: adb install -r $LOCAL_OUTPUT_DIR/wzp-debug.apk" + echo " └──────────────────────────────────────────────────────────────┘" +} + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- +ACTION="" +BUILD_RELEASE=0 + +for arg in "$@"; do + case "$arg" in + --release) BUILD_RELEASE=1 ;; + --prepare|--pull|--build|--upload|--transfer|--all) + if [ -n "$ACTION" ]; then + err "Multiple actions specified: $ACTION and $arg" + exit 1 + fi + ACTION="$arg" + ;; + *) + echo "Usage: $0 [--prepare|--pull|--build|--upload|--transfer|--all] [--release]" + echo "" + echo "Actions:" + echo " (no action) Full pipeline: pull → prepare → build → upload → transfer" + echo " --prepare Build Docker image + sync keystores to remote" + echo " --pull Clone/update source from Gitea + inject keystores" + echo " --build Build debug APK inside Docker container" + echo " --upload Upload APKs to rustypaste" + echo " --transfer SCP APKs + .so back to local machine" + echo " --all pull → build → upload → transfer (Docker image ready)" + echo "" + echo "Flags:" + echo " --release Also build release APK (default: debug only)" + echo "" + echo "Examples:" + echo " $0 # full pipeline, debug only" + echo " $0 --release # full pipeline, debug + release" + echo " $0 --build # debug APK only" + echo " $0 --build --release # debug + release APKs" + echo " $0 --all # iterate: pull+build+upload+transfer (debug)" + echo " $0 --all --release # iterate with release too" + echo "" + echo "Environment:" + echo " WZP_BRANCH=$BRANCH" + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- +case "${ACTION:-}" in + --prepare) + do_prepare + ;; + --pull) + do_pull + ;; + --build) + do_build "$BUILD_RELEASE" + ;; + --upload) + do_upload + ;; + --transfer) + do_transfer + ;; + --all) + do_pull + do_build "$BUILD_RELEASE" + do_upload + do_transfer + show_summary + ;; + "") + do_pull + do_prepare + do_build "$BUILD_RELEASE" + do_upload + do_transfer + show_summary + ;; +esac diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..a62ac1d --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "caveman": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "computedHash": "aa7939fc4d1fe31484090290da77f2d21e026aa4b34b329d00e6630feb985d75" + } + } +}