使用Ktor替代NanoHTTPD后端

This commit is contained in:
aoi
2026-05-22 01:55:38 +08:00
committed by Kudomaga
parent b7e07a6478
commit d1dcbc7e11
10 changed files with 348 additions and 406 deletions
@@ -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(
@@ -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"
}
}
@@ -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"
}
}
@@ -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
}
}
}
@@ -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<String>()
@@ -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<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) {
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()))
}
}
}
@@ -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<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
}
}
}
} 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<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
}
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()
@@ -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<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) {
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()
}
}
}
}
@@ -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"
}
}