@@ -4,17 +4,25 @@ package com.penumbraos.bridge_settings
44
55import android.util.Log
66import com.penumbraos.bridge_settings.json.toJsonElement
7+ import com.penumbraos.bridge_settings.server.EndpointCallback
8+ import com.penumbraos.bridge_settings.server.EndpointRequest
9+ import com.penumbraos.bridge_settings.server.RegisteredEndpoint
710import io.ktor.http.ContentType
811import io.ktor.http.HttpStatusCode
912import io.ktor.serialization.kotlinx.json.json
1013import io.ktor.server.application.Application
14+ import io.ktor.server.application.ApplicationCallPipeline
15+ import io.ktor.server.application.call
1116import io.ktor.server.application.install
1217import io.ktor.server.engine.EmbeddedServer
1318import io.ktor.server.engine.embeddedServer
1419import io.ktor.server.netty.Netty
1520import io.ktor.server.netty.NettyApplicationEngine
1621import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
22+ import io.ktor.server.request.httpMethod
1723import io.ktor.server.request.receive
24+ import io.ktor.server.request.receiveText
25+ import io.ktor.server.request.uri
1826import io.ktor.server.response.respond
1927import io.ktor.server.response.respondBytes
2028import io.ktor.server.response.respondText
@@ -25,6 +33,7 @@ import io.ktor.server.websocket.WebSockets
2533import io.ktor.server.websocket.pingPeriod
2634import io.ktor.server.websocket.timeout
2735import io.ktor.server.websocket.webSocket
36+ import io.ktor.util.toMap
2837import io.ktor.websocket.DefaultWebSocketSession
2938import io.ktor.websocket.Frame
3039import io.ktor.websocket.readText
@@ -139,9 +148,55 @@ class SettingsWebServer(
139148 private val webSocketSessions = ConcurrentHashMap <String , DefaultWebSocketSession >()
140149 private val serverScope = CoroutineScope (SupervisorJob () + Dispatchers .IO )
141150 private val logStreamProvider = LogStreamProvider ()
151+ private val registeredEndpoints = ConcurrentHashMap <String , RegisteredEndpoint >()
142152
143153 fun getLogStreamProvider (): LogStreamProvider = logStreamProvider
144154
155+ fun registerEndpoint (
156+ providerId : String ,
157+ path : String ,
158+ method : String ,
159+ callback : EndpointCallback
160+ ): Boolean {
161+ val normalizedPath = if (path.startsWith(" /" )) path else " /$path "
162+ val endpointKey = " ${method.uppercase()} :$normalizedPath "
163+
164+ if (registeredEndpoints.containsKey(endpointKey)) {
165+ Log .w(TAG , " Endpoint already registered: $endpointKey " )
166+ return false
167+ }
168+
169+ val endpoint = RegisteredEndpoint (normalizedPath, method.uppercase(), callback, providerId)
170+ registeredEndpoints[endpointKey] = endpoint
171+ Log .i(TAG , " Registered endpoint: $endpointKey for provider: $providerId " )
172+ return true
173+ }
174+
175+ fun unregisterEndpoint (providerId : String , path : String , method : String ): Boolean {
176+ val normalizedPath = if (path.startsWith(" /" )) path else " /$path "
177+ val endpointKey = " ${method.uppercase()} :$normalizedPath "
178+
179+ val endpoint = registeredEndpoints[endpointKey]
180+ if (endpoint?.providerId == providerId) {
181+ registeredEndpoints.remove(endpointKey)
182+ Log .i(TAG , " Unregistered endpoint: $endpointKey for provider: $providerId " )
183+ return true
184+ }
185+
186+ Log .w(TAG , " Cannot unregister endpoint $endpointKey - not found or wrong provider" )
187+ return false
188+ }
189+
190+ fun unregisterAllEndpointsForProvider (providerId : String ) {
191+ val toRemove = registeredEndpoints.entries.filter { it.value.providerId == providerId }
192+ toRemove.forEach { registeredEndpoints.remove(it.key) }
193+ Log .i(TAG , " Unregistered ${toRemove.size} endpoints for provider: $providerId " )
194+ }
195+
196+ fun getRegisteredEndpoints (): List <RegisteredEndpoint > {
197+ return registeredEndpoints.values.toList()
198+ }
199+
145200 suspend fun start () {
146201 Log .i(TAG , " Starting settings web server on port $port " )
147202
@@ -224,12 +279,63 @@ class SettingsWebServer(
224279 masking = false
225280 }
226281
282+ intercept(ApplicationCallPipeline .Call ) {
283+ val fullPath =
284+ call.request.uri.substringBefore(' ?' ) // Remove query params from path
285+ val method = call.request.httpMethod.value
286+ val endpointKey = " ${method} :$fullPath "
287+
288+ val endpoint = registeredEndpoints[endpointKey]
289+ if (endpoint != null ) {
290+ try {
291+ val headers = call.request.headers.toMap()
292+ .mapValues { it.value.firstOrNull() ? : " " }
293+ val queryParams = call.request.queryParameters.toMap()
294+ .mapValues { it.value.firstOrNull() ? : " " }
295+ val body = try {
296+ call.receiveText()
297+ } catch (e: Exception ) {
298+ null
299+ }
300+
301+ val request = EndpointRequest (
302+ path = fullPath,
303+ method = method,
304+ headers = headers,
305+ queryParams = queryParams,
306+ body = body
307+ )
308+
309+ val response = endpoint.callback.handle(request)
310+
311+ response.headers.forEach { (key, value) ->
312+ call.response.headers.append(key, value)
313+ }
314+
315+ val contentType = ContentType .parse(response.contentType)
316+ call.respondText(
317+ response.body,
318+ contentType,
319+ HttpStatusCode .fromValue(response.statusCode)
320+ )
321+ return @intercept finish()
322+ } catch (e: Exception ) {
323+ Log .e(TAG , " Error handling dynamic endpoint $endpointKey " , e)
324+ call.respond(
325+ HttpStatusCode .InternalServerError ,
326+ mapOf (" error" to " Internal server error" )
327+ )
328+ return @intercept finish()
329+ }
330+ }
331+ }
332+
227333 routing {
228334 // WebSocket endpoint for real-time communication
229335 webSocket(" /ws/settings" ) {
230336 handleWebSocketConnection(this )
231337 }
232-
338+
233339 // REST API endpoints
234340 get(" /api/settings" ) {
235341 try {
@@ -273,7 +379,8 @@ class SettingsWebServer(
273379 get(" /api/settings/app/{appId}" ) {
274380 try {
275381 val appId =
276- call.parameters[" appId" ] ? : throw IllegalArgumentException (" Missing appId" )
382+ call.parameters[" appId" ]
383+ ? : throw IllegalArgumentException (" Missing appId" )
277384 val appSettings = settingsRegistry.getAllAppSettings(appId)
278385 call.respond(appSettings)
279386 } catch (e: Exception ) {
@@ -285,7 +392,8 @@ class SettingsWebServer(
285392 post(" /api/settings/app/{appId}/{category}/{key}" ) {
286393 try {
287394 val appId =
288- call.parameters[" appId" ] ? : throw IllegalArgumentException (" Missing appId" )
395+ call.parameters[" appId" ]
396+ ? : throw IllegalArgumentException (" Missing appId" )
289397 val category = call.parameters[" category" ]
290398 ? : throw IllegalArgumentException (" Missing category" )
291399 val key =
@@ -334,7 +442,10 @@ class SettingsWebServer(
334442 )
335443 call.respondBytes(zipBytes)
336444
337- Log .i(TAG , " Logs zip generated and sent: $filename (${zipBytes.size} bytes)" )
445+ Log .i(
446+ TAG ,
447+ " Logs zip generated and sent: $filename (${zipBytes.size} bytes)"
448+ )
338449 } catch (e: Exception ) {
339450 Log .e(TAG , " Error generating logs zip" , e)
340451 call.respond(
@@ -353,8 +464,11 @@ class SettingsWebServer(
353464 val inputStream = getResourceFromApk(resourcePath)
354465 if (inputStream != null ) {
355466 val contentType = getContentType(resourcePath)
356-
357- call.response.headers.append(" Cache-Control" , " no-cache, no-store, must-revalidate" )
467+
468+ call.response.headers.append(
469+ " Cache-Control" ,
470+ " no-cache, no-store, must-revalidate"
471+ )
358472 call.response.headers.append(" Pragma" , " no-cache" )
359473 call.response.headers.append(" Expires" , " 0" )
360474
@@ -364,7 +478,10 @@ class SettingsWebServer(
364478 // File not found, try to serve index.html for SPA routing
365479 val indexStream = getResourceFromApk(" react-build/index.html" )
366480 if (indexStream != null ) {
367- call.response.headers.append(" Cache-Control" , " no-cache, no-store, must-revalidate" )
481+ call.response.headers.append(
482+ " Cache-Control" ,
483+ " no-cache, no-store, must-revalidate"
484+ )
368485 call.response.headers.append(" Pragma" , " no-cache" )
369486 call.response.headers.append(" Expires" , " 0" )
370487
@@ -383,7 +500,10 @@ class SettingsWebServer(
383500 get(" /" ) {
384501 val indexStream = getResourceFromApk(" react-build/index.html" )
385502 if (indexStream != null ) {
386- call.response.headers.append(" Cache-Control" , " no-cache, no-store, must-revalidate" )
503+ call.response.headers.append(
504+ " Cache-Control" ,
505+ " no-cache, no-store, must-revalidate"
506+ )
387507 call.response.headers.append(" Pragma" , " no-cache" )
388508 call.response.headers.append(" Expires" , " 0" )
389509
0 commit comments