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 0000000..2e0a0cb Binary files /dev/null and b/app/src/main/res/drawable/input_icon.png differ diff --git a/app/src/main/res/drawable/input_message_bg.xml b/app/src/main/res/drawable/input_message_bg.xml new file mode 100644 index 0000000..fe29d2d --- /dev/null +++ b/app/src/main/res/drawable/input_message_bg.xml @@ -0,0 +1,5 @@ + + + + + \ 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 0000000..f86a284 Binary files /dev/null and b/app/src/main/res/drawable/send_icon.png differ 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"> - -