From d8fa8c575853a1ae9579738872baa7de922e313d Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Wed, 18 Feb 2026 17:56:48 -0600 Subject: [PATCH 01/10] First pass for android --- android/app/src/main/AndroidManifest.xml | 2 +- .../defined/mobile_nebula/NebulaVpnService.kt | 45 +++++++++++++------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d798e98..b0187f37 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ + android:value="true"/> ().build() workManager!!.enqueue(workRequest) + lastActiveSiteFile.writeText(sitePath) + site = startSite + path = sitePath + // We don't actually start here. In order to properly capture boot errors we wait until an IPC connection is made return super.onStartCommand(intent, flags, startId) } From 0e155b6e01f762c8fdf0f04ec9c51447e2d732c0 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Wed, 18 Feb 2026 22:08:38 -0600 Subject: [PATCH 02/10] First pass for ios --- ios/NebulaNetworkExtension/Site.swift | 10 +++ lib/components/site_item.dart | 117 +++++++++++++++++--------- lib/models/site.dart | 6 ++ 3 files changed, 93 insertions(+), 40 deletions(-) diff --git a/ios/NebulaNetworkExtension/Site.swift b/ios/NebulaNetworkExtension/Site.swift index 97a64a0a..32531426 100644 --- a/ios/NebulaNetworkExtension/Site.swift +++ b/ios/NebulaNetworkExtension/Site.swift @@ -164,6 +164,7 @@ class Site: Codable { var connected: Bool? //TODO: active is a better name var status: String? var logFile: String? + var alwaysOn: Bool var managed: Bool var dnsResolvers: [String] // The following fields are present if managed = true @@ -188,6 +189,7 @@ class Site: Codable { self.manager = manager self.connected = statusMap[manager.connection.status] self.status = statusString[manager.connection.status] + self.alwaysOn = manager.isOnDemandEnabled } convenience init(proto: NETunnelProviderProtocol) throws { @@ -237,6 +239,7 @@ class Site: Codable { lastManagedUpdate = incoming.lastManagedUpdate dnsResolvers = incoming.dnsResolvers ?? [] rawConfig = incoming.rawConfig + alwaysOn = incoming.alwaysOn ?? false // Default these to disconnected for the UI status = statusString[.disconnected] @@ -385,6 +388,7 @@ class Site: Codable { case lastManagedUpdate case dnsResolvers case rawConfig + case alwaysOn } } @@ -444,6 +448,7 @@ struct IncomingSite: Codable { var key: String? var managed: Bool? var dnsResolvers: [String]? + var alwaysOn: Bool? // The following fields are present if managed = true var dnCredentials: DNCredentials? var lastManagedUpdate: String? @@ -545,6 +550,11 @@ struct IncomingSite: Codable { //TODO: This is what is shown on the vpn page. We should add more identifying details in manager.localizedDescription = self.name manager.isEnabled = true + + manager.isOnDemandEnabled = self.alwaysOn == true + let rule = NEOnDemandRuleConnect() + rule.interfaceTypeMatch = .any + manager.onDemandRules = [rule] manager.saveToPreferences { error in return callback(error) diff --git a/lib/components/site_item.dart b/lib/components/site_item.dart index f38c9a95..637d91b9 100644 --- a/lib/components/site_item.dart +++ b/lib/components/site_item.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:mobile_nebula/models/site.dart'; import 'package:mobile_nebula/services/utils.dart'; @@ -6,12 +8,17 @@ import 'config/config_item.dart'; import 'config/config_page_item.dart'; import 'config/config_section.dart'; -class SiteItem extends StatelessWidget { +class SiteItem extends StatefulWidget { const SiteItem({super.key, required this.site, this.onPressed}); final Site site; final void Function()? onPressed; + @override + State createState() => _SiteItemState(); +} + +class _SiteItemState extends State { @override Widget build(BuildContext context) { return _buildContent(context); @@ -23,9 +30,9 @@ class SiteItem extends StatelessWidget { List children = []; // Add the name - children.add(TextSpan(text: site.name, style: nameStyle)); + children.add(TextSpan(text: widget.site.name, style: nameStyle)); - if (site.managed) { + if (widget.site.managed) { // Toss some space in children.add(TextSpan(text: ' ', style: nameStyle)); @@ -50,7 +57,7 @@ class SiteItem extends StatelessWidget { Widget _siteStatusWidget(BuildContext context) { final grayTextColor = Theme.of(context).colorScheme.onSecondaryContainer; var fontStyle = TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500); - if (site.errors.isNotEmpty) { + if (widget.site.errors.isNotEmpty) { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -61,56 +68,86 @@ class SiteItem extends StatelessWidget { ); } - return Text(site.status, style: fontStyle); + return Text(widget.site.status, style: fontStyle); } Widget _buildContent(BuildContext context) { - void handleChange(v) async { - try { - if (v) { - await site.start(); - } else { - await site.stop(); - } - } catch (error) { - var action = v ? 'start' : 'stop'; - Utils.popError('Failed to $action the site', error.toString()); - } - } - final grayTextColor = Theme.of(context).colorScheme.onSecondaryContainer; - return ConfigSection( - children: [ + List children = []; + children.add( + ConfigItem( + labelWidth: 0, + padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16), + content: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_siteNameWidget(context), Container(height: 4), _siteStatusWidget(context)], + ), + ), + Switch.adaptive( + value: widget.site.connected, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: widget.site.errors.isNotEmpty && !widget.site.connected ? null : toggleSite, + ), + ], + ), + ), + ); + + if (Platform.isIOS || Platform.isMacOS) { + children.add( ConfigItem( - labelWidth: 0, - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16), + label: Text( + 'Always-on', + style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500), + ), content: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [_siteNameWidget(context), Container(height: 4), _siteStatusWidget(context)], - ), - ), + mainAxisAlignment: MainAxisAlignment.end, + children: [ Switch.adaptive( - value: site.connected, + value: widget.site.alwaysOn, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onChanged: site.errors.isNotEmpty && !site.connected ? null : handleChange, + onChanged: (val) async { + widget.site.alwaysOn = val; + await widget.site.save(); + setState(() {}); + }, ), ], ), ), - ConfigPageItem( - padding: EdgeInsets.fromLTRB(16, 8, 16, 16), - label: Text( - 'Details', - style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500), - ), - onPressed: onPressed, + ); + } + + children.add( + ConfigPageItem( + padding: EdgeInsets.fromLTRB(16, 8, 16, 16), + label: Text( + 'Details', + style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500), ), - ], + onPressed: widget.onPressed, + ), ); + + return ConfigSection(children: children); + } + + void toggleSite(bool val) async { + try { + if (val) { + await widget.site.start(); + } else { + await widget.site.stop(); + } + setState(() {}); + } catch (error) { + var action = val ? 'start' : 'stop'; + Utils.popError('Failed to $action the site', error.toString()); + } } } diff --git a/lib/models/site.dart b/lib/models/site.dart index 2538926b..dbc5c830 100644 --- a/lib/models/site.dart +++ b/lib/models/site.dart @@ -55,6 +55,7 @@ class Site { late String status; late String logFile; late String logVerbosity; + late bool alwaysOn; late List dnsResolvers; late bool managed; @@ -85,6 +86,7 @@ class Site { this.managed = false, this.rawConfig, this.lastManagedUpdate, + this.alwaysOn = false, List? dnsResolvers, }) { this.id = id ?? uuid.v4(); @@ -141,6 +143,7 @@ class Site { rawConfig: decoded['rawConfig'], lastManagedUpdate: decoded['lastManagedUpdate'], dnsResolvers: decoded['dnsResolvers'], + alwaysOn: decoded['alwaysOn'], ); } @@ -186,6 +189,7 @@ class Site { rawConfig = decoded['rawConfig']; lastManagedUpdate = decoded['lastManagedUpdate']; dnsResolvers = decoded['dnsResolvers']; + alwaysOn = decoded['alwaysOn']; } static Map _fromJson(Map json) { @@ -245,6 +249,7 @@ class Site { "rawConfig": json['rawConfig'], "lastManagedUpdate": json["lastManagedUpdate"] == null ? null : DateTime.parse(json["lastManagedUpdate"]), "dnsResolvers": dnsResolvers, + "alwaysOn": json['alwaysOn'] ?? false, }; } @@ -274,6 +279,7 @@ class Site { 'managed': managed, 'rawConfig': rawConfig, 'dnsResolvers': dnsResolvers, + 'alwaysOn': alwaysOn, }; } From 9b7e25e53521a27eafa49497836cd08ad4364836 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Wed, 18 Feb 2026 22:10:57 -0600 Subject: [PATCH 03/10] swiftfmt --- ios/NebulaNetworkExtension/Site.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/NebulaNetworkExtension/Site.swift b/ios/NebulaNetworkExtension/Site.swift index 32531426..31a9bada 100644 --- a/ios/NebulaNetworkExtension/Site.swift +++ b/ios/NebulaNetworkExtension/Site.swift @@ -550,7 +550,7 @@ struct IncomingSite: Codable { //TODO: This is what is shown on the vpn page. We should add more identifying details in manager.localizedDescription = self.name manager.isEnabled = true - + manager.isOnDemandEnabled = self.alwaysOn == true let rule = NEOnDemandRuleConnect() rule.interfaceTypeMatch = .any From 154f4d9a53bce447478c9eb74c64131572361fb3 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 19 Feb 2026 12:02:03 -0600 Subject: [PATCH 04/10] Rework ios and add a deep link for android to settings --- .../net/defined/mobile_nebula/MainActivity.kt | 8 ++++ ios/Runner/AppDelegate.swift | 7 ++- ios/Runner/Sites.swift | 45 ++++++++++--------- lib/components/site_item.dart | 27 ----------- lib/screens/site_detail_screen.dart | 40 +++++++++++++++++ 5 files changed, 78 insertions(+), 49 deletions(-) diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt index ca669a7f..e0fdfc28 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -9,6 +9,7 @@ import android.content.ServiceConnection import android.content.pm.PackageManager import android.net.VpnService import android.os.* +import android.provider.Settings import android.util.Log import androidx.core.content.ContextCompat import androidx.work.* @@ -66,6 +67,7 @@ class MainActivity: FlutterActivity() { when(call.method) { "android.registerActiveSite" -> registerActiveSite(result) "android.deviceHasCamera" -> deviceHasCamera(result) + "android.openVpnSettings" -> openVpnSettings(result) "nebula.parseCerts" -> nebulaParseCerts(call, result) "nebula.generateKeyPair" -> nebulaGenerateKeyPair(result) @@ -134,6 +136,12 @@ class MainActivity: FlutterActivity() { result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) } + private fun openVpnSettings(result: MethodChannel.Result) { + val intent = Intent(Settings.ACTION_VPN_SETTINGS) + startActivity(intent) + result.success(null) + } + private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) { val certs = call.argument("certs") if (certs == "") { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5ea07705..b52e4d1b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -262,10 +262,13 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { updater?.update(connected: false) #else - let manager = self.sites?.getSite(id: id)?.manager + let container = self.sites?.getContainer(id: id) + let manager = container?.site.manager + manager?.loadFromPreferences { error in //TODO: Handle load error - + manager?.isOnDemandEnabled = false + manager?.saveToPreferences() manager?.connection.stopVPNTunnel() return result(nil) } diff --git a/ios/Runner/Sites.swift b/ios/Runner/Sites.swift index ed7b198d..f4d12559 100644 --- a/ios/Runner/Sites.swift +++ b/ios/Runner/Sites.swift @@ -82,7 +82,6 @@ class SiteUpdater: NSObject, FlutterStreamHandler { private var eventSink: FlutterEventSink? private var eventChannel: FlutterEventChannel private var site: Site - private var notification: Any? public var startFunc: (() -> Void)? private var configFd: Int32? = nil private var configObserver: (any DispatchSourceFileSystemObject)? = nil @@ -137,31 +136,37 @@ class SiteUpdater: NSObject, FlutterStreamHandler { code: "Internal Error", message: "Flutter manager was not present", details: nil) } - self.notification = NotificationCenter.default.addObserver( - forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection, - queue: nil - ) { n in - let oldConnected = self.site.connected - self.site.status = statusString[self.site.manager!.connection.status] - self.site.connected = statusMap[self.site.manager!.connection.status] - - // Check to see if we just moved to connected and if we have a start function to call when that happens - if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil { - self.startFunc!() - self.startFunc = nil - } - - self.update(connected: self.site.connected!) - } + NotificationCenter.default.addObserver( + self, selector: #selector(onNotification), + name: NSNotification.Name.NEVPNConfigurationChange, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(onNotification), name: NSNotification.Name.NEVPNStatusDidChange, + object: nil) #endif return nil } + @objc func onNotification(n: Notification) { + let oldConnected = self.site.connected + self.site.status = statusString[self.site.manager!.connection.status] + self.site.connected = statusMap[self.site.manager!.connection.status] + self.site.alwaysOn = self.site.manager?.isOnDemandEnabled == true + + // Check to see if we just moved to connected and if we have a start function to call when that happens + if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil { + self.startFunc!() + self.startFunc = nil + } + + self.update(connected: self.site.connected!) + } + /// onCancel is called when the flutter listener stops listening func onCancel(withArguments arguments: Any?) -> FlutterError? { - if self.notification != nil { - NotificationCenter.default.removeObserver(self.notification!) - } + NotificationCenter.default.removeObserver( + self, name: NSNotification.Name.NEVPNConfigurationChange, object: nil) + NotificationCenter.default.removeObserver( + self, name: NSNotification.Name.NEVPNStatusDidChange, object: nil) return nil } diff --git a/lib/components/site_item.dart b/lib/components/site_item.dart index 637d91b9..818d5dd6 100644 --- a/lib/components/site_item.dart +++ b/lib/components/site_item.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:mobile_nebula/models/site.dart'; import 'package:mobile_nebula/services/utils.dart'; @@ -98,31 +96,6 @@ class _SiteItemState extends State { ), ); - if (Platform.isIOS || Platform.isMacOS) { - children.add( - ConfigItem( - label: Text( - 'Always-on', - style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500), - ), - content: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Switch.adaptive( - value: widget.site.alwaysOn, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onChanged: (val) async { - widget.site.alwaysOn = val; - await widget.site.save(); - setState(() {}); - }, - ), - ], - ), - ), - ); - } - children.add( ConfigPageItem( padding: EdgeInsets.fromLTRB(16, 8, 16, 16), diff --git a/lib/screens/site_detail_screen.dart b/lib/screens/site_detail_screen.dart index adbd0b3c..23c3691a 100644 --- a/lib/screens/site_detail_screen.dart +++ b/lib/screens/site_detail_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -105,6 +106,7 @@ class SiteDetailScreenState extends State { _buildConfig(), site.connected ? _buildHosts() : Container(), _buildSiteDetails(), + _buildAlwaysOn(), _buildDelete(), ], ), @@ -271,6 +273,44 @@ class SiteDetailScreenState extends State { ); } + Widget _buildAlwaysOn() { + if (Platform.isAndroid) { + return ConfigSection( + children: [ + ConfigPageItem( + crossAxisAlignment: CrossAxisAlignment.center, + content: Text('Enable always-on'), + onPressed: () async => await platform.invokeMethod('android.openVpnSettings'), + ), + ], + ); + } else if (Platform.isIOS || Platform.isMacOS) { + return ConfigSection( + children: [ + ConfigItem( + label: Text('Always-on'), + content: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Switch.adaptive( + value: widget.site.alwaysOn, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (val) async { + widget.site.alwaysOn = val; + await widget.site.save(); + setState(() {}); + }, + ), + ], + ), + ), + ], + ); + } + + return Container(); + } + Widget _buildDelete() { final outerContext = context; return Padding( From d15814f27ab2619f29e1840e3386d34ed130fcce Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 19 Feb 2026 12:53:41 -0600 Subject: [PATCH 05/10] Review notes --- ios/Runner/AppDelegate.swift | 5 +++-- ios/Runner/Sites.swift | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b52e4d1b..1224bb4a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -268,8 +268,9 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { manager?.loadFromPreferences { error in //TODO: Handle load error manager?.isOnDemandEnabled = false - manager?.saveToPreferences() - manager?.connection.stopVPNTunnel() + manager?.saveToPreferences { _ in + manager?.connection.stopVPNTunnel() + } return result(nil) } #endif diff --git a/ios/Runner/Sites.swift b/ios/Runner/Sites.swift index f4d12559..2686b01d 100644 --- a/ios/Runner/Sites.swift +++ b/ios/Runner/Sites.swift @@ -138,16 +138,17 @@ class SiteUpdater: NSObject, FlutterStreamHandler { NotificationCenter.default.addObserver( self, selector: #selector(onNotification), - name: NSNotification.Name.NEVPNConfigurationChange, object: nil) + name: NSNotification.Name.NEVPNConfigurationChange, object: site.manager) NotificationCenter.default.addObserver( self, selector: #selector(onNotification), name: NSNotification.Name.NEVPNStatusDidChange, - object: nil) + object: site.manager!.connection) #endif return nil } @objc func onNotification(n: Notification) { let oldConnected = self.site.connected + self.site.status = statusString[self.site.manager!.connection.status] self.site.connected = statusMap[self.site.manager!.connection.status] self.site.alwaysOn = self.site.manager?.isOnDemandEnabled == true From 92738d26604ee5a704d2b8d5b55d4f2394bf1339 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 19 Feb 2026 15:44:57 -0600 Subject: [PATCH 06/10] Rework android to maybe a bit more pleasant --- .../net/defined/mobile_nebula/MainActivity.kt | 38 +++++++++++- .../defined/mobile_nebula/NebulaVpnService.kt | 18 ++++-- .../kotlin/net/defined/mobile_nebula/Sites.kt | 28 +++++++++ lib/screens/site_detail_screen.dart | 61 +++++++++++++++++-- 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt index e0fdfc28..53d0d78f 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -45,6 +45,7 @@ class MainActivity: FlutterActivity() { private var startingSiteContainer: SiteContainer? = null private var activeSiteId: String? = null + private var onStopCallback: (() -> Unit)? = null private lateinit var workManager: WorkManager private val refreshReceiver: BroadcastReceiver = RefreshReceiver() @@ -68,6 +69,7 @@ class MainActivity: FlutterActivity() { "android.registerActiveSite" -> registerActiveSite(result) "android.deviceHasCamera" -> deviceHasCamera(result) "android.openVpnSettings" -> openVpnSettings(result) + "android.isAlwaysOnEnabled" -> isAlwaysOnEnabled(result) "nebula.parseCerts" -> nebulaParseCerts(call, result) "nebula.generateKeyPair" -> nebulaGenerateKeyPair(result) @@ -142,6 +144,11 @@ class MainActivity: FlutterActivity() { result.success(null) } + private fun isAlwaysOnEnabled(result: MethodChannel.Result) { + val alwaysOnApp = Settings.Secure.getString(contentResolver, "always_on_vpn_app") + result.success(alwaysOnApp == packageName) + } + private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) { val certs = call.argument("certs") if (certs == "") { @@ -243,6 +250,16 @@ class MainActivity: FlutterActivity() { sites?.refreshSites() + if (site.alwaysOn == true) { + if (activeSiteId != null && activeSiteId != site.id) { + stopSite { sites?.getSite(site.id)?.let { startSiteDirectly(it) } } + } else if (activeSiteId != site.id) { + sites?.getSite(site.id)?.let { startSiteDirectly(it) } + } + } + + sites?.updateAll() + result.success(null) } @@ -261,6 +278,22 @@ class MainActivity: FlutterActivity() { return true } + private fun startSiteDirectly(siteContainer: SiteContainer) { + if (VpnService.prepare(this) != null) { + // VPN permission not granted; cannot start without user interaction + return + } + siteContainer.updater.setState(true, "Initializing...") + val intent = Intent(this, NebulaVpnService::class.java).apply { + putExtra("path", siteContainer.site.path) + putExtra("id", siteContainer.site.id) + } + startService(intent) + if (outMessenger == null) { + bindService(intent, connection, 0) + } + } + private fun startSite(call: MethodCall, result: MethodChannel.Result) { val id = call.argument("id") if (id == "") { @@ -279,7 +312,8 @@ class MainActivity: FlutterActivity() { } } - private fun stopSite() { + private fun stopSite(onStopped: (() -> Unit)? = null) { + onStopCallback = onStopped val intent = Intent(this, NebulaVpnService::class.java).apply { action = NebulaVpnService.ACTION_STOP } @@ -538,6 +572,8 @@ class MainActivity: FlutterActivity() { // a site while another is actively running. activeSiteId = null unbindVpnService() + onStopCallback?.invoke() + onStopCallback = null } } } diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt index 551d695b..4247be8e 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt @@ -63,14 +63,19 @@ class NebulaVpnService : VpnService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { stopVpn() - return Service.START_NOT_STICKY + return START_NOT_STICKY } - val lastActiveSiteFile = filesDir.resolve("last-active-site"); + var autoStart = false var sitePath: String? = null try { - sitePath = intent?.getStringExtra("path") ?: lastActiveSiteFile.readText() - } catch (err: Exception) { + sitePath = intent?.getStringExtra("path") + if (sitePath == null) { + // If the UI is starting us we expect a path, if there is no path then we expect the system is starting us + autoStart = true + sitePath = filesDir.resolve("always-on-site").readText() + } + } catch (_: Exception) { // Ignore errors } if (sitePath.isNullOrEmpty()) { @@ -95,6 +100,7 @@ class NebulaVpnService : VpnService() { if (site!!.id != startSite.id) { announceExit(startSite.id, "Trying to run nebula but it is already running") } + return super.onStartCommand(intent, flags, startId) } @@ -113,10 +119,12 @@ class NebulaVpnService : VpnService() { val workRequest = OneTimeWorkRequestBuilder().build() workManager!!.enqueue(workRequest) - lastActiveSiteFile.writeText(sitePath) site = startSite path = sitePath + if (autoStart) { + startVpn() + } // We don't actually start here. In order to properly capture boot errors we wait until an IPC connection is made return super.onStartCommand(intent, flags, startId) } diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt index 088c883a..e94ff67f 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt @@ -1,6 +1,7 @@ package net.defined.mobile_nebula import android.content.Context +import android.provider.Settings import android.util.Log import com.google.gson.Gson import com.google.gson.annotations.SerializedName @@ -48,10 +49,19 @@ class Sites(private var engine: FlutterEngine) { return containers.mapValues { it.value.site } } + fun updateAll() { + containers.values.forEach { it.updater.notifyChanged() } + } + fun deleteSite(id: String) { val context = MainActivity.getContext()!! val site = containers[id]!!.site + val alwaysOnFile = context.filesDir.resolve("always-on-site") + if (alwaysOnFile.exists() && alwaysOnFile.readText() == site.path) { + alwaysOnFile.delete() + } + val baseDir = if(site.managed) context.noBackupFilesDir else context.filesDir val siteDir = baseDir.resolve("sites").resolve(id) siteDir.deleteRecursively() @@ -132,6 +142,10 @@ class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.S } } + fun notifyChanged() { + eventSink?.success(gson.toJson(site)) + } + init { eventChannel.setStreamHandler(this) } @@ -213,6 +227,7 @@ class Site(context: Context, siteDir: File) { // The following fields are present when managed = true val rawConfig: String? val lastManagedUpdate: String? + val alwaysOn: Boolean // Path to this site on disk @Transient @@ -247,6 +262,9 @@ class Site(context: Context, siteDir: File) { connected = false status = "Disconnected" + val alwaysOnPath = try { context.filesDir.resolve("always-on-site").readText() } catch (_: Exception) { null } + alwaysOn = alwaysOnPath == path + try { val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert) val certs = gson.fromJson(rawDetails, Array::class.java) @@ -352,6 +370,7 @@ class IncomingSite( val lastManagedUpdate: String?, val rawConfig: String?, var dnCredentials: DNCredentials?, + val alwaysOn: Boolean?, ) { fun save(context: Context): File { // Don't allow backups of DN-managed sites @@ -373,6 +392,15 @@ class IncomingSite( dnCredentials?.save(context, siteDir) dnCredentials = null + val alwaysOnFile = context.filesDir.resolve("always-on-site") + when (alwaysOn) { + true -> alwaysOnFile.writeText(siteDir.absolutePath) + false -> if (alwaysOnFile.exists() && alwaysOnFile.readText() == siteDir.absolutePath) { + alwaysOnFile.delete() + } + null -> {} + } + val confFile = siteDir.resolve("config.json") confFile.writeText(Gson().toJson(this)) diff --git a/lib/screens/site_detail_screen.dart b/lib/screens/site_detail_screen.dart index 23c3691a..c3dec25e 100644 --- a/lib/screens/site_detail_screen.dart +++ b/lib/screens/site_detail_screen.dart @@ -275,12 +275,65 @@ class SiteDetailScreenState extends State { Widget _buildAlwaysOn() { if (Platform.isAndroid) { + // return ConfigSection( + // children: [ + // ConfigPageItem( + // crossAxisAlignment: CrossAxisAlignment.center, + // content: Text('Enable always-on'), + // onPressed: () async => await platform.invokeMethod('android.openVpnSettings'), + // ), + // ], + // ); return ConfigSection( children: [ - ConfigPageItem( - crossAxisAlignment: CrossAxisAlignment.center, - content: Text('Enable always-on'), - onPressed: () async => await platform.invokeMethod('android.openVpnSettings'), + ConfigItem( + label: Text('Always-on'), + content: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Switch.adaptive( + value: widget.site.alwaysOn, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (val) async { + widget.site.alwaysOn = val; + try { + await widget.site.save(); + } catch (e) { + Utils.popError('Failed to update always-on', e.toString()); + widget.site.alwaysOn = !val; + setState(() {}); + return; + } + setState(() {}); + if (val && context.mounted) { + final bool isEnabled = await platform.invokeMethod('android.isAlwaysOnEnabled'); + if (!isEnabled && context.mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Enable Always-On VPN'), + content: Text('To complete setup, enable Always-On VPN for Nebula in Android\'s VPN settings.'), + actions: [ + TextButton( + child: Text('Cancel'), + onPressed: () => Navigator.pop(context), + ), + TextButton( + child: Text('OK'), + onPressed: () async { + Navigator.pop(context); + await platform.invokeMethod('android.openVpnSettings'); + }, + ), + ], + ), + ); + } + } + }, + ), + ], + ), ), ], ); From 87027eabe20d603f6296d472019b25357c5ea6e8 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 19 Feb 2026 16:08:11 -0600 Subject: [PATCH 07/10] Add John's fun button --- lib/screens/site_detail_screen.dart | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/lib/screens/site_detail_screen.dart b/lib/screens/site_detail_screen.dart index c3dec25e..4fc67afe 100644 --- a/lib/screens/site_detail_screen.dart +++ b/lib/screens/site_detail_screen.dart @@ -275,15 +275,6 @@ class SiteDetailScreenState extends State { Widget _buildAlwaysOn() { if (Platform.isAndroid) { - // return ConfigSection( - // children: [ - // ConfigPageItem( - // crossAxisAlignment: CrossAxisAlignment.center, - // content: Text('Enable always-on'), - // onPressed: () async => await platform.invokeMethod('android.openVpnSettings'), - // ), - // ], - // ); return ConfigSection( children: [ ConfigItem( @@ -312,12 +303,11 @@ class SiteDetailScreenState extends State { context: context, builder: (context) => AlertDialog( title: Text('Enable Always-On VPN'), - content: Text('To complete setup, enable Always-On VPN for Nebula in Android\'s VPN settings.'), + content: Text( + 'To complete setup, enable Always-On VPN for Nebula in Android\'s VPN settings.', + ), actions: [ - TextButton( - child: Text('Cancel'), - onPressed: () => Navigator.pop(context), - ), + TextButton(child: Text('Cancel'), onPressed: () => Navigator.pop(context)), TextButton( child: Text('OK'), onPressed: () async { @@ -335,6 +325,12 @@ class SiteDetailScreenState extends State { ], ), ), + ConfigPageItem( + content: Text('Verify system always-on is enabled'), + onPressed: () { + platform.invokeMethod('android.openVpnSettings'); + }, + ), ], ); } else if (Platform.isIOS || Platform.isMacOS) { From f75c8d71d00160141239c7b54965ba3f65b0f684 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 19 Feb 2026 16:51:15 -0600 Subject: [PATCH 08/10] Playing now --- .../defined/mobile_nebula/NebulaVpnService.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt index 4247be8e..1ccb7ab3 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt @@ -79,8 +79,9 @@ class NebulaVpnService : VpnService() { // Ignore errors } if (sitePath.isNullOrEmpty()) { - Log.e(TAG, "Could not find site path in intent or last-active-site file") - return super.onStartCommand(intent, flags, startId) + Log.e(TAG, "Could not find site path in intent or always-on-site file") + stopSelf(startId) + return START_NOT_STICKY } var startSite: Site? = null @@ -91,7 +92,8 @@ class NebulaVpnService : VpnService() { } if (startSite == null) { Log.e(TAG, "Could not get site details from: $sitePath") - return super.onStartCommand(intent, flags, startId) + stopSelf(startId) + return START_NOT_STICKY } if (running) { @@ -101,18 +103,20 @@ class NebulaVpnService : VpnService() { announceExit(startSite.id, "Trying to run nebula but it is already running") } - return super.onStartCommand(intent, flags, startId) + return START_NOT_STICKY } // Make sure we don't accept commands for a different site id if (site != null && site!!.id != startSite.id) { announceExit(startSite.id, "Command received for a site id that is different from the current active site") - return super.onStartCommand(intent, flags, startId) + stopSelf(startId) + return START_NOT_STICKY } if (startSite.cert == null) { announceExit(startSite.id, "Site is missing a certificate") - return super.onStartCommand(intent, flags, startId) + stopSelf(startId) + return START_NOT_STICKY } // Kick off a site update @@ -130,6 +134,11 @@ class NebulaVpnService : VpnService() { } private fun startVpn() { + if (site == null) { + Log.e(TAG, "Got a start command but we don't have a site to run") + return + } + val builder = Builder() .setMtu(site!!.mtu) .setSession(TAG) From f6cd0b47d4662dd8e0ef7c27832b0ebefb93bfc1 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 19 Feb 2026 17:53:27 -0600 Subject: [PATCH 09/10] Fix bug if vpn service responds when not running a site --- .../kotlin/net/defined/mobile_nebula/MainActivity.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt index 53d0d78f..50a9b57d 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -248,8 +248,6 @@ class MainActivity: FlutterActivity() { return result.error("failure", "Site config was incomplete, please review and try again", null) } - sites?.refreshSites() - if (site.alwaysOn == true) { if (activeSiteId != null && activeSiteId != site.id) { stopSite { sites?.getSite(site.id)?.let { startSiteDirectly(it) } } @@ -258,6 +256,7 @@ class MainActivity: FlutterActivity() { } } + sites?.refreshSites(activeSiteId) sites?.updateAll() result.success(null) @@ -541,9 +540,13 @@ class MainActivity: FlutterActivity() { inner class IncomingHandler: Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { val id = msg.data.getString("id") + if (id == null) { + Log.i(TAG, "got a message without an id from the vpn service") + return + } //TODO: If the elvis hits then we had a deleted site running, which shouldn't happen - val site = sites!!.getSite(id!!) ?: return + val site = sites!!.getSite(id) ?: return when (msg.what) { NebulaVpnService.MSG_IS_RUNNING -> isRunning(site, msg) From 3c00c60e4f1f93b06eb4a6765b7490fc623e6d82 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 19 Feb 2026 20:17:44 -0600 Subject: [PATCH 10/10] Remove stuff that didn't work on android --- .../net/defined/mobile_nebula/MainActivity.kt | 6 ----- lib/screens/site_detail_screen.dart | 24 ------------------- 2 files changed, 30 deletions(-) diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt index 50a9b57d..186cc265 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -69,7 +69,6 @@ class MainActivity: FlutterActivity() { "android.registerActiveSite" -> registerActiveSite(result) "android.deviceHasCamera" -> deviceHasCamera(result) "android.openVpnSettings" -> openVpnSettings(result) - "android.isAlwaysOnEnabled" -> isAlwaysOnEnabled(result) "nebula.parseCerts" -> nebulaParseCerts(call, result) "nebula.generateKeyPair" -> nebulaGenerateKeyPair(result) @@ -144,11 +143,6 @@ class MainActivity: FlutterActivity() { result.success(null) } - private fun isAlwaysOnEnabled(result: MethodChannel.Result) { - val alwaysOnApp = Settings.Secure.getString(contentResolver, "always_on_vpn_app") - result.success(alwaysOnApp == packageName) - } - private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) { val certs = call.argument("certs") if (certs == "") { diff --git a/lib/screens/site_detail_screen.dart b/lib/screens/site_detail_screen.dart index 4fc67afe..89c50cb0 100644 --- a/lib/screens/site_detail_screen.dart +++ b/lib/screens/site_detail_screen.dart @@ -296,30 +296,6 @@ class SiteDetailScreenState extends State { return; } setState(() {}); - if (val && context.mounted) { - final bool isEnabled = await platform.invokeMethod('android.isAlwaysOnEnabled'); - if (!isEnabled && context.mounted) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Enable Always-On VPN'), - content: Text( - 'To complete setup, enable Always-On VPN for Nebula in Android\'s VPN settings.', - ), - actions: [ - TextButton(child: Text('Cancel'), onPressed: () => Navigator.pop(context)), - TextButton( - child: Text('OK'), - onPressed: () async { - Navigator.pop(context); - await platform.invokeMethod('android.openVpnSettings'); - }, - ), - ], - ), - ); - } - } }, ), ],