@tamer4lynx/tamer-dev-client 0.0.1

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/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # tamer-dev-client
2
+
3
+ Native dev client module for Tamer4Lynx — QR scan, discovery, URL persistence, reload bridge, embedded dev launcher UI.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @tamer4lynx/tamer-dev-client
9
+ ```
10
+
11
+ Add to your app's dependencies and run `t4l link`. Used by **tamer-dev-app** for the dev launcher experience.
12
+
13
+ ## Usage
14
+
15
+ The dev client provides:
16
+
17
+ - **Discovery** — Find dev servers on the local network
18
+ - **Connect** — Enter URL or scan QR code to connect
19
+ - **Recent** — List of recently used dev server URLs
20
+ - **Reload** — Reload the Lynx bundle
21
+ - **Compatibility check** — Validates native modules between app and project
22
+
23
+ When building the dev app (`t4l build-dev-app`), the dev client UI is embedded and the Lynx bundle is loaded from the connected dev server.
24
+
25
+ ## Dependencies
26
+
27
+ Requires: `tamer-app-shell`, `tamer-insets`, `tamer-system-ui`, `tamer-plugin`, `tamer-router`, `react-router`, `@lynx-js/react`.
28
+
29
+ ## Platform
30
+
31
+ Uses **lynx.ext.json**. Run `t4l link` after adding to your app.
@@ -0,0 +1,34 @@
1
+ plugins {
2
+ id("com.android.library")
3
+ id("org.jetbrains.kotlin.android")
4
+ }
5
+
6
+ android {
7
+ namespace = "com.nanofuxion.tamerdevclient"
8
+ compileSdk = 35
9
+
10
+ defaultConfig {
11
+ minSdk = 28
12
+ }
13
+
14
+ compileOptions {
15
+ sourceCompatibility = JavaVersion.VERSION_17
16
+ targetCompatibility = JavaVersion.VERSION_17
17
+ }
18
+
19
+ kotlinOptions {
20
+ jvmTarget = "17"
21
+ }
22
+ }
23
+
24
+ dependencies {
25
+ implementation("org.lynxsdk.lynx:lynx-service-log:3.3.1")
26
+ implementation("androidx.activity:activity-ktx:1.8.2")
27
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
28
+ implementation("org.lynxsdk.lynx:lynx:3.3.1")
29
+ implementation("org.lynxsdk.lynx:lynx-jssdk:3.3.1")
30
+ implementation("org.lynxsdk.lynx:lynx-trace:3.3.1")
31
+ implementation("com.squareup.okhttp3:okhttp:4.9.0")
32
+ implementation("com.journeyapps:zxing-android-embedded:4.3.0")
33
+ implementation("androidx.appcompat:appcompat:1.6.1")
34
+ }
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.CAMERA" />
3
+ </manifest>
@@ -0,0 +1,311 @@
1
+ package com.nanofuxion.tamerdevclient
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.util.Log
7
+ import androidx.core.content.ContextCompat
8
+ import com.google.zxing.integration.android.IntentIntegrator
9
+ import com.lynx.jsbridge.LynxMethod
10
+ import com.lynx.jsbridge.LynxModule
11
+ import com.lynx.react.bridge.Callback
12
+ import com.lynx.react.bridge.JavaOnlyArray
13
+ import com.lynx.react.bridge.JavaOnlyMap
14
+ import com.lynx.tasm.LynxView
15
+ import com.lynx.tasm.behavior.LynxContext
16
+ import com.nanofuxion.tamerdevclient.nsd.DiscoveredServer
17
+ import com.nanofuxion.tamerdevclient.nsd.NsdDiscovery
18
+ import org.json.JSONObject
19
+
20
+ class DevClientModule(context: Context) : LynxModule(context) {
21
+
22
+ companion object {
23
+ private const val TAG = "DevClientModule"
24
+ const val ACTION_RELOAD_PROJECT = "com.tamer4lynx.RELOAD_PROJECT"
25
+ private const val PREFS = "tamer_dev_server"
26
+ private const val KEY_URL = "dev_server_url"
27
+ private const val KEY_RECENT = "dev_server_recent"
28
+
29
+ @Volatile
30
+ var instance: DevClientModule? = null
31
+
32
+ @Volatile
33
+ private var hostActivity: Activity? = null
34
+
35
+ @Volatile
36
+ private var lynxViewRef: LynxView? = null
37
+
38
+ private var requestCameraPermission: ((Runnable) -> Unit)? = null
39
+ private var launchScan: Runnable? = null
40
+ private var reloadProjectLauncher: Runnable? = null
41
+
42
+ fun attachHostActivity(activity: Activity?) {
43
+ hostActivity = activity
44
+ }
45
+
46
+ fun attachLynxView(view: LynxView?) {
47
+ lynxViewRef = view
48
+ }
49
+
50
+ fun attachCameraPermissionRequester(requester: (Runnable) -> Unit) {
51
+ requestCameraPermission = requester
52
+ }
53
+
54
+ fun attachScanLauncher(launcher: Runnable) {
55
+ launchScan = launcher
56
+ }
57
+
58
+ fun attachReloadProjectLauncher(launcher: Runnable?) {
59
+ reloadProjectLauncher = launcher
60
+ }
61
+
62
+ @Volatile
63
+ private var supportedModuleClassNames: Set<String> = emptySet()
64
+
65
+ fun attachSupportedModuleClassNames(names: List<String>) {
66
+ supportedModuleClassNames = names.filter { it.isNotBlank() }.toSet()
67
+ }
68
+
69
+ fun getSupportedModuleClassNames(): Set<String> = supportedModuleClassNames
70
+ }
71
+
72
+ private var nsdDiscovery: NsdDiscovery? = null
73
+ private var lastDiscovered: List<DiscoveredServer> = emptyList()
74
+
75
+ data class CompatibilityResult(val compatible: Boolean, val requiredModules: List<Pair<String, String>>)
76
+
77
+ private fun fetchMetaAndCheckCompatibility(baseUrl: String): CompatibilityResult? {
78
+ val supported = DevClientModule.getSupportedModuleClassNames()
79
+ if (supported.isEmpty()) return CompatibilityResult(true, emptyList())
80
+ return try {
81
+ val metaUrl = baseUrl.trimEnd('/') + "/meta.json"
82
+ val request = okhttp3.Request.Builder().url(metaUrl).build()
83
+ val client = okhttp3.OkHttpClient.Builder()
84
+ .connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
85
+ .readTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
86
+ .build()
87
+ val response = client.newCall(request).execute()
88
+ if (!response.isSuccessful || response.body == null) return null
89
+ val body = response.body!!.string()
90
+ val json = org.json.JSONObject(body)
91
+ val arr = json.optJSONArray("nativeModules") ?: return CompatibilityResult(true, emptyList())
92
+ val required = mutableListOf<Pair<String, String>>()
93
+ for (i in 0 until arr.length()) {
94
+ val obj = arr.getJSONObject(i)
95
+ val pkg = obj.optString("packageName", "")
96
+ val cls = obj.optString("moduleClassName", "")
97
+ if (cls.isNotBlank() && cls !in supported) {
98
+ required.add(pkg to cls)
99
+ }
100
+ }
101
+ CompatibilityResult(required.isEmpty(), required)
102
+ } catch (_: Exception) {
103
+ null
104
+ }
105
+ }
106
+
107
+ init {
108
+ instance = this
109
+ }
110
+
111
+ fun setActivity(activity: Activity?) {
112
+ attachHostActivity(activity)
113
+ }
114
+
115
+ fun setCameraPermissionRequester(requester: (Runnable) -> Unit) {
116
+ attachCameraPermissionRequester(requester)
117
+ }
118
+
119
+ private fun getLynxContext(): LynxContext? = mContext as? LynxContext
120
+
121
+ private fun emitDiscoveredServers(servers: List<DiscoveredServer>) {
122
+ lastDiscovered = servers
123
+ val arr = org.json.JSONArray()
124
+ for (s in servers) {
125
+ val obj = org.json.JSONObject()
126
+ obj.put("url", s.url)
127
+ obj.put("name", s.name)
128
+ arr.put(obj)
129
+ }
130
+ val payload = org.json.JSONObject().put("servers", arr).toString()
131
+ val eventDetails = JavaOnlyMap()
132
+ eventDetails.putString("payload", payload)
133
+ val params = JavaOnlyArray()
134
+ params.pushMap(eventDetails)
135
+ val lynxContext = getLynxContext()
136
+ if (lynxContext != null) {
137
+ lynxContext.sendGlobalEvent("devclient:discoveredServers", params)
138
+ } else {
139
+ lynxViewRef?.sendGlobalEvent("devclient:discoveredServers", params)
140
+ }
141
+ }
142
+
143
+ private fun prefs() = mContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
144
+
145
+ @LynxMethod
146
+ fun getDevServerUrl(callback: Callback) {
147
+ callback.invoke(prefs().getString(KEY_URL, null) ?: "")
148
+ }
149
+
150
+ @LynxMethod
151
+ fun setDevServerUrl(url: String) {
152
+ val normalized = url.trim().removeSuffix("/").let { u ->
153
+ when {
154
+ u.endsWith("/main.lynx.bundle") -> u.removeSuffix("/main.lynx.bundle")
155
+ u.endsWith("main.lynx.bundle") -> u.removeSuffix("main.lynx.bundle").trimEnd('/')
156
+ else -> u
157
+ }
158
+ }
159
+ prefs().edit().putString(KEY_URL, normalized).commit()
160
+ addRecent(normalized)
161
+ }
162
+
163
+ private fun addRecent(url: String) {
164
+ val current = parseRecentJson().filter { it != url }
165
+ val updated = listOf(url) + current
166
+ prefs().edit()
167
+ .putString(KEY_RECENT, org.json.JSONArray(updated.take(10)).toString())
168
+ .apply()
169
+ }
170
+
171
+ private fun parseRecentJson(): List<String> {
172
+ val json = prefs().getString(KEY_RECENT, "[]") ?: "[]"
173
+ return try {
174
+ val ja = org.json.JSONArray(json)
175
+ (0 until ja.length()).map { ja.getString(it) }
176
+ } catch (_: Exception) { emptyList() }
177
+ }
178
+
179
+ @LynxMethod
180
+ fun getRecentUrls(callback: Callback) {
181
+ val arr = JavaOnlyArray()
182
+ for (s in parseRecentJson()) arr.pushString(s)
183
+ callback.invoke(arr)
184
+ }
185
+
186
+ @LynxMethod
187
+ fun clearDevServerUrl() {
188
+ prefs().edit().clear().apply()
189
+ }
190
+
191
+ @LynxMethod
192
+ fun scanQR() {
193
+ val activity = hostActivity ?: (mContext as? LynxContext)?.activity as? Activity
194
+ if (activity == null) {
195
+ Log.e(TAG, "No activity for QR scan")
196
+ emitScanResult(null)
197
+ return
198
+ }
199
+ activity.runOnUiThread {
200
+ if (ContextCompat.checkSelfPermission(mContext, android.Manifest.permission.CAMERA) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
201
+ launchScanner()
202
+ return@runOnUiThread
203
+ }
204
+ val requester = requestCameraPermission
205
+ if (requester != null) {
206
+ requester(Runnable { launchScanner() })
207
+ } else {
208
+ Log.e(TAG, "No camera permission requester set; call setCameraPermissionRequester from Activity")
209
+ emitScanResult(null)
210
+ }
211
+ }
212
+ }
213
+
214
+ private fun launchScanner() {
215
+ launchScan?.run() ?: run {
216
+ val activity = hostActivity
217
+ if (activity != null) {
218
+ IntentIntegrator(activity).setPrompt("Scan dev server QR").initiateScan()
219
+ } else {
220
+ Log.e(TAG, "No scan launcher or activity for QR scan")
221
+ emitScanResult(null)
222
+ }
223
+ }
224
+ }
225
+
226
+ fun deliverScanResult(contents: String?) {
227
+ emitScanResult(contents)
228
+ }
229
+
230
+ private fun emitScanResult(url: String?) {
231
+ val json = JSONObject().apply { put("url", url ?: "") }.toString()
232
+ val eventDetails = JavaOnlyMap()
233
+ eventDetails.putString("payload", json)
234
+ val params = JavaOnlyArray()
235
+ params.pushMap(eventDetails)
236
+ val lynxContext = getLynxContext()
237
+ if (lynxContext != null) {
238
+ lynxContext.sendGlobalEvent("devclient:scanResult", params)
239
+ } else {
240
+ lynxViewRef?.sendGlobalEvent("devclient:scanResult", params)
241
+ }
242
+ }
243
+
244
+ @LynxMethod
245
+ fun reloadWithProjectBundle() {
246
+ val launcher = reloadProjectLauncher
247
+ val activity = hostActivity
248
+ if (launcher != null && activity != null) {
249
+ activity.runOnUiThread { launcher.run() }
250
+ } else {
251
+ mContext.sendBroadcast(Intent(ACTION_RELOAD_PROJECT).setPackage(mContext.packageName))
252
+ }
253
+ }
254
+
255
+ @LynxMethod
256
+ fun startDiscovery() {
257
+ val app = mContext.applicationContext
258
+ if (nsdDiscovery != null) return
259
+ nsdDiscovery = NsdDiscovery(
260
+ app as android.app.Application,
261
+ { url -> fetchMetaAndCheckCompatibility(url)?.let { it.compatible } ?: true }
262
+ ) { servers ->
263
+ emitDiscoveredServers(servers)
264
+ }
265
+ nsdDiscovery?.start()
266
+ }
267
+
268
+ @LynxMethod
269
+ fun stopDiscovery() {
270
+ nsdDiscovery?.stop()
271
+ nsdDiscovery = null
272
+ }
273
+
274
+ @LynxMethod
275
+ fun getDiscoveredServers(callback: Callback) {
276
+ val arr = JavaOnlyArray()
277
+ for (s in lastDiscovered) {
278
+ val map = JavaOnlyMap()
279
+ map.putString("url", s.url)
280
+ map.putString("name", s.name)
281
+ arr.pushMap(map)
282
+ }
283
+ callback.invoke(arr)
284
+ }
285
+
286
+ @LynxMethod
287
+ fun checkServerCompatibility(baseUrl: String, callback: Callback) {
288
+ Thread {
289
+ val result = fetchMetaAndCheckCompatibility(baseUrl)
290
+ val activity = hostActivity
291
+ if (activity != null) {
292
+ activity.runOnUiThread {
293
+ if (result == null) {
294
+ callback.invoke(true, JavaOnlyArray())
295
+ } else {
296
+ val requiredArr = JavaOnlyArray()
297
+ for ((pkg, cls) in result.requiredModules) {
298
+ val map = JavaOnlyMap()
299
+ map.putString("packageName", pkg)
300
+ map.putString("moduleClassName", cls)
301
+ requiredArr.pushMap(map)
302
+ }
303
+ callback.invoke(result.compatible, requiredArr)
304
+ }
305
+ }
306
+ } else {
307
+ callback.invoke(true, JavaOnlyArray())
308
+ }
309
+ }.start()
310
+ }
311
+ }
@@ -0,0 +1,141 @@
1
+ package com.nanofuxion.tamerdevclient
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import androidx.annotation.Keep
8
+ import com.lynx.service.log.LynxLogService
9
+ import com.lynx.tasm.service.ILynxLogService
10
+ import okhttp3.OkHttpClient
11
+ import okhttp3.Request
12
+ import okhttp3.WebSocket
13
+ import okhttp3.WebSocketListener
14
+ import org.json.JSONArray
15
+ import org.json.JSONObject
16
+ import java.util.concurrent.TimeUnit
17
+ import java.util.regex.Pattern
18
+
19
+ @Keep
20
+ object TamerRelogLogService : ILynxLogService {
21
+ private const val PREFS = "tamer_dev_server"
22
+ private const val KEY_URL = "dev_server_url"
23
+ private const val MAX_QUEUE = 100
24
+ private const val RECONNECT_DELAY_MS = 3000L
25
+
26
+ @Volatile private var appContext: Context? = null
27
+ @Volatile private var ws: WebSocket? = null
28
+ @Volatile private var connecting = false
29
+ @Volatile private var shouldReconnect = false
30
+ private val pendingQueue = ArrayDeque<String>()
31
+ private val handler = Handler(Looper.getMainLooper())
32
+
33
+ private val client = OkHttpClient.Builder()
34
+ .connectTimeout(5, TimeUnit.SECONDS)
35
+ .readTimeout(0, TimeUnit.SECONDS)
36
+ .build()
37
+
38
+ private val consoleLogPattern = Pattern.compile(
39
+ """\[.*?:(?:INFO|ERROR|WARN(?:ING)?|DEBUG|VERBOSE|FATAL):lynx_console\.cc\(\d+\)] (.+)""",
40
+ Pattern.DOTALL
41
+ )
42
+
43
+ fun init(context: Context) {
44
+ appContext = context.applicationContext
45
+ }
46
+
47
+ fun connect() {
48
+ shouldReconnect = true
49
+ if (connecting || ws != null) return
50
+ val ctx = appContext ?: return
51
+ val devUrl = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
52
+ .getString(KEY_URL, null) ?: return
53
+ val wsUrl = buildWsUrl(devUrl) ?: return
54
+ connecting = true
55
+ val request = Request.Builder().url(wsUrl).build()
56
+ client.newWebSocket(request, object : WebSocketListener() {
57
+ override fun onOpen(socket: WebSocket, response: okhttp3.Response) {
58
+ ws = socket
59
+ connecting = false
60
+ val ping = JSONObject().apply {
61
+ put("type", "console_log")
62
+ put("tag", "lynx-console")
63
+ put("message", JSONArray().put("[TamerRelog] connected"))
64
+ }.toString()
65
+ socket.send(ping)
66
+ synchronized(pendingQueue) {
67
+ while (pendingQueue.isNotEmpty()) {
68
+ socket.send(pendingQueue.removeFirst())
69
+ }
70
+ }
71
+ }
72
+ override fun onFailure(socket: WebSocket, t: Throwable, response: okhttp3.Response?) {
73
+ ws = null
74
+ connecting = false
75
+ synchronized(pendingQueue) { pendingQueue.clear() }
76
+ scheduleReconnect()
77
+ }
78
+ override fun onClosed(socket: WebSocket, code: Int, reason: String) {
79
+ ws = null
80
+ connecting = false
81
+ scheduleReconnect()
82
+ }
83
+ })
84
+ }
85
+
86
+ private fun scheduleReconnect() {
87
+ if (!shouldReconnect) return
88
+ handler.postDelayed({ connect() }, RECONNECT_DELAY_MS)
89
+ }
90
+
91
+ fun disconnect() {
92
+ shouldReconnect = false
93
+ handler.removeCallbacksAndMessages(null)
94
+ ws?.close(1000, null)
95
+ ws = null
96
+ connecting = false
97
+ synchronized(pendingQueue) { pendingQueue.clear() }
98
+ }
99
+
100
+ override fun logByPlatform(level: Int, tag: String, msg: String) {
101
+ LynxLogService.logByPlatform(level, tag, msg)
102
+
103
+ val (forwardTag, forwardMsg) = if (tag == "lynx") {
104
+ val m = consoleLogPattern.matcher(msg)
105
+ if (m.find()) "lynx-console" to m.group(1)!! else tag to msg
106
+ } else {
107
+ tag to msg
108
+ }
109
+
110
+ val payload = try {
111
+ JSONObject().apply {
112
+ put("type", "console_log")
113
+ put("tag", forwardTag)
114
+ put("message", JSONArray().put(forwardMsg))
115
+ }.toString()
116
+ } catch (_: Exception) { return }
117
+
118
+ val socket = ws
119
+ if (socket != null) {
120
+ socket.send(payload)
121
+ } else if (connecting) {
122
+ synchronized(pendingQueue) {
123
+ if (pendingQueue.size < MAX_QUEUE) pendingQueue.addLast(payload)
124
+ }
125
+ }
126
+ }
127
+
128
+ private fun buildWsUrl(devUrl: String): String? {
129
+ val uri = Uri.parse(devUrl)
130
+ val scheme = if (uri.scheme == "https") "wss" else "ws"
131
+ val host = uri.host ?: return null
132
+ val port = if (uri.port > 0) ":${uri.port}" else ""
133
+ val path = (uri.path ?: "").let { p -> (if (p.endsWith("/")) p else p + "/") + "__hmr" }
134
+ return "$scheme://$host$port$path"
135
+ }
136
+
137
+ override fun isLogOutputByPlatform(): Boolean = true
138
+ override fun getDefaultWriteFunction(): Long = 0
139
+ override fun switchLogToSystem(enableSystemLog: Boolean) {}
140
+ override fun getLogToSystemStatus(): Boolean = false
141
+ }
@@ -0,0 +1,138 @@
1
+ package com.nanofuxion.tamerdevclient.nsd
2
+
3
+ import android.app.Application
4
+ import android.content.Context
5
+ import android.net.nsd.NsdManager
6
+ import android.net.nsd.NsdServiceInfo
7
+ import android.util.Log
8
+ import kotlinx.coroutines.CoroutineScope
9
+ import kotlinx.coroutines.Dispatchers
10
+ import kotlinx.coroutines.Job
11
+ import kotlinx.coroutines.SupervisorJob
12
+ import kotlinx.coroutines.async
13
+ import kotlinx.coroutines.awaitAll
14
+ import kotlinx.coroutines.delay
15
+ import kotlinx.coroutines.ensureActive
16
+ import kotlinx.coroutines.isActive
17
+ import kotlinx.coroutines.launch
18
+ import okhttp3.OkHttpClient
19
+ import okhttp3.Request
20
+ import java.util.concurrent.TimeUnit
21
+
22
+ private const val SERVICE_TYPE = "_tamer._tcp."
23
+ private const val TAG = "NsdDiscovery"
24
+
25
+ data class DiscoveredServer(val url: String, val name: String)
26
+
27
+ class NsdDiscovery(
28
+ application: Application,
29
+ private val isCompatible: (String) -> Boolean = { true },
30
+ private val onServersChanged: (List<DiscoveredServer>) -> Unit
31
+ ) {
32
+ private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
33
+ private val nsdManager = application.getSystemService(Context.NSD_SERVICE) as? NsdManager
34
+ private val httpClient = OkHttpClient.Builder()
35
+ .connectTimeout(5, TimeUnit.SECONDS)
36
+ .readTimeout(5, TimeUnit.SECONDS)
37
+ .build()
38
+
39
+ private var isDiscovering = false
40
+ private var healthCheckJob: Job? = null
41
+ private val potentialServers = mutableSetOf<PotentialServer>()
42
+
43
+ private data class PotentialServer(val serviceName: String, val url: String, val name: String)
44
+
45
+ private val discoveryListener = object : NsdManager.DiscoveryListener {
46
+ override fun onDiscoveryStarted(serviceType: String) {}
47
+ override fun onServiceFound(service: NsdServiceInfo) {
48
+ scope.launch { resolveAndAdd(service) }
49
+ }
50
+
51
+ override fun onServiceLost(service: NsdServiceInfo) {
52
+ val name = service.serviceName ?: return
53
+ synchronized(potentialServers) {
54
+ potentialServers.removeAll { it.serviceName == name }
55
+ }
56
+ }
57
+
58
+ override fun onDiscoveryStopped(serviceType: String) {
59
+ synchronized(potentialServers) { potentialServers.clear() }
60
+ onServersChanged(emptyList())
61
+ }
62
+
63
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
64
+ Log.e(TAG, "Discovery start failed: $errorCode")
65
+ isDiscovering = false
66
+ }
67
+
68
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
69
+ Log.e(TAG, "Discovery stop failed: $errorCode")
70
+ }
71
+ }
72
+
73
+ fun start() {
74
+ if (nsdManager == null) {
75
+ Log.e(TAG, "NsdManager not available")
76
+ return
77
+ }
78
+ if (isDiscovering) return
79
+ nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
80
+ isDiscovering = true
81
+ startHealthCheck()
82
+ }
83
+
84
+ fun stop() {
85
+ if (!isDiscovering) return
86
+ nsdManager?.stopServiceDiscovery(discoveryListener)
87
+ isDiscovering = false
88
+ healthCheckJob?.cancel()
89
+ healthCheckJob = null
90
+ synchronized(potentialServers) { potentialServers.clear() }
91
+ onServersChanged(emptyList())
92
+ }
93
+
94
+ private suspend fun resolveAndAdd(serviceInfo: NsdServiceInfo) {
95
+ if (nsdManager == null) return
96
+ try {
97
+ val resolved = nsdManager.resolveServiceCoroutine(serviceInfo)
98
+ scope.ensureActive()
99
+ val baseUrl = resolved.getUrl() ?: return
100
+ val path = resolved.getAttribute("path") ?: ""
101
+ val url = baseUrl.trimEnd('/') + path
102
+ val name = resolved.getAttribute("name") ?: resolved.serviceName ?: url
103
+ synchronized(potentialServers) {
104
+ potentialServers.add(PotentialServer(resolved.serviceName ?: "", url, name))
105
+ }
106
+ } catch (e: Exception) {
107
+ Log.w(TAG, "Resolve failed: ${e.message}")
108
+ }
109
+ }
110
+
111
+ private fun startHealthCheck() {
112
+ healthCheckJob?.cancel()
113
+ healthCheckJob = scope.launch {
114
+ while (isActive && isDiscovering) {
115
+ val current = synchronized(potentialServers) { potentialServers.toList() }
116
+ val alive = current
117
+ .map { s -> async { s to (checkStatus(s.url) && isCompatible(s.url)) } }
118
+ .awaitAll()
119
+ .filter { (_, ok) -> ok }
120
+ .map { (s, _) -> DiscoveredServer(s.url, s.name) }
121
+ ensureActive()
122
+ onServersChanged(alive)
123
+ delay(3000)
124
+ }
125
+ }
126
+ }
127
+
128
+ private fun checkStatus(url: String): Boolean {
129
+ return try {
130
+ val statusUrl = url.trimEnd('/') + "/status"
131
+ val request = Request.Builder().url(statusUrl).build()
132
+ val response = httpClient.newCall(request).execute()
133
+ response.isSuccessful && (response.body?.string()?.contains("packager-status:running") == true)
134
+ } catch (_: Exception) {
135
+ false
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,26 @@
1
+ package com.nanofuxion.tamerdevclient.nsd
2
+
3
+ import android.net.nsd.NsdServiceInfo
4
+ import android.os.Build
5
+ import java.net.Inet4Address
6
+
7
+ private fun NsdServiceInfo.getHostIpv4(): Inet4Address? {
8
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
9
+ hostAddresses.filterIsInstance<Inet4Address>().firstOrNull()
10
+ } else {
11
+ @Suppress("DEPRECATION")
12
+ host as? Inet4Address
13
+ }
14
+ }
15
+
16
+ fun NsdServiceInfo.getUrl(): String? {
17
+ val hostIp = getHostIpv4() ?: return null
18
+ val port = port
19
+ return "http://${hostIp.hostAddress}:$port"
20
+ }
21
+
22
+ fun NsdServiceInfo.getAttribute(attributeName: String): String? {
23
+ val attributes = attributes ?: return null
24
+ val attributeValue = attributes[attributeName] ?: return null
25
+ return String(attributeValue, Charsets.UTF_8)
26
+ }