@elizaos/capacitor-gateway 1.0.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.
- package/ElizaosCapacitorGateway.podspec +17 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/ai/eliza/plugins/gateway/GatewayPlugin.kt +614 -0
- package/dist/esm/definitions.d.ts +271 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +99 -0
- package/dist/esm/web.js +419 -0
- package/dist/plugin.cjs.js +435 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +438 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/GatewayPlugin/GatewayPlugin.swift +631 -0
- package/package.json +103 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'ElizaosCapacitorGateway'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.license = package['license'] || { :type => 'MIT' }
|
|
10
|
+
s.homepage = 'https://elizaos.ai'
|
|
11
|
+
s.authors = { 'elizaOS' => 'dev@elizaos.ai' }
|
|
12
|
+
s.source = { :git => 'https://github.com/elizaOS/eliza.git', :tag => s.version.to_s }
|
|
13
|
+
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
|
|
14
|
+
s.ios.deployment_target = '15.0'
|
|
15
|
+
s.dependency 'Capacitor'
|
|
16
|
+
s.swift_version = '5.9'
|
|
17
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
ext {
|
|
2
|
+
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
|
3
|
+
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
|
|
4
|
+
androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
|
|
5
|
+
androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
|
|
6
|
+
kotlinVersion = project.hasProperty('kotlinVersion') ? rootProject.ext.kotlinVersion : '1.9.25'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
apply plugin: 'com.android.library'
|
|
10
|
+
android {
|
|
11
|
+
namespace = "ai.eliza.plugins.gateway"
|
|
12
|
+
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
|
|
13
|
+
defaultConfig {
|
|
14
|
+
minSdk project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
|
15
|
+
targetSdk project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
|
|
16
|
+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
17
|
+
}
|
|
18
|
+
buildTypes {
|
|
19
|
+
release {
|
|
20
|
+
minifyEnabled false
|
|
21
|
+
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
compileOptions {
|
|
25
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
26
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
repositories {
|
|
31
|
+
google()
|
|
32
|
+
maven {
|
|
33
|
+
url = uri(rootProject.ext.mavenCentralMirrorUrl)
|
|
34
|
+
}
|
|
35
|
+
mavenCentral()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
dependencies {
|
|
39
|
+
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
40
|
+
implementation project(':capacitor-android')
|
|
41
|
+
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
|
42
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
|
43
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"
|
|
44
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
|
|
45
|
+
implementation "com.squareup.okhttp3:okhttp:5.3.2"
|
|
46
|
+
testImplementation "junit:junit:$junitVersion"
|
|
47
|
+
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
48
|
+
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
49
|
+
}
|
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
package ai.eliza.plugins.gateway
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.nsd.NsdManager
|
|
5
|
+
import android.net.nsd.NsdServiceInfo
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import com.getcapacitor.JSArray
|
|
8
|
+
import com.getcapacitor.JSObject
|
|
9
|
+
import com.getcapacitor.Plugin
|
|
10
|
+
import com.getcapacitor.PluginCall
|
|
11
|
+
import com.getcapacitor.PluginMethod
|
|
12
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
13
|
+
import kotlinx.coroutines.*
|
|
14
|
+
import okhttp3.*
|
|
15
|
+
import org.json.JSONArray
|
|
16
|
+
import org.json.JSONObject
|
|
17
|
+
import java.util.UUID
|
|
18
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
19
|
+
import java.util.concurrent.TimeUnit
|
|
20
|
+
import kotlin.coroutines.Continuation
|
|
21
|
+
import kotlin.coroutines.resume
|
|
22
|
+
import kotlin.coroutines.resumeWithException
|
|
23
|
+
import kotlin.coroutines.suspendCoroutine
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Gateway Plugin for Capacitor
|
|
27
|
+
*
|
|
28
|
+
* Provides WebSocket connectivity to an Eliza Gateway server.
|
|
29
|
+
* This implementation handles authentication, reconnection, and RPC-style
|
|
30
|
+
* request/response as well as event streaming.
|
|
31
|
+
*/
|
|
32
|
+
@CapacitorPlugin(name = "Gateway")
|
|
33
|
+
class GatewayPlugin : Plugin() {
|
|
34
|
+
private val TAG = "GatewayPlugin"
|
|
35
|
+
|
|
36
|
+
private var webSocket: WebSocket? = null
|
|
37
|
+
private var okHttpClient: OkHttpClient? = null
|
|
38
|
+
private val pendingRequests = ConcurrentHashMap<String, Continuation<JSObject>>()
|
|
39
|
+
private var options: JSObject? = null
|
|
40
|
+
private var sessionId: String? = null
|
|
41
|
+
private var protocolVersion: Int? = null
|
|
42
|
+
private var role: String? = null
|
|
43
|
+
private var scopes: List<String> = emptyList()
|
|
44
|
+
private var methods: List<String> = emptyList()
|
|
45
|
+
private var events: List<String> = emptyList()
|
|
46
|
+
private var lastSeq: Int? = null
|
|
47
|
+
private var isClosed = false
|
|
48
|
+
private var backoffMs: Long = 800
|
|
49
|
+
private var reconnectJob: Job? = null
|
|
50
|
+
private var connectContinuation: Continuation<JSObject>? = null
|
|
51
|
+
|
|
52
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
53
|
+
|
|
54
|
+
// Discovery
|
|
55
|
+
private var nsdManager: NsdManager? = null
|
|
56
|
+
private var isDiscovering = false
|
|
57
|
+
private val discoveredGateways = ConcurrentHashMap<String, JSObject>()
|
|
58
|
+
private val serviceType = "_eliza-gw._tcp."
|
|
59
|
+
|
|
60
|
+
private val discoveryListener = object : NsdManager.DiscoveryListener {
|
|
61
|
+
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
62
|
+
Log.e(TAG, "Discovery start failed: $errorCode")
|
|
63
|
+
isDiscovering = false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
67
|
+
Log.e(TAG, "Discovery stop failed: $errorCode")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun onDiscoveryStarted(serviceType: String) {
|
|
71
|
+
Log.d(TAG, "Discovery started for $serviceType")
|
|
72
|
+
isDiscovering = true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override fun onDiscoveryStopped(serviceType: String) {
|
|
76
|
+
Log.d(TAG, "Discovery stopped for $serviceType")
|
|
77
|
+
isDiscovering = false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
|
81
|
+
if (serviceInfo.serviceType != this@GatewayPlugin.serviceType) return
|
|
82
|
+
resolveService(serviceInfo)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
|
86
|
+
val serviceName = decodeServiceName(serviceInfo.serviceName)
|
|
87
|
+
val id = stableId(serviceName, "local.")
|
|
88
|
+
val removed = discoveredGateways.remove(id)
|
|
89
|
+
if (removed != null) {
|
|
90
|
+
notifyListeners("discovery", JSObject().apply {
|
|
91
|
+
put("type", "lost")
|
|
92
|
+
put("gateway", removed)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private fun decodeServiceName(raw: String): String {
|
|
99
|
+
// Basic Bonjour escape decoding
|
|
100
|
+
return raw.replace(Regex("\\\\(\\d{3})")) {
|
|
101
|
+
it.groupValues[1].toIntOrNull()?.let { code ->
|
|
102
|
+
code.toChar().toString()
|
|
103
|
+
} ?: it.value
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun stableId(serviceName: String, domain: String): String {
|
|
108
|
+
return "${serviceType}|${domain}|${serviceName.trim().lowercase()}"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Suppress("DEPRECATION")
|
|
112
|
+
private fun resolveService(serviceInfo: NsdServiceInfo) {
|
|
113
|
+
nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
|
|
114
|
+
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
|
115
|
+
Log.e(TAG, "Resolve failed for ${serviceInfo.serviceName}: $errorCode")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
|
119
|
+
val host = resolved.host?.hostAddress ?: return
|
|
120
|
+
val port = resolved.port
|
|
121
|
+
if (port <= 0) return
|
|
122
|
+
|
|
123
|
+
val serviceName = decodeServiceName(resolved.serviceName)
|
|
124
|
+
val displayName = txt(resolved, "displayName") ?: serviceName
|
|
125
|
+
val lanHost = txt(resolved, "lanHost")
|
|
126
|
+
val tailnetDns = txt(resolved, "tailnetDns")
|
|
127
|
+
val gatewayPort = txtInt(resolved, "gatewayPort")
|
|
128
|
+
val canvasPort = txtInt(resolved, "canvasPort")
|
|
129
|
+
val tlsEnabled = txtBool(resolved, "gatewayTls")
|
|
130
|
+
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
|
|
131
|
+
val id = stableId(serviceName, "local.")
|
|
132
|
+
|
|
133
|
+
val gateway = JSObject().apply {
|
|
134
|
+
put("stableId", id)
|
|
135
|
+
put("name", displayName)
|
|
136
|
+
put("host", host)
|
|
137
|
+
put("port", gatewayPort ?: port)
|
|
138
|
+
put("lanHost", lanHost)
|
|
139
|
+
put("tailnetDns", tailnetDns)
|
|
140
|
+
put("gatewayPort", gatewayPort ?: port)
|
|
141
|
+
put("canvasPort", canvasPort)
|
|
142
|
+
put("tlsEnabled", tlsEnabled)
|
|
143
|
+
put("tlsFingerprintSha256", tlsFingerprint)
|
|
144
|
+
put("isLocal", true)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
val isNew = discoveredGateways.put(id, gateway) == null
|
|
148
|
+
notifyListeners("discovery", JSObject().apply {
|
|
149
|
+
put("type", if (isNew) "found" else "updated")
|
|
150
|
+
put("gateway", gateway)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private fun txt(info: NsdServiceInfo, key: String): String? {
|
|
157
|
+
val bytes = info.attributes[key] ?: return null
|
|
158
|
+
return try {
|
|
159
|
+
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
|
160
|
+
} catch (_: Throwable) {
|
|
161
|
+
null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
|
166
|
+
return txt(info, key)?.toIntOrNull()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
|
170
|
+
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
|
171
|
+
return raw == "1" || raw == "true" || raw == "yes"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@PluginMethod
|
|
175
|
+
fun startDiscovery(call: PluginCall) {
|
|
176
|
+
if (isDiscovering) {
|
|
177
|
+
call.resolve(buildDiscoveryResult())
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
|
183
|
+
nsdManager?.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
|
184
|
+
|
|
185
|
+
// Return initial result after a brief delay for discovery
|
|
186
|
+
scope.launch {
|
|
187
|
+
delay(500)
|
|
188
|
+
call.resolve(buildDiscoveryResult())
|
|
189
|
+
}
|
|
190
|
+
} catch (e: Exception) {
|
|
191
|
+
call.reject("Failed to start discovery: ${e.message}")
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@PluginMethod
|
|
196
|
+
fun stopDiscovery(call: PluginCall) {
|
|
197
|
+
if (isDiscovering) {
|
|
198
|
+
try {
|
|
199
|
+
nsdManager?.stopServiceDiscovery(discoveryListener)
|
|
200
|
+
} catch (_: Throwable) {
|
|
201
|
+
// Ignore - best effort
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
isDiscovering = false
|
|
205
|
+
call.resolve()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@PluginMethod
|
|
209
|
+
fun getDiscoveredGateways(call: PluginCall) {
|
|
210
|
+
call.resolve(buildDiscoveryResult())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private fun buildDiscoveryResult(): JSObject {
|
|
214
|
+
val gateways = JSArray()
|
|
215
|
+
for (gateway in discoveredGateways.values.sortedBy { it.getString("name")?.lowercase() }) {
|
|
216
|
+
gateways.put(gateway)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return JSObject().apply {
|
|
220
|
+
put("gateways", gateways)
|
|
221
|
+
put("status", if (isDiscovering) "Discovering..." else "Discovery stopped")
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@PluginMethod
|
|
226
|
+
fun connect(call: PluginCall) {
|
|
227
|
+
val urlString = call.getString("url")
|
|
228
|
+
if (urlString == null) {
|
|
229
|
+
call.reject("Missing URL parameter")
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Store options for reconnection
|
|
234
|
+
options = call.data
|
|
235
|
+
|
|
236
|
+
// Close existing connection
|
|
237
|
+
closeConnection()
|
|
238
|
+
isClosed = false
|
|
239
|
+
backoffMs = 800
|
|
240
|
+
|
|
241
|
+
scope.launch {
|
|
242
|
+
try {
|
|
243
|
+
val result = establishConnection(urlString, call.data)
|
|
244
|
+
call.resolve(result)
|
|
245
|
+
} catch (e: Exception) {
|
|
246
|
+
call.reject("Connection failed: ${e.message}")
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@PluginMethod
|
|
252
|
+
fun disconnect(call: PluginCall) {
|
|
253
|
+
isClosed = true
|
|
254
|
+
reconnectJob?.cancel()
|
|
255
|
+
reconnectJob = null
|
|
256
|
+
closeConnection()
|
|
257
|
+
sessionId = null
|
|
258
|
+
protocolVersion = null
|
|
259
|
+
notifyStateChange("disconnected", "Client disconnect")
|
|
260
|
+
call.resolve()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@PluginMethod
|
|
264
|
+
fun isConnected(call: PluginCall) {
|
|
265
|
+
val connected = webSocket != null
|
|
266
|
+
call.resolve(JSObject().apply {
|
|
267
|
+
put("connected", connected)
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@PluginMethod
|
|
272
|
+
fun send(call: PluginCall) {
|
|
273
|
+
val method = call.getString("method")
|
|
274
|
+
if (method == null) {
|
|
275
|
+
call.reject("Missing method parameter")
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
val ws = webSocket
|
|
280
|
+
if (ws == null) {
|
|
281
|
+
call.resolve(JSObject().apply {
|
|
282
|
+
put("ok", false)
|
|
283
|
+
put("error", JSObject().apply {
|
|
284
|
+
put("code", "NOT_CONNECTED")
|
|
285
|
+
put("message", "Not connected to gateway")
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
val id = UUID.randomUUID().toString()
|
|
292
|
+
val params = call.getObject("params") ?: JSObject()
|
|
293
|
+
|
|
294
|
+
val frame = JSONObject().apply {
|
|
295
|
+
put("type", "req")
|
|
296
|
+
put("id", id)
|
|
297
|
+
put("method", method)
|
|
298
|
+
put("params", params.toJson())
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
scope.launch {
|
|
302
|
+
try {
|
|
303
|
+
val result = sendRequest(id, frame.toString())
|
|
304
|
+
call.resolve(result)
|
|
305
|
+
} catch (e: Exception) {
|
|
306
|
+
call.resolve(JSObject().apply {
|
|
307
|
+
put("ok", false)
|
|
308
|
+
put("error", JSObject().apply {
|
|
309
|
+
put("code", "REQUEST_FAILED")
|
|
310
|
+
put("message", e.message ?: "Unknown error")
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@PluginMethod
|
|
318
|
+
fun getConnectionInfo(call: PluginCall) {
|
|
319
|
+
call.resolve(JSObject().apply {
|
|
320
|
+
put("url", options?.getString("url"))
|
|
321
|
+
put("sessionId", sessionId)
|
|
322
|
+
put("protocol", protocolVersion)
|
|
323
|
+
put("role", role)
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Private methods
|
|
328
|
+
|
|
329
|
+
private suspend fun establishConnection(url: String, options: JSObject): JSObject {
|
|
330
|
+
return suspendCoroutine { continuation ->
|
|
331
|
+
connectContinuation = continuation
|
|
332
|
+
|
|
333
|
+
okHttpClient = OkHttpClient.Builder()
|
|
334
|
+
.connectTimeout(30, TimeUnit.SECONDS)
|
|
335
|
+
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for WebSocket
|
|
336
|
+
.writeTimeout(30, TimeUnit.SECONDS)
|
|
337
|
+
.build()
|
|
338
|
+
|
|
339
|
+
val request = Request.Builder()
|
|
340
|
+
.url(url)
|
|
341
|
+
.build()
|
|
342
|
+
|
|
343
|
+
webSocket = okHttpClient?.newWebSocket(request, object : WebSocketListener() {
|
|
344
|
+
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
345
|
+
Log.d(TAG, "WebSocket connected")
|
|
346
|
+
sendConnectFrame(options)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
350
|
+
handleMessage(text)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
354
|
+
Log.e(TAG, "WebSocket failure: ${t.message}")
|
|
355
|
+
handleClose(t)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
359
|
+
Log.d(TAG, "WebSocket closed: $code $reason")
|
|
360
|
+
handleClose(null)
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
// Set timeout
|
|
365
|
+
scope.launch {
|
|
366
|
+
delay(30000)
|
|
367
|
+
if (connectContinuation != null) {
|
|
368
|
+
connectContinuation?.resumeWithException(Exception("Connection timeout"))
|
|
369
|
+
connectContinuation = null
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private fun sendConnectFrame(options: JSObject) {
|
|
376
|
+
val clientName = options.getString("clientName") ?: "eliza-capacitor-android"
|
|
377
|
+
val clientVersion = options.getString("clientVersion") ?: "1.0.0"
|
|
378
|
+
val roleParam = options.getString("role") ?: "operator"
|
|
379
|
+
val scopesParam = options.optJSONArray("scopes")?.let { arr ->
|
|
380
|
+
(0 until arr.length()).map { arr.getString(it) }
|
|
381
|
+
} ?: listOf("operator.admin")
|
|
382
|
+
|
|
383
|
+
val auth = JSONObject().apply {
|
|
384
|
+
options.getString("token")?.let { put("token", it) }
|
|
385
|
+
options.getString("password")?.let { put("password", it) }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
val params = JSONObject().apply {
|
|
389
|
+
put("minProtocol", 3)
|
|
390
|
+
put("maxProtocol", 3)
|
|
391
|
+
put("client", JSONObject().apply {
|
|
392
|
+
put("id", clientName)
|
|
393
|
+
put("version", clientVersion)
|
|
394
|
+
put("platform", "android")
|
|
395
|
+
put("mode", "ui")
|
|
396
|
+
})
|
|
397
|
+
put("role", roleParam)
|
|
398
|
+
put("scopes", JSONArray(scopesParam))
|
|
399
|
+
put("caps", JSONArray())
|
|
400
|
+
put("auth", auth)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
val id = UUID.randomUUID().toString()
|
|
404
|
+
val frame = JSONObject().apply {
|
|
405
|
+
put("type", "req")
|
|
406
|
+
put("id", id)
|
|
407
|
+
put("method", "connect")
|
|
408
|
+
put("params", params)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
webSocket?.send(frame.toString())
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private suspend fun sendRequest(id: String, frameJson: String): JSObject {
|
|
415
|
+
return suspendCoroutine { continuation ->
|
|
416
|
+
pendingRequests[id] = continuation
|
|
417
|
+
|
|
418
|
+
val sent = webSocket?.send(frameJson) ?: false
|
|
419
|
+
if (!sent) {
|
|
420
|
+
pendingRequests.remove(id)
|
|
421
|
+
continuation.resumeWithException(Exception("Failed to send request"))
|
|
422
|
+
return@suspendCoroutine
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Set timeout
|
|
426
|
+
scope.launch {
|
|
427
|
+
delay(60000)
|
|
428
|
+
pendingRequests.remove(id)?.let {
|
|
429
|
+
it.resume(JSObject().apply {
|
|
430
|
+
put("ok", false)
|
|
431
|
+
put("error", JSObject().apply {
|
|
432
|
+
put("code", "TIMEOUT")
|
|
433
|
+
put("message", "Request timed out")
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private fun handleMessage(text: String) {
|
|
442
|
+
try {
|
|
443
|
+
val json = JSONObject(text)
|
|
444
|
+
val frameType = json.optString("type")
|
|
445
|
+
|
|
446
|
+
// Handle response frames
|
|
447
|
+
if (frameType == "res") {
|
|
448
|
+
val id = json.optString("id")
|
|
449
|
+
|
|
450
|
+
// Check if this is the connect response
|
|
451
|
+
if (connectContinuation != null) {
|
|
452
|
+
val ok = json.optBoolean("ok", false)
|
|
453
|
+
if (ok) {
|
|
454
|
+
val payload = json.optJSONObject("payload")
|
|
455
|
+
if (payload != null) {
|
|
456
|
+
handleHelloOk(payload)
|
|
457
|
+
}
|
|
458
|
+
val result = JSObject().apply {
|
|
459
|
+
put("connected", true)
|
|
460
|
+
put("sessionId", sessionId ?: "")
|
|
461
|
+
put("protocol", protocolVersion ?: 3)
|
|
462
|
+
put("methods", JSONArray(methods))
|
|
463
|
+
put("events", JSONArray(events))
|
|
464
|
+
put("role", role ?: "")
|
|
465
|
+
put("scopes", JSONArray(scopes))
|
|
466
|
+
}
|
|
467
|
+
connectContinuation?.resume(result)
|
|
468
|
+
connectContinuation = null
|
|
469
|
+
} else {
|
|
470
|
+
val errorMsg = json.optJSONObject("error")?.optString("message") ?: "Connection failed"
|
|
471
|
+
connectContinuation?.resumeWithException(Exception(errorMsg))
|
|
472
|
+
connectContinuation = null
|
|
473
|
+
}
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Handle pending request
|
|
478
|
+
pendingRequests.remove(id)?.let { continuation ->
|
|
479
|
+
val ok = json.optBoolean("ok", false)
|
|
480
|
+
val result = JSObject().apply {
|
|
481
|
+
put("ok", ok)
|
|
482
|
+
json.opt("payload")?.let { put("payload", it) }
|
|
483
|
+
json.optJSONObject("error")?.let { error ->
|
|
484
|
+
put("error", JSObject().apply {
|
|
485
|
+
put("code", error.optString("code"))
|
|
486
|
+
put("message", error.optString("message"))
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
continuation.resume(result)
|
|
491
|
+
}
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Handle event frames
|
|
496
|
+
if (frameType == "event") {
|
|
497
|
+
val event = json.optString("event")
|
|
498
|
+
val payload = json.opt("payload")
|
|
499
|
+
val seq = if (json.has("seq")) json.optInt("seq") else null
|
|
500
|
+
|
|
501
|
+
// Check for sequence gap
|
|
502
|
+
if (seq != null && lastSeq != null && seq > lastSeq!! + 1) {
|
|
503
|
+
Log.w(TAG, "Event sequence gap: expected ${lastSeq!! + 1}, got $seq")
|
|
504
|
+
}
|
|
505
|
+
if (seq != null) {
|
|
506
|
+
lastSeq = seq
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Emit event
|
|
510
|
+
val eventData = JSObject().apply {
|
|
511
|
+
put("event", event)
|
|
512
|
+
payload?.let { put("payload", it) }
|
|
513
|
+
seq?.let { put("seq", it) }
|
|
514
|
+
}
|
|
515
|
+
notifyListeners("gatewayEvent", eventData)
|
|
516
|
+
}
|
|
517
|
+
} catch (e: Exception) {
|
|
518
|
+
Log.e(TAG, "Error handling message: ${e.message}")
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private fun handleHelloOk(payload: JSONObject) {
|
|
523
|
+
sessionId = UUID.randomUUID().toString()
|
|
524
|
+
protocolVersion = payload.optInt("protocol", 3)
|
|
525
|
+
|
|
526
|
+
payload.optJSONObject("auth")?.let { auth ->
|
|
527
|
+
role = auth.optString("role")
|
|
528
|
+
scopes = auth.optJSONArray("scopes")?.let { arr ->
|
|
529
|
+
(0 until arr.length()).map { arr.getString(it) }
|
|
530
|
+
} ?: emptyList()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
payload.optJSONObject("features")?.let { features ->
|
|
534
|
+
methods = features.optJSONArray("methods")?.let { arr ->
|
|
535
|
+
(0 until arr.length()).map { arr.getString(it) }
|
|
536
|
+
} ?: emptyList()
|
|
537
|
+
events = features.optJSONArray("events")?.let { arr ->
|
|
538
|
+
(0 until arr.length()).map { arr.getString(it) }
|
|
539
|
+
} ?: emptyList()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
backoffMs = 800
|
|
543
|
+
notifyStateChange("connected")
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private fun handleClose(error: Throwable?) {
|
|
547
|
+
webSocket = null
|
|
548
|
+
|
|
549
|
+
// Reject all pending requests
|
|
550
|
+
pendingRequests.forEach { (_, continuation) ->
|
|
551
|
+
continuation.resumeWithException(Exception("Connection closed"))
|
|
552
|
+
}
|
|
553
|
+
pendingRequests.clear()
|
|
554
|
+
|
|
555
|
+
if (isClosed) {
|
|
556
|
+
notifyStateChange("disconnected", error?.message)
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Attempt reconnection
|
|
561
|
+
notifyStateChange("reconnecting", error?.message)
|
|
562
|
+
notifyListeners("error", JSObject().apply {
|
|
563
|
+
put("message", "Connection lost: ${error?.message ?: "unknown"}")
|
|
564
|
+
put("willRetry", true)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
scheduleReconnect()
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private fun scheduleReconnect() {
|
|
571
|
+
if (isClosed || reconnectJob?.isActive == true) return
|
|
572
|
+
|
|
573
|
+
val delay = backoffMs
|
|
574
|
+
backoffMs = minOf((backoffMs * 1.7).toLong(), 15000)
|
|
575
|
+
|
|
576
|
+
reconnectJob = scope.launch {
|
|
577
|
+
delay(delay)
|
|
578
|
+
val url = options?.getString("url")
|
|
579
|
+
if (url != null && !isClosed) {
|
|
580
|
+
try {
|
|
581
|
+
establishConnection(url, options ?: JSObject())
|
|
582
|
+
} catch (e: Exception) {
|
|
583
|
+
handleClose(e)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private fun closeConnection() {
|
|
590
|
+
webSocket?.close(1000, "Client disconnect")
|
|
591
|
+
webSocket = null
|
|
592
|
+
okHttpClient?.dispatcher?.executorService?.shutdown()
|
|
593
|
+
okHttpClient = null
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private fun notifyStateChange(state: String, reason: String? = null) {
|
|
597
|
+
val data = JSObject().apply {
|
|
598
|
+
put("state", state)
|
|
599
|
+
reason?.let { put("reason", it) }
|
|
600
|
+
}
|
|
601
|
+
notifyListeners("stateChange", data)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
override fun handleOnDestroy() {
|
|
605
|
+
super.handleOnDestroy()
|
|
606
|
+
scope.cancel()
|
|
607
|
+
closeConnection()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Helper extension
|
|
611
|
+
private fun JSObject.toJson(): JSONObject {
|
|
612
|
+
return JSONObject(this.toString())
|
|
613
|
+
}
|
|
614
|
+
}
|