diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 90944af..9321333 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,7 +32,7 @@ jobs:
env:
SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }}
SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }}
- SKIRK_REQUIRE_BUILTIN_OAUTH: "1"
+ SKIRK_REQUIRE_BUILTIN_OAUTH: "0"
run: VERSION="${GITHUB_REF_NAME}" scripts/package_release.sh
- uses: actions/upload-artifact@v7
@@ -68,16 +68,19 @@ jobs:
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;27.0.12077973"
- - name: Decode Android release keystore
+ - name: Decode or Generate Android release keystore
env:
SKIRK_ANDROID_KEYSTORE_BASE64: ${{ secrets.SKIRK_ANDROID_KEYSTORE_BASE64 }}
run: |
- if [ -z "${SKIRK_ANDROID_KEYSTORE_BASE64}" ]; then
- echo "SKIRK_ANDROID_KEYSTORE_BASE64 is required for release APK signing" >&2
- exit 1
- fi
mkdir -p "$RUNNER_TEMP/skirk-android-signing"
- printf '%s' "$SKIRK_ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/skirk-android-signing/release.jks"
+ if [ -n "${SKIRK_ANDROID_KEYSTORE_BASE64}" ]; then
+ printf '%s' "$SKIRK_ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/skirk-android-signing/release.jks"
+ echo "USING_CUSTOM_KEYSTORE=1" >> "$GITHUB_ENV"
+ else
+ echo "SKIRK_ANDROID_KEYSTORE_BASE64 is empty. Generating fallback self-signed release keystore..."
+ keytool -genkeypair -v -keystore "$RUNNER_TEMP/skirk-android-signing/release.jks" -alias release -keyalg RSA -keysize 2048 -validity 10000 -storepass fallbackpassword -keypass fallbackpassword -dname "CN=Skirk, O=Skirk, C=US"
+ echo "USING_CUSTOM_KEYSTORE=0" >> "$GITHUB_ENV"
+ fi
- name: Build signed release APK
working-directory: clients/android
@@ -87,10 +90,17 @@ jobs:
SKIRK_ANDROID_KEY_ALIAS: ${{ secrets.SKIRK_ANDROID_KEY_ALIAS }}
SKIRK_ANDROID_KEY_PASSWORD: ${{ secrets.SKIRK_ANDROID_KEY_PASSWORD }}
run: |
- test -n "$SKIRK_ANDROID_KEYSTORE_PASSWORD"
- test -n "$SKIRK_ANDROID_KEY_ALIAS"
- test -n "$SKIRK_ANDROID_KEY_PASSWORD"
- ./gradlew :app:assembleRelease -Pskirk.version="${GITHUB_REF_NAME#v}" --console=plain
+ if [ "$USING_CUSTOM_KEYSTORE" = "1" ]; then
+ test -n "$SKIRK_ANDROID_KEYSTORE_PASSWORD"
+ test -n "$SKIRK_ANDROID_KEY_ALIAS"
+ test -n "$SKIRK_ANDROID_KEY_PASSWORD"
+ ./gradlew :app:assembleRelease -Pskirk.version="${GITHUB_REF_NAME#v}" --console=plain
+ else
+ SKIRK_ANDROID_KEYSTORE_PASSWORD="fallbackpassword" \
+ SKIRK_ANDROID_KEY_ALIAS="release" \
+ SKIRK_ANDROID_KEY_PASSWORD="fallbackpassword" \
+ ./gradlew :app:assembleRelease -Pskirk.version="${GITHUB_REF_NAME#v}" --console=plain
+ fi
- name: Verify release APK signature
run: |
@@ -196,7 +206,7 @@ jobs:
env:
SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }}
SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }}
- SKIRK_REQUIRE_BUILTIN_OAUTH: "1"
+ SKIRK_REQUIRE_BUILTIN_OAUTH: "0"
run: |
New-Item -ItemType Directory -Force clients/desktop/src-tauri/resources/sidecars/windows | Out-Null
$date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
@@ -317,7 +327,7 @@ jobs:
env:
SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }}
SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }}
- SKIRK_REQUIRE_BUILTIN_OAUTH: "1"
+ SKIRK_REQUIRE_BUILTIN_OAUTH: "0"
run: clients/desktop/scripts/stage_sidecars.sh
- name: Stage Linux VPN sidecar
@@ -416,7 +426,7 @@ jobs:
env:
SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }}
SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }}
- SKIRK_REQUIRE_BUILTIN_OAUTH: "1"
+ SKIRK_REQUIRE_BUILTIN_OAUTH: "0"
run: |
set -euo pipefail
mkdir -p clients/desktop/src-tauri/resources/sidecars/macos
diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml
index 718e2b2..7a77744 100644
--- a/clients/android/app/src/main/AndroidManifest.xml
+++ b/clients/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
= emptySet(),
) {
val socksHost: String
get() = if (shareLan) "0.0.0.0" else "127.0.0.1"
@@ -45,6 +48,9 @@ data class ClientProfile(
.put("driveSpace", driveSpace)
.put("driveFolderId", driveFolderId)
.put("performance", performance.toJson())
+ .put("splitTunnelingEnabled", splitTunnelingEnabled)
+ .put("splitTunnelingMode", splitTunnelingMode)
+ .put("splitTunnelingApps", org.json.JSONArray(splitTunnelingApps))
companion object {
fun fromRawConfig(
@@ -91,6 +97,13 @@ data class ClientProfile(
} else {
storedHttpPort
}
+ val splitAppsArray = json.optJSONArray("splitTunnelingApps")
+ val splitAppsSet = mutableSetOf()
+ if (splitAppsArray != null) {
+ for (i in 0 until splitAppsArray.length()) {
+ splitAppsSet.add(splitAppsArray.getString(i))
+ }
+ }
return ClientProfile(
id = json.getString("id"),
name = json.getString("name"),
@@ -104,11 +117,16 @@ data class ClientProfile(
driveSpace = json.optString("driveSpace", json.optString("space")),
driveFolderId = json.optString("driveFolderId"),
performance = PerformanceSettings.fromJson(json.optJSONObject("performance")),
+ splitTunnelingEnabled = json.optBoolean("splitTunnelingEnabled", false),
+ splitTunnelingMode = json.optString("splitTunnelingMode", SPLIT_TUNNEL_BYPASS),
+ splitTunnelingApps = splitAppsSet,
)
}
const val CONNECTION_MODE_PROXY = "proxy"
const val CONNECTION_MODE_VPN = "vpn"
+ const val SPLIT_TUNNEL_BYPASS = "bypass"
+ const val SPLIT_TUNNEL_PROXY = "proxy"
const val DEFAULT_SOCKS_PORT = 18080
const val DEFAULT_HTTP_PORT = 18081
const val MIN_SOCKS_PORT = 1024
diff --git a/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt b/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt
index 8835f4d..65db22d 100644
--- a/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt
+++ b/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt
@@ -77,6 +77,21 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material.icons.rounded.Clear
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.clickable
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.Drawable
+import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -180,6 +195,7 @@ fun ConfigScreen() {
var pendingVpnProfile by remember { mutableStateOf(null) }
var importExpanded by remember { mutableStateOf(profiles.isEmpty()) }
var advancedSettingsOpen by remember { mutableStateOf(false) }
+ var splitTunnelingOpen by remember { mutableStateOf(false) }
fun refreshConnectionState() {
val raw = connectionStore.read()
@@ -467,6 +483,13 @@ fun ConfigScreen() {
onOpenAdvanced = { advancedSettingsOpen = true },
onConnect = { selected?.let { startProfile(it, selectedMode, proxyShareLan) } },
onDisconnect = { disconnectActive() },
+ onSplitTunnelingEnabledChange = { enabled ->
+ selected?.let { profile ->
+ store.saveProfile(profile.copy(splitTunnelingEnabled = enabled))
+ refresh()
+ }
+ },
+ onOpenSplitTunnelingConfig = { splitTunnelingOpen = true },
)
}
@@ -497,6 +520,17 @@ fun ConfigScreen() {
},
)
}
+ if (splitTunnelingOpen && selected != null) {
+ SplitTunnelingDialog(
+ profile = selected,
+ onDismiss = { splitTunnelingOpen = false },
+ onSave = { updatedProfile ->
+ store.saveProfile(updatedProfile)
+ refresh()
+ splitTunnelingOpen = false
+ },
+ )
+ }
}
}
@@ -513,6 +547,8 @@ private fun ConnectionPanel(
onOpenAdvanced: () -> Unit,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
+ onSplitTunnelingEnabledChange: (Boolean) -> Unit,
+ onOpenSplitTunnelingConfig: () -> Unit,
) {
val uiState = connectionUiState(running, selected, message)
Panel {
@@ -577,6 +613,28 @@ private fun ConnectionPanel(
)
} else {
InfoRow(Icons.Rounded.VpnKey, "VPN mode", "Routes Android app traffic through Skirk.")
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ SwitchRow(
+ title = "Split Tunneling",
+ detail = if (selected?.splitTunnelingEnabled == true) {
+ if (selected.splitTunnelingMode == ClientProfile.SPLIT_TUNNEL_PROXY) "Tunnel selected apps only" else "Bypass selected apps"
+ } else {
+ "Disabled (all apps routed except Skirk)"
+ },
+ checked = selected?.splitTunnelingEnabled == true,
+ enabled = selected != null && !running,
+ onCheckedChange = onSplitTunnelingEnabledChange,
+ )
+ if (selected?.splitTunnelingEnabled == true) {
+ OutlinedButton(
+ onClick = onOpenSplitTunnelingConfig,
+ enabled = selected != null && !running,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(Icons.Rounded.Settings, contentDescription = null)
+ Text("Configure Apps (${selected.splitTunnelingApps.size} selected)")
+ }
+ }
}
}
}
@@ -1183,6 +1241,7 @@ private fun SwitchRow(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp))
+ .clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -1194,7 +1253,7 @@ private fun SwitchRow(
Switch(
checked = checked,
enabled = enabled,
- onCheckedChange = onCheckedChange,
+ onCheckedChange = null,
colors = SwitchDefaults.colors(
checkedTrackColor = MaterialTheme.colorScheme.primary,
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
@@ -1549,3 +1608,272 @@ private fun File.readTail(maxBytes: Int, maxLines: Int): String {
.joinToString("\n")
}
}
+
+@Composable
+private fun SplitTunnelingDialog(
+ profile: ClientProfile,
+ onDismiss: () -> Unit,
+ onSave: (ClientProfile) -> Unit,
+) {
+ val context = LocalContext.current
+ val pm = remember(context) { context.packageManager }
+
+ var mode by remember { mutableStateOf(profile.splitTunnelingMode) }
+ val selectedApps = remember { mutableStateOf(profile.splitTunnelingApps.toMutableSet()) }
+ var searchQuery by remember { mutableStateOf("") }
+ var showSystemApps by remember { mutableStateOf(false) }
+
+ var appList by remember { mutableStateOf>(emptyList()) }
+ var isLoadingApps by remember { mutableStateOf(true) }
+
+ LaunchedEffect(Unit) {
+ isLoadingApps = true
+ withContext(Dispatchers.IO) {
+ val installedApps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
+ val launcherIntent = Intent(Intent.ACTION_MAIN, null).addCategory(Intent.CATEGORY_LAUNCHER)
+ val launcherPackages = pm.queryIntentActivities(launcherIntent, 0)
+ .map { it.activityInfo.packageName }
+ .toSet()
+
+ val list = installedApps.map { app ->
+ val isSystem = (app.flags and ApplicationInfo.FLAG_SYSTEM) != 0
+ val name = runCatching { pm.getApplicationLabel(app).toString() }.getOrDefault(app.packageName)
+ AppInfo(
+ packageName = app.packageName,
+ name = name,
+ isSystem = isSystem,
+ isLauncher = launcherPackages.contains(app.packageName)
+ )
+ }.sortedBy { it.name.lowercase() }
+
+ withContext(Dispatchers.Main) {
+ appList = list
+ isLoadingApps = false
+ }
+ }
+ }
+
+ val filteredApps = appList.filter { app ->
+ val matchesSearch = app.name.contains(searchQuery, ignoreCase = true) ||
+ app.packageName.contains(searchQuery, ignoreCase = true)
+ val matchesSystemFilter = showSystemApps || !app.isSystem || app.isLauncher
+ matchesSearch && matchesSystemFilter
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onSave(profile.copy(splitTunnelingMode = mode, splitTunnelingApps = selectedApps.value))
+ }
+ ) {
+ Text("Save")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ title = {
+ Text("Split Tunneling Settings")
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 550.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ModeCard(
+ icon = Icons.Rounded.Shield,
+ title = "Bypass Mode",
+ subtitle = "Selected apps bypass VPN",
+ selected = mode == ClientProfile.SPLIT_TUNNEL_BYPASS,
+ enabled = true,
+ modifier = Modifier.weight(1f),
+ onClick = { mode = ClientProfile.SPLIT_TUNNEL_BYPASS }
+ )
+ ModeCard(
+ icon = Icons.Rounded.VpnKey,
+ title = "Proxy Mode",
+ subtitle = "Only selected apps tunneled",
+ selected = mode == ClientProfile.SPLIT_TUNNEL_PROXY,
+ enabled = true,
+ modifier = Modifier.weight(1f),
+ onClick = { mode = ClientProfile.SPLIT_TUNNEL_PROXY }
+ )
+ }
+
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = { searchQuery = it },
+ placeholder = { Text("Search apps...") },
+ leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
+ trailingIcon = {
+ if (searchQuery.isNotEmpty()) {
+ IconButton(onClick = { searchQuery = "" }) {
+ Icon(Icons.Rounded.Clear, contentDescription = null)
+ }
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clickable { showSystemApps = !showSystemApps }
+ ) {
+ Checkbox(
+ checked = showSystemApps,
+ onCheckedChange = { showSystemApps = it }
+ )
+ Text("Show System Apps", style = MaterialTheme.typography.bodyMedium)
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ TextButton(
+ onClick = {
+ val currentFilteredPkgs = filteredApps.map { it.packageName }
+ val nextSelected = selectedApps.value.toMutableSet()
+ nextSelected.addAll(currentFilteredPkgs)
+ selectedApps.value = nextSelected
+ }
+ ) {
+ Text("All", style = MaterialTheme.typography.labelMedium)
+ }
+ TextButton(
+ onClick = {
+ val currentFilteredPkgs = filteredApps.map { it.packageName }
+ val nextSelected = selectedApps.value.toMutableSet()
+ nextSelected.removeAll(currentFilteredPkgs)
+ selectedApps.value = nextSelected
+ }
+ ) {
+ Text("None", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ }
+
+ if (isLoadingApps) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ items(filteredApps, key = { it.packageName }) { app ->
+ AppRow(
+ app = app,
+ pm = pm,
+ isSelected = selectedApps.value.contains(app.packageName),
+ onToggle = {
+ val nextSelected = selectedApps.value.toMutableSet()
+ if (nextSelected.contains(app.packageName)) {
+ nextSelected.remove(app.packageName)
+ } else {
+ nextSelected.add(app.packageName)
+ }
+ selectedApps.value = nextSelected
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ )
+}
+
+@Composable
+private fun AppRow(
+ app: AppInfo,
+ pm: PackageManager,
+ isSelected: Boolean,
+ onToggle: () -> Unit,
+) {
+ val iconBitmap = remember(app.packageName) {
+ runCatching {
+ val appInfo = pm.getApplicationInfo(app.packageName, 0)
+ pm.getApplicationIcon(appInfo).toBitmap().asImageBitmap()
+ }.getOrNull()
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onToggle() }
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ else Color.Transparent,
+ RoundedCornerShape(6.dp)
+ )
+ .padding(vertical = 6.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ if (iconBitmap != null) {
+ Image(
+ bitmap = iconBitmap,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp)
+ )
+ } else {
+ Icon(
+ Icons.Rounded.AccountCircle,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(app.name, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
+ Text(app.packageName, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall)
+ }
+
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onToggle() }
+ )
+ }
+}
+
+private data class AppInfo(
+ val packageName: String,
+ val name: String,
+ val isSystem: Boolean,
+ val isLauncher: Boolean
+)
+
+private fun Drawable.toBitmap(): Bitmap {
+ val bitmap = Bitmap.createBitmap(
+ if (intrinsicWidth > 0) intrinsicWidth else 48,
+ if (intrinsicHeight > 0) intrinsicHeight else 48,
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ setBounds(0, 0, canvas.width, canvas.height)
+ draw(canvas)
+ return bitmap
+}
diff --git a/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt b/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt
index 6687b39..a73f3ea 100644
--- a/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt
+++ b/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt
@@ -204,8 +204,32 @@ class SkirkVpnService : VpnService() {
.setConfigureIntent(configureIntent)
addLocalNetworkExclusions(builder)
- runCatching { builder.addDisallowedApplication(packageName) }
- .getOrElse { throw IllegalStateException("Could not exclude Skirk app from its VPN route", it) }
+ if (localProfile.splitTunnelingEnabled) {
+ if (localProfile.splitTunnelingMode == ClientProfile.SPLIT_TUNNEL_PROXY) {
+ localProfile.splitTunnelingApps.forEach { pkg ->
+ runCatching {
+ builder.addAllowedApplication(pkg)
+ }.onFailure { error ->
+ Log.w(TAG, "Could not add allowed application $pkg", error)
+ }
+ }
+ } else {
+ localProfile.splitTunnelingApps.forEach { pkg ->
+ if (pkg != packageName) {
+ runCatching {
+ builder.addDisallowedApplication(pkg)
+ }.onFailure { error ->
+ Log.w(TAG, "Could not add disallowed application $pkg", error)
+ }
+ }
+ }
+ runCatching { builder.addDisallowedApplication(packageName) }
+ .getOrElse { throw IllegalStateException("Could not exclude Skirk app from its VPN route", it) }
+ }
+ } else {
+ runCatching { builder.addDisallowedApplication(packageName) }
+ .getOrElse { throw IllegalStateException("Could not exclude Skirk app from its VPN route", it) }
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
builder.setUnderlyingNetworks(underlyingNetworks)
}