From d1dcbc7e118663fd012d33044336df5ee879073e Mon Sep 17 00:00:00 2001 From: aoi Date: Tue, 19 May 2026 15:52:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8Ktor=E6=9B=BF=E4=BB=A3NanoHTT?= =?UTF-8?q?PD=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 8 +- .../java/io/legado/app/service/WebService.kt | 27 +-- .../main/java/io/legado/app/web/HttpServer.kt | 152 ------------- .../main/java/io/legado/app/web/KtorServer.kt | 207 ++++++++++++++++++ .../java/io/legado/app/web/WebSocketServer.kt | 24 -- .../app/web/socket/BookSearchWebSocket.kt | 101 +++------ .../web/socket/BookSourceDebugWebSocket.kt | 106 ++++----- .../app/web/socket/RssSourceDebugWebSocket.kt | 93 +++----- .../java/io/legado/app/web/utils/AssetsWeb.kt | 28 +-- gradle/libs.versions.toml | 8 + 10 files changed, 348 insertions(+), 406 deletions(-) delete mode 100644 app/src/main/java/io/legado/app/web/HttpServer.kt create mode 100644 app/src/main/java/io/legado/app/web/KtorServer.kt delete mode 100644 app/src/main/java/io/legado/app/web/WebSocketServer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1343b12df..928d109e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -218,8 +218,12 @@ dependencies { implementation(libs.androidsvg) implementation(libs.glide.svg) 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.colorpicker) implementation(libs.colorpicker.compose) diff --git a/app/src/main/java/io/legado/app/service/WebService.kt b/app/src/main/java/io/legado/app/service/WebService.kt index 563812b2d..f9d017475 100644 --- a/app/src/main/java/io/legado/app/service/WebService.kt +++ b/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.stopService 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.systemservices.powerManager import splitties.systemservices.wifiManager @@ -76,8 +75,7 @@ class WebService : BaseService() { 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 val networkChangedListener by lazy { NetworkChangedListener(this) @@ -143,32 +141,21 @@ class WebService : BaseService() { } networkChangedListener.unRegister() isRun = false - if (httpServer?.isAlive == true) { - httpServer?.stop() - } - if (webSocketServer?.isAlive == true) { - webSocketServer?.stop() - } + ktorServer?.stop() postEvent(EventBus.WEB_SERVICE, "") FlowEventBus.post(EventBus.WEB_SERVICE, "") upTile(false) } private fun upWebServer() { - if (httpServer?.isAlive == true) { - httpServer?.stop() - } - if (webSocketServer?.isAlive == true) { - webSocketServer?.stop() - } + ktorServer?.stop() val addressList = NetworkUtils.getLocalIPAddress() if (addressList.any()) { val port = getPort() - httpServer = HttpServer(port) - webSocketServer = WebSocketServer(port + 1) + ktorServer = KtorServer(port) try { - httpServer?.start() - webSocketServer?.start(1000 * 30) // 通信超时设置 + ktorServer?.start() + ktorServer?.startWebSocket(port + 1) notificationList.clear() notificationList.addAll(addressList.map { address -> getString( diff --git a/app/src/main/java/io/legado/app/web/HttpServer.kt b/app/src/main/java/io/legado/app/web/HttpServer.kt deleted file mode 100644 index 201aa17d6..000000000 --- a/app/src/main/java/io/legado/app/web/HttpServer.kt +++ /dev/null @@ -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() - 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" - } - -} diff --git a/app/src/main/java/io/legado/app/web/KtorServer.kt b/app/src/main/java/io/legado/app/web/KtorServer.kt new file mode 100644 index 000000000..94c877aeb --- /dev/null +++ b/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.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.handleGet( + block: (Map>) -> 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.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> { + val map = mutableMapOf>() + this.forEach { s, list -> map[s] = list } + return map + } + + companion object { + private const val TAG = "KtorServer" + } +} diff --git a/app/src/main/java/io/legado/app/web/WebSocketServer.kt b/app/src/main/java/io/legado/app/web/WebSocketServer.kt deleted file mode 100644 index f2c7c27a0..000000000 --- a/app/src/main/java/io/legado/app/web/WebSocketServer.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt index f0d147331..987b41d94 100644 --- a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt +++ b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt @@ -1,7 +1,7 @@ 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.constant.PreferKey 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.help.config.AppConfig 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 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 searchControl = BookSearchControl() private val sentBookUrls = linkedSetOf() @@ -39,62 +26,40 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : 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>(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>(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) { searchJob?.cancel() sentBookUrls.clear() searchControl.resume() - searchJob = launch(IO) { + searchJob = launch(Dispatchers.IO) { try { searchBooksUseCase .execute( @@ -113,17 +78,17 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : is SearchRunEvent.Progress -> { val newBooks = event.upsertBooks.filter { sentBookUrls.add(it.bookUrl) } 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) { throw exception } catch (exception: Throwable) { - close(normalClosure, exception.toString(), false) + session.close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, exception.toString())) } } } diff --git a/app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt index 1e158399d..197293b16 100644 --- a/app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt +++ b/app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt @@ -1,97 +1,71 @@ 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.data.appDb import io.legado.app.model.Debug import io.legado.app.utils.* import kotlinx.coroutines.* -import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.channels.consumeEach import splitties.init.appCtx -import java.io.IOException - /** * web端书源调试 */ -class BookSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : - NanoWSD.WebSocket(handshakeRequest), - CoroutineScope by MainScope(), +class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) : + CoroutineScope by session, Debug.Callback { private val notPrintState = arrayOf(10, 20, 30, 40) - override fun onOpen() { - launch(IO) { - kotlin.runCatching { - while (isOpen) { - ping("ping".toByteArray()) - delay(30000) + 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 + } + val debugBean = GSON.fromJsonObject>(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 + } } } + } catch (e: Exception) { + e.printOnDebug() + } finally { + Debug.cancelDebug(true) } } - 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>(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 - } - appDb.bookSourceDao.getBookSource(tag)?.let { - Debug.callback = this@BookSourceDebugWebSocket - Debug.startDebug(this, it, key) - } - } else { - send("数据必须为Json格式") - close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) - return@launch - } - } - } - } - - override fun onPong(pong: NanoWSD.WebSocketFrame) { - - } - - override fun onException(exception: IOException) { - Debug.cancelDebug(true) - } - override fun printLog(state: Int, msg: String) { if (state in notPrintState) { return } - runOnIO { + launch(Dispatchers.IO) { runCatching { - send(msg) + session.send(msg) if (state == -1 || state == 1000) { Debug.cancelDebug(true) - close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束")) } }.onFailure { it.printOnDebug() diff --git a/app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt index 6eae0d39b..9057aaae0 100644 --- a/app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt +++ b/app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt @@ -1,97 +1,70 @@ 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.data.appDb import io.legado.app.model.Debug import io.legado.app.utils.* import kotlinx.coroutines.* -import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.channels.consumeEach import splitties.init.appCtx -import java.io.IOException - /** * web端订阅源调试 */ -class RssSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : - NanoWSD.WebSocket(handshakeRequest), - CoroutineScope by MainScope(), +class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) : + CoroutineScope by session, Debug.Callback { 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>(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>(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) { if (state in notPrintState) { return } - runOnIO { + launch(Dispatchers.IO) { runCatching { - send(msg) + session.send(msg) if (state == -1 || state == 1000) { Debug.cancelDebug(true) - close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束")) } }.onFailure { it.printOnDebug() } } } - } diff --git a/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt b/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt index 2e07979f5..5f58e27cd 100644 --- a/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt +++ b/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.text.TextUtils -import fi.iki.elonen.NanoHTTPD import splitties.init.appCtx import java.io.File -import java.io.IOException +import java.io.InputStream 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 { suffix.equals(".html", ignoreCase = true) || suffix.equals(".htm", ignoreCase = true) -> "text/html" @@ -39,6 +37,8 @@ class AssetsWeb(rootPath: String) { suffix.equals(".css", ignoreCase = true) -> "text/css" suffix.equals(".ico", ignoreCase = true) -> "image/x-icon" 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" } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68760ee17..aefc32034 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ lifecycle = "2.10.0" glide = "5.0.7" gson = "2.14.0" jsonPath = "3.0.0" +ktor = "2.3.12" # issue #3811,不要更新版本,新版引入了一个破坏性变更(详见https://github.com/jhy/jsoup/pull/2017) # 若要升级请确保相关代码不会受此变更影响(如AnalyzeByJSoup.kt、JsoupXpath库等) 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-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-websocket = { module = "org.nanohttpd:nanohttpd-websocket", version.ref = "nanoHttpd" }