From f42bf88ad91637a3fbe096a9596c67f1685eb9ec Mon Sep 17 00:00:00 2001 From: yaoxieyoulei <1622968661@qq.com> Date: Mon, 22 Apr 2024 11:39:31 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20=E8=B7=9F=E6=8D=A2nanohttpd=EF=BC=8C?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=E6=96=B0=E5=A2=9E=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0apk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 +- .../top/yogiczy/mytv/ui/utils/HttpServer.kt | 240 +++++++++--------- app/src/main/res/raw/index.html | 47 +++- gradle/libs.versions.toml | 4 +- 4 files changed, 166 insertions(+), 128 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0f783c82..48f2e928 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -115,7 +115,8 @@ dependencies { // 网络请求 implementation(libs.okhttp) - implementation(libs.nanohttpd) + implementation(libs.androidasync) + // 二维码 implementation(libs.qrose) diff --git a/app/src/main/java/top/yogiczy/mytv/ui/utils/HttpServer.kt b/app/src/main/java/top/yogiczy/mytv/ui/utils/HttpServer.kt index 8666d093..047d7c20 100644 --- a/app/src/main/java/top/yogiczy/mytv/ui/utils/HttpServer.kt +++ b/app/src/main/java/top/yogiczy/mytv/ui/utils/HttpServer.kt @@ -1,32 +1,56 @@ package top.yogiczy.mytv.ui.utils -import android.annotation.SuppressLint import android.content.Context import android.widget.Toast -import fi.iki.elonen.NanoHTTPD +import com.koushikdutta.async.AsyncServer +import com.koushikdutta.async.http.body.JSONObjectBody +import com.koushikdutta.async.http.body.MultipartFormDataBody +import com.koushikdutta.async.http.server.AsyncHttpServer +import com.koushikdutta.async.http.server.AsyncHttpServerRequest +import com.koushikdutta.async.http.server.AsyncHttpServerResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import top.yogiczy.mytv.AppGlobal import top.yogiczy.mytv.R +import top.yogiczy.mytv.ui.screens.toast.ToastState +import java.io.File import java.net.Inet4Address import java.net.NetworkInterface import java.net.SocketException - object HttpServer : Loggable() { const val SERVER_PORT = 10481 - private lateinit var server: NanoHTTPD + private val uploadedApkFile = File(AppGlobal.cacheDir, "uploaded_apk.apk").apply { + deleteOnExit() + } fun start(context: Context) { - if (this::server.isInitialized) return - CoroutineScope(Dispatchers.IO).launch { try { - server = ServerApplication(context, SERVER_PORT) + val server = AsyncHttpServer() + server.listen(AsyncServer.getDefault(), SERVER_PORT) + + server.get("/") { _, response -> + handleHomePage(response, context) + } + + server.get("/api/settings") { _, response -> + handleGetSettings(response) + } + + server.post("/api/settings") { request, response -> + handleSetSettings(request, response) + } + + server.post("/api/upload/apk") { request, response -> + handleUploadApk(request, response, context) + } + log.i("服务已启动: 0.0.0.0:${SERVER_PORT}") } catch (ex: Exception) { log.e("服务启动失败: ${ex.message}", ex.cause) @@ -37,6 +61,97 @@ object HttpServer : Loggable() { } } + private fun handleHomePage(response: AsyncHttpServerResponse, context: Context) { + response.apply { + setContentType("text/html; charset=utf-8") + send(context.resources.openRawResource(R.raw.index).readBytes().decodeToString()) + } + } + + private fun handleGetSettings(response: AsyncHttpServerResponse) { + response.apply { + setContentType("application/json") + send( + Json.encodeToString( + AllSettings( + appBootLaunch = SP.appBootLaunch, + + iptvChannelChangeFlip = SP.iptvChannelChangeFlip, + iptvSourceSimplify = SP.iptvSourceSimplify, + iptvSourceCachedAt = SP.iptvSourceCachedAt, + iptvSourceUrl = SP.iptvSourceUrl, + iptvSourceCacheTime = SP.iptvSourceCacheTime, + + epgEnable = SP.epgEnable, + epgXmlCachedAt = SP.epgXmlCachedAt, + epgCachedHash = SP.epgCachedHash, + epgXmlUrl = SP.epgXmlUrl, + epgRefreshTimeThreshold = SP.epgRefreshTimeThreshold, + + debugShowFps = SP.debugShowFps, + ) + ) + ) + } + } + + private fun handleSetSettings( + request: AsyncHttpServerRequest, + response: AsyncHttpServerResponse, + ) { + val body = request.getBody().get() + SP.appBootLaunch = body.get("appBootLaunch") as Boolean + + SP.iptvChannelChangeFlip = body.get("iptvChannelChangeFlip").toString().toBoolean() + SP.iptvSourceSimplify = body.get("iptvSourceSimplify").toString().toBoolean() + SP.iptvSourceCachedAt = body.get("iptvSourceCachedAt").toString().toLong() + SP.iptvSourceUrl = body.get("iptvSourceUrl").toString() + SP.iptvSourceCacheTime = body.get("iptvSourceCacheTime").toString().toLong() + + SP.epgEnable = body.get("epgEnable").toString().toBoolean() + SP.epgXmlCachedAt = body.get("epgXmlCachedAt").toString().toLong() + SP.epgCachedHash = body.get("epgCachedHash").toString().toInt() + SP.epgXmlUrl = body.get("epgXmlUrl").toString() + SP.epgRefreshTimeThreshold = body.get("epgRefreshTimeThreshold").toString().toInt() + + SP.debugShowFps = body.get("debugShowFps").toString().toBoolean() + + response.send("success") + } + + private fun handleUploadApk( + request: AsyncHttpServerRequest, + response: AsyncHttpServerResponse, + context: Context, + ) { + val body = request.getBody() + + val os = uploadedApkFile.outputStream() + val contentLength = request.headers["Content-Length"]?.toLong() ?: 1 + var hasReceived = 0L + + body.setMultipartCallback { part -> + if (part.isFile) { + body.setDataCallback { _, bb -> + val byteArray = bb.allByteArray + hasReceived += byteArray.size + ToastState.I.showToast("正在接收文件: ${(hasReceived * 100f / contentLength).toInt()}%") + os.write(byteArray) + } + } + } + + body.setEndCallback { + ToastState.I.showToast("文件接收完成") + body.dataEmitter.close() + os.flush() + os.close() + ApkInstaller.installApk(context, uploadedApkFile.path) + } + + response.send("success") + } + fun getLocalIpAddress(): String { val defaultIp = "0.0.0.0" @@ -58,117 +173,6 @@ object HttpServer : Loggable() { return defaultIp } } - - - private class ServerApplication(private val context: Context, port: Int) : NanoHTTPD(port) { - init { - start(SOCKET_READ_TIMEOUT, false) - } - - private fun isPreflightRequest(session: IHTTPSession): Boolean { - val headers = session.headers - return Method.OPTIONS == session.method && headers.contains("origin") && headers.containsKey( - "access-control-request-method" - ) && headers.containsKey("access-control-request-headers") - } - - private fun responseCORS(session: IHTTPSession): Response { - val resp = wrapResponse(session, newFixedLengthResponse("")) - val headers = session.headers - resp.addHeader("Access-Control-Allow-Methods", "POST,GET,OPTIONS") - val requestHeaders = headers["access-control-request-headers"] - if (requestHeaders != null) { - resp.addHeader("Access-Control-Allow-Headers", requestHeaders) - } - resp.addHeader("Access-Control-Max-Age", "0") - return resp - } - - private fun wrapResponse(session: IHTTPSession, resp: Response): Response { - val headers = session.headers - resp.addHeader("Access-Control-Allow-Credentials", "true") - resp.addHeader("Access-Control-Allow-Origin", headers.getOrElse("origin") { "*" }) - val requestHeaders = headers["access-control-request-headers"] - if (requestHeaders != null) { - resp.addHeader("Access-Control-Allow-Headers", requestHeaders) - } - return resp - } - - private inline fun responseJson(data: T): Response { - return newFixedLengthResponse( - Response.Status.OK, "application/json", Json.encodeToString(data) - ) - } - - private inline fun parseBody(session: IHTTPSession): T { - val files = mutableMapOf() - session.parseBody(files) - return Json.decodeFromString(files["postData"]!!) - } - - @SuppressLint("ResourceType") - override fun serve(session: IHTTPSession): Response { - if (isPreflightRequest(session)) { - return responseCORS(session) - } - - if (session.uri == "/") { - return newFixedLengthResponse( - context.resources.openRawResource(R.raw.index).readBytes().decodeToString(), - ) - } else if (session.uri.startsWith("/api/settings")) { - if (session.method == Method.GET) { - return wrapResponse( - session, - responseJson( - AllSettings( - appBootLaunch = SP.appBootLaunch, - - iptvChannelChangeFlip = SP.iptvChannelChangeFlip, - iptvSourceSimplify = SP.iptvSourceSimplify, - iptvSourceCachedAt = SP.iptvSourceCachedAt, - iptvSourceUrl = SP.iptvSourceUrl, - iptvSourceCacheTime = SP.iptvSourceCacheTime, - - epgEnable = SP.epgEnable, - epgXmlCachedAt = SP.epgXmlCachedAt, - epgCachedHash = SP.epgCachedHash, - epgXmlUrl = SP.epgXmlUrl, - epgRefreshTimeThreshold = SP.epgRefreshTimeThreshold, - - debugShowFps = SP.debugShowFps, - ) - ), - ) - } else if (session.method == Method.POST) { - val data = parseBody(session) - SP.appBootLaunch = data.appBootLaunch - - SP.iptvChannelChangeFlip = data.iptvChannelChangeFlip - SP.iptvSourceSimplify = data.iptvSourceSimplify - SP.iptvSourceCachedAt = data.iptvSourceCachedAt - SP.iptvSourceUrl = data.iptvSourceUrl - SP.iptvSourceCacheTime = data.iptvSourceCacheTime - - SP.epgEnable = data.epgEnable - SP.epgXmlCachedAt = data.epgXmlCachedAt - SP.epgCachedHash = data.epgCachedHash - SP.epgXmlUrl = data.epgXmlUrl - SP.epgRefreshTimeThreshold = data.epgRefreshTimeThreshold - - SP.debugShowFps = data.debugShowFps - - return wrapResponse(session, newFixedLengthResponse("success")) - } - } - - return wrapResponse( - session, - newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not Found") - ) - } - } } @Serializable diff --git a/app/src/main/res/raw/index.html b/app/src/main/res/raw/index.html index 40ed02df..2876783b 100644 --- a/app/src/main/res/raw/index.html +++ b/app/src/main/res/raw/index.html @@ -237,6 +237,14 @@ v-model="settings.debugShowFps" /> + + + + @@ -246,6 +254,15 @@ const baseUrl = "" + async function requestApi(url, config) { + const resp = await fetch(`${baseUrl}${url}`, config) + if (resp.status !== 200) { + throw new Error(`请求失败:${resp.status}`) + } + + return resp + } + dayjs.locale('zh-cn') dayjs.extend(dayjs_plugin_relativeTime) @@ -256,25 +273,27 @@ const settings = ref() async function confirmSettings() { - let loading = vant.Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 }) + vant.Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 }) try { - await fetch(`${baseUrl}/api/settings`, { + await requestApi('/api/settings', { method: "POST", - body: JSON.stringify(settings.value) + body: JSON.stringify(settings.value), + headers: { 'Content-Type': 'application/json' } }) await refreshSettings() - loading.clear() + vant.Toast.clear() } catch (e) { vant.Toast.fail('无法修改设置') console.error(e) + refreshSettings() } } async function refreshSettings() { - let loading = vant.Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 }) + vant.Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 }) try { - settings.value = await (await fetch(`${baseUrl}/api/settings`)).json() - loading.clear() + settings.value = await (await requestApi('/api/settings')).json() + vant.Toast.clear() } catch (e) { vant.Toast.fail('无法获取设置') console.error(e) @@ -290,12 +309,26 @@ return humanizeDuration(ms, { language: 'zh_CN', round: true, largest: 2 }) } + async function uploaderAfterRead(file) { + vant.Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 }) + try { + const formData = new FormData() + formData.append('filename', file.file) + await requestApi('/api/upload/apk', { method: "POST", body: formData }) + vant.Toast.clear() + } catch (e) { + vant.Toast.fail('上传apk失败') + console.error(e) + } + } + return { dayjs, foramtDuration, tabActive, settings, confirmSettings, + uploaderAfterRead, } } }) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6882c194..5431cec9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] agp = "8.3.2" +androidasync = "3.1.0" coreSplashscreen = "1.0.1" kotlin = "1.9.0" coreKtx = "1.12.0" appcompat = "1.6.1" composeBom = "2024.04.00" media3 = "1.3.1" -nanohttpdVersion = "2.3.1" okhttp = "4.12.0" qrose = "1.0.1" tvFoundation = "1.0.0-alpha10" @@ -17,6 +17,7 @@ kotlin-android = "1.9.0" kotlinx-serialization = "1.6.3" [libraries] +androidasync = { module = "com.koushikdutta.async:androidasync", version.ref = "androidasync" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -36,7 +37,6 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpdVersion" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }