react-native-ovpn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +20 -0
  2. package/Openvpn.podspec +34 -0
  3. package/README.md +80 -0
  4. package/android/build.gradle +98 -0
  5. package/android/libs/README.md +46 -0
  6. package/android/libs/ics-openvpn.aar +0 -0
  7. package/android/src/main/AndroidManifest.xml +54 -0
  8. package/android/src/main/java/com/openvpn/NotificationHelper.kt +59 -0
  9. package/android/src/main/java/com/openvpn/OpenvpnEventBus.kt +52 -0
  10. package/android/src/main/java/com/openvpn/OpenvpnException.kt +6 -0
  11. package/android/src/main/java/com/openvpn/OpenvpnModule.kt +140 -0
  12. package/android/src/main/java/com/openvpn/OpenvpnPackage.kt +31 -0
  13. package/android/src/main/java/com/openvpn/OpenvpnService.kt +248 -0
  14. package/android/src/main/java/com/openvpn/PermissionLauncher.kt +39 -0
  15. package/android/src/main/java/com/openvpn/ProfileBuilder.kt +68 -0
  16. package/android/src/main/res/drawable/ic_vpn_default.xml +10 -0
  17. package/android/src/main/res/values/strings.xml +6 -0
  18. package/android/src/test/java/com/openvpn/NotificationHelperTest.kt +49 -0
  19. package/android/src/test/java/com/openvpn/ProfileBuilderTest.kt +83 -0
  20. package/app.plugin.js +3 -0
  21. package/ios/Openvpn-Bridging-Header.h +8 -0
  22. package/ios/Openvpn.h +5 -0
  23. package/ios/Openvpn.mm +123 -0
  24. package/ios/OpenvpnAppGroup.swift +59 -0
  25. package/ios/OpenvpnConstants.swift +46 -0
  26. package/ios/OpenvpnEventBridge.swift +58 -0
  27. package/ios/OpenvpnManager.swift +219 -0
  28. package/ios/PacketTunnelProvider/Info.plist +31 -0
  29. package/ios/PacketTunnelProvider/PacketTunnelProvider.swift +199 -0
  30. package/ios/PacketTunnelProvider/README.md +106 -0
  31. package/lib/module/NativeOpenvpn.js +5 -0
  32. package/lib/module/NativeOpenvpn.js.map +1 -0
  33. package/lib/module/OpenVPNClient.js +185 -0
  34. package/lib/module/OpenVPNClient.js.map +1 -0
  35. package/lib/module/errors.js +13 -0
  36. package/lib/module/errors.js.map +1 -0
  37. package/lib/module/index.js +5 -0
  38. package/lib/module/index.js.map +1 -0
  39. package/lib/module/package.json +1 -0
  40. package/lib/module/reconnect.js +51 -0
  41. package/lib/module/reconnect.js.map +1 -0
  42. package/lib/module/types.js +2 -0
  43. package/lib/module/types.js.map +1 -0
  44. package/lib/typescript/package.json +1 -0
  45. package/lib/typescript/plugin/src/android/index.d.ts +5 -0
  46. package/lib/typescript/plugin/src/android/index.d.ts.map +1 -0
  47. package/lib/typescript/plugin/src/android/withAndroidAarCheck.d.ts +5 -0
  48. package/lib/typescript/plugin/src/android/withAndroidAarCheck.d.ts.map +1 -0
  49. package/lib/typescript/plugin/src/android/withAndroidLegacyPackaging.d.ts +5 -0
  50. package/lib/typescript/plugin/src/android/withAndroidLegacyPackaging.d.ts.map +1 -0
  51. package/lib/typescript/plugin/src/android/withAndroidMinSdk.d.ts +5 -0
  52. package/lib/typescript/plugin/src/android/withAndroidMinSdk.d.ts.map +1 -0
  53. package/lib/typescript/plugin/src/android/withAndroidNotificationIcon.d.ts +5 -0
  54. package/lib/typescript/plugin/src/android/withAndroidNotificationIcon.d.ts.map +1 -0
  55. package/lib/typescript/plugin/src/android/withAndroidPermissions.d.ts +5 -0
  56. package/lib/typescript/plugin/src/android/withAndroidPermissions.d.ts.map +1 -0
  57. package/lib/typescript/plugin/src/android/withAndroidService.d.ts +5 -0
  58. package/lib/typescript/plugin/src/android/withAndroidService.d.ts.map +1 -0
  59. package/lib/typescript/plugin/src/index.d.ts +6 -0
  60. package/lib/typescript/plugin/src/index.d.ts.map +1 -0
  61. package/lib/typescript/plugin/src/ios/index.d.ts +5 -0
  62. package/lib/typescript/plugin/src/ios/index.d.ts.map +1 -0
  63. package/lib/typescript/plugin/src/ios/withIosDeploymentTarget.d.ts +5 -0
  64. package/lib/typescript/plugin/src/ios/withIosDeploymentTarget.d.ts.map +1 -0
  65. package/lib/typescript/plugin/src/ios/withIosEntitlements.d.ts +5 -0
  66. package/lib/typescript/plugin/src/ios/withIosEntitlements.d.ts.map +1 -0
  67. package/lib/typescript/plugin/src/ios/withIosInfoPlist.d.ts +5 -0
  68. package/lib/typescript/plugin/src/ios/withIosInfoPlist.d.ts.map +1 -0
  69. package/lib/typescript/plugin/src/types.d.ts +14 -0
  70. package/lib/typescript/plugin/src/types.d.ts.map +1 -0
  71. package/lib/typescript/src/NativeOpenvpn.d.ts +41 -0
  72. package/lib/typescript/src/NativeOpenvpn.d.ts.map +1 -0
  73. package/lib/typescript/src/OpenVPNClient.d.ts +37 -0
  74. package/lib/typescript/src/OpenVPNClient.d.ts.map +1 -0
  75. package/lib/typescript/src/errors.d.ts +9 -0
  76. package/lib/typescript/src/errors.d.ts.map +1 -0
  77. package/lib/typescript/src/index.d.ts +5 -0
  78. package/lib/typescript/src/index.d.ts.map +1 -0
  79. package/lib/typescript/src/reconnect.d.ts +23 -0
  80. package/lib/typescript/src/reconnect.d.ts.map +1 -0
  81. package/lib/typescript/src/types.d.ts +41 -0
  82. package/lib/typescript/src/types.d.ts.map +1 -0
  83. package/package.json +193 -0
  84. package/plugin/build/android/index.d.ts +4 -0
  85. package/plugin/build/android/index.js +24 -0
  86. package/plugin/build/android/withAndroidAarCheck.d.ts +4 -0
  87. package/plugin/build/android/withAndroidAarCheck.js +60 -0
  88. package/plugin/build/android/withAndroidLegacyPackaging.d.ts +4 -0
  89. package/plugin/build/android/withAndroidLegacyPackaging.js +18 -0
  90. package/plugin/build/android/withAndroidMinSdk.d.ts +4 -0
  91. package/plugin/build/android/withAndroidMinSdk.js +13 -0
  92. package/plugin/build/android/withAndroidNotificationIcon.d.ts +4 -0
  93. package/plugin/build/android/withAndroidNotificationIcon.js +64 -0
  94. package/plugin/build/android/withAndroidPermissions.d.ts +4 -0
  95. package/plugin/build/android/withAndroidPermissions.js +30 -0
  96. package/plugin/build/android/withAndroidService.d.ts +4 -0
  97. package/plugin/build/android/withAndroidService.js +40 -0
  98. package/plugin/build/index.d.ts +5 -0
  99. package/plugin/build/index.js +18 -0
  100. package/plugin/build/ios/index.d.ts +4 -0
  101. package/plugin/build/ios/index.js +15 -0
  102. package/plugin/build/ios/withIosDeploymentTarget.d.ts +4 -0
  103. package/plugin/build/ios/withIosDeploymentTarget.js +28 -0
  104. package/plugin/build/ios/withIosEntitlements.d.ts +4 -0
  105. package/plugin/build/ios/withIosEntitlements.js +15 -0
  106. package/plugin/build/ios/withIosInfoPlist.d.ts +4 -0
  107. package/plugin/build/ios/withIosInfoPlist.js +14 -0
  108. package/plugin/build/types.d.ts +13 -0
  109. package/plugin/build/types.js +2 -0
  110. package/src/NativeOpenvpn.ts +46 -0
  111. package/src/OpenVPNClient.ts +239 -0
  112. package/src/errors.ts +29 -0
  113. package/src/index.ts +12 -0
  114. package/src/reconnect.ts +68 -0
  115. package/src/types.ts +53 -0
@@ -0,0 +1,248 @@
1
+ package com.openvpn
2
+
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.graphics.Color
8
+ import android.os.Build
9
+ import android.os.RemoteException
10
+ import android.util.Log
11
+ import de.blinkt.openvpn.VpnProfile
12
+ import de.blinkt.openvpn.core.ConnectionStatus
13
+ import de.blinkt.openvpn.core.OpenVPNService
14
+ import de.blinkt.openvpn.core.ProfileManager
15
+ import de.blinkt.openvpn.core.VpnStatus
16
+
17
+ private const val TAG = "OpenvpnDbg"
18
+
19
+ /**
20
+ * Extends [OpenVPNService] to intercept state/byte-count callbacks and forward
21
+ * them to [OpenvpnEventBus].
22
+ *
23
+ * Starting the tunnel
24
+ * -------------------
25
+ * The parent's `startOpenVPN(Intent, startId)` is private, so we cannot call it
26
+ * directly from a subclass. Instead we:
27
+ * 1. Register the [VpnProfile] as a temporary profile via
28
+ * [ProfileManager.setTemporaryProfile], which persists it by UUID.
29
+ * 2. Re-build the intent with the UUID extras that the parent's
30
+ * `fetchVPNProfile(intent)` expects (`VpnProfile.EXTRA_PROFILEUUID` +
31
+ * `VpnProfile.EXTRA_PROFILE_VERSION`) and delegate to
32
+ * `super.onStartCommand(...)`.
33
+ *
34
+ * Stopping the tunnel
35
+ * -------------------
36
+ * [OpenVPNService.stopVPN] is declared in `IOpenVPNServiceInternal` and throws
37
+ * [RemoteException]; we catch and ignore it here.
38
+ *
39
+ * Listener overrides
40
+ * ------------------
41
+ * The parent already implements [VpnStatus.StateListener] and
42
+ * [VpnStatus.ByteCountListener]. We override those methods, forward to the
43
+ * EventBus, and then call super so the parent's notification logic still runs.
44
+ * We do NOT register/unregister the listeners separately — the parent
45
+ * `onStartCommand` and `onDestroy` handle that.
46
+ */
47
+ class OpenvpnService : OpenVPNService() {
48
+
49
+ private var lastEmittedAt: Long = 0L
50
+ // Latched at the moment we first observe LEVEL_CONNECTED so that
51
+ // durationMs is honest after service restarts / reconnections.
52
+ private var sessionStartedAt: Long = 0L
53
+
54
+ override fun onCreate() {
55
+ Log.i(TAG, "OpenvpnService.onCreate")
56
+ createOpenVpnChannels()
57
+ super.onCreate()
58
+ // Capture ics-openvpn's internal log lines (including the openvpn binary's
59
+ // stdout/stderr) and forward them both to logcat (for debugging) and to
60
+ // the JS bridge.
61
+ VpnStatus.addLogListener { item ->
62
+ val s = item.getString(this)
63
+ Log.i(TAG, "vpn-log: $s")
64
+ OpenvpnEventBus.emitLog(s)
65
+ }
66
+ }
67
+
68
+ private fun createOpenVpnChannels() {
69
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
70
+ val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
71
+ ?: return
72
+
73
+ if (mgr.getNotificationChannel(NOTIFICATION_CHANNEL_BG_ID) == null) {
74
+ val bg = NotificationChannel(
75
+ NOTIFICATION_CHANNEL_BG_ID,
76
+ "VPN background",
77
+ NotificationManager.IMPORTANCE_MIN,
78
+ ).apply {
79
+ description = "Ongoing VPN connection status"
80
+ enableLights(false)
81
+ lightColor = Color.DKGRAY
82
+ }
83
+ mgr.createNotificationChannel(bg)
84
+ }
85
+ if (mgr.getNotificationChannel(NOTIFICATION_CHANNEL_NEWSTATUS_ID) == null) {
86
+ val status = NotificationChannel(
87
+ NOTIFICATION_CHANNEL_NEWSTATUS_ID,
88
+ "VPN status",
89
+ NotificationManager.IMPORTANCE_LOW,
90
+ ).apply {
91
+ description = "VPN status change notifications"
92
+ enableLights(true)
93
+ lightColor = Color.BLUE
94
+ }
95
+ mgr.createNotificationChannel(status)
96
+ }
97
+ if (mgr.getNotificationChannel(NOTIFICATION_CHANNEL_USERREQ_ID) == null) {
98
+ val req = NotificationChannel(
99
+ NOTIFICATION_CHANNEL_USERREQ_ID,
100
+ "VPN requests",
101
+ NotificationManager.IMPORTANCE_HIGH,
102
+ ).apply {
103
+ description = "Urgent VPN requests (e.g. 2FA)"
104
+ enableVibration(true)
105
+ lightColor = Color.CYAN
106
+ }
107
+ mgr.createNotificationChannel(req)
108
+ }
109
+ }
110
+
111
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
112
+ if (intent?.action == ACTION_STOP) {
113
+ stopTunnel()
114
+ return START_NOT_STICKY
115
+ }
116
+
117
+ @Suppress("DEPRECATION")
118
+ val profile = intent?.getSerializableExtra(EXTRA_PROFILE) as? VpnProfile
119
+
120
+ if (profile == null) {
121
+ OpenvpnEventBus.emitError("NATIVE_ERROR", "OpenvpnService started without a profile")
122
+ return START_NOT_STICKY
123
+ }
124
+
125
+ // Register the profile so the parent's fetchVPNProfile can find it by UUID.
126
+ ProfileManager.setTemporaryProfile(this, profile)
127
+
128
+ // Build an intent the parent's fetchVPNProfile understands.
129
+ val parentIntent = Intent(intent).apply {
130
+ putExtra(VpnProfile.EXTRA_PROFILEUUID, profile.uuid.toString())
131
+ putExtra(VpnProfile.EXTRA_PROFILE_VERSION, profile.mVersion)
132
+ // Remove our own extras so the parent doesn't trip on them.
133
+ removeExtra(EXTRA_PROFILE)
134
+ removeExtra(EXTRA_NOTIF_TITLE)
135
+ removeExtra(EXTRA_NOTIF_TEXT)
136
+ removeExtra(EXTRA_NOTIF_ICON)
137
+ removeExtra(EXTRA_NOTIF_CHANNEL)
138
+ }
139
+
140
+ return super.onStartCommand(parentIntent, flags, startId)
141
+ }
142
+
143
+ private fun stopTunnel() {
144
+ // Emit transition states synchronously: the openvpn binary takes ~50-200ms
145
+ // to actually exit after SIGINT, and stopSelf() below unregisters our
146
+ // VpnStatus listener before the binary's exit callback fires. Without
147
+ // these explicit emits the JS bridge never observes the disconnected state
148
+ // and the UI stays stuck on "connected".
149
+ OpenvpnEventBus.emitState("disconnecting")
150
+ try {
151
+ stopVPN(false)
152
+ } catch (_: RemoteException) {
153
+ // Management interface not running; nothing to stop.
154
+ }
155
+ OpenvpnEventBus.emitState("disconnected")
156
+ sessionStartedAt = 0L
157
+ @Suppress("DEPRECATION")
158
+ stopForeground(true)
159
+ stopSelf()
160
+ }
161
+
162
+ // ── VpnStatus.StateListener ──────────────────────────────────────────────
163
+
164
+ override fun updateState(
165
+ state: String?,
166
+ logmessage: String?,
167
+ resid: Int,
168
+ level: ConnectionStatus?,
169
+ intent: Intent?,
170
+ ) {
171
+ Log.i(TAG, "updateState: state=$state level=$level msg=$logmessage")
172
+ // Let the parent update the foreground notification.
173
+ super.updateState(state, logmessage, resid, level, intent)
174
+
175
+ val mapped = mapState(level)
176
+ OpenvpnEventBus.emitState(mapped)
177
+ if (!logmessage.isNullOrEmpty()) OpenvpnEventBus.emitLog(logmessage)
178
+
179
+ // Latch session start when we first observe the connected level. Reset
180
+ // on disconnect so that a subsequent connection re-latches. This makes
181
+ // durationMs accurate across service restarts / reconnects.
182
+ when (level) {
183
+ ConnectionStatus.LEVEL_CONNECTED ->
184
+ if (sessionStartedAt == 0L) sessionStartedAt = System.currentTimeMillis()
185
+ ConnectionStatus.LEVEL_NOTCONNECTED,
186
+ ConnectionStatus.LEVEL_NONETWORK,
187
+ ConnectionStatus.LEVEL_AUTH_FAILED ->
188
+ sessionStartedAt = 0L
189
+ else -> { /* keep latched */ }
190
+ }
191
+
192
+ // Map ics-openvpn ConnectionStatus levels to our error codes. The `state`
193
+ // string varies across versions, so we discriminate on the typed enum.
194
+ when (level) {
195
+ ConnectionStatus.LEVEL_AUTH_FAILED ->
196
+ OpenvpnEventBus.emitError("AUTH_FAILED", logmessage)
197
+ ConnectionStatus.LEVEL_NONETWORK ->
198
+ OpenvpnEventBus.emitError("NETWORK_UNREACHABLE", logmessage)
199
+ else -> { /* not an error level */ }
200
+ }
201
+ }
202
+
203
+ override fun setConnectedVPN(uuid: String?) {
204
+ super.setConnectedVPN(uuid)
205
+ }
206
+
207
+ // ── VpnStatus.ByteCountListener ──────────────────────────────────────────
208
+
209
+ override fun updateByteCount(inBytes: Long, outBytes: Long, diffIn: Long, diffOut: Long) {
210
+ super.updateByteCount(inBytes, outBytes, diffIn, diffOut)
211
+
212
+ val now = System.currentTimeMillis()
213
+ if (now - lastEmittedAt < BYTES_EMIT_INTERVAL_MS) return
214
+ lastEmittedAt = now
215
+ // Before we've observed LEVEL_CONNECTED, durationMs is 0 — better than
216
+ // reporting time-since-service-create as connected duration.
217
+ val durationMs = if (sessionStartedAt == 0L) 0L else (now - sessionStartedAt)
218
+ OpenvpnEventBus.emitStats(inBytes, outBytes, durationMs)
219
+ }
220
+
221
+ // ── Helpers ──────────────────────────────────────────────────────────────
222
+
223
+ private fun mapState(level: ConnectionStatus?): String =
224
+ when (level) {
225
+ ConnectionStatus.LEVEL_CONNECTED -> "connected"
226
+ ConnectionStatus.LEVEL_NOTCONNECTED -> "disconnected"
227
+ ConnectionStatus.LEVEL_AUTH_FAILED -> "error"
228
+ ConnectionStatus.LEVEL_CONNECTING_NO_SERVER_REPLY_YET,
229
+ ConnectionStatus.LEVEL_CONNECTING_SERVER_REPLIED -> "connecting"
230
+ ConnectionStatus.LEVEL_WAITING_FOR_USER_INPUT -> "connecting"
231
+ ConnectionStatus.LEVEL_VPNPAUSED -> "disconnecting"
232
+ ConnectionStatus.LEVEL_NONETWORK -> "disconnected"
233
+ ConnectionStatus.LEVEL_START -> "connecting"
234
+ ConnectionStatus.UNKNOWN_LEVEL,
235
+ null -> "idle"
236
+ }
237
+
238
+ companion object {
239
+ const val ACTION_START = "com.openvpn.START"
240
+ const val ACTION_STOP = "com.openvpn.STOP"
241
+ const val EXTRA_PROFILE = "profile"
242
+ const val EXTRA_NOTIF_TITLE = "notif_title"
243
+ const val EXTRA_NOTIF_TEXT = "notif_text"
244
+ const val EXTRA_NOTIF_ICON = "notif_icon"
245
+ const val EXTRA_NOTIF_CHANNEL = "notif_channel"
246
+ private const val BYTES_EMIT_INTERVAL_MS = 1000L
247
+ }
248
+ }
@@ -0,0 +1,39 @@
1
+ package com.openvpn
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.net.VpnService
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import java.util.concurrent.atomic.AtomicReference
9
+
10
+ object PermissionLauncher {
11
+
12
+ private const val REQUEST_CODE = 0xCAF3
13
+
14
+ private val pending = AtomicReference<Promise?>(null)
15
+
16
+ fun request(reactContext: ReactApplicationContext, promise: Promise) {
17
+ val intent = VpnService.prepare(reactContext)
18
+ if (intent == null) {
19
+ promise.resolve(true)
20
+ return
21
+ }
22
+ val activity = reactContext.currentActivity
23
+ if (activity == null) {
24
+ promise.reject("PERMISSION_DENIED", "No current Activity to launch the VPN consent dialog")
25
+ return
26
+ }
27
+ if (!pending.compareAndSet(null, promise)) {
28
+ promise.reject("PERMISSION_DENIED", "Another permission request is already in flight")
29
+ return
30
+ }
31
+ activity.startActivityForResult(intent, REQUEST_CODE)
32
+ }
33
+
34
+ fun onActivityResult(requestCode: Int, resultCode: Int, @Suppress("UNUSED_PARAMETER") data: Intent?) {
35
+ if (requestCode != REQUEST_CODE) return
36
+ val promise = pending.getAndSet(null) ?: return
37
+ promise.resolve(resultCode == Activity.RESULT_OK)
38
+ }
39
+ }
@@ -0,0 +1,68 @@
1
+ package com.openvpn
2
+
3
+ import de.blinkt.openvpn.VpnProfile
4
+ import de.blinkt.openvpn.core.ConfigParser
5
+ import java.io.StringReader
6
+
7
+ object ProfileBuilder {
8
+
9
+ fun build(
10
+ ovpn: String,
11
+ username: String,
12
+ password: String,
13
+ dns: List<String>,
14
+ killSwitch: Boolean,
15
+ ): VpnProfile {
16
+ val sanitized = ensureLegacyCipherSupport(ovpn)
17
+ val parser = ConfigParser()
18
+ try {
19
+ parser.parseConfig(StringReader(sanitized))
20
+ } catch (e: Exception) {
21
+ throw OpenvpnException("INVALID_CONFIG", e.message ?: "failed to parse .ovpn")
22
+ }
23
+
24
+ val profile = parser.convertProfile()
25
+ profile.mUsername = username
26
+ profile.mPassword = password
27
+
28
+ if (dns.isNotEmpty()) {
29
+ profile.mOverrideDNS = true
30
+ profile.mDNS1 = dns.getOrNull(0).orEmpty()
31
+ profile.mDNS2 = dns.getOrNull(1).orEmpty()
32
+ }
33
+
34
+ if (killSwitch) {
35
+ profile.mBlockUnusedAddressFamilies = true
36
+ profile.mAllowLocalLAN = false
37
+ }
38
+
39
+ return profile
40
+ }
41
+
42
+ // OpenVPN 2.6+ rejects deprecated ciphers (AES-128-CBC, AES-256-CBC, BF-CBC)
43
+ // from negotiation by default. Many legacy servers (VPN Gate, SoftEther
44
+ // exports, older OpenVPN Access Servers) still push these. To preserve
45
+ // compatibility we ensure the resulting profile advertises both the modern
46
+ // defaults and the common legacy fallbacks unless the user already supplied
47
+ // an explicit data-ciphers directive.
48
+ private fun ensureLegacyCipherSupport(ovpn: String): String {
49
+ val hasDataCiphers = ovpn.lineSequence().any {
50
+ val t = it.trim()
51
+ t.startsWith("data-ciphers ") || t == "data-ciphers"
52
+ }
53
+ val hasFallback = ovpn.lineSequence().any {
54
+ val t = it.trim()
55
+ t.startsWith("data-ciphers-fallback ") || t == "data-ciphers-fallback"
56
+ }
57
+ if (hasDataCiphers && hasFallback) return ovpn
58
+ val builder = StringBuilder(ovpn)
59
+ if (!ovpn.endsWith("\n")) builder.append('\n')
60
+ if (!hasDataCiphers) {
61
+ builder.append("data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305:AES-256-CBC:AES-128-CBC\n")
62
+ }
63
+ if (!hasFallback) {
64
+ builder.append("data-ciphers-fallback AES-128-CBC\n")
65
+ }
66
+ return builder.toString()
67
+ }
68
+ }
@@ -0,0 +1,10 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24"
6
+ android:tint="?attr/colorControlNormal">
7
+ <path
8
+ android:fillColor="#FFFFFFFF"
9
+ android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12c5.16,-1.26 9,-6.45 9,-12V5l-9,-4zM10.94,16.93L7.4,13.4l1.41,-1.41l2.12,2.12l5.66,-5.66l1.41,1.41l-7.07,7.07z"/>
10
+ </vector>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <string name="openvpn_notification_title">Connected</string>
4
+ <string name="openvpn_notification_text">VPN is active</string>
5
+ <string name="openvpn_notification_channel_name">VPN status</string>
6
+ </resources>
@@ -0,0 +1,49 @@
1
+ package com.openvpn
2
+
3
+ import android.content.Context
4
+ import androidx.test.core.app.ApplicationProvider
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertNotNull
7
+ import org.junit.Test
8
+ import org.junit.runner.RunWith
9
+ import org.robolectric.RobolectricTestRunner
10
+
11
+ @RunWith(RobolectricTestRunner::class)
12
+ class NotificationHelperTest {
13
+
14
+ private val context: Context = ApplicationProvider.getApplicationContext()
15
+
16
+ @Test
17
+ fun `build returns a Notification with defaults`() {
18
+ val n = NotificationHelper.build(context, NotificationOverrides())
19
+ assertNotNull(n)
20
+ val extras = n.extras
21
+ assertEquals("Connected", extras.getString("android.title"))
22
+ assertEquals("VPN is active", extras.getString("android.text"))
23
+ }
24
+
25
+ @Test
26
+ fun `build applies title override`() {
27
+ val n = NotificationHelper.build(
28
+ context,
29
+ NotificationOverrides(title = "MyVPN connected"),
30
+ )
31
+ assertEquals("MyVPN connected", n.extras.getString("android.title"))
32
+ }
33
+
34
+ @Test
35
+ fun `build applies text override`() {
36
+ val n = NotificationHelper.build(
37
+ context,
38
+ NotificationOverrides(text = "Routing your traffic"),
39
+ )
40
+ assertEquals("Routing your traffic", n.extras.getString("android.text"))
41
+ }
42
+
43
+ @Test
44
+ fun `ensureChannel does not throw`() {
45
+ NotificationHelper.ensureChannel(context, "openvpn", "VPN status")
46
+ // Channel manager assertions vary by Robolectric version; this test just
47
+ // verifies the call does not throw.
48
+ }
49
+ }
@@ -0,0 +1,83 @@
1
+ package com.openvpn
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNotNull
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+
10
+ @RunWith(RobolectricTestRunner::class)
11
+ class ProfileBuilderTest {
12
+
13
+ private val minimalOvpn = """
14
+ client
15
+ dev tun
16
+ proto udp
17
+ remote 1.2.3.4 1194
18
+ cipher AES-256-CBC
19
+ """.trimIndent()
20
+
21
+ @Test
22
+ fun `builds VpnProfile from minimal config`() {
23
+ val profile = ProfileBuilder.build(
24
+ ovpn = minimalOvpn,
25
+ username = "alice",
26
+ password = "s3cret",
27
+ dns = emptyList(),
28
+ killSwitch = false,
29
+ )
30
+ assertNotNull(profile)
31
+ assertEquals("alice", profile.mUsername)
32
+ assertEquals("s3cret", profile.mPassword)
33
+ }
34
+
35
+ @Test
36
+ fun `injects DNS override when provided`() {
37
+ val profile = ProfileBuilder.build(
38
+ ovpn = minimalOvpn,
39
+ username = "u",
40
+ password = "p",
41
+ dns = listOf("1.1.1.1", "1.0.0.1"),
42
+ killSwitch = false,
43
+ )
44
+ assertTrue(profile.mOverrideDNS)
45
+ assertEquals("1.1.1.1", profile.mDNS1)
46
+ assertEquals("1.0.0.1", profile.mDNS2)
47
+ }
48
+
49
+ @Test
50
+ fun `does not override DNS when list is empty`() {
51
+ val profile = ProfileBuilder.build(
52
+ ovpn = minimalOvpn,
53
+ username = "u",
54
+ password = "p",
55
+ dns = emptyList(),
56
+ killSwitch = false,
57
+ )
58
+ assertEquals(false, profile.mOverrideDNS)
59
+ }
60
+
61
+ @Test
62
+ fun `sets kill-switch flag when requested`() {
63
+ val profile = ProfileBuilder.build(
64
+ ovpn = minimalOvpn,
65
+ username = "u",
66
+ password = "p",
67
+ dns = emptyList(),
68
+ killSwitch = true,
69
+ )
70
+ assertTrue(profile.mBlockUnusedAddressFamilies)
71
+ }
72
+
73
+ @Test(expected = OpenvpnException::class)
74
+ fun `throws OpenvpnException on garbage input`() {
75
+ ProfileBuilder.build(
76
+ ovpn = "this is not an ovpn config",
77
+ username = "u",
78
+ password = "p",
79
+ dns = emptyList(),
80
+ killSwitch = false,
81
+ )
82
+ }
83
+ }
package/app.plugin.js ADDED
@@ -0,0 +1,3 @@
1
+ // This file is the entry point Expo expects for a config plugin shipped
2
+ // alongside an npm package. It re-exports the compiled plugin from plugin/build.
3
+ module.exports = require('./plugin/build');
@@ -0,0 +1,8 @@
1
+ //
2
+ // Openvpn-Bridging-Header.h
3
+ // react-native-ovpn
4
+ //
5
+ // Exposes Objective-C / Objective-C++ symbols to the Swift sources in this
6
+ // pod. React Native's framework imports propagate automatically; add types
7
+ // here only when a Swift compile error demands them.
8
+ //
package/ios/Openvpn.h ADDED
@@ -0,0 +1,5 @@
1
+ #import <OpenvpnSpec/OpenvpnSpec.h>
2
+
3
+ @interface Openvpn : NSObject <NativeOpenvpnSpec>
4
+
5
+ @end
package/ios/Openvpn.mm ADDED
@@ -0,0 +1,123 @@
1
+ #import "Openvpn.h"
2
+ #import <React/RCTBridge.h>
3
+ #import <React/RCTEventEmitter.h>
4
+
5
+ // Bridges to Swift sources in this pod. CocoaPods generates this header
6
+ // from the @objc-annotated Swift classes (OpenvpnManager, OpenvpnEventBridge,
7
+ // OpenvpnConstants, etc.). Header name pattern: "<ModuleName>-Swift.h".
8
+ #import "Openvpn-Swift.h"
9
+
10
+ @implementation Openvpn
11
+
12
+ RCT_EXPORT_MODULE(Openvpn)
13
+
14
+ + (BOOL)requiresMainQueueSetup {
15
+ return NO;
16
+ }
17
+
18
+ - (NSArray<NSString *> *)supportedEvents {
19
+ return @[ @"OpenVpn:state", @"OpenVpn:stats", @"OpenVpn:log", @"OpenVpn:error" ];
20
+ }
21
+
22
+ - (void)setBridge:(RCTBridge *)bridge {
23
+ [super setBridge:bridge];
24
+ [[OpenvpnEventBridge shared] attach:bridge];
25
+ }
26
+
27
+ - (void)invalidate {
28
+ [[OpenvpnEventBridge shared] detach];
29
+ [super invalidate];
30
+ }
31
+
32
+ // MARK: TurboModule methods
33
+
34
+ - (void)requestPermission:(RCTPromiseResolveBlock)resolve
35
+ reject:(RCTPromiseRejectBlock)reject {
36
+ [[OpenvpnManager shared] requestPermissionWithCompletion:^(BOOL granted, NSError * _Nullable error) {
37
+ if (error) {
38
+ reject(@"PERMISSION_DENIED", error.localizedDescription, error);
39
+ return;
40
+ }
41
+ resolve(@(granted));
42
+ }];
43
+ }
44
+
45
+ - (void)connect:(NSDictionary *)params
46
+ resolve:(RCTPromiseResolveBlock)resolve
47
+ reject:(RCTPromiseRejectBlock)reject {
48
+ // Per-app routing rejection (iOS does not support it without MDM).
49
+ NSArray *allowedApps = params[@"allowedApps"];
50
+ NSArray *disallowedApps = params[@"disallowedApps"];
51
+ if ((allowedApps && allowedApps.count > 0) ||
52
+ (disallowedApps && disallowedApps.count > 0)) {
53
+ reject(@"INVALID_CONFIG",
54
+ @"per-app routing not supported on iOS (MDM-only)",
55
+ nil);
56
+ return;
57
+ }
58
+
59
+ NSString *ovpn = params[@"config"];
60
+ if (!ovpn) {
61
+ reject(@"INVALID_CONFIG", @"config missing", nil);
62
+ return;
63
+ }
64
+ NSString *username = params[@"username"] ?: @"";
65
+ NSString *password = params[@"password"] ?: @"";
66
+ NSArray<NSString *> *dns = params[@"dns"] ?: @[];
67
+
68
+ // killSwitch is silently ignored on iOS (deferred to Plan 3.1) per the
69
+ // Plan 3 spec extension §5.
70
+
71
+ [[OpenvpnManager shared] connectWithOvpn:ovpn
72
+ username:username
73
+ password:password
74
+ dns:dns
75
+ completion:^(BOOL ok, NSError * _Nullable error) {
76
+ if (error) {
77
+ reject(@"NATIVE_ERROR", error.localizedDescription, error);
78
+ return;
79
+ }
80
+ resolve(nil);
81
+ }];
82
+ }
83
+
84
+ - (void)disconnect:(RCTPromiseResolveBlock)resolve
85
+ reject:(RCTPromiseRejectBlock)reject {
86
+ [[OpenvpnManager shared] disconnectWithCompletion:^(BOOL ok, NSError * _Nullable error) {
87
+ if (error) {
88
+ reject(@"NATIVE_ERROR", error.localizedDescription, error);
89
+ return;
90
+ }
91
+ resolve(nil);
92
+ }];
93
+ }
94
+
95
+ - (void)getStatus:(RCTPromiseResolveBlock)resolve
96
+ reject:(RCTPromiseRejectBlock)reject {
97
+ resolve(@{ @"state": @"idle" });
98
+ }
99
+
100
+ - (void)getStats:(RCTPromiseResolveBlock)resolve
101
+ reject:(RCTPromiseRejectBlock)reject {
102
+ resolve(@{
103
+ @"bytesIn": @0,
104
+ @"bytesOut": @0,
105
+ @"durationMs": @0,
106
+ });
107
+ }
108
+
109
+ // addListener / removeListeners are required by NativeEventEmitter and are
110
+ // no-ops on our side (the event bus emits unconditionally).
111
+ - (void)addListener:(NSString *)eventName {}
112
+ - (void)removeListeners:(double)count {}
113
+
114
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
115
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
116
+ return std::make_shared<facebook::react::NativeOpenvpnSpecJSI>(params);
117
+ }
118
+
119
+ + (NSString *)moduleName {
120
+ return @"Openvpn";
121
+ }
122
+
123
+ @end