From 348550e9776ddc1b268d02f0ec6a9272bfc620a6 Mon Sep 17 00:00:00 2001 From: pengxiaolong <15716207+pengxiaolong711@user.noreply.gitee.com> Date: Fri, 12 Dec 2025 21:41:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 4 +- .../example/myapplication/GuideActivity.kt | 43 +++- .../myapplication/MyInputMethodService.kt | 32 ++- .../myapplication/keyboard/AiKeyboard.kt | 226 ++++++++++++++++++ .../myapplication/network/ApiResponse.kt | 4 +- .../myapplication/network/ApiService.kt | 29 +-- .../myapplication/network/FileDownloader.kt | 2 +- .../myapplication/network/HttpInterceptors.kt | 53 +++- .../network/LlmStreamCallback.kt | 6 + .../example/myapplication/network/Models.kt | 5 +- .../myapplication/network/NetworkClient.kt | 116 +++++++++ .../myapplication/network/RetrofitClient.kt | 4 +- .../myapplication/theme/ThemeManager.kt | 14 +- .../myapplication/ui/login/LoginFragment.kt | 44 +++- .../example/myapplication/utils/unzipToDir.kt | 53 +++- app/src/main/res/anim/item_slide_in_up.xml | 4 +- app/src/main/res/drawable/input_icon.png | Bin 0 -> 856 bytes .../main/res/drawable/input_message_bg.xml | 5 + .../main/res/drawable/keyboard_button_bg4.xml | 5 + app/src/main/res/drawable/send_icon.png | Bin 0 -> 4341 bytes app/src/main/res/layout/activity_guide.xml | 87 +++++-- app/src/main/res/layout/ai_keyboard.xml | 101 +++++--- app/src/main/res/layout/fragment_login.xml | 2 +- app/src/main/res/layout/item_ai_message.xml | 15 ++ .../res/layout/item_other_party_message.xml | 3 +- .../main/res/layout/item_our_news_message.xml | 3 +- .../main/res/xml/network_security_config.xml | 12 + 27 files changed, 742 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/example/myapplication/network/LlmStreamCallback.kt create mode 100644 app/src/main/java/com/example/myapplication/network/NetworkClient.kt create mode 100644 app/src/main/res/drawable/input_icon.png create mode 100644 app/src/main/res/drawable/input_message_bg.xml create mode 100644 app/src/main/res/drawable/keyboard_button_bg4.xml create mode 100644 app/src/main/res/drawable/send_icon.png create mode 100644 app/src/main/res/layout/item_ai_message.xml create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a2f524c..f5c0a58 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + @@ -11,7 +12,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@style/Theme.MyApplication"> + android:theme="@style/Theme.MyApplication" + android:networkSecurityConfig="@xml/network_security_config"> (R.id.rootCoordinator) + + inputMessage.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // 不需要实现 + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + // 不需要实现 + } + + override fun afterTextChanged(s: Editable?) { + hintLayout.visibility = + if (s.isNullOrEmpty()) View.VISIBLE else View.GONE + } + }) // 动画 itemAnim = AnimationUtils.loadAnimation(this, R.anim.item_slide_in_up) //自动聚焦 @@ -139,6 +163,10 @@ class GuideActivity : AppCompatActivity() { sendMessage() } } + + fun dp2px(dp: Int): Int { + return (dp * resources.displayMetrics.density).toInt() + } // 发送消息 private fun sendMessage() { val text = inputMessage.text.toString().trim() @@ -155,12 +183,21 @@ class GuideActivity : AppCompatActivity() { inputMessage.setText("") val replyText = replyData.random() - + + // 保存原来的标题文本 + val originalTitle = titleTextView.text.toString() + // 延迟执行我方回复 scrollView.postDelayed({ + // 先恢复标题文本 + titleTextView.text = originalTitle + // 然后添加我方回复 addOurMessage(replyText) - }, 500) + }, 1500) + scrollView.postDelayed({ + titleTextView.text = "The other party is typing..." + }, 500) inputMessage.isFocusable = true inputMessage.isFocusableInTouchMode = true diff --git a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt index 56f9786..7c4a5df 100644 --- a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt +++ b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt @@ -483,19 +483,35 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // 发送(标准 SEND + 回车 fallback) override fun performSendAction() { val ic = currentInputConnection ?: return - - // 1. 尝试执行标准发送动作(IME_ACTION_SEND) - val handled = ic.performEditorAction(EditorInfo.IME_ACTION_SEND) - - if (!handled) { - // 2. 如果输入框不支持 SEND,则退回到插入换行 - ic.commitText("\n", 1) + val info = currentInputEditorInfo + + var handled = false + + if (info != null) { + // 取出当前 EditText 声明的 action + val actionId = info.imeOptions and EditorInfo.IME_MASK_ACTION + + // 只有当它明确是 IME_ACTION_SEND 时,才当“发送”用 + if (actionId == EditorInfo.IME_ACTION_SEND) { + handled = ic.performEditorAction(actionId) + } } - + + // 如果当前输入框不支持 SEND 或者 performEditorAction 返回了 false + // 就降级为“标准回车” + if (!handled) { + sendEnterKey(ic) + } + playKeyClick() clearEditorState() } + private fun sendEnterKey(ic: InputConnection) { + // 按下+抬起 KEYCODE_ENTER + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + } // 按键音效 override fun playKeyClick() { diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt index b25efb9..50e4d4d 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt @@ -13,11 +13,173 @@ import android.widget.LinearLayout import android.widget.TextView import com.example.myapplication.MainActivity import com.example.myapplication.theme.ThemeManager +import android.os.Handler +import android.os.Looper +import android.widget.ScrollView +import com.example.myapplication.network.NetworkClient +import com.example.myapplication.network.LlmStreamCallback +import okhttp3.Call class AiKeyboard( env: KeyboardEnvironment ) : BaseKeyboard(env) { + private var currentStreamCall: Call? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + private val messagesContainer: LinearLayout by lazy { + val res = env.ctx.resources + val id = res.getIdentifier("container_messages", "id", env.ctx.packageName) + rootView.findViewById(id) + } + + private val messagesScrollView: ScrollView by lazy { + val res = env.ctx.resources + val id = res.getIdentifier("scroll_messages", "id", env.ctx.packageName) + rootView.findViewById(id) + } + + // 当前正在流式更新的那一个 AI 文本 + private var currentAssistantTextView: TextView? = null + + // 用来处理 的缓冲 + private val streamBuffer = StringBuilder() + + + //新建一条 AI 消息(空内容),返回里面的 TextView 用来后续流式更新 + + private fun addAssistantMessage(initialText: String = ""): TextView { + val inflater = env.layoutInflater + val res = env.ctx.resources + val layoutId = res.getIdentifier("item_ai_message", "layout", env.ctx.packageName) + + val itemView = inflater.inflate(layoutId, messagesContainer, false) as LinearLayout + val tv = itemView.findViewById( + res.getIdentifier("tv_content", "id", env.ctx.packageName) + ) + tv.text = initialText + messagesContainer.addView(itemView) + + scrollToBottom() + return tv + } + + /** + * (可选)如果你也想显示用户提问 + */ + private fun addUserMessage(text: String) { + // 简单写:复用同一个 item 布局 + val tv = addAssistantMessage(text) + // 这里可以改成设置 gravity、背景区分用户/AI 等 + } + + private fun scrollToBottom() { + // 延迟一点点执行,保证 addView 完成后再滚动 + messagesScrollView.post { + messagesScrollView.fullScroll(View.FOCUS_DOWN) + } + } + + //后端每来一个 llm_chunk 的 data,就调用一次这个方法 + private fun onLlmChunk(data: String) { + // 丢掉 data=":\n\n" 这条 + if (data == ":\n\n") return + + // 确保在主线程更新 UI + mainHandler.post { + // 如果还没有正在流式的 TextView,就新建一条 AI 消息 + if (currentAssistantTextView == null) { + currentAssistantTextView = addAssistantMessage("") + streamBuffer.clear() + } + + // 累积到缓冲区 + streamBuffer.append(data) + + // 先整体把 ":\n\n" 删掉(以防万一有别的地方混进来) + var text = streamBuffer.toString().replace(":\n\n", "") + + // 处理 :代表下一句/下一条消息 + val splitTag = "" + var index = text.indexOf(splitTag) + + while (index != -1) { + // split 前面这一段是上一条消息的最终内容 + val before = text.substring(0, index) + currentAssistantTextView?.text = before + scrollToBottom() + + // 开启下一条 AI 消息 + currentAssistantTextView = addAssistantMessage("") + + // 剩下的留给下一轮 + text = text.substring(index + splitTag.length) + index = text.indexOf(splitTag) + } + + // 循环结束后 text 就是「当前这条消息的未完成尾巴」 + currentAssistantTextView?.text = text + scrollToBottom() + + // 缓冲区只保留尾巴(避免无限变长) + streamBuffer.clear() + streamBuffer.append(text) + } + } + + + // 收到 type="done" 时调用,表示这一轮回答结束 + private fun onLlmDone() { + mainHandler.post { + // 这里目前不需要做太多事,必要的话可以清掉 buffer + streamBuffer.clear() + currentAssistantTextView = null + } + } + + + // 开始一次新的 AI 回答流式请求 + fun startAiStream(userQuestion: String) { + // 可选:先把用户问题显示出来 + addUserMessage(userQuestion) + + // 如果之前有没结束的流,先取消 + currentStreamCall?.cancel() + + currentStreamCall = NetworkClient.startLlmStream( + question = userQuestion, + callback = object : LlmStreamCallback { + override fun onEvent(type: String, data: String?) { + when (type) { + "llm_chunk" -> { + if (data != null) { + onLlmChunk(data) // 这里就是之前写的流式 UI 更新 + } + } + "done" -> { + onLlmDone() // 一轮结束 + } + "search_result" -> { + } + } + } + + override fun onError(t: Throwable) { + addAssistantMessage("出错了:${t.message}") + } + } + ) + } + + // 比如键盘关闭时可以调用一次,避免内存泄漏 / 多余请求 + fun cancelAiStream() { + currentStreamCall?.cancel() + currentStreamCall = null + } + + + + // 以下是 BaseKeyboard 的实现 override val rootView: View = run { val res = env.ctx.resources val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName) @@ -93,6 +255,17 @@ class AiKeyboard( val res = env.ctx.resources val pkg = env.ctx.packageName + // 获取ai_persona和ai_output视图引用 + val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg) + val aiOutputId = res.getIdentifier("ai_output", "id", pkg) + + val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById(aiPersonaId) else null + val aiOutputView = if (aiOutputId != 0) rootView.findViewById(aiOutputId) else null + + // 初始化显示状态:显示ai_persona,隐藏ai_output + aiPersonaView?.visibility = View.VISIBLE + aiOutputView?.visibility = View.GONE + // 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc,就绑定一下 val backId = res.getIdentifier("key_abc", "id", pkg) if (backId != 0) { @@ -108,6 +281,59 @@ class AiKeyboard( navigateToRechargeFragment() } } + + //显示切换 + val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg) + if (returnButtonId != 0) { + rootView.findViewById(returnButtonId)?.let { returnButton -> + // 确保按钮可点击且可获得焦点,防止事件穿透 + returnButton.isClickable = true + returnButton.isFocusable = true + returnButton.setOnClickListener { + // 点击Return_keyboard:先隐藏ai_output,再显示ai_persona(顺序动画) + aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction { + aiOutputView?.visibility = View.GONE + // 等ai_output完全隐藏后再显示ai_persona + aiPersonaView?.visibility = View.VISIBLE + aiPersonaView?.alpha = 0f + aiPersonaView?.animate()?.alpha(1f)?.setDuration(150) + } + } + } + } + + val cardButtonId = res.getIdentifier("card", "id", pkg) + if (cardButtonId != 0) { + rootView.findViewById(cardButtonId)?.setOnClickListener { + // 点击card:先隐藏ai_persona,再显示ai_output(顺序动画) + aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction { + aiPersonaView?.visibility = View.GONE + // 等ai_persona完全隐藏后再显示ai_output + aiOutputView?.visibility = View.VISIBLE + aiOutputView?.alpha = 0f + aiOutputView?.animate()?.alpha(1f)?.setDuration(150) + } + } + } + + + + + + // // 假设 ai_keyboard.xml 里有一个发送按钮 key_send + // val sendId = res.getIdentifier("key_send", "id", pkg) + // val inputId = res.getIdentifier("et_prompt", "id", pkg) // 假设这是你的输入框 id + + // if (sendId != 0 && inputId != 0) { + // val inputView = rootView.findViewById(inputId) + + // rootView.findViewById(sendId)?.setOnClickListener { + // val question = inputView?.text?.toString()?.trim().orEmpty() + // if (question.isNotEmpty()) { + // startAiStream(question) + // } + // } + // } } private fun navigateToRechargeFragment() { diff --git a/app/src/main/java/com/example/myapplication/network/ApiResponse.kt b/app/src/main/java/com/example/myapplication/network/ApiResponse.kt index 3530708..bdaf628 100644 --- a/app/src/main/java/com/example/myapplication/network/ApiResponse.kt +++ b/app/src/main/java/com/example/myapplication/network/ApiResponse.kt @@ -1,8 +1,8 @@ // ApiResponse.kt -package com.example.network +package com.example.myapplication.network data class ApiResponse( val code: Int, - val msg: String, + val message: String, val data: T? ) diff --git a/app/src/main/java/com/example/myapplication/network/ApiService.kt b/app/src/main/java/com/example/myapplication/network/ApiService.kt index bd8e792..1692dfe 100644 --- a/app/src/main/java/com/example/myapplication/network/ApiService.kt +++ b/app/src/main/java/com/example/myapplication/network/ApiService.kt @@ -1,5 +1,5 @@ // 请求方法 -package com.example.network +package com.example.myapplication.network import okhttp3.ResponseBody import retrofit2.Response @@ -8,31 +8,24 @@ import retrofit2.http.* interface ApiService { // GET 示例:/users/{id} - @GET("users/{id}") - suspend fun getUser( - @Path("id") id: String - ): ApiResponse + // @GET("users/{id}") + // suspend fun getUser( + // @Path("id") id: String + // ): ApiResponse // GET 示例:带查询参数 /users?page=1&pageSize=20 - @GET("users") - suspend fun getUsers( - @Query("page") page: Int, - @Query("pageSize") pageSize: Int - ): ApiResponse> + // @GET("users") + // suspend fun getUsers( + // @Query("page") page: Int, + // @Query("pageSize") pageSize: Int + // ): ApiResponse> // POST JSON 示例:Body 为 JSON:{"username": "...", "password": "..."} - @POST("auth/login") + @POST("user/login") suspend fun login( @Body body: LoginRequest ): ApiResponse - // POST 表单示例:x-www-form-urlencoded - @FormUrlEncoded - @POST("auth/loginForm") - suspend fun loginForm( - @Field("username") username: String, - @Field("password") password: String - ): ApiResponse // zip 文件下载(或其它大文件)——必须 @Streaming @Streaming diff --git a/app/src/main/java/com/example/myapplication/network/FileDownloader.kt b/app/src/main/java/com/example/myapplication/network/FileDownloader.kt index a6400ed..d252d9f 100644 --- a/app/src/main/java/com/example/myapplication/network/FileDownloader.kt +++ b/app/src/main/java/com/example/myapplication/network/FileDownloader.kt @@ -1,5 +1,5 @@ // zip 文件下载器 -package com.example.network +package com.example.myapplication.network import android.content.Context import android.util.Log diff --git a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt index fdb2a49..7a4d6e3 100644 --- a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt +++ b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt @@ -1,5 +1,5 @@ // 定义请求 & 响应拦截器 -package com.example.network +package com.example.myapplication.network import android.util.Log import okhttp3.Interceptor @@ -11,17 +11,56 @@ import okhttp3.ResponseBody.Companion.toResponseBody */ val requestInterceptor = Interceptor { chain -> val original = chain.request() - val token = "your_token" // 这里换成你自己的 token + + val token = "" // 你的 token + val newRequest = original.newBuilder() - .addHeader("Accept", "application/json") - .addHeader("Content-Type", "application/json") - // 这里加你自己的 token,如果没有就注释掉 .addHeader("Authorization", "Bearer $token") + .addHeader("Accept-Language", "lang") .build() - chain.proceed(newRequest) + // ====== 打印请求信息 ====== + val request = newRequest + val url = request.url + + val sb = StringBuilder() + sb.append("\n======== HTTP Request ========\n") + sb.append("Method: ${request.method}\n") + sb.append("URL: $url\n") + + // 打印 Header + sb.append("Headers:\n") + for (name in request.headers.names()) { + sb.append(" $name: ${request.header(name)}\n") + } + + // 打印 Query 参数 + if (url.querySize > 0) { + sb.append("Query Params:\n") + for (i in 0 until url.querySize) { + sb.append(" ${url.queryParameterName(i)} = ${url.queryParameterValue(i)}\n") + } + } + + // 打印 Body(仅限可读类型) + val requestBody = request.body + if (requestBody != null) { + val buffer = okio.Buffer() + requestBody.writeTo(buffer) + + sb.append("Body:\n") + sb.append(buffer.readUtf8()) + sb.append("\n") + } + + sb.append("================================\n") + + android.util.Log.d("1314520-OkHttp-Request", sb.toString()) + + chain.proceed(request) } + /** * 响应拦截器:统一打印日志、做一些简单的错误处理 */ @@ -36,7 +75,7 @@ val responseInterceptor = Interceptor { chain -> val bodyString = rawBody?.string() ?: "" Log.d( - "HTTP", + "1314520-HTTP", "⬇⬇⬇\n" + "URL : ${request.url}\n" + "Method: ${request.method}\n" + diff --git a/app/src/main/java/com/example/myapplication/network/LlmStreamCallback.kt b/app/src/main/java/com/example/myapplication/network/LlmStreamCallback.kt new file mode 100644 index 0000000..7665eb4 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/LlmStreamCallback.kt @@ -0,0 +1,6 @@ +package com.example.myapplication.network + +interface LlmStreamCallback { + fun onEvent(type: String, data: String?) + fun onError(t: Throwable) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/network/Models.kt b/app/src/main/java/com/example/myapplication/network/Models.kt index 1701394..afa4412 100644 --- a/app/src/main/java/com/example/myapplication/network/Models.kt +++ b/app/src/main/java/com/example/myapplication/network/Models.kt @@ -1,5 +1,5 @@ // Models.kt -package com.example.network +package com.example.myapplication.network data class User( val id: String, @@ -7,8 +7,9 @@ data class User( val age: Int ) +// 登录 data class LoginRequest( - val username: String, + val mail: String, val password: String ) diff --git a/app/src/main/java/com/example/myapplication/network/NetworkClient.kt b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt new file mode 100644 index 0000000..97fae86 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt @@ -0,0 +1,116 @@ +package com.example.myapplication.network + +import okhttp3.* +import okio.Buffer +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody + +object NetworkClient { + + // 你自己后端的 base url + private const val BASE_URL = "http://192.168.2.21:7529/api" + + // 专门用于 SSE 的 OkHttpClient:readTimeout = 0 代表不超时,一直保持连接 + private val sseClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) // SSE 必须不能有读超时 + .build() + } + + /** + * 启动一次 SSE 流式请求 + * @param question 用户问题(你要传给后端的) + * @return Call,可用于取消(比如用户关闭键盘时) + */ + fun startLlmStream( + question: String, + callback: LlmStreamCallback + ): Call { + // 根据你后端的接口改:是 POST 还是 GET,参数格式是什么 + val json = JSONObject().apply { + put("query", question) // 假设你后端字段叫 query + } + + val requestBody = json.toString() + .toRequestBody("application/json; charset=utf-8".toMediaType()) + + val request = Request.Builder() + .url("$BASE_URL/llm/stream") // TODO: 换成你真实的 SSE 路径 + .post(requestBody) + // 有些 SSE 接口会要求 Accept + .addHeader("Accept", "text/event-stream") + .build() + + val call = sseClient.newCall(request) + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (call.isCanceled()) return // 被主动取消就不用回调错误了 + callback.onError(e) + } + + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + callback.onError(IOException("SSE failed: ${response.code}")) + response.close() + return + } + + val body = response.body ?: run { + callback.onError(IOException("Empty body")) + return + } + + // 长连接读取:一行一行读,直到服务器关闭或我们取消 + body.use { b -> + val source = b.source() + try { + while (!source.exhausted() && !call.isCanceled()) { + val line = source.readUtf8Line() ?: break + if (line.isBlank()) { + // SSE 中空行代表一个 event 结束,这里可以忽略 + continue + } + + // 兼容两种格式: + // 1) 标准 SSE: "data: { ... }" + // 2) 服务器直接一行一个 JSON: "{ ... }" + val payload = if (line.startsWith("data:")) { + line.substringAfter("data:").trim() + } else { + line.trim() + } + + // 你日志里是: + // {"type":"llm_chunk","data":"Her"} + // {"type":"done","data":null} + try { + val jsonObj = JSONObject(payload) + val type = jsonObj.optString("type") + val data = + if (jsonObj.has("data") && !jsonObj.isNull("data")) + jsonObj.getString("data") + else + null + + callback.onEvent(type, data) + } catch (e: Exception) { + // 解析失败就忽略这一行(或者你可以打印下日志) + // Log.e("NetworkClient", "Bad SSE line: $payload", e) + } + } + } catch (ioe: IOException) { + if (!call.isCanceled()) { + callback.onError(ioe) + } + } + } + } + }) + + return call + } +} diff --git a/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt b/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt index af5d3a3..99298bd 100644 --- a/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt +++ b/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt @@ -1,5 +1,5 @@ // RetrofitClient.kt -package com.example.network +package com.example.myapplication.network import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit object RetrofitClient { - private const val BASE_URL = "https://api.example.com/" // 换成你的地址 + private const val BASE_URL = "http://192.168.2.21:7529/api/" // 换成你的地址 // 日志拦截器(可选) private val loggingInterceptor = HttpLoggingInterceptor().apply { diff --git a/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt b/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt index 815c0f4..464d381 100644 --- a/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt +++ b/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt @@ -24,24 +24,12 @@ object ThemeManager { /** 主题根目录:/Android/data//files/keyboard_themes */ private fun getThemeRootDir(context: Context): File = - File(context.getExternalFilesDir(null), "keyboard_themes") + File(context.filesDir, "keyboard_themes") /** 某个具体主题目录:/Android/.../keyboard_themes/ */ private fun getThemeDir(context: Context, themeName: String): File = File(getThemeRootDir(context), themeName) - // ==================== 内置主题拷贝(assets -> 外部目录) ==================== - - /** - * 确保 APK 自带的主题(assets/keyboard_themes/...) 已经复制到 - * /Android/data/.../files/keyboard_themes 目录下。 - * - * 行为: - * - 如果主题目录不存在:整套拷贝过去。 - * - 如果主题目录已经存在:只复制“新增文件”,不会覆盖已有文件。 - * - * 建议在 IME 的 onCreate() 里调用一次。 - */ fun ensureBuiltInThemesInstalled(context: Context) { val am = context.assets val rootName = "keyboard_themes" diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt index a325829..3f7ad3f 100644 --- a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt @@ -2,23 +2,31 @@ package com.example.myapplication.ui.login import android.os.Bundle import android.text.InputType +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.ImageView import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import android.widget.FrameLayout import android.widget.TextView import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.LoginRequest +import com.example.myapplication.network.RetrofitClient import com.google.android.material.button.MaterialButton +import android.widget.Toast +import kotlinx.coroutines.launch class LoginFragment : Fragment() { private lateinit var passwordEditText: EditText private lateinit var toggleImageView: ImageView - private lateinit var loginButton: MaterialButton // 如果你 XML 里有这个按钮 id: btn_login + private lateinit var loginButton: TextView + private lateinit var emailEditText: EditText private var isPasswordVisible = false @@ -47,8 +55,10 @@ class LoginFragment : Fragment() { } // 绑定控件(id 必须和 xml 里的一样) passwordEditText = view.findViewById(R.id.et_password) + emailEditText = view.findViewById(R.id.et_email) toggleImageView = view.findViewById(R.id.iv_toggle) - // loginButton = view.findViewById(R.id.btn_login) // 如果没有这个按钮就把这一行和变量删了 + loginButton = view.findViewById(R.id.btn_login) + // 初始是隐藏密码状态 passwordEditText.inputType = @@ -73,10 +83,30 @@ class LoginFragment : Fragment() { passwordEditText.setSelection(passwordEditText.text?.length ?: 0) } - // // 登录按钮逻辑你自己填 - // loginButton.setOnClickListener { - // val pwd = passwordEditText.text?.toString().orEmpty() - // // TODO: 登录处理 - // } + // // 登录按钮逻辑 + loginButton.setOnClickListener { + val pwd = passwordEditText.text?.toString().orEmpty() + val email = emailEditText.text?.toString().orEmpty() + if (pwd.isEmpty() || email.isEmpty()) { + // 输入框不能为空 + Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show() + } else { + // 调用登录API + lifecycleScope.launch { + try { + val loginRequest = LoginRequest( + mail = email, // 使用email作为username + password = pwd + ) + val response = RetrofitClient.apiService.login(loginRequest) + Log.d("1314520-LoginFragment", "登录API响应: $response") + + } catch (e: Exception) { + Log.e("1314520-LoginFragment", "登录请求失败: ${e.message}", e) + Toast.makeText(requireContext(), "登录失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } } } diff --git a/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt b/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt index 53ca7b1..17b81fa 100644 --- a/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt +++ b/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt @@ -4,28 +4,61 @@ import java.io.* import java.util.zip.ZipEntry import java.util.zip.ZipInputStream -fun unzipToDir(zipInputStream: InputStream, targetDir: File) { + +// @param zipInputStream zip 的输入流(网络/本地/asset都行) +// @param targetDir 解压目标目录 +// @param overwrite 是否覆盖同名文件(默认 true:存在则跳过) + +fun unzipToDir( + zipInputStream: InputStream, + targetDir: File, + overwrite: Boolean = true +) { + // 确保目标目录存在 + if (!targetDir.exists()) targetDir.mkdirs() + + // 规范化目标目录路径(用于 Zip Slip 防护) + val canonicalDirPath = targetDir.canonicalFile.path + File.separator + ZipInputStream(BufferedInputStream(zipInputStream)).use { zis -> - var entry: ZipEntry? = zis.nextEntry val buffer = ByteArray(4096) - while (entry != null) { - val file = File(targetDir, entry.name) + while (true) { + val entry: ZipEntry = zis.nextEntry ?: break + + // entry.name 可能包含 ../ 之类的路径,必须防护 + val outFile = File(targetDir, entry.name) + + // ===== Zip Slip 防护:确保输出文件仍在 targetDir 内 ===== + val canonicalOutPath = outFile.canonicalFile.path + if (!canonicalOutPath.startsWith(canonicalDirPath)) { + // 越界路径:直接跳过 + zis.closeEntry() + continue + } if (entry.isDirectory) { - file.mkdirs() + outFile.mkdirs() } else { - file.parentFile?.mkdirs() - FileOutputStream(file).use { fos -> - var count: Int - while (zis.read(buffer).also { count = it } != -1) { + outFile.parentFile?.mkdirs() + + // 覆盖策略:默认不覆盖(存在就跳过) + if (outFile.exists() && !overwrite) { + zis.closeEntry() + continue + } + + FileOutputStream(outFile).use { fos -> + while (true) { + val count = zis.read(buffer) + if (count == -1) break fos.write(buffer, 0, count) } + fos.flush() } } zis.closeEntry() - entry = zis.nextEntry } } } diff --git a/app/src/main/res/anim/item_slide_in_up.xml b/app/src/main/res/anim/item_slide_in_up.xml index a11766b..1dbc9e1 100644 --- a/app/src/main/res/anim/item_slide_in_up.xml +++ b/app/src/main/res/anim/item_slide_in_up.xml @@ -6,12 +6,12 @@ + android:duration="300" /> + android:duration="300" /> diff --git a/app/src/main/res/drawable/input_icon.png b/app/src/main/res/drawable/input_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2e0a0cb3a7699c46438a38aa091a163b246b53da GIT binary patch literal 856 zcmV-e1E>6nP)00001b5ch_0Itp) z=>Px&5=lfsR7gv;mQ83AQ4q)f^Hw9VqNGuwdXS1Js0VL?;ztfD;z4XTj|R06MbuUi zJ*fyPD2jq&J!pltR!X6>NfC0eh$1M0Cq4Ov-#7K3hmxAm?2cK8HKyILP1ko^-kab4 z=Ra@W5dOnrW`l^f12_%fplO=Vh{!2N(SP8=vaGEDt^!yF;1hs-rfEJZhX)QG92{I) zC=^`Cf|4i^kvE26>}qaqe*Q;j;9zE6NJNi`s8K}5;)}>v02{T|Z(Vp`;B-2@*tTua zT7P2ZRYY_Lz{+xlh@|52xF({%e>4@ZnRz*Y9wPcaGBR=?91edq3}e0HI6cMhi`>{E z^1-(4cp{N_5r|@DZUityL`zG6rvNr-t$$gTwMIl96f651fNfgqSAlrU%u9&qI)E(| zw%scf3T=r*;&UpMTJ1QFipS&bm69ognR#Bx$ISp#g;k>f(jwBXwf-@YjWrclZ*On3 zuCC4!k&RvjDk49XQtM39d>IHhlgZ4@=ku3~#j+cKF;P$#Y8b}x`uh6rSS(hUGMJg? z5YZI?I|0n_xCuETIvR~eZ^vS>oY!sfo$9{6zS*HrsEdepR8cyrlsXoTMsIp4om6p^ zl%5B$AHXyZ6~Bn+^6>ERg=8}6snj<(lgZ4?<#J~Mv;zoxcxnT1Ex71a#g*r(rni+C=0W5k)*y2oZHFrA{_AHGTK1w5rF + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/keyboard_button_bg4.xml b/app/src/main/res/drawable/keyboard_button_bg4.xml new file mode 100644 index 0000000..235ad94 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_button_bg4.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/send_icon.png b/app/src/main/res/drawable/send_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f86a28457d9feaf82020cb8c74ab3948da99e2ef GIT binary patch literal 4341 zcmV!P)Px_uSrBfRCr$PU3-j_)fNApyJJcP0)qG;QhWf4VwXj!K_j{|qgHFG?#>de6;iNK zYvL*~HJDUan@E}%lqSAngn(KNJG<7#N|D_egeb8nV0{3B7D{7%t<~@-mfd^W`_0ZW zyUfmfuls%Xo1O2U{bTPv=iGCCb06oPb1tFMzwGj1;8ACSc(P!e4qze^kB8(F0UArh zqW~HX!uP-wv(!P{4aRo>A7G|$0q%mJ&xz?%0DC0i6EN)r9k&DZ^@l21g;Y_4%VuMs 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/activity_guide.xml b/app/src/main/res/layout/activity_guide.xml index d2488af..54d24e8 100644 --- a/app/src/main/res/layout/activity_guide.xml +++ b/app/src/main/res/layout/activity_guide.xml @@ -33,6 +33,17 @@ android:rotation="180" android:scaleType="fitCenter" /> + @@ -127,32 +139,61 @@ android:id="@+id/bottom_panel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:gravity="center_horizontal" + android:gravity="center_vertical" + android:background="@drawable/input_message_bg" + android:padding="5dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="16dp" + android:paddingStart="16dp" android:orientation="horizontal" - android:padding="16dp" android:layout_gravity="bottom"> - -