Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d4e518c
Documentation: Updated community-supported-devices.md to bring 1 new …
GarryStraitYT Jun 27, 2026
bc6c536
docs: remove garbled characters from the Android installation guide (…
Jesse205 Jun 28, 2026
4206f21
Added translation using Weblate (Tamil) (#221)
weblate Jun 28, 2026
ea1c703
Update translation files (#224)
weblate Jun 29, 2026
901502d
net: self-heal delegated-LAN clients when the gateway (re)boots
ravindu644 Jun 29, 2026
d035ce8
mount: don't force a full e2fsck on every container start
ravindu644 Jun 29, 2026
f702611
net: add --upstream to pin NAT WAN to specific interface(s)
ravindu644 Jun 29, 2026
9ac6ed3
net: re-assert ip_forward on every platform, not just Android
ravindu644 Jun 30, 2026
371ffd8
app: block back gesture on backend install screen, force Continue button
ravindu644 Jun 30, 2026
f30f467
net: dedupe NAT/gateway helpers (iptables ACCEPT, MAC, link up/down)
ravindu644 Jun 30, 2026
2dfdf89
net: log decoded target name instead of '' for standard verdicts
ravindu644 Jun 30, 2026
e876f99
net: fix DHCP source MAC for bridgeless NAT; harden gateway on no-bri…
ravindu644 Jun 30, 2026
06b1378
app: fix Panel tab polling not resuming after returning from background
ravindu644 Jun 30, 2026
c7c6296
app: drop unused host systemUsage polling and its collector
ravindu644 Jun 30, 2026
e50ebe8
docs: document gateway mode, automatic uplink monitor, and --upstream…
ravindu644 Jun 30, 2026
c4129eb
docs: cover gateway mode and --upstream pinning in built-in help
ravindu644 Jun 30, 2026
d9e6938
docs: link Networking From Zero guide in README
ravindu644 Jun 30, 2026
9e738a8
net: heal stale gateway cable when its peer outlives the gateway netns
ravindu644 Jun 30, 2026
8510a02
net: explicitly reap gateway veth on gateway stop to free orphan netns
ravindu644 Jun 30, 2026
39dbbdf
net: wire gateway clients before unblocking gateway init
ravindu644 Jul 1, 2026
7957014
mount: force image rootfs read-write before pivot_root
ravindu644 Jul 1, 2026
5f5c613
docs: Add Galaxy A15 4G (sma155f) to GKI support list (#225)
poqdavid Jul 1, 2026
ac346ff
ci: remove redundant run-name configuration from CI workflow
ravindu644 Jul 1, 2026
fe67da3
ci: name CI apk Droidspaces-universal-CI-<commit>.apk
ravindu644 Jul 1, 2026
35c6dd4
build: name tarball with commit hash instead of date
ravindu644 Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
name: Droidspaces CI
run-name: ${{ github.event.head_commit.message || format('Release {0}', github.event.inputs.tag_name) || github.workflow }}

on:
push:
Expand Down Expand Up @@ -105,9 +104,9 @@ jobs:

- name: Stage & Rename Artifacts
run: |
TAG="${{ github.event.inputs.tag_name || 'test' }}"
DATE=$(date +%Y-%m-%d)
APK_NAME="Droidspaces-universal-${TAG}-${DATE}.apk"
TAG="${{ github.event.inputs.tag_name || 'CI' }}"
COMMIT_HASH=$(git log -1 --pretty=%h)
APK_NAME="Droidspaces-universal-${TAG}-${COMMIT_HASH}.apk"
TAR_NAME=$(ls droidspaces-v*-*.tar.gz)
mkdir -p dist
mv Android/app/build/outputs/apk/release/app-release.apk dist/${APK_NAME}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
package com.droidspaces.app.ui.component

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.droidspaces.app.R
import com.droidspaces.app.util.ContainerManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpstreamInterfaceList(
upstreamInterfaces: List<String>,
onInterfacesChange: (List<String>) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var showUpstreamDialog by remember { mutableStateOf(false) }
var availableUpstreams by remember { mutableStateOf<List<String>>(emptyList()) }

LaunchedEffect(Unit) {
availableUpstreams = ContainerManager.listUpstreamInterfaces()
}

Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Selected interfaces
upstreamInterfaces.forEach { iface ->
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(20.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = iface, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = { onInterfacesChange(upstreamInterfaces - iface) }) {
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
}
}
}
}

// Add Button
if (upstreamInterfaces.size < 8) {
val addBtnShape = RoundedCornerShape(16.dp)
Surface(
modifier = Modifier.fillMaxWidth().clip(addBtnShape).clickable(
onClick = { showUpstreamDialog = true }
),
shape = addBtnShape,
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.width(8.dp))
Text(
context.getString(R.string.add_upstream_interface),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

if (showUpstreamDialog) {
AddUpstreamDialog(
availableUpstreams = availableUpstreams,
selectedInterfaces = upstreamInterfaces,
onDismiss = { showUpstreamDialog = false },
onRefresh = {
val newOnes = ContainerManager.listUpstreamInterfaces()
availableUpstreams = newOnes
},
onAdd = { iface ->
if (!upstreamInterfaces.contains(iface)) {
onInterfacesChange(upstreamInterfaces + iface)
}
showUpstreamDialog = false
}
)
}
}

@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AddUpstreamDialog(
availableUpstreams: List<String>,
selectedInterfaces: List<String>,
onDismiss: () -> Unit,
onRefresh: suspend () -> Unit,
onAdd: (String) -> Unit
) {
val context = LocalContext.current
var customIface by remember { mutableStateOf("") }
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()

val rotation by animateFloatAsState(
targetValue = if (isRefreshing) 360f else 0f,
animationSpec = if (isRefreshing) {
tween(durationMillis = 600, easing = LinearEasing)
} else {
tween(durationMillis = 0, easing = LinearEasing)
},
label = "refresh_rotation"
)

Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
modifier = Modifier.fillMaxWidth(0.92f).wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)),
tonalElevation = 0.dp
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = context.getString(R.string.add_upstream_interface),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = {
if (!isRefreshing) {
isRefreshing = true
scope.launch {
val startTime = System.currentTimeMillis()
onRefresh()
val elapsed = System.currentTimeMillis() - startTime
if (elapsed < 600L) delay(600L - elapsed)
isRefreshing = false
}
}
},
enabled = !isRefreshing
) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }
)
}
}

if (availableUpstreams.isNotEmpty()) {
Text(context.getString(R.string.available_system_interfaces), style = MaterialTheme.typography.labelMedium)
Box(modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp)) {
FlowRow(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
availableUpstreams.forEach { iface ->
OutlinedButton(
onClick = { onAdd(iface) },
enabled = !selectedInterfaces.contains(iface),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
) {
Text(iface)
}
}
}
}
}

Spacer(modifier = Modifier.height(4.dp))
Text(context.getString(R.string.enter_manually), style = MaterialTheme.typography.labelMedium)
OutlinedTextField(
value = customIface,
onValueChange = { customIface = it },
label = { Text(context.getString(R.string.interface_name_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
)

Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)),
tonalElevation = 0.dp
) {
Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) {
Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
}
}
Surface(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(
enabled = customIface.isNotBlank() && selectedInterfaces.size < 8,
onClick = { onAdd(customIface.trim()) }
),
shape = RoundedCornerShape(14.dp),
color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
tonalElevation = 0.dp
) {
Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) {
Text(
context.getString(R.string.add),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -327,15 +327,16 @@ fun DroidspacesNavigation(
initialBlockNestedNs = viewModel.blockNestedNs,
initialPrivileged = viewModel.privileged,
initialEnvFileContent = viewModel.envFileContent ?: "",
initialUpstreamInterfaces = viewModel.upstreamInterfaces,
initialPortForwards = viewModel.portForwards,
initialGatewayContainer = viewModel.gatewayContainer,
initialGatewayNet = viewModel.gatewayNet,
initialGatewayIface = viewModel.gatewayIface,
initialGatewayBridge = viewModel.gatewayBridge,
containerName = viewModel.containerName,
installedContainers = sharedContainerViewModel.containerList,
onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge ->
viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge)
onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge ->
viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, upstreamInterfaces, portForwards, forceCgroupv1, blockNestedNs, privileged, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge)
navController.navigate(Screen.SparseImageConfig.route)
},
onBack = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import com.droidspaces.app.ui.component.ToggleCard
import com.droidspaces.app.ui.component.DsDropdown
import androidx.compose.material.icons.filled.Public
import com.droidspaces.app.ui.component.UpstreamInterfaceList
import com.droidspaces.app.ui.component.PortForwardingList
import androidx.compose.ui.platform.LocalContext
import com.droidspaces.app.R
Expand Down Expand Up @@ -75,6 +76,7 @@ fun ContainerConfigScreen(
initialBlockNestedNs: Boolean = false,
initialPrivileged: String = "",
initialEnvFileContent: String = "",
initialUpstreamInterfaces: List<String> = emptyList(),
initialPortForwards: List<PortForward> = emptyList(),
initialGatewayContainer: String = "",
initialGatewayNet: String = "",
Expand Down Expand Up @@ -104,6 +106,7 @@ fun ContainerConfigScreen(
blockNestedNs: Boolean,
privileged: String,
envFileContent: String?,
upstreamInterfaces: List<String>,
portForwards: List<PortForward>,
gatewayContainer: String,
gatewayNet: String,
Expand Down Expand Up @@ -132,6 +135,7 @@ fun ContainerConfigScreen(
var forceCgroupv1 by remember { mutableStateOf(initialForceCgroupv1) }
var blockNestedNs by remember { mutableStateOf(initialBlockNestedNs) }
var envFileContent by remember { mutableStateOf(initialEnvFileContent) }
var upstreamInterfaces by remember { mutableStateOf(initialUpstreamInterfaces) }
var portForwards by remember { mutableStateOf(initialPortForwards) }
var privileged by remember { mutableStateOf(initialPrivileged) }
var gatewayContainer by remember { mutableStateOf(initialGatewayContainer) }
Expand Down Expand Up @@ -319,7 +323,7 @@ fun ContainerConfigScreen(
.clickable(
enabled = canProceed,
onClick = {
onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge)
onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, upstreamInterfaces, portForwards, gatewayContainer, gatewayNet, gatewayIface, gatewayBridge)
},
indication = androidx.compose.material.ripple.rememberRipple(bounded = true),
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
Expand Down Expand Up @@ -520,6 +524,24 @@ fun ContainerConfigScreen(
)
}

// Upstream Interface (optional pin)
Text(
text = context.getString(R.string.upstream_interface_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = context.getString(R.string.upstream_interface_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

UpstreamInterfaceList(
upstreamInterfaces = upstreamInterfaces,
onInterfacesChange = { upstreamInterfaces = it }
)

// Port Forwards
Text(
text = context.getString(R.string.port_forwarding),
Expand Down
Loading
Loading