Skip to content

Commit e6e2b3c

Browse files
committed
Sync all local changes: move config key to native JNI, update Rust/Kotlin sources
- LockPasswordUtil: use native JNI for config encryption key instead of BuildConfig - Rust: add build.rs for config key obfuscation, update runtime/android/FFI sources - Update resolvers list, VPN service, scanner repository, and profile screens
1 parent 9aea3cb commit e6e2b3c

18 files changed

Lines changed: 315 additions & 37 deletions

File tree

‎app/src/main/AndroidManifest.xml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
<!-- Notification Permission (Android 13+) -->
1818
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1919

20+
<!-- Wake Lock to prevent CPU sleep during VPN (Doze mode) -->
21+
<uses-permission android:name="android.permission.WAKE_LOCK" />
22+
23+
<!-- Battery Optimization Exemption -->
24+
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
25+
2026
<!-- Split Tunneling: query all installed apps -->
2127
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
2228
tools:ignore="QueryAllPackagesPermission" />
@@ -56,6 +62,7 @@
5662
<category android:name="android.intent.category.DEFAULT" />
5763
<category android:name="android.intent.category.BROWSABLE" />
5864
<data android:scheme="slipnet" />
65+
<data android:scheme="slipnet-enc" />
5966
</intent-filter>
6067
</activity>
6168

‎app/src/main/java/app/slipnet/data/repository/ResolverScannerRepositoryImpl.kt‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ class ResolverScannerRepositoryImpl @Inject constructor(
138138
// First, do a basic connectivity check (against the parent zone)
139139
val basicCheck = withTimeoutOrNull(timeoutMs) {
140140
val randSub = generateRandomSubdomain()
141-
performDnsQuery(host, port, "$randSub.$parentDomain", DNS_TYPE_A)
141+
performDnsQuery(host, port, "$randSub.$parentDomain", DNS_TYPE_A, timeoutMs)
142142
}
143143

144144
if (basicCheck == null) {
@@ -205,7 +205,7 @@ class ResolverScannerRepositoryImpl @Inject constructor(
205205
val randSub = generateRandomSubdomain()
206206
val queryDomain = "$randSub.$testDomain"
207207
val result = withTimeoutOrNull(timeoutMs) {
208-
performDnsQuery(host, port, queryDomain, recordType)
208+
performDnsQuery(host, port, queryDomain, recordType, timeoutMs)
209209
}
210210
// Success if we got a response OR if we got NXDOMAIN/NOERROR
211211
// (NXDOMAIN is acceptable - means server queried properly)
@@ -229,7 +229,7 @@ class ResolverScannerRepositoryImpl @Inject constructor(
229229
val randSub2 = generateRandomSubdomain()
230230
val queryDomain = "$randSub1.$randSub2.$testDomain"
231231
val result = withTimeoutOrNull(timeoutMs) {
232-
performDnsQuery(host, port, queryDomain, DNS_TYPE_A)
232+
performDnsQuery(host, port, queryDomain, DNS_TYPE_A, timeoutMs)
233233
}
234234
// Any response (including NXDOMAIN) means the resolver processed the query
235235
result != null
@@ -330,7 +330,7 @@ class ResolverScannerRepositoryImpl @Inject constructor(
330330
for (ip in testNetIps) {
331331
try {
332332
val result = withTimeoutOrNull(timeoutMs) {
333-
performDnsQuery(ip, 53, testDomain, DNS_TYPE_A)
333+
performDnsQuery(ip, 53, testDomain, DNS_TYPE_A, timeoutMs)
334334
}
335335
if (result != null) {
336336
// A TEST-NET IP responded — ISP is intercepting DNS traffic
@@ -967,12 +967,13 @@ class ResolverScannerRepositoryImpl @Inject constructor(
967967
host: String,
968968
port: Int,
969969
domain: String,
970-
recordType: Int = DNS_TYPE_A
970+
recordType: Int = DNS_TYPE_A,
971+
timeoutMs: Long = 3000
971972
): DnsQueryResult = withContext(Dispatchers.IO) {
972973
var socket: DatagramSocket? = null
973974
try {
974975
socket = DatagramSocket()
975-
socket.soTimeout = 3000 // 3 second socket timeout
976+
socket.soTimeout = timeoutMs.toInt().coerceIn(500, 30000)
976977

977978
val dnsQuery = buildDnsQuery(domain, recordType)
978979
val serverAddress = InetAddress.getByName(host)
@@ -1315,7 +1316,7 @@ class ResolverScannerRepositoryImpl @Inject constructor(
13151316
// the delegation "t.example.com NS ns.example.com").
13161317
val nsDomain = getParentDomain(testDomain)
13171318
val nsResult = withTimeoutOrNull(timeoutMs) {
1318-
performDnsQuery(host, port, nsDomain, DNS_TYPE_NS)
1319+
performDnsQuery(host, port, nsDomain, DNS_TYPE_NS, timeoutMs)
13191320
} ?: return@withContext false
13201321

13211322
if (!nsResult.success || nsResult.rawResponse == null) return@withContext false
@@ -1327,7 +1328,7 @@ class ResolverScannerRepositoryImpl @Inject constructor(
13271328
// Resolve the first NS hostname (glue record) via A query
13281329
val nsHostname = nsHosts[0].trimEnd('.')
13291330
val glueResult = withTimeoutOrNull(timeoutMs) {
1330-
performDnsQuery(host, port, nsHostname, DNS_TYPE_A)
1331+
performDnsQuery(host, port, nsHostname, DNS_TYPE_A, timeoutMs)
13311332
} ?: return@withContext false
13321333

13331334
// Pass only if the A query succeeded and returned an IP

‎app/src/main/java/app/slipnet/data/repository/VpnRepositoryImpl.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,8 @@ class VpnRepositoryImpl @Inject constructor(
582582
gsoEnabled = profile.gsoEnabled,
583583
debugPoll = debugLogging,
584584
debugStreams = debugLogging,
585-
idlePollIntervalMs = 2000
585+
idlePollIntervalMs = 2000,
586+
idleTimeoutMs = 120000
586587
)
587588
if (result.isFailure) {
588589
val exception = result.exceptionOrNull()

‎app/src/main/java/app/slipnet/presentation/MainActivity.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
9191

9292
private fun handleDeepLink(intent: Intent?) {
9393
val uri = intent?.data?.toString()
94-
if (uri != null && uri.startsWith("slipnet://")) {
94+
if (uri != null && (uri.startsWith("slipnet://") || uri.startsWith("slipnet-enc://"))) {
9595
_deepLinkUri.value = uri
9696
}
9797
}

‎app/src/main/java/app/slipnet/presentation/navigation/NavGraph.kt‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ fun NavGraph(
5959
onNavigateBack = {
6060
navController.popBackStack()
6161
},
62-
onNavigateToScanner = {
63-
navController.navigate(NavRoutes.DnsScanner.createRoute(fromProfile = true))
62+
onNavigateToScanner = { savedId ->
63+
navController.navigate(NavRoutes.DnsScanner.createRoute(savedId, fromProfile = true))
6464
},
6565
selectedResolvers = selectedResolvers
6666
)
@@ -81,8 +81,8 @@ fun NavGraph(
8181
onNavigateBack = {
8282
navController.popBackStack()
8383
},
84-
onNavigateToScanner = {
85-
navController.navigate(NavRoutes.DnsScanner.createRoute(profileId, fromProfile = true))
84+
onNavigateToScanner = { savedId ->
85+
navController.navigate(NavRoutes.DnsScanner.createRoute(savedId ?: profileId, fromProfile = true))
8686
},
8787
selectedResolvers = selectedResolvers
8888
)

‎app/src/main/java/app/slipnet/presentation/profiles/EditProfileScreen.kt‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import app.slipnet.tunnel.DohServer
8383
fun EditProfileScreen(
8484
profileId: Long?,
8585
onNavigateBack: () -> Unit,
86-
onNavigateToScanner: (() -> Unit)? = null,
86+
onNavigateToScanner: ((Long?) -> Unit)? = null,
8787
selectedResolvers: String? = null,
8888
viewModel: EditProfileViewModel = hiltViewModel()
8989
) {
@@ -117,6 +117,14 @@ fun EditProfileScreen(
117117
}
118118
}
119119

120+
// Navigate to scanner after profile is saved
121+
LaunchedEffect(uiState.savedProfileIdForScanner) {
122+
uiState.savedProfileIdForScanner?.let { savedId ->
123+
viewModel.clearScannerNavigation()
124+
onNavigateToScanner?.invoke(savedId)
125+
}
126+
}
127+
120128
LaunchedEffect(uiState.error) {
121129
uiState.error?.let { error ->
122130
snackbarHostState.showSnackbar(error)
@@ -294,7 +302,7 @@ fun EditProfileScreen(
294302

295303
if (onNavigateToScanner != null) {
296304
OutlinedButton(
297-
onClick = onNavigateToScanner,
305+
onClick = { viewModel.saveForScanner() },
298306
modifier = Modifier.fillMaxWidth()
299307
) {
300308
Icon(Icons.Default.Search, contentDescription = null)
@@ -695,7 +703,7 @@ fun EditProfileScreen(
695703
// Scan Resolvers button
696704
if (onNavigateToScanner != null) {
697705
OutlinedButton(
698-
onClick = onNavigateToScanner,
706+
onClick = { viewModel.saveForScanner() },
699707
modifier = Modifier.fillMaxWidth()
700708
) {
701709
Icon(Icons.Default.Search, contentDescription = null)

‎app/src/main/java/app/slipnet/presentation/profiles/EditProfileViewModel.kt‎

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ data class EditProfileUiState(
9292
val isAutoDetecting: Boolean = false,
9393
val saveSuccess: Boolean = false,
9494
val showRestartVpnMessage: Boolean = false,
95+
val savedProfileIdForScanner: Long? = null,
9596
val error: String? = null,
9697
val nameError: String? = null,
9798
val domainError: String? = null,
@@ -887,9 +888,28 @@ class EditProfileViewModel @Inject constructor(
887888

888889

889890
fun save() {
890-
val state = _uiState.value
891+
if (!validateProfile()) return
892+
persistProfile(forScanner = false)
893+
}
891894

892-
// Validation
895+
/**
896+
* Save the profile and signal navigation to the DNS scanner.
897+
* If validation fails, errors are shown on the form fields.
898+
*/
899+
fun saveForScanner() {
900+
if (!validateProfile()) return
901+
persistProfile(forScanner = true)
902+
}
903+
904+
fun clearScannerNavigation() {
905+
_uiState.value = _uiState.value.copy(savedProfileIdForScanner = null)
906+
}
907+
908+
/**
909+
* Validate the current profile form. Sets field errors and returns false if invalid.
910+
*/
911+
private fun validateProfile(): Boolean {
912+
val state = _uiState.value
893913
var hasError = false
894914

895915
if (state.name.isBlank()) {
@@ -1003,7 +1023,15 @@ class EditProfileViewModel @Inject constructor(
10031023
}
10041024
}
10051025

1006-
if (hasError) return
1026+
return !hasError
1027+
}
1028+
1029+
/**
1030+
* Persist the validated profile. If [forScanner] is true, signals scanner navigation
1031+
* instead of navigating back.
1032+
*/
1033+
private fun persistProfile(forScanner: Boolean) {
1034+
val state = _uiState.value
10071035

10081036
viewModelScope.launch {
10091037
_uiState.value = _uiState.value.copy(isSaving = true)
@@ -1047,16 +1075,24 @@ class EditProfileViewModel @Inject constructor(
10471075
val savedId = saveProfileUseCase(profile)
10481076
setActiveProfileUseCase(savedId)
10491077

1050-
// Check if VPN is currently connected to this profile
1051-
val connState = connectionManager.connectionState.value
1052-
val isVpnActive = connState is ConnectionState.Connected ||
1053-
connState is ConnectionState.Connecting
1078+
if (forScanner) {
1079+
_uiState.value = _uiState.value.copy(
1080+
isSaving = false,
1081+
profileId = savedId,
1082+
savedProfileIdForScanner = savedId
1083+
)
1084+
} else {
1085+
// Check if VPN is currently connected to this profile
1086+
val connState = connectionManager.connectionState.value
1087+
val isVpnActive = connState is ConnectionState.Connected ||
1088+
connState is ConnectionState.Connecting
10541089

1055-
_uiState.value = _uiState.value.copy(
1056-
isSaving = false,
1057-
saveSuccess = true,
1058-
showRestartVpnMessage = isVpnActive
1059-
)
1090+
_uiState.value = _uiState.value.copy(
1091+
isSaving = false,
1092+
saveSuccess = true,
1093+
showRestartVpnMessage = isVpnActive
1094+
)
1095+
}
10601096
} catch (e: Exception) {
10611097
_uiState.value = _uiState.value.copy(
10621098
isSaving = false,

‎app/src/main/java/app/slipnet/service/SlipNetVpnService.kt‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import android.net.ProxyInfo
1313
import android.net.VpnService
1414
import android.os.Build
1515
import android.os.ParcelFileDescriptor
16+
import android.os.PowerManager
1617
import app.slipnet.util.AppLog as Log
1718
import app.slipnet.data.local.datastore.PreferencesDataStore
1819
import app.slipnet.data.local.datastore.SplitTunnelingMode
@@ -125,6 +126,8 @@ class SlipNetVpnService : VpnService() {
125126
// Persistence for service resilience
126127
private lateinit var prefs: SharedPreferences
127128

129+
// WakeLock to prevent CPU sleep during VPN (Doze mode kills QUIC keep-alives)
130+
private var wakeLock: PowerManager.WakeLock? = null
128131

129132
override fun onCreate() {
130133
super.onCreate()
@@ -238,6 +241,22 @@ class SlipNetVpnService : VpnService() {
238241
val notification = notificationHelper.createVpnNotification(ConnectionState.Connecting)
239242
startForeground(NotificationHelper.VPN_NOTIFICATION_ID, notification)
240243

244+
// Acquire WakeLock to prevent CPU sleep (Doze mode kills QUIC keep-alives)
245+
if (wakeLock == null) {
246+
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
247+
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SlipNet:VpnWakeLock").apply {
248+
setReferenceCounted(false)
249+
acquire()
250+
}
251+
Log.d(TAG, "WakeLock acquired")
252+
}
253+
254+
// Warn if battery optimization is not disabled
255+
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
256+
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
257+
Log.w(TAG, "Battery optimization is enabled - VPN may be interrupted by Doze mode")
258+
}
259+
241260
try {
242261
// Read proxy-only mode setting
243262
isProxyOnly = preferencesDataStore.proxyOnlyMode.first()
@@ -3020,6 +3039,15 @@ class SlipNetVpnService : VpnService() {
30203039
private suspend fun cleanupConnection() {
30213040
Log.d(TAG, "Cleaning up connection resources")
30223041

3042+
// Release WakeLock
3043+
wakeLock?.let {
3044+
if (it.isHeld) {
3045+
it.release()
3046+
Log.d(TAG, "WakeLock released")
3047+
}
3048+
}
3049+
wakeLock = null
3050+
30233051
// Stop health monitoring
30243052
healthCheckJob?.cancel()
30253053
healthCheckJob = null
@@ -3095,9 +3123,38 @@ class SlipNetVpnService : VpnService() {
30953123
}
30963124
}
30973125

3126+
override fun onTaskRemoved(rootIntent: Intent?) {
3127+
super.onTaskRemoved(rootIntent)
3128+
Log.i(TAG, "Task removed (app swiped from recents)")
3129+
3130+
// If VPN is active, save state and re-deliver start intent to keep running
3131+
if (currentProfileId != -1L) {
3132+
saveConnectionState(currentProfileId, true)
3133+
val restartIntent = Intent(this, SlipNetVpnService::class.java).apply {
3134+
action = ACTION_CONNECT
3135+
putExtra(EXTRA_PROFILE_ID, currentProfileId)
3136+
}
3137+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
3138+
startForegroundService(restartIntent)
3139+
} else {
3140+
startService(restartIntent)
3141+
}
3142+
Log.i(TAG, "Re-delivered start intent for profile $currentProfileId")
3143+
}
3144+
}
3145+
30983146
override fun onDestroy() {
30993147
Log.i(TAG, "Service onDestroy")
31003148

3149+
// Safety net: release WakeLock if still held
3150+
wakeLock?.let {
3151+
if (it.isHeld) {
3152+
it.release()
3153+
Log.d(TAG, "WakeLock released (onDestroy safety net)")
3154+
}
3155+
}
3156+
wakeLock = null
3157+
31013158
// Capture state before cleanup resets currentProfileId to -1
31023159
val wasActive = currentProfileId != -1L
31033160
val profileName = currentProfileName
@@ -3136,6 +3193,15 @@ class SlipNetVpnService : VpnService() {
31363193
private fun cleanupConnectionSync() {
31373194
Log.d(TAG, "Quick cleanup (sync)")
31383195

3196+
// Release WakeLock
3197+
wakeLock?.let {
3198+
if (it.isHeld) {
3199+
it.release()
3200+
Log.d(TAG, "WakeLock released (sync)")
3201+
}
3202+
}
3203+
wakeLock = null
3204+
31393205
// Stop health monitoring
31403206
healthCheckJob?.cancel()
31413207
healthCheckJob = null

0 commit comments

Comments
 (0)