Compare commits
8 Commits
9eed94850d
...
build/last
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
264ef9c4d4 | ||
|
|
a9adb5cfd7 | ||
|
|
a39b074d6e | ||
|
|
9cab6e2347 | ||
|
|
5e93cb74f2 | ||
|
|
b56b4a759c | ||
|
|
6f99841cc7 | ||
|
|
3b0811ce2e |
@@ -7,6 +7,8 @@ on:
|
|||||||
- 'feat/*'
|
- 'feat/*'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
paths-ignore:
|
||||||
|
- '.gitea/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
43
.gitea/workflows/mirror-github.yml
Normal file
43
.gitea/workflows/mirror-github.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Mirror to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'feat/*'
|
||||||
|
- 'feature/*'
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to GitHub
|
||||||
|
env:
|
||||||
|
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
git remote add github git@github.com:manawenuz/wzp.git
|
||||||
|
|
||||||
|
# Push the current branch
|
||||||
|
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
|
||||||
|
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
|
||||||
|
echo "Pushing tag: ${TAG}"
|
||||||
|
git push github "refs/tags/${TAG}" --force
|
||||||
|
else
|
||||||
|
echo "Pushing branch: ${BRANCH}"
|
||||||
|
git push github "HEAD:refs/heads/${BRANCH}" --force
|
||||||
|
fi
|
||||||
@@ -60,6 +60,16 @@ class AudioPipeline(private val context: Context) {
|
|||||||
var debugRecording: Boolean = true
|
var debugRecording: Boolean = true
|
||||||
private var captureThread: Thread? = null
|
private var captureThread: Thread? = null
|
||||||
private var playoutThread: Thread? = null
|
private var playoutThread: Thread? = null
|
||||||
|
|
||||||
|
// DirectByteBuffers for zero-copy JNI audio transfer.
|
||||||
|
// Allocated as class fields (NOT locals) because ART's JIT OSR
|
||||||
|
// can null local variables when it replaces the stack frame mid-loop.
|
||||||
|
// These survive OSR because they're on the heap.
|
||||||
|
private val captureDirectBuf: ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
private val playoutDirectBuf: ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
/** Latch counted down by each audio thread after exiting its loop.
|
/** Latch counted down by each audio thread after exiting its loop.
|
||||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||||
private var drainLatch: CountDownLatch? = null
|
private var drainLatch: CountDownLatch? = null
|
||||||
@@ -205,8 +215,6 @@ class AudioPipeline(private val context: Context) {
|
|||||||
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
|
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
|
||||||
|
|
||||||
val pcm = ShortArray(FRAME_SAMPLES)
|
val pcm = ShortArray(FRAME_SAMPLES)
|
||||||
// DirectByteBuffer for zero-copy JNI (avoids ART GC SIGBUS on Android 16)
|
|
||||||
val directBuf = ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
// Debug: PCM file + RMS CSV
|
// Debug: PCM file + RMS CSV
|
||||||
var pcmOut: BufferedOutputStream? = null
|
var pcmOut: BufferedOutputStream? = null
|
||||||
var rmsCsv: OutputStreamWriter? = null
|
var rmsCsv: OutputStreamWriter? = null
|
||||||
@@ -226,10 +234,10 @@ class AudioPipeline(private val context: Context) {
|
|||||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||||
if (read > 0) {
|
if (read > 0) {
|
||||||
applyGain(pcm, read, captureGainDb)
|
applyGain(pcm, read, captureGainDb)
|
||||||
// Zero-copy write via DirectByteBuffer (no GC array interaction)
|
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
||||||
directBuf.clear()
|
captureDirectBuf.clear()
|
||||||
directBuf.asShortBuffer().put(pcm, 0, read)
|
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
||||||
engine.writeAudioDirect(directBuf, read)
|
engine.writeAudioDirect(captureDirectBuf, read)
|
||||||
|
|
||||||
// Debug: write raw PCM + RMS
|
// Debug: write raw PCM + RMS
|
||||||
if (pcmOut != null) {
|
if (pcmOut != null) {
|
||||||
@@ -292,8 +300,6 @@ class AudioPipeline(private val context: Context) {
|
|||||||
|
|
||||||
val pcm = ShortArray(FRAME_SAMPLES)
|
val pcm = ShortArray(FRAME_SAMPLES)
|
||||||
val silence = ShortArray(FRAME_SAMPLES)
|
val silence = ShortArray(FRAME_SAMPLES)
|
||||||
// DirectByteBuffer for zero-copy JNI (avoids ART GC SIGBUS on Android 16)
|
|
||||||
val directBuf = ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
// Debug: PCM file + RMS CSV for playout
|
// Debug: PCM file + RMS CSV for playout
|
||||||
var pcmOut: BufferedOutputStream? = null
|
var pcmOut: BufferedOutputStream? = null
|
||||||
var rmsCsv: OutputStreamWriter? = null
|
var rmsCsv: OutputStreamWriter? = null
|
||||||
@@ -310,14 +316,12 @@ class AudioPipeline(private val context: Context) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
while (running) {
|
while (running) {
|
||||||
// Zero-copy read via DirectByteBuffer
|
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
||||||
directBuf.clear()
|
playoutDirectBuf.clear()
|
||||||
val read = engine.readAudioDirect(directBuf, FRAME_SAMPLES)
|
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
||||||
if (read > 0) {
|
|
||||||
directBuf.rewind()
|
|
||||||
directBuf.asShortBuffer().get(pcm, 0, read)
|
|
||||||
}
|
|
||||||
if (read >= FRAME_SAMPLES) {
|
if (read >= FRAME_SAMPLES) {
|
||||||
|
playoutDirectBuf.rewind()
|
||||||
|
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
||||||
applyGain(pcm, read, playoutGainDb)
|
applyGain(pcm, read, playoutGainDb)
|
||||||
track.write(pcm, 0, read)
|
track.write(pcm, 0, read)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class SettingsRepository(context: Context) {
|
|||||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||||
|
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||||
|
private const val TOFU_PREFIX = "tofu_"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Servers ---
|
// --- Servers ---
|
||||||
@@ -138,4 +140,43 @@ class SettingsRepository(context: Context) {
|
|||||||
fun saveSeedHex(hex: String) {
|
fun saveSeedHex(hex: String) {
|
||||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Recent rooms ---
|
||||||
|
|
||||||
|
data class RecentRoom(val relay: String, val room: String)
|
||||||
|
|
||||||
|
fun addRecentRoom(relay: String, room: String) {
|
||||||
|
val rooms = loadRecentRooms().toMutableList()
|
||||||
|
rooms.removeAll { it.relay == relay && it.room == room }
|
||||||
|
rooms.add(0, RecentRoom(relay, room))
|
||||||
|
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
|
||||||
|
val arr = JSONArray()
|
||||||
|
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
|
||||||
|
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRecentRooms(): List<RecentRoom> {
|
||||||
|
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
RecentRoom(o.getString("relay"), o.getString("room"))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearRecentRooms() {
|
||||||
|
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server fingerprint TOFU ---
|
||||||
|
|
||||||
|
fun saveServerFingerprint(address: String, fingerprint: String) {
|
||||||
|
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadServerFingerprint(address: String): String? {
|
||||||
|
return prefs.getString("$TOFU_PREFIX$address", null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,15 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
init {
|
init {
|
||||||
System.loadLibrary("wzp_android")
|
System.loadLibrary("wzp_android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping a relay server. Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}`
|
||||||
|
* or null if unreachable. Does not require an engine instance.
|
||||||
|
*/
|
||||||
|
fun pingRelay(address: String): String? = nativePingRelay(address)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativePingRelay(relay: String): String?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.wzp.engine.CallStats
|
|||||||
import com.wzp.service.CallService
|
import com.wzp.service.CallService
|
||||||
import com.wzp.engine.WzpCallback
|
import com.wzp.engine.WzpCallback
|
||||||
import com.wzp.engine.WzpEngine
|
import com.wzp.engine.WzpEngine
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,6 +20,8 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
@@ -26,6 +29,13 @@ import java.net.InetAddress
|
|||||||
|
|
||||||
data class ServerEntry(val address: String, val label: String)
|
data class ServerEntry(val address: String, val label: String)
|
||||||
|
|
||||||
|
data class PingResult(
|
||||||
|
val rttMs: Int,
|
||||||
|
val serverFingerprint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||||
|
|
||||||
class CallViewModel : ViewModel(), WzpCallback {
|
class CallViewModel : ViewModel(), WzpCallback {
|
||||||
|
|
||||||
private var engine: WzpEngine? = null
|
private var engine: WzpEngine? = null
|
||||||
@@ -70,6 +80,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _preferIPv6 = MutableStateFlow(false)
|
private val _preferIPv6 = MutableStateFlow(false)
|
||||||
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
||||||
|
|
||||||
|
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
|
||||||
|
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
|
||||||
|
|
||||||
|
/** Ping results keyed by server address. */
|
||||||
|
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
|
||||||
|
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
|
||||||
|
|
||||||
|
/** Known server fingerprints (TOFU). */
|
||||||
|
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
|
||||||
private val _playoutGainDb = MutableStateFlow(0f)
|
private val _playoutGainDb = MutableStateFlow(0f)
|
||||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||||
|
|
||||||
@@ -139,6 +159,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
_captureGainDb.value = s.loadCaptureGain()
|
_captureGainDb.value = s.loadCaptureGain()
|
||||||
_seedHex.value = s.getOrCreateSeedHex()
|
_seedHex.value = s.getOrCreateSeedHex()
|
||||||
_aecEnabled.value = s.loadAecEnabled()
|
_aecEnabled.value = s.loadAecEnabled()
|
||||||
|
_recentRooms.value = s.loadRecentRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectServer(index: Int) {
|
fun selectServer(index: Int) {
|
||||||
@@ -182,6 +203,51 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveSelectedServer(_selectedServer.value)
|
settings?.saveSelectedServer(_selectedServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ping all servers in background, update results. */
|
||||||
|
fun pingAllServers() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val results = mutableMapOf<String, PingResult>()
|
||||||
|
val known = mutableMapOf<String, String>()
|
||||||
|
_servers.value.forEach { server ->
|
||||||
|
val pr = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val json = WzpEngine.pingRelay(server.address) ?: return@withContext null
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
PingResult(
|
||||||
|
rttMs = obj.getInt("rtt_ms"),
|
||||||
|
serverFingerprint = obj.optString("server_fingerprint", ""),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "ping ${server.address} failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pr != null) {
|
||||||
|
results[server.address] = pr
|
||||||
|
// TOFU: save fingerprint on first contact
|
||||||
|
if (pr.serverFingerprint.isNotEmpty()) {
|
||||||
|
val saved = settings?.loadServerFingerprint(server.address)
|
||||||
|
if (saved == null) {
|
||||||
|
settings?.saveServerFingerprint(server.address, pr.serverFingerprint)
|
||||||
|
}
|
||||||
|
known[server.address] = saved ?: pr.serverFingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pingResults.value = results
|
||||||
|
_knownFingerprints.value = known
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get lock status for a server. */
|
||||||
|
fun lockStatus(address: String): LockStatus {
|
||||||
|
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
||||||
|
val known = _knownFingerprints.value[address]
|
||||||
|
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
||||||
|
if (known == null) return LockStatus.NEW
|
||||||
|
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||||
|
}
|
||||||
|
|
||||||
fun setRoomName(name: String) {
|
fun setRoomName(name: String) {
|
||||||
_roomName.value = name
|
_roomName.value = name
|
||||||
settings?.saveRoom(name)
|
settings?.saveRoom(name)
|
||||||
@@ -287,6 +353,8 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
_debugReportAvailable.value = false
|
_debugReportAvailable.value = false
|
||||||
_debugReportStatus.value = null
|
_debugReportStatus.value = null
|
||||||
lastCallServer = serverEntry.address
|
lastCallServer = serverEntry.address
|
||||||
|
settings?.addRecentRoom(serverEntry.address, room)
|
||||||
|
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
|
||||||
debugReporter?.prepareForCall()
|
debugReporter?.prepareForCall()
|
||||||
try {
|
try {
|
||||||
// Teardown previous call but don't stop the service (we're about to restart it)
|
// Teardown previous call but don't stop the service (we're about to restart it)
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.wzp.engine.CallStats
|
import com.wzp.engine.CallStats
|
||||||
|
import com.wzp.ui.call.LockStatus
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -117,18 +119,31 @@ fun InCallScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
val pingResults by viewModel.pingResults.collectAsState()
|
||||||
|
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
servers.forEachIndexed { idx, entry ->
|
servers.forEachIndexed { idx, entry ->
|
||||||
val isSelected = selectedServer == idx
|
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(
|
FilledTonalIconButton(
|
||||||
onClick = { viewModel.selectServer(idx) },
|
onClick = { viewModel.selectServer(idx) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
.height(36.dp)
|
.height(40.dp)
|
||||||
.width(140.dp),
|
.width(160.dp),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
colors = if (isSelected) {
|
colors = if (isSelected) {
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
@@ -139,11 +154,28 @@ fun InCallScreen(
|
|||||||
IconButtonDefaults.filledTonalIconButtonColors()
|
IconButtonDefaults.filledTonalIconButtonColors()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = entry.label,
|
if (lockIcon.isNotEmpty()) {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Text(text = lockIcon, fontSize = 12.sp)
|
||||||
maxLines = 1
|
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
|
// + Add button
|
||||||
@@ -151,13 +183,18 @@ fun InCallScreen(
|
|||||||
onClick = { showAddServerDialog = true },
|
onClick = { showAddServerDialog = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
.height(36.dp),
|
.height(40.dp),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
Text("+", style = MaterialTheme.typography.labelMedium)
|
Text("+", style = MaterialTheme.typography.labelMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ping button
|
||||||
|
TextButton(onClick = { viewModel.pingAllServers() }) {
|
||||||
|
Text("Ping All", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
|
||||||
// IPv4/IPv6 preference
|
// IPv4/IPv6 preference
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
@@ -200,6 +237,36 @@ fun InCallScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(0.6f)
|
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))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -262,11 +329,33 @@ fun InCallScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
unique.forEach { member ->
|
unique.forEach { member ->
|
||||||
Text(
|
Row(
|
||||||
text = member.displayName,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
modifier = Modifier.padding(vertical = 2.dp)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
)
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package com.wzp.ui.components
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic identicon — generates a unique 5x5 symmetric pattern
|
||||||
|
* from a hex fingerprint string. Identical algorithm to the desktop
|
||||||
|
* TypeScript implementation in identicon.ts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Identicon(
|
||||||
|
fingerprint: String,
|
||||||
|
size: Dp = 36.dp,
|
||||||
|
clickToCopy: Boolean = true,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bytes = hashBytes(fingerprint)
|
||||||
|
val (bg, fg) = deriveColors(bytes)
|
||||||
|
val grid = buildGrid(bytes)
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(RoundedCornerShape(size * 0.12f))
|
||||||
|
.then(
|
||||||
|
if (clickToCopy && fingerprint.isNotEmpty()) {
|
||||||
|
Modifier.clickable {
|
||||||
|
clipboard.setText(AnnotatedString(fingerprint))
|
||||||
|
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val cellW = this.size.width / 5f
|
||||||
|
val cellH = this.size.height / 5f
|
||||||
|
|
||||||
|
// Background
|
||||||
|
drawRect(color = bg, size = this.size)
|
||||||
|
|
||||||
|
// Foreground cells
|
||||||
|
for (y in 0 until 5) {
|
||||||
|
for (x in 0 until 5) {
|
||||||
|
if (grid[y][x]) {
|
||||||
|
drawRect(
|
||||||
|
color = fg,
|
||||||
|
topLeft = Offset(x * cellW, y * cellH),
|
||||||
|
size = Size(cellW, cellH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint text that copies to clipboard on tap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CopyableFingerprint(
|
||||||
|
fingerprint: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
text = fingerprint,
|
||||||
|
style = style,
|
||||||
|
color = color,
|
||||||
|
modifier = modifier.clickable {
|
||||||
|
if (fingerprint.isNotEmpty()) {
|
||||||
|
clipboard.setText(AnnotatedString(fingerprint))
|
||||||
|
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers (matching desktop identicon.ts) ---
|
||||||
|
|
||||||
|
private fun hashBytes(hex: String): List<Int> {
|
||||||
|
val clean = hex.filter { it.isLetterOrDigit() }
|
||||||
|
val bytes = mutableListOf<Int>()
|
||||||
|
var i = 0
|
||||||
|
while (i + 1 < clean.length) {
|
||||||
|
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
|
||||||
|
bytes.add(b)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
// Pad to at least 16 bytes
|
||||||
|
while (bytes.size < 16) bytes.add(0)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
|
||||||
|
val hue1 = bytes[0] * 360f / 256f
|
||||||
|
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
|
||||||
|
val bg = hslToColor(hue1, 0.65f, 0.35f)
|
||||||
|
val fg = hslToColor(hue2, 0.70f, 0.55f)
|
||||||
|
return bg to fg
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
|
||||||
|
return (0 until 5).map { y ->
|
||||||
|
val left = (0 until 3).map { x ->
|
||||||
|
val idx = 2 + y * 3 + x
|
||||||
|
bytes[idx % bytes.size] > 128
|
||||||
|
}
|
||||||
|
// Mirror: col3 = col1, col4 = col0
|
||||||
|
listOf(left[0], left[1], left[2], left[1], left[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
||||||
|
val k = { n: Float -> (n + h / 30f) % 12f }
|
||||||
|
val a = s * min(l, 1f - l)
|
||||||
|
val f = { n: Float ->
|
||||||
|
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
|
||||||
|
}
|
||||||
|
return Color(f(0f), f(8f), f(4f))
|
||||||
|
}
|
||||||
@@ -158,20 +158,30 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Fingerprint display
|
// Fingerprint display with identicon
|
||||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
||||||
Text(
|
Text(
|
||||||
text = "Fingerprint",
|
text = "Fingerprint",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Text(
|
Row(
|
||||||
text = fingerprint.chunked(4).joinToString(" "),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
fontFamily = FontFamily.Monospace
|
) {
|
||||||
),
|
com.wzp.ui.components.Identicon(
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
fingerprint = draftSeedHex,
|
||||||
)
|
size = 40.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
com.wzp.ui.components.CopyableFingerprint(
|
||||||
|
fingerprint = fingerprint.chunked(4).joinToString(" "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ wzp-crypto = { workspace = true }
|
|||||||
wzp-transport = { workspace = true }
|
wzp-transport = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -41,8 +41,17 @@ fn init_logging() {
|
|||||||
let _ = std::panic::catch_unwind(|| {
|
let _ = std::panic::catch_unwind(|| {
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||||
let _ = tracing_subscriber::registry().with(layer).try_init();
|
// Filter: INFO for our crates, WARN for everything else.
|
||||||
|
// The jni crate emits VERBOSE logs for every method lookup
|
||||||
|
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
||||||
|
// and causes the system to kill the app.
|
||||||
|
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
||||||
|
let _ = tracing_subscriber::registry()
|
||||||
|
.with(layer)
|
||||||
|
.with(filter)
|
||||||
|
.try_init();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -308,3 +317,79 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
|||||||
drop(h);
|
drop(h);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ping a relay server — returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
||||||
|
/// Does NOT require an engine handle — creates a temporary QUIC connection.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
relay_j: JString,
|
||||||
|
) -> jstring {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let addr: std::net::SocketAddr = match relay.parse() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(conn)) => {
|
||||||
|
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||||
|
let server_fp = conn
|
||||||
|
.peer_identity()
|
||||||
|
.and_then(|id| {
|
||||||
|
id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok()
|
||||||
|
})
|
||||||
|
.and_then(|certs| {
|
||||||
|
certs.first().map(|c| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
c.as_ref().hash(&mut h);
|
||||||
|
format!("{:016x}", h.finish())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
conn.close(0u32.into(), b"ping");
|
||||||
|
Some(format!(
|
||||||
|
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
|
||||||
|
rtt_ms, server_fp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
let json = match result {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return JObject::null().into_raw(),
|
||||||
|
};
|
||||||
|
env.new_string(&json)
|
||||||
|
.map(|s| s.into_raw())
|
||||||
|
.unwrap_or(JObject::null().into_raw())
|
||||||
|
}
|
||||||
|
|||||||
376
scripts/build-android-cloud.sh
Executable file
376
scripts/build-android-cloud.sh
Executable file
@@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build WarzonePhone Android APK using a temporary Hetzner Cloud VPS.
|
||||||
|
# Creates a VM, builds both debug and release APKs, downloads them, destroys the VM.
|
||||||
|
#
|
||||||
|
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/build-android-cloud.sh Full build (create → build → download → destroy)
|
||||||
|
# ./scripts/build-android-cloud.sh --prepare Create VM and install deps only
|
||||||
|
# ./scripts/build-android-cloud.sh --build Build on existing VM
|
||||||
|
# ./scripts/build-android-cloud.sh --transfer Download APKs from VM
|
||||||
|
# ./scripts/build-android-cloud.sh --destroy Delete the VM
|
||||||
|
# ./scripts/build-android-cloud.sh --all prepare + build + transfer (VM persists)
|
||||||
|
# ./scripts/build-android-cloud.sh --upload Re-upload source to existing VM
|
||||||
|
#
|
||||||
|
# Environment variables (all optional):
|
||||||
|
# WZP_BRANCH Branch to build (default: feat/android-voip-client)
|
||||||
|
# WZP_SERVER_TYPE Hetzner server type (default: cx32 — 4 vCPU, 8GB RAM)
|
||||||
|
# WZP_KEEP_VM Set to 1 to skip destroy on full build
|
||||||
|
|
||||||
|
SSH_KEY_NAME="wz"
|
||||||
|
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||||
|
SERVER_TYPE="${WZP_SERVER_TYPE:-cx33}"
|
||||||
|
IMAGE="ubuntu-24.04"
|
||||||
|
SERVER_NAME="wzp-android-builder"
|
||||||
|
REMOTE_USER="root"
|
||||||
|
OUTPUT_DIR="target/android-apk"
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
|
||||||
|
KEEP_VM="${WZP_KEEP_VM:-0}"
|
||||||
|
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR"
|
||||||
|
|
||||||
|
# NDK 26.1 — NDK 27 crashes scudo on Android 16 MTE devices
|
||||||
|
NDK_VERSION="26.1.10909125"
|
||||||
|
ANDROID_API="34"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
|
||||||
|
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
|
||||||
|
die() { err "$@"; do_destroy_quiet; exit 1; }
|
||||||
|
|
||||||
|
get_vm_ip() {
|
||||||
|
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_cmd() {
|
||||||
|
local ip
|
||||||
|
ip=$(get_vm_ip)
|
||||||
|
[ -n "$ip" ] || die "No VM found. Run --prepare first."
|
||||||
|
ssh $SSH_OPTS -A -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_down() {
|
||||||
|
local ip
|
||||||
|
ip=$(get_vm_ip)
|
||||||
|
[ -n "$ip" ] || die "No VM found."
|
||||||
|
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_destroy_quiet() {
|
||||||
|
local name
|
||||||
|
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo ""
|
||||||
|
err "Cleaning up — destroying VM $name"
|
||||||
|
hcloud server delete "$name" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --prepare: Create VM, install all build dependencies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
do_prepare() {
|
||||||
|
# Check if VM already exists
|
||||||
|
local existing
|
||||||
|
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||||
|
if [ -n "$existing" ]; then
|
||||||
|
log "VM already exists: $existing — reusing"
|
||||||
|
do_upload
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
|
||||||
|
hcloud server create \
|
||||||
|
--name "$SERVER_NAME" \
|
||||||
|
--type "$SERVER_TYPE" \
|
||||||
|
--image "$IMAGE" \
|
||||||
|
--ssh-key "$SSH_KEY_NAME" \
|
||||||
|
--location fsn1 \
|
||||||
|
--quiet \
|
||||||
|
|| die "Failed to create VM"
|
||||||
|
|
||||||
|
local ip
|
||||||
|
ip=$(get_vm_ip)
|
||||||
|
[ -n "$ip" ] || die "VM created but no IP found"
|
||||||
|
echo " VM: $SERVER_NAME @ $ip"
|
||||||
|
|
||||||
|
# Wait for SSH
|
||||||
|
log "Waiting for SSH..."
|
||||||
|
local ok=0
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ "$ok" -eq 1 ] || die "SSH timeout after 60s"
|
||||||
|
|
||||||
|
# System packages
|
||||||
|
log "Installing system packages (cmake, JDK 17, build tools)..."
|
||||||
|
ssh_cmd "export DEBIAN_FRONTEND=noninteractive && \
|
||||||
|
apt-get update -qq && \
|
||||||
|
apt-get install -y -qq \
|
||||||
|
build-essential cmake curl git libssl-dev pkg-config \
|
||||||
|
unzip wget zip openjdk-17-jdk-headless \
|
||||||
|
> /dev/null 2>&1" \
|
||||||
|
|| die "Failed to install system packages"
|
||||||
|
|
||||||
|
# Verify cmake version (must be <= 3.30)
|
||||||
|
local cmake_ver
|
||||||
|
cmake_ver=$(ssh_cmd "cmake --version | head -1")
|
||||||
|
echo " cmake: $cmake_ver"
|
||||||
|
echo " java: $(ssh_cmd "java -version 2>&1 | head -1")"
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
log "Installing Rust toolchain..."
|
||||||
|
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" \
|
||||||
|
|| die "Failed to install Rust"
|
||||||
|
ssh_cmd "source \$HOME/.cargo/env && rustup target add aarch64-linux-android > /dev/null 2>&1"
|
||||||
|
ssh_cmd "source \$HOME/.cargo/env && cargo install cargo-ndk > /dev/null 2>&1" \
|
||||||
|
|| die "Failed to install cargo-ndk"
|
||||||
|
echo " rust: $(ssh_cmd "source \$HOME/.cargo/env && rustc --version")"
|
||||||
|
|
||||||
|
# Android SDK + NDK
|
||||||
|
log "Installing Android SDK + NDK $NDK_VERSION..."
|
||||||
|
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||||
|
mkdir -p \$HOME/android-sdk/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 \$HOME/android-sdk/cmdline-tools && \
|
||||||
|
mv \$HOME/android-sdk/cmdline-tools/cmdline-tools \$HOME/android-sdk/cmdline-tools/latest 2>/dev/null; \
|
||||||
|
yes | \$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1; \
|
||||||
|
\$HOME/android-sdk/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" \
|
||||||
|
|| die "Failed to install Android SDK/NDK"
|
||||||
|
|
||||||
|
ssh_cmd "[ -d \$HOME/android-sdk/ndk/$NDK_VERSION ]" \
|
||||||
|
|| die "NDK not found after install"
|
||||||
|
echo " NDK: $NDK_VERSION"
|
||||||
|
|
||||||
|
# Upload source
|
||||||
|
do_upload
|
||||||
|
|
||||||
|
log "VM ready!"
|
||||||
|
echo " IP: $ip"
|
||||||
|
echo " SSH: ssh -A -i $SSH_KEY_PATH root@$ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --upload: Upload source code to VM
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
do_upload() {
|
||||||
|
log "Uploading source code (rsync)..."
|
||||||
|
local ip
|
||||||
|
ip=$(get_vm_ip)
|
||||||
|
[ -n "$ip" ] || die "No VM found."
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude='target' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.claude' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='dist' \
|
||||||
|
--exclude='desktop/src-tauri/gen' \
|
||||||
|
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
|
||||||
|
"$PROJECT_DIR/" "$REMOTE_USER@$ip:/root/wzp-build/"
|
||||||
|
echo " Source uploaded."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --build: Build native .so + debug & release APKs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
do_build() {
|
||||||
|
log "Building Rust native library (arm64-v8a, release)..."
|
||||||
|
|
||||||
|
# Clean Rust release target to force full rebuild.
|
||||||
|
# cargo-ndk only copies libc++_shared.so when it actually links — a partial
|
||||||
|
# clean that skips relinking leaves libc++_shared.so missing from jniLibs.
|
||||||
|
ssh_cmd "rm -rf /root/wzp-build/target/aarch64-linux-android/release \
|
||||||
|
/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a"
|
||||||
|
|
||||||
|
# ANDROID_NDK must be set (not just ANDROID_NDK_HOME) — cmake checks it
|
||||||
|
ssh_cmd "source \$HOME/.cargo/env && \
|
||||||
|
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||||
|
export ANDROID_NDK_HOME=\$ANDROID_HOME/ndk/$NDK_VERSION && \
|
||||||
|
export ANDROID_NDK=\$ANDROID_NDK_HOME && \
|
||||||
|
cd /root/wzp-build && \
|
||||||
|
cargo ndk -t arm64-v8a \
|
||||||
|
-o android/app/src/main/jniLibs \
|
||||||
|
build --release -p wzp-android 2>&1" | tail -5 \
|
||||||
|
|| die "Rust native build failed"
|
||||||
|
|
||||||
|
ssh_cmd "[ -f /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ]" \
|
||||||
|
|| die "libwzp_android.so not found after build"
|
||||||
|
|
||||||
|
local so_size
|
||||||
|
so_size=$(ssh_cmd "du -h /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so | cut -f1")
|
||||||
|
echo " .so: $so_size"
|
||||||
|
|
||||||
|
# Generate debug keystore if missing
|
||||||
|
ssh_cmd "[ -f /root/wzp-build/android/keystore/wzp-debug.jks ] || \
|
||||||
|
(mkdir -p /root/wzp-build/android/keystore && \
|
||||||
|
keytool -genkey -v \
|
||||||
|
-keystore /root/wzp-build/android/keystore/wzp-debug.jks \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-alias wzp-debug -storepass android -keypass android \
|
||||||
|
-dname 'CN=WZP Debug' > /dev/null 2>&1)"
|
||||||
|
|
||||||
|
# Build debug APK
|
||||||
|
log "Building debug APK..."
|
||||||
|
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||||
|
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||||
|
cd /root/wzp-build/android && \
|
||||||
|
chmod +x ./gradlew && \
|
||||||
|
./gradlew assembleDebug --no-daemon --warning-mode=none 2>&1" | tail -3 \
|
||||||
|
|| die "Debug APK build failed"
|
||||||
|
|
||||||
|
# Build release APK (uses debug keystore for now)
|
||||||
|
log "Building release APK..."
|
||||||
|
# Copy debug keystore as release keystore (same password in build.gradle)
|
||||||
|
ssh_cmd "cp /root/wzp-build/android/keystore/wzp-debug.jks /root/wzp-build/android/keystore/wzp-release.jks 2>/dev/null; true"
|
||||||
|
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||||
|
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||||
|
cd /root/wzp-build/android && \
|
||||||
|
./gradlew assembleRelease --no-daemon --warning-mode=none 2>&1" | tail -3 \
|
||||||
|
|| echo " (release APK failed — debug APK still available)"
|
||||||
|
|
||||||
|
log "Build complete!"
|
||||||
|
ssh_cmd "find /root/wzp-build/android -name '*.apk' -path '*/outputs/apk/*' -exec ls -lh {} \;"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --transfer: Download APKs to local machine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
do_transfer() {
|
||||||
|
log "Downloading APKs..."
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
local ip
|
||||||
|
ip=$(get_vm_ip)
|
||||||
|
|
||||||
|
# Debug APK
|
||||||
|
local debug_apk
|
||||||
|
debug_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-debug*.apk' -path '*/outputs/apk/*' | head -1")
|
||||||
|
if [ -n "$debug_apk" ]; then
|
||||||
|
scp_down "$debug_apk" "$OUTPUT_DIR/wzp-debug.apk"
|
||||||
|
echo " debug: $OUTPUT_DIR/wzp-debug.apk ($(du -h "$OUTPUT_DIR/wzp-debug.apk" | cut -f1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Release APK
|
||||||
|
local release_apk
|
||||||
|
release_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-release*.apk' -path '*/outputs/apk/*' | head -1" || true)
|
||||||
|
if [ -n "$release_apk" ]; then
|
||||||
|
scp_down "$release_apk" "$OUTPUT_DIR/wzp-release.apk"
|
||||||
|
echo " release: $OUTPUT_DIR/wzp-release.apk ($(du -h "$OUTPUT_DIR/wzp-release.apk" | cut -f1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also copy the .so for inspection
|
||||||
|
scp_down "/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so" "$OUTPUT_DIR/libwzp_android.so"
|
||||||
|
echo " .so: $OUTPUT_DIR/libwzp_android.so"
|
||||||
|
|
||||||
|
log "Transfer complete!"
|
||||||
|
echo ""
|
||||||
|
echo " Install debug: adb install -r $OUTPUT_DIR/wzp-debug.apk"
|
||||||
|
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && echo " Install release: adb install -r $OUTPUT_DIR/wzp-release.apk"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --destroy: Delete the VM
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
do_destroy() {
|
||||||
|
local name
|
||||||
|
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
echo "No VM to destroy."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "Deleting VM: $name"
|
||||||
|
hcloud server delete "$name"
|
||||||
|
echo " Done."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Full build: create → build → transfer → destroy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
do_full() {
|
||||||
|
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
|
||||||
|
|
||||||
|
do_prepare
|
||||||
|
|
||||||
|
# Disable trap during build — release APK failure is non-fatal
|
||||||
|
trap - ERR
|
||||||
|
do_build
|
||||||
|
do_transfer
|
||||||
|
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
|
||||||
|
|
||||||
|
if [ "$KEEP_VM" = "1" ]; then
|
||||||
|
log "VM kept alive (WZP_KEEP_VM=1). Destroy with: $0 --destroy"
|
||||||
|
else
|
||||||
|
do_destroy
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "All done!"
|
||||||
|
echo ""
|
||||||
|
echo " ┌──────────────────────────────────────────────────┐"
|
||||||
|
echo " │ Debug APK: $OUTPUT_DIR/wzp-debug.apk"
|
||||||
|
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && \
|
||||||
|
echo " │ Release APK: $OUTPUT_DIR/wzp-release.apk"
|
||||||
|
echo " │"
|
||||||
|
echo " │ Install: adb install -r $OUTPUT_DIR/wzp-debug.apk"
|
||||||
|
echo " └──────────────────────────────────────────────────┘"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--prepare) do_prepare ;;
|
||||||
|
--build) do_build ;;
|
||||||
|
--transfer) do_transfer ;;
|
||||||
|
--destroy) do_destroy ;;
|
||||||
|
--upload) do_upload ;;
|
||||||
|
--all)
|
||||||
|
do_prepare
|
||||||
|
do_build
|
||||||
|
do_transfer
|
||||||
|
log "VM still running. Destroy with: $0 --destroy"
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
do_full
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [--prepare|--build|--transfer|--destroy|--all|--upload]"
|
||||||
|
echo ""
|
||||||
|
echo " (no args) Full build: create VM → build → download → destroy VM"
|
||||||
|
echo " --prepare Create VM and install deps"
|
||||||
|
echo " --build Build on existing VM"
|
||||||
|
echo " --transfer Download APKs from VM"
|
||||||
|
echo " --destroy Delete the VM"
|
||||||
|
echo " --all prepare + build + transfer (VM persists)"
|
||||||
|
echo " --upload Re-upload source to existing VM"
|
||||||
|
echo ""
|
||||||
|
echo "Environment:"
|
||||||
|
echo " WZP_BRANCH=$BRANCH"
|
||||||
|
echo " WZP_SERVER_TYPE=$SERVER_TYPE"
|
||||||
|
echo " WZP_KEEP_VM=$KEEP_VM (set to 1 to skip auto-destroy)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user