Compare commits
10 Commits
9eed94850d
...
build/last
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e0d8d212 | ||
|
|
4e0356ef37 | ||
|
|
264ef9c4d4 | ||
|
|
a9adb5cfd7 | ||
|
|
a39b074d6e | ||
|
|
9cab6e2347 | ||
|
|
5e93cb74f2 | ||
|
|
b56b4a759c | ||
|
|
6f99841cc7 | ||
|
|
3b0811ce2e |
@@ -7,6 +7,8 @@ on:
|
||||
- 'feat/*'
|
||||
tags:
|
||||
- 'v*'
|
||||
paths-ignore:
|
||||
- '.gitea/**'
|
||||
workflow_dispatch:
|
||||
|
||||
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
|
||||
private var captureThread: 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.
|
||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||
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}")
|
||||
|
||||
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
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
@@ -226,10 +234,10 @@ class AudioPipeline(private val context: Context) {
|
||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||
if (read > 0) {
|
||||
applyGain(pcm, read, captureGainDb)
|
||||
// Zero-copy write via DirectByteBuffer (no GC array interaction)
|
||||
directBuf.clear()
|
||||
directBuf.asShortBuffer().put(pcm, 0, read)
|
||||
engine.writeAudioDirect(directBuf, read)
|
||||
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
||||
captureDirectBuf.clear()
|
||||
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
||||
engine.writeAudioDirect(captureDirectBuf, read)
|
||||
|
||||
// Debug: write raw PCM + RMS
|
||||
if (pcmOut != null) {
|
||||
@@ -292,8 +300,6 @@ class AudioPipeline(private val context: Context) {
|
||||
|
||||
val pcm = 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
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
@@ -310,14 +316,12 @@ class AudioPipeline(private val context: Context) {
|
||||
}
|
||||
try {
|
||||
while (running) {
|
||||
// Zero-copy read via DirectByteBuffer
|
||||
directBuf.clear()
|
||||
val read = engine.readAudioDirect(directBuf, FRAME_SAMPLES)
|
||||
if (read > 0) {
|
||||
directBuf.rewind()
|
||||
directBuf.asShortBuffer().get(pcm, 0, read)
|
||||
}
|
||||
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
||||
playoutDirectBuf.clear()
|
||||
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
||||
if (read >= FRAME_SAMPLES) {
|
||||
playoutDirectBuf.rewind()
|
||||
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
||||
applyGain(pcm, read, playoutGainDb)
|
||||
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_IDENTITY_SEED = "identity_seed_hex"
|
||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||
private const val TOFU_PREFIX = "tofu_"
|
||||
}
|
||||
|
||||
// --- Servers ---
|
||||
@@ -138,4 +140,43 @@ class SettingsRepository(context: Context) {
|
||||
fun saveSeedHex(hex: String) {
|
||||
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 {
|
||||
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.engine.WzpCallback
|
||||
import com.wzp.engine.WzpEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -19,6 +20,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
@@ -26,6 +29,13 @@ import java.net.InetAddress
|
||||
|
||||
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 {
|
||||
|
||||
private var engine: WzpEngine? = null
|
||||
@@ -70,6 +80,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _preferIPv6 = MutableStateFlow(false)
|
||||
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)
|
||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||
|
||||
@@ -139,6 +159,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
_captureGainDb.value = s.loadCaptureGain()
|
||||
_seedHex.value = s.getOrCreateSeedHex()
|
||||
_aecEnabled.value = s.loadAecEnabled()
|
||||
_recentRooms.value = s.loadRecentRooms()
|
||||
}
|
||||
|
||||
fun selectServer(index: Int) {
|
||||
@@ -182,6 +203,51 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
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) {
|
||||
_roomName.value = name
|
||||
settings?.saveRoom(name)
|
||||
@@ -287,6 +353,8 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
_debugReportAvailable.value = false
|
||||
_debugReportStatus.value = null
|
||||
lastCallServer = serverEntry.address
|
||||
settings?.addRecentRoom(serverEntry.address, room)
|
||||
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
|
||||
debugReporter?.prepareForCall()
|
||||
try {
|
||||
// Teardown previous call but don't stop the service (we're about to restart it)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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))
|
||||
|
||||
// Fingerprint display
|
||||
// Fingerprint display with identicon
|
||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
||||
Text(
|
||||
text = "Fingerprint",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = fingerprint.chunked(4).joinToString(" "),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
com.wzp.ui.components.Identicon(
|
||||
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))
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ wzp-crypto = { workspace = true }
|
||||
wzp-transport = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
@@ -28,7 +27,9 @@ libc = "0.2"
|
||||
jni = { version = "0.21", default-features = false }
|
||||
rand = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
tracing-android = "0.2"
|
||||
android_logger = "0.14"
|
||||
log = "0.4"
|
||||
tracing-log = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
@@ -35,15 +35,17 @@ static INIT_LOGGING: Once = Once::new();
|
||||
/// Safe to call multiple times — only the first call takes effect.
|
||||
fn init_logging() {
|
||||
INIT_LOGGING.call_once(|| {
|
||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
||||
// tracing_subscriber::registry() can crash on some Android
|
||||
// devices if scudo malloc fails during early initialization.
|
||||
// Use android_logger directly — tracing_subscriber::registry() allocates
|
||||
// a sharded_slab which causes SIGSEGV on Android 16 MTE devices.
|
||||
// android_logger is lightweight and doesn't trigger scudo crashes.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||
let _ = tracing_subscriber::registry().with(layer).try_init();
|
||||
}
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("wzp"),
|
||||
);
|
||||
// Bridge tracing → log so our tracing::info! macros work
|
||||
let _ = tracing_log::LogTracer::init();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -308,3 +310,79 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
||||
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