浏览代码

使用Ktor替代NanoHTTPD后端

aoi 5 月之前
父节点
当前提交
d1dcbc7e11

+ 6 - 2
app/build.gradle.kts

@@ -218,8 +218,12 @@ dependencies {
     implementation(libs.androidsvg)
     implementation(libs.androidsvg)
     implementation(libs.glide.svg)
     implementation(libs.glide.svg)
     implementation(libs.glide.recyclerview)
     implementation(libs.glide.recyclerview)
-    implementation(libs.nanohttpd.nanohttpd)
-    implementation(libs.nanohttpd.websocket)
+    implementation(libs.ktor.server.core)
+    implementation(libs.ktor.server.cio)
+    implementation(libs.ktor.server.content.negotiation)
+    implementation(libs.ktor.serialization.gson)
+    implementation(libs.ktor.server.cors)
+    implementation(libs.ktor.server.websockets)
     implementation(libs.zxing.lite)
     implementation(libs.zxing.lite)
     implementation(libs.colorpicker)
     implementation(libs.colorpicker)
     implementation(libs.colorpicker.compose)
     implementation(libs.colorpicker.compose)

+ 7 - 20
app/src/main/java/io/legado/app/service/WebService.kt

@@ -29,8 +29,7 @@ import io.legado.app.utils.startForegroundServiceCompat
 import io.legado.app.utils.startService
 import io.legado.app.utils.startService
 import io.legado.app.utils.stopService
 import io.legado.app.utils.stopService
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
-import io.legado.app.web.HttpServer
-import io.legado.app.web.WebSocketServer
+import io.legado.app.web.KtorServer
 import splitties.init.appCtx
 import splitties.init.appCtx
 import splitties.systemservices.powerManager
 import splitties.systemservices.powerManager
 import splitties.systemservices.wifiManager
 import splitties.systemservices.wifiManager
@@ -76,8 +75,7 @@ class WebService : BaseService() {
                 setReferenceCounted(false)
                 setReferenceCounted(false)
             }
             }
     }
     }
-    private var httpServer: HttpServer? = null
-    private var webSocketServer: WebSocketServer? = null
+    private var ktorServer: KtorServer? = null
     private var notificationList = mutableListOf(appCtx.getString(R.string.service_starting))
     private var notificationList = mutableListOf(appCtx.getString(R.string.service_starting))
     private val networkChangedListener by lazy {
     private val networkChangedListener by lazy {
         NetworkChangedListener(this)
         NetworkChangedListener(this)
@@ -143,32 +141,21 @@ class WebService : BaseService() {
         }
         }
         networkChangedListener.unRegister()
         networkChangedListener.unRegister()
         isRun = false
         isRun = false
-        if (httpServer?.isAlive == true) {
-            httpServer?.stop()
-        }
-        if (webSocketServer?.isAlive == true) {
-            webSocketServer?.stop()
-        }
+        ktorServer?.stop()
         postEvent(EventBus.WEB_SERVICE, "")
         postEvent(EventBus.WEB_SERVICE, "")
         FlowEventBus.post(EventBus.WEB_SERVICE, "")
         FlowEventBus.post(EventBus.WEB_SERVICE, "")
         upTile(false)
         upTile(false)
     }
     }
 
 
     private fun upWebServer() {
     private fun upWebServer() {
-        if (httpServer?.isAlive == true) {
-            httpServer?.stop()
-        }
-        if (webSocketServer?.isAlive == true) {
-            webSocketServer?.stop()
-        }
+        ktorServer?.stop()
         val addressList = NetworkUtils.getLocalIPAddress()
         val addressList = NetworkUtils.getLocalIPAddress()
         if (addressList.any()) {
         if (addressList.any()) {
             val port = getPort()
             val port = getPort()
-            httpServer = HttpServer(port)
-            webSocketServer = WebSocketServer(port + 1)
+            ktorServer = KtorServer(port)
             try {
             try {
-                httpServer?.start()
-                webSocketServer?.start(1000 * 30) // 通信超时设置
+                ktorServer?.start()
+                ktorServer?.startWebSocket(port + 1)
                 notificationList.clear()
                 notificationList.clear()
                 notificationList.addAll(addressList.map { address ->
                 notificationList.addAll(addressList.map { address ->
                     getString(
                     getString(

+ 0 - 152
app/src/main/java/io/legado/app/web/HttpServer.kt

@@ -1,152 +0,0 @@
-package io.legado.app.web
-
-import android.graphics.Bitmap
-import fi.iki.elonen.NanoHTTPD
-import io.legado.app.api.ReturnData
-import io.legado.app.api.controller.BookController
-import io.legado.app.api.controller.BookSourceController
-import io.legado.app.api.controller.ReplaceRuleController
-import io.legado.app.api.controller.RssSourceController
-import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.service.WebService
-import io.legado.app.utils.GSON
-import io.legado.app.utils.LogUtils
-import io.legado.app.utils.stackTraceStr
-import io.legado.app.web.utils.AssetsWeb
-import kotlinx.coroutines.runBlocking
-import okio.Pipe
-import okio.buffer
-import java.io.ByteArrayInputStream
-import java.io.ByteArrayOutputStream
-
-class HttpServer(port: Int) : NanoHTTPD(port) {
-    private val assetsWeb = AssetsWeb("web")
-
-    override fun serve(session: IHTTPSession): Response {
-        WebService.serve()
-        var returnData: ReturnData? = null
-        val ct = ContentType(session.headers["content-type"]).tryUTF8()
-        session.headers["content-type"] = ct.contentTypeHeader
-        var uri = session.uri
-
-        val startAt = System.currentTimeMillis()
-        LogUtils.d(TAG) {
-            "${session.method.name} - $uri - ${session.queryParameterString} - Start($startAt)"
-        }
-
-        try {
-            when (session.method) {
-                Method.OPTIONS -> {
-                    val response = newFixedLengthResponse("")
-                    response.addHeader("Access-Control-Allow-Methods", "POST")
-                    response.addHeader("Access-Control-Allow-Headers", "content-type")
-                    response.addHeader("Access-Control-Allow-Origin", session.headers["origin"])
-                    //response.addHeader("Access-Control-Max-Age", "3600");
-                    return response
-                }
-
-                Method.POST -> {
-                    val files = HashMap<String, String>()
-                    session.parseBody(files)
-                    val postData = files["postData"]
-
-                    returnData = runBlocking {
-                        when (uri) {
-                            "/saveBookSource" -> BookSourceController.saveSource(postData)
-                            "/saveBookSources" -> BookSourceController.saveSources(postData)
-                            "/deleteBookSources" -> BookSourceController.deleteSources(postData)
-                            "/saveBook" -> BookController.saveBook(postData)
-                            "/deleteBook" -> BookController.deleteBook(postData)
-                            "/saveBookProgress" -> BookController.saveBookProgress(postData)
-                            "/addLocalBook" -> BookController.addLocalBook(session.parameters, files)
-                            "/saveReadConfig" -> BookController.saveWebReadConfig(postData)
-                            "/saveRssSource" -> RssSourceController.saveSource(postData)
-                            "/saveRssSources" -> RssSourceController.saveSources(postData)
-                            "/deleteRssSources" -> RssSourceController.deleteSources(postData)
-                            "/saveReplaceRule" -> ReplaceRuleController.saveRule(postData)
-                            "/deleteReplaceRule" -> ReplaceRuleController.delete(postData)
-                            "/testReplaceRule" -> ReplaceRuleController.testRule(postData)
-                            else -> null
-                        }
-                    }
-                }
-
-                Method.GET -> {
-                    val parameters = session.parameters
-
-                    returnData = when (uri) {
-                        "/getBookSource" -> BookSourceController.getSource(parameters)
-                        "/getBookSources" -> BookSourceController.sources
-                        "/getBookshelf" -> BookController.bookshelf
-                        "/getChapterList" -> BookController.getChapterList(parameters)
-                        "/refreshToc" -> BookController.refreshToc(parameters)
-                        "/getBookContent" -> BookController.getBookContent(parameters)
-                        "/cover" -> BookController.getCover(parameters)
-                        "/image" -> BookController.getImg(parameters)
-                        "/getReadConfig" -> BookController.getWebReadConfig()
-                        "/getRssSource" -> RssSourceController.getSource(parameters)
-                        "/getRssSources" -> RssSourceController.sources
-                        "/getReplaceRules" -> ReplaceRuleController.allRules
-                        else -> null
-                    }
-                }
-
-                else -> Unit
-            }
-
-            if (returnData == null) {
-                if (uri.endsWith("/"))
-                    uri += "index.html"
-                return assetsWeb.getResponse(uri)
-            }
-
-            val response = if (returnData.data is Bitmap) {
-                val outputStream = ByteArrayOutputStream()
-                (returnData.data as Bitmap).compress(Bitmap.CompressFormat.PNG, 100, outputStream)
-                val byteArray = outputStream.toByteArray()
-                outputStream.close()
-                val inputStream = ByteArrayInputStream(byteArray)
-                newFixedLengthResponse(
-                    Response.Status.OK,
-                    "image/png",
-                    inputStream,
-                    byteArray.size.toLong()
-                )
-            } else {
-                val data = returnData.data
-                if (data is List<*> && data.size > 3000) {
-                    val pipe = Pipe(16 * 1024)
-                    Coroutine.async {
-                        pipe.sink.buffer().outputStream().bufferedWriter(Charsets.UTF_8).use {
-                            GSON.toJson(returnData, it)
-                        }
-                    }
-                    newChunkedResponse(
-                        Response.Status.OK,
-                        "application/json",
-                        pipe.source.buffer().inputStream()
-                    )
-                } else {
-                    newFixedLengthResponse(GSON.toJson(returnData))
-                }
-            }
-            response.addHeader("Access-Control-Allow-Methods", "GET, POST")
-            response.addHeader("Access-Control-Allow-Origin", session.headers["origin"])
-            LogUtils.d(TAG) {
-                "${session.method.name} - $uri - ${session.queryParameterString} - End($startAt)"
-            }
-            return response
-        } catch (e: Exception) {
-            LogUtils.d(TAG) {
-                "${session.method.name} - $uri - ${session.queryParameterString} - Error End($startAt)\n$e\n${e.stackTraceStr}"
-            }
-            return newFixedLengthResponse(e.message)
-        }
-
-    }
-
-    companion object {
-        private const val TAG = "HttpServer"
-    }
-
-}

+ 207 - 0
app/src/main/java/io/legado/app/web/KtorServer.kt

@@ -0,0 +1,207 @@
+package io.legado.app.web
+
+import android.graphics.Bitmap
+import io.ktor.http.*
+import io.ktor.http.content.*
+import io.ktor.serialization.gson.*
+import io.ktor.server.application.*
+import io.ktor.server.cio.*
+import io.ktor.server.engine.*
+import io.ktor.server.plugins.contentnegotiation.*
+import io.ktor.server.plugins.cors.routing.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.server.websocket.*
+import io.ktor.util.pipeline.*
+import io.legado.app.api.ReturnData
+import io.legado.app.api.controller.BookController
+import io.legado.app.api.controller.BookSourceController
+import io.legado.app.api.controller.ReplaceRuleController
+import io.legado.app.api.controller.RssSourceController
+import io.legado.app.model.localBook.LocalBook
+import io.legado.app.service.WebService
+import io.legado.app.utils.LogUtils
+import io.legado.app.utils.stackTraceStr
+import io.legado.app.web.socket.BookSearchWebSocket
+import io.legado.app.web.socket.BookSourceDebugWebSocket
+import io.legado.app.web.socket.RssSourceDebugWebSocket
+import io.legado.app.web.utils.AssetsWeb
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+
+class KtorServer(private val port: Int) {
+    private var server: ApplicationEngine? = null
+    private var wsServer: ApplicationEngine? = null
+    private val assetsWeb = AssetsWeb("web")
+
+    fun start() {
+        server = embeddedServer(CIO, port = port) {
+            install(ContentNegotiation) {
+                gson {
+                    setLenient()
+                }
+            }
+            install(CORS) {
+                anyHost()
+                allowHeader(HttpHeaders.ContentType)
+                allowMethod(HttpMethod.Options)
+                allowMethod(HttpMethod.Post)
+                allowMethod(HttpMethod.Get)
+            }
+
+            routing {
+                post("/saveBookSource") { handlePost { BookSourceController.saveSource(it) } }
+                post("/saveBookSources") { handlePost { BookSourceController.saveSources(it) } }
+                post("/deleteBookSources") { handlePost { BookSourceController.deleteSources(it) } }
+                post("/saveBook") { handlePost { BookController.saveBook(it) } }
+                post("/deleteBook") { handlePost { BookController.deleteBook(it) } }
+                post("/saveBookProgress") { handlePost { BookController.saveBookProgress(it) } }
+                post("/addLocalBook") {
+                    WebService.serve()
+                    val multipart = call.receiveMultipart()
+                    var fileName: String? = null
+                    var fileBytes: ByteArray? = null
+                    multipart.forEachPart { part ->
+                        when (part) {
+                            is PartData.FormItem -> {
+                                if (part.name == "fileName") fileName = part.value
+                            }
+                            is PartData.FileItem -> {
+                                fileBytes = part.streamProvider().readBytes()
+                            }
+                            else -> {}
+                        }
+                        part.dispose()
+                    }
+                    if (fileName != null && fileBytes != null) {
+                        val returnData = withContext(Dispatchers.IO) {
+                            kotlin.runCatching {
+                                val uri = LocalBook.saveBookFile(fileBytes!!.inputStream(), fileName!!)
+                                LocalBook.importFile(uri)
+                                ReturnData().setData(true)
+                            }.getOrElse {
+                                ReturnData().setErrorMsg(it.localizedMessage ?: "Save book error")
+                            }
+                        }
+                        respondReturnData(returnData)
+                    } else {
+                        call.respond(HttpStatusCode.BadRequest, "Missing fileName or fileData")
+                    }
+                }
+                post("/saveReadConfig") { handlePost { BookController.saveWebReadConfig(it) } }
+                post("/saveRssSource") { handlePost { RssSourceController.saveSource(it) } }
+                post("/saveRssSources") { handlePost { RssSourceController.saveSources(it) } }
+                post("/deleteRssSources") { handlePost { RssSourceController.deleteSources(it) } }
+                post("/saveReplaceRule") { handlePost { ReplaceRuleController.saveRule(it) } }
+                post("/deleteReplaceRule") { handlePost { ReplaceRuleController.delete(it) } }
+                post("/testReplaceRule") { handlePost { ReplaceRuleController.testRule(it) } }
+
+                get("/getBookSource") { handleGet { BookSourceController.getSource(it) } }
+                get("/getBookSources") { handleGet { BookSourceController.sources } }
+                get("/getBookshelf") { handleGet { BookController.bookshelf } }
+                get("/getChapterList") { handleGet { BookController.getChapterList(it) } }
+                get("/refreshToc") { handleGet { BookController.refreshToc(it) } }
+                get("/getBookContent") { handleGet { BookController.getBookContent(it) } }
+                get("/cover") { handleGet { BookController.getCover(it) } }
+                get("/image") { handleGet { BookController.getImg(it) } }
+                get("/getReadConfig") { handleGet { BookController.getWebReadConfig() } }
+                get("/getRssSource") { handleGet { RssSourceController.getSource(it) } }
+                get("/getRssSources") { handleGet { RssSourceController.sources } }
+                get("/getReplaceRules") { handleGet { ReplaceRuleController.allRules } }
+
+                get("{...}") {
+                    WebService.serve()
+                    var uri = call.request.uri.substringBefore("?")
+                    if (uri.endsWith("/")) uri += "index.html"
+                    val inputStream = assetsWeb.getInputStream(uri)
+                    if (inputStream != null) {
+                        call.respondOutputStream(ContentType.parse(assetsWeb.getMimeType(uri))) {
+                            inputStream.copyTo(this)
+                        }
+                    } else {
+                        call.respond(HttpStatusCode.NotFound)
+                    }
+                }
+            }
+        }.start(wait = false)
+    }
+
+    fun startWebSocket(wsPort: Int) {
+        wsServer = embeddedServer(CIO, port = wsPort) {
+            install(WebSockets)
+            routing {
+                webSocket("/bookSourceDebug") {
+                    BookSourceDebugWebSocket(this).handle()
+                }
+                webSocket("/rssSourceDebug") {
+                    RssSourceDebugWebSocket(this).handle()
+                }
+                webSocket("/searchBook") {
+                    BookSearchWebSocket(this).handle()
+                }
+            }
+        }.start(wait = false)
+    }
+
+    fun stop() {
+        server?.stop(1000, 1000)
+        wsServer?.stop(1000, 1000)
+    }
+
+    private suspend fun PipelineContext<Unit, ApplicationCall>.handlePost(
+        block: suspend (String?) -> ReturnData
+    ) {
+        WebService.serve()
+        try {
+            val postData = call.receiveText()
+            val returnData = block(postData)
+            respondReturnData(returnData)
+        } catch (e: Exception) {
+            LogUtils.e(TAG, e.stackTraceStr)
+            call.respondText(e.message ?: "Unknown error")
+        }
+    }
+
+    private suspend fun PipelineContext<Unit, ApplicationCall>.handleGet(
+        block: (Map<String, List<String>>) -> ReturnData?
+    ) {
+        WebService.serve()
+        try {
+            val parameters = call.request.queryParameters.toMap()
+            val returnData = block(parameters)
+            if (returnData != null) {
+                respondReturnData(returnData)
+            } else {
+                call.respond(HttpStatusCode.NotFound)
+            }
+        } catch (e: Exception) {
+            LogUtils.e(TAG, e.stackTraceStr)
+            call.respondText(e.message ?: "Unknown error")
+        }
+    }
+
+    private suspend fun PipelineContext<Unit, ApplicationCall>.respondReturnData(returnData: ReturnData) {
+        if (returnData.data is Bitmap) {
+            val bitmap = returnData.data as Bitmap
+            val outputStream = ByteArrayOutputStream()
+            withContext(Dispatchers.IO) {
+                bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+            }
+            call.respondBytes(outputStream.toByteArray(), ContentType.Image.PNG)
+        } else {
+            call.respond(returnData)
+        }
+    }
+
+    private fun Parameters.toMap(): Map<String, List<String>> {
+        val map = mutableMapOf<String, List<String>>()
+        this.forEach { s, list -> map[s] = list }
+        return map
+    }
+
+    companion object {
+        private const val TAG = "KtorServer"
+    }
+}

+ 0 - 24
app/src/main/java/io/legado/app/web/WebSocketServer.kt

@@ -1,24 +0,0 @@
-package io.legado.app.web
-
-import fi.iki.elonen.NanoWSD
-import io.legado.app.service.WebService
-import io.legado.app.web.socket.*
-
-class WebSocketServer(port: Int) : NanoWSD(port) {
-
-    override fun openWebSocket(handshake: IHTTPSession): WebSocket? {
-        WebService.serve()
-        return when (handshake.uri) {
-            "/bookSourceDebug" -> {
-                BookSourceDebugWebSocket(handshake)
-            }
-            "/rssSourceDebug" -> {
-                RssSourceDebugWebSocket(handshake)
-            }
-            "/searchBook" -> {
-                BookSearchWebSocket(handshake)
-            }
-            else -> null
-        }
-    }
-}

+ 33 - 68
app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt

@@ -1,7 +1,7 @@
 package io.legado.app.web.socket
 package io.legado.app.web.socket
 
 
-import fi.iki.elonen.NanoHTTPD
-import fi.iki.elonen.NanoWSD
+import io.ktor.server.websocket.*
+import io.ktor.websocket.*
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.BookSearchScope
@@ -11,27 +11,14 @@ import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.SearchRunEvent
 import io.legado.app.domain.usecase.SearchRunEvent
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.utils.GSON
-import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.getPrefBoolean
-import io.legado.app.utils.isJson
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
+import io.legado.app.utils.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.consumeEach
 import org.koin.core.context.GlobalContext
 import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
 import splitties.init.appCtx
-import java.io.IOException
 
 
-class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
-    NanoWSD.WebSocket(handshakeRequest),
-    CoroutineScope by MainScope() {
+class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) : CoroutineScope by session {
 
 
-    private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
     private val searchBooksUseCase: SearchBooksUseCase by lazy { GlobalContext.get().get() }
     private val searchBooksUseCase: SearchBooksUseCase by lazy { GlobalContext.get().get() }
     private val searchControl = BookSearchControl()
     private val searchControl = BookSearchControl()
     private val sentBookUrls = linkedSetOf<String>()
     private val sentBookUrls = linkedSetOf<String>()
@@ -39,62 +26,40 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
 
 
     private val SEARCH_FINISH = "Search finish"
     private val SEARCH_FINISH = "Search finish"
 
 
-    override fun onOpen() {
-        launch(IO) {
-            kotlin.runCatching {
-                while (isOpen) {
-                    ping("ping".toByteArray())
-                    delay(30000)
-                }
-            }
-        }
-    }
-
-    override fun onClose(
-        code: NanoWSD.WebSocketFrame.CloseCode,
-        reason: String,
-        initiatedByRemote: Boolean
-    ) {
-        searchJob?.cancel()
-        cancel()
-    }
-
-    override fun onMessage(message: NanoWSD.WebSocketFrame) {
-        launch(IO) {
-            kotlin.runCatching {
-                if (!message.textPayload.isJson()) {
-                    send("数据必须为Json格式")
-                    close(normalClosure, SEARCH_FINISH, false)
-                    return@launch
-                }
-                val searchMap =
-                    GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
-                if (searchMap != null) {
-                    val key = searchMap["key"]?.trim()
-                    if (key.isNullOrBlank()) {
-                        send(appCtx.getString(R.string.cannot_empty))
-                        close(normalClosure, SEARCH_FINISH, false)
-                        return@launch
+    suspend fun handle() {
+        try {
+            session.incoming.consumeEach { frame ->
+                if (frame is Frame.Text) {
+                    val text = frame.readText()
+                    if (!text.isJson()) {
+                        session.send("数据必须为Json格式")
+                        session.close(CloseReason(CloseReason.Codes.NORMAL, SEARCH_FINISH))
+                        return@consumeEach
+                    }
+                    val searchMap = GSON.fromJsonObject<Map<String, String>>(text).getOrNull()
+                    if (searchMap != null) {
+                        val key = searchMap["key"]?.trim()
+                        if (key.isNullOrBlank()) {
+                            session.send(appCtx.getString(R.string.cannot_empty))
+                            session.close(CloseReason(CloseReason.Codes.NORMAL, SEARCH_FINISH))
+                            return@consumeEach
+                        }
+                        startSearch(key)
                     }
                     }
-                    startSearch(key)
                 }
                 }
             }
             }
+        } catch (e: Exception) {
+            e.printOnDebug()
+        } finally {
+            searchJob?.cancel()
         }
         }
     }
     }
 
 
-    override fun onPong(pong: NanoWSD.WebSocketFrame) {
-
-    }
-
-    override fun onException(exception: IOException) {
-
-    }
-
     private fun startSearch(key: String) {
     private fun startSearch(key: String) {
         searchJob?.cancel()
         searchJob?.cancel()
         sentBookUrls.clear()
         sentBookUrls.clear()
         searchControl.resume()
         searchControl.resume()
-        searchJob = launch(IO) {
+        searchJob = launch(Dispatchers.IO) {
             try {
             try {
                 searchBooksUseCase
                 searchBooksUseCase
                     .execute(
                     .execute(
@@ -113,17 +78,17 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
                             is SearchRunEvent.Progress -> {
                             is SearchRunEvent.Progress -> {
                                 val newBooks = event.upsertBooks.filter { sentBookUrls.add(it.bookUrl) }
                                 val newBooks = event.upsertBooks.filter { sentBookUrls.add(it.bookUrl) }
                                 if (newBooks.isNotEmpty()) {
                                 if (newBooks.isNotEmpty()) {
-                                    send(GSON.toJson(newBooks))
+                                    session.send(GSON.toJson(newBooks))
                                 }
                                 }
                             }
                             }
 
 
-                            is SearchRunEvent.Finished -> close(normalClosure, SEARCH_FINISH, false)
+                            is SearchRunEvent.Finished -> session.close(CloseReason(CloseReason.Codes.NORMAL, SEARCH_FINISH))
                         }
                         }
                     }
                     }
             } catch (exception: CancellationException) {
             } catch (exception: CancellationException) {
                 throw exception
                 throw exception
             } catch (exception: Throwable) {
             } catch (exception: Throwable) {
-                close(normalClosure, exception.toString(), false)
+                session.close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, exception.toString()))
             }
             }
         }
         }
     }
     }

+ 38 - 64
app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt

@@ -1,97 +1,71 @@
 package io.legado.app.web.socket
 package io.legado.app.web.socket
 
 
-
-import fi.iki.elonen.NanoHTTPD
-import fi.iki.elonen.NanoWSD
+import io.ktor.server.websocket.*
+import io.ktor.websocket.*
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
 import io.legado.app.utils.*
 import io.legado.app.utils.*
 import kotlinx.coroutines.*
 import kotlinx.coroutines.*
-import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.channels.consumeEach
 import splitties.init.appCtx
 import splitties.init.appCtx
 
 
-import java.io.IOException
-
 /**
 /**
  * web端书源调试
  * web端书源调试
  */
  */
-class BookSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
-    NanoWSD.WebSocket(handshakeRequest),
-    CoroutineScope by MainScope(),
+class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) :
+    CoroutineScope by session,
     Debug.Callback {
     Debug.Callback {
 
 
     private val notPrintState = arrayOf(10, 20, 30, 40)
     private val notPrintState = arrayOf(10, 20, 30, 40)
 
 
-    override fun onOpen() {
-        launch(IO) {
-            kotlin.runCatching {
-                while (isOpen) {
-                    ping("ping".toByteArray())
-                    delay(30000)
-                }
-            }
-        }
-    }
-
-    override fun onClose(
-        code: NanoWSD.WebSocketFrame.CloseCode,
-        reason: String,
-        initiatedByRemote: Boolean
-    ) {
-        cancel()
-        Debug.cancelDebug(true)
-    }
-
-    override fun onMessage(message: NanoWSD.WebSocketFrame) {
-        launch(IO) {
-            kotlin.runCatching {
-                if (!message.textPayload.isJson()) {
-                    send("数据必须为Json格式")
-                    close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
-                    return@launch
-                }
-                val debugBean =
-                    GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
-                if (debugBean != null) {
-                    val tag = debugBean["tag"]
-                    val key = debugBean["key"]
-                    if (tag.isNullOrBlank() || key.isNullOrBlank()) {
-                        send(appCtx.getString(R.string.cannot_empty))
-                        close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
-                        return@launch
+    suspend fun handle() {
+        try {
+            session.incoming.consumeEach { frame ->
+                if (frame is Frame.Text) {
+                    val text = frame.readText()
+                    if (!text.isJson()) {
+                        session.send("数据必须为Json格式")
+                        session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                        return@consumeEach
                     }
                     }
-                    appDb.bookSourceDao.getBookSource(tag)?.let {
-                        Debug.callback = this@BookSourceDebugWebSocket
-                        Debug.startDebug(this, it, key)
+                    val debugBean = GSON.fromJsonObject<Map<String, String>>(text).getOrNull()
+                    if (debugBean != null) {
+                        val tag = debugBean["tag"]
+                        val key = debugBean["key"]
+                        if (tag.isNullOrBlank() || key.isNullOrBlank()) {
+                            session.send(appCtx.getString(R.string.cannot_empty))
+                            session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                            return@consumeEach
+                        }
+                        appDb.bookSourceDao.getBookSource(tag)?.let {
+                            Debug.callback = this@BookSourceDebugWebSocket
+                            Debug.startDebug(this, it, key)
+                        }
+                    } else {
+                        session.send("数据必须为Json格式")
+                        session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                        return@consumeEach
                     }
                     }
-                } else {
-                    send("数据必须为Json格式")
-                    close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
-                    return@launch
                 }
                 }
             }
             }
+        } catch (e: Exception) {
+            e.printOnDebug()
+        } finally {
+            Debug.cancelDebug(true)
         }
         }
     }
     }
 
 
-    override fun onPong(pong: NanoWSD.WebSocketFrame) {
-
-    }
-
-    override fun onException(exception: IOException) {
-        Debug.cancelDebug(true)
-    }
-
     override fun printLog(state: Int, msg: String) {
     override fun printLog(state: Int, msg: String) {
         if (state in notPrintState) {
         if (state in notPrintState) {
             return
             return
         }
         }
-        runOnIO {
+        launch(Dispatchers.IO) {
             runCatching {
             runCatching {
-                send(msg)
+                session.send(msg)
                 if (state == -1 || state == 1000) {
                 if (state == -1 || state == 1000) {
                     Debug.cancelDebug(true)
                     Debug.cancelDebug(true)
-                    close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
+                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
                 }
                 }
             }.onFailure {
             }.onFailure {
                 it.printOnDebug()
                 it.printOnDebug()

+ 33 - 60
app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt

@@ -1,97 +1,70 @@
 package io.legado.app.web.socket
 package io.legado.app.web.socket
 
 
-
-import fi.iki.elonen.NanoHTTPD
-import fi.iki.elonen.NanoWSD
+import io.ktor.server.websocket.*
+import io.ktor.websocket.*
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
 import io.legado.app.utils.*
 import io.legado.app.utils.*
 import kotlinx.coroutines.*
 import kotlinx.coroutines.*
-import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.channels.consumeEach
 import splitties.init.appCtx
 import splitties.init.appCtx
 
 
-import java.io.IOException
-
 /**
 /**
  * web端订阅源调试
  * web端订阅源调试
  */
  */
-class RssSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
-    NanoWSD.WebSocket(handshakeRequest),
-    CoroutineScope by MainScope(),
+class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) :
+    CoroutineScope by session,
     Debug.Callback {
     Debug.Callback {
 
 
     private val notPrintState = arrayOf(10, 20, 30, 40)
     private val notPrintState = arrayOf(10, 20, 30, 40)
 
 
-    override fun onOpen() {
-        launch(IO) {
-            kotlin.runCatching {
-                while (isOpen) {
-                    ping("ping".toByteArray())
-                    delay(30000)
-                }
-            }
-        }
-    }
-
-    override fun onClose(
-        code: NanoWSD.WebSocketFrame.CloseCode,
-        reason: String,
-        initiatedByRemote: Boolean
-    ) {
-        cancel()
-        Debug.cancelDebug(true)
-    }
-
-    override fun onMessage(message: NanoWSD.WebSocketFrame) {
-        launch(IO) {
-            kotlin.runCatching {
-                if (!message.textPayload.isJson()) {
-                    send("数据必须为Json格式")
-                    close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
-                    return@launch
-                }
-                val debugBean =
-                    GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
-                if (debugBean != null) {
-                    val tag = debugBean["tag"]
-                    if (tag.isNullOrBlank()) {
-                        send(appCtx.getString(R.string.cannot_empty))
-                        close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
-                        return@launch
+    suspend fun handle() {
+        try {
+            session.incoming.consumeEach { frame ->
+                if (frame is Frame.Text) {
+                    val text = frame.readText()
+                    if (!text.isJson()) {
+                        session.send("数据必须为Json格式")
+                        session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                        return@consumeEach
                     }
                     }
-                    appDb.rssSourceDao.getByKey(tag)?.let {
-                        Debug.callback = this@RssSourceDebugWebSocket
-                        Debug.startDebug(this, it)
+                    val debugBean = GSON.fromJsonObject<Map<String, String>>(text).getOrNull()
+                    if (debugBean != null) {
+                        val tag = debugBean["tag"]
+                        if (tag.isNullOrBlank()) {
+                            session.send(appCtx.getString(R.string.cannot_empty))
+                            session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                            return@consumeEach
+                        }
+                        appDb.rssSourceDao.getByKey(tag)?.let {
+                            Debug.callback = this@RssSourceDebugWebSocket
+                            Debug.startDebug(this, it)
+                        }
                     }
                     }
                 }
                 }
             }
             }
+        } catch (e: Exception) {
+            e.printOnDebug()
+        } finally {
+            Debug.cancelDebug(true)
         }
         }
     }
     }
 
 
-    override fun onPong(pong: NanoWSD.WebSocketFrame) {
-
-    }
-
-    override fun onException(exception: IOException) {
-        Debug.cancelDebug(true)
-    }
-
     override fun printLog(state: Int, msg: String) {
     override fun printLog(state: Int, msg: String) {
         if (state in notPrintState) {
         if (state in notPrintState) {
             return
             return
         }
         }
-        runOnIO {
+        launch(Dispatchers.IO) {
             runCatching {
             runCatching {
-                send(msg)
+                session.send(msg)
                 if (state == -1 || state == 1000) {
                 if (state == -1 || state == 1000) {
                     Debug.cancelDebug(true)
                     Debug.cancelDebug(true)
-                    close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
+                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
                 }
                 }
             }.onFailure {
             }.onFailure {
                 it.printOnDebug()
                 it.printOnDebug()
             }
             }
         }
         }
     }
     }
-
 }
 }

+ 14 - 14
app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt

@@ -2,10 +2,9 @@ package io.legado.app.web.utils
 
 
 import android.content.res.AssetManager
 import android.content.res.AssetManager
 import android.text.TextUtils
 import android.text.TextUtils
-import fi.iki.elonen.NanoHTTPD
 import splitties.init.appCtx
 import splitties.init.appCtx
 import java.io.File
 import java.io.File
-import java.io.IOException
+import java.io.InputStream
 
 
 
 
 class AssetsWeb(rootPath: String) {
 class AssetsWeb(rootPath: String) {
@@ -18,20 +17,19 @@ class AssetsWeb(rootPath: String) {
         }
         }
     }
     }
 
 
-    @Throws(IOException::class)
-    fun getResponse(path: String): NanoHTTPD.Response {
-        var path1 = path
-        path1 = (rootPath + path1).replace("/+".toRegex(), File.separator)
-        val inputStream = assetManager.open(path1)
-        return NanoHTTPD.newChunkedResponse(
-            NanoHTTPD.Response.Status.OK,
-            getMimeType(path1),
-            inputStream
-        )
+    fun getInputStream(path: String): InputStream? {
+        val path1 = (rootPath + path).replace("/+".toRegex(), File.separator)
+        return try {
+            assetManager.open(path1)
+        } catch (e: Exception) {
+            null
+        }
     }
     }
 
 
-    private fun getMimeType(path: String): String {
-        val suffix = path.substring(path.lastIndexOf("."))
+    fun getMimeType(path: String): String {
+        val lastDot = path.lastIndexOf(".")
+        if (lastDot == -1) return "text/html"
+        val suffix = path.substring(lastDot)
         return when {
         return when {
             suffix.equals(".html", ignoreCase = true)
             suffix.equals(".html", ignoreCase = true)
                     || suffix.equals(".htm", ignoreCase = true) -> "text/html"
                     || suffix.equals(".htm", ignoreCase = true) -> "text/html"
@@ -39,6 +37,8 @@ class AssetsWeb(rootPath: String) {
             suffix.equals(".css", ignoreCase = true) -> "text/css"
             suffix.equals(".css", ignoreCase = true) -> "text/css"
             suffix.equals(".ico", ignoreCase = true) -> "image/x-icon"
             suffix.equals(".ico", ignoreCase = true) -> "image/x-icon"
             suffix.equals(".jpg", ignoreCase = true) -> "image/jpg"
             suffix.equals(".jpg", ignoreCase = true) -> "image/jpg"
+            suffix.equals(".png", ignoreCase = true) -> "image/png"
+            suffix.equals(".svg", ignoreCase = true) -> "image/svg+xml"
             else -> "text/html"
             else -> "text/html"
         }
         }
     }
     }

+ 8 - 0
gradle/libs.versions.toml

@@ -37,6 +37,7 @@ lifecycle = "2.10.0"
 glide = "5.0.7"
 glide = "5.0.7"
 gson = "2.14.0"
 gson = "2.14.0"
 jsonPath = "3.0.0"
 jsonPath = "3.0.0"
+ktor = "2.3.12"
 # issue #3811,不要更新版本,新版引入了一个破坏性变更(详见https://github.com/jhy/jsoup/pull/2017)
 # issue #3811,不要更新版本,新版引入了一个破坏性变更(详见https://github.com/jhy/jsoup/pull/2017)
 # 若要升级请确保相关代码不会受此变更影响(如AnalyzeByJSoup.kt、JsoupXpath库等)
 # 若要升级请确保相关代码不会受此变更影响(如AnalyzeByJSoup.kt、JsoupXpath库等)
 jsoup = "1.16.2"
 jsoup = "1.16.2"
@@ -180,6 +181,13 @@ markwon-image-glide = { module = "io.noties.markwon:image-glide", version.ref =
 markwon-ext-tables = { module = "io.noties.markwon:ext-tables", version.ref = "markwon" }
 markwon-ext-tables = { module = "io.noties.markwon:ext-tables", version.ref = "markwon" }
 markwon-html = { module = "io.noties.markwon:html", version.ref = "markwon" }
 markwon-html = { module = "io.noties.markwon:html", version.ref = "markwon" }
 
 
+ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
+ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
+ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
+ktor-serialization-gson = { module = "io.ktor:ktor-serialization-gson", version.ref = "ktor" }
+ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
+ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
+
 nanohttpd-nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanoHttpd" }
 nanohttpd-nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanoHttpd" }
 nanohttpd-websocket = { module = "org.nanohttpd:nanohttpd-websocket", version.ref = "nanoHttpd" }
 nanohttpd-websocket = { module = "org.nanohttpd:nanohttpd-websocket", version.ref = "nanoHttpd" }