完善请求

This commit is contained in:
pengxiaolong
2025-12-12 21:41:46 +08:00
parent 79b5bc0273
commit 348550e977
27 changed files with 742 additions and 130 deletions

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
@@ -11,7 +12,8 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MyApplication"> android:theme="@style/Theme.MyApplication"
android:networkSecurityConfig="@xml/network_security_config">
<!-- 启动页 Activity --> <!-- 启动页 Activity -->
<activity <activity

View File

@@ -20,15 +20,22 @@ import android.view.WindowManager
import android.graphics.Rect import android.graphics.Rect
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.content.ContextCompat
import android.widget.ImageView
import android.text.TextWatcher
import android.text.Editable
class GuideActivity : AppCompatActivity() { class GuideActivity : AppCompatActivity() {
private lateinit var scrollView: NestedScrollView private lateinit var scrollView: NestedScrollView
private lateinit var listLayout: LinearLayout private lateinit var listLayout: LinearLayout
private lateinit var inputMessage: EditText private lateinit var inputMessage: EditText
private lateinit var btnSend: Button private lateinit var btnSend: ImageView
private lateinit var itemAnim: Animation private lateinit var itemAnim: Animation
private lateinit var bottomPanel: LinearLayout private lateinit var bottomPanel: LinearLayout
private lateinit var hintLayout: LinearLayout
private lateinit var titleTextView: TextView
// 我方的预设回复 // 我方的预设回复
private val replyData = listOf( private val replyData = listOf(
@@ -51,7 +58,24 @@ class GuideActivity : AppCompatActivity() {
inputMessage = findViewById(R.id.input_message) inputMessage = findViewById(R.id.input_message)
btnSend = findViewById(R.id.btn_send) btnSend = findViewById(R.id.btn_send)
bottomPanel = findViewById(R.id.bottom_panel) bottomPanel = findViewById(R.id.bottom_panel)
hintLayout = findViewById(R.id.hintLayout)
titleTextView = findViewById(R.id.title)
val rootView = findViewById<View>(R.id.rootCoordinator) val rootView = findViewById<View>(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) itemAnim = AnimationUtils.loadAnimation(this, R.anim.item_slide_in_up)
//自动聚焦 //自动聚焦
@@ -139,6 +163,10 @@ class GuideActivity : AppCompatActivity() {
sendMessage() sendMessage()
} }
} }
fun dp2px(dp: Int): Int {
return (dp * resources.displayMetrics.density).toInt()
}
// 发送消息 // 发送消息
private fun sendMessage() { private fun sendMessage() {
val text = inputMessage.text.toString().trim() val text = inputMessage.text.toString().trim()
@@ -156,11 +184,20 @@ class GuideActivity : AppCompatActivity() {
val replyText = replyData.random() val replyText = replyData.random()
// 保存原来的标题文本
val originalTitle = titleTextView.text.toString()
// 延迟执行我方回复 // 延迟执行我方回复
scrollView.postDelayed({ scrollView.postDelayed({
// 先恢复标题文本
titleTextView.text = originalTitle
// 然后添加我方回复
addOurMessage(replyText) addOurMessage(replyText)
}, 500) }, 1500)
scrollView.postDelayed({
titleTextView.text = "The other party is typing..."
}, 500)
inputMessage.isFocusable = true inputMessage.isFocusable = true
inputMessage.isFocusableInTouchMode = true inputMessage.isFocusableInTouchMode = true

View File

@@ -483,19 +483,35 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 发送(标准 SEND + 回车 fallback // 发送(标准 SEND + 回车 fallback
override fun performSendAction() { override fun performSendAction() {
val ic = currentInputConnection ?: return val ic = currentInputConnection ?: return
val info = currentInputEditorInfo
// 1. 尝试执行标准发送动作IME_ACTION_SEND var handled = false
val handled = ic.performEditorAction(EditorInfo.IME_ACTION_SEND)
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) { if (!handled) {
// 2. 如果输入框不支持 SEND则退回到插入换行 sendEnterKey(ic)
ic.commitText("\n", 1)
} }
playKeyClick() playKeyClick()
clearEditorState() 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() { override fun playKeyClick() {

View File

@@ -13,11 +13,173 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.example.myapplication.MainActivity import com.example.myapplication.MainActivity
import com.example.myapplication.theme.ThemeManager 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( class AiKeyboard(
env: KeyboardEnvironment env: KeyboardEnvironment
) : BaseKeyboard(env) { ) : 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
// 用来处理 <SPLIT> 的缓冲
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<TextView>(
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", "")
// 处理 <SPLIT>:代表下一句/下一条消息
val splitTag = "<SPLIT>"
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 { override val rootView: View = run {
val res = env.ctx.resources val res = env.ctx.resources
val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName) val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName)
@@ -93,6 +255,17 @@ class AiKeyboard(
val res = env.ctx.resources val res = env.ctx.resources
val pkg = env.ctx.packageName 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<View?>(aiPersonaId) else null
val aiOutputView = if (aiOutputId != 0) rootView.findViewById<View?>(aiOutputId) else null
// 初始化显示状态显示ai_persona隐藏ai_output
aiPersonaView?.visibility = View.VISIBLE
aiOutputView?.visibility = View.GONE
// 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc就绑定一下 // 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc就绑定一下
val backId = res.getIdentifier("key_abc", "id", pkg) val backId = res.getIdentifier("key_abc", "id", pkg)
if (backId != 0) { if (backId != 0) {
@@ -108,6 +281,59 @@ class AiKeyboard(
navigateToRechargeFragment() navigateToRechargeFragment()
} }
} }
//显示切换
val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg)
if (returnButtonId != 0) {
rootView.findViewById<View?>(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<View?>(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<TextView?>(inputId)
// rootView.findViewById<View?>(sendId)?.setOnClickListener {
// val question = inputView?.text?.toString()?.trim().orEmpty()
// if (question.isNotEmpty()) {
// startAiStream(question)
// }
// }
// }
} }
private fun navigateToRechargeFragment() { private fun navigateToRechargeFragment() {

View File

@@ -1,8 +1,8 @@
// ApiResponse.kt // ApiResponse.kt
package com.example.network package com.example.myapplication.network
data class ApiResponse<T>( data class ApiResponse<T>(
val code: Int, val code: Int,
val msg: String, val message: String,
val data: T? val data: T?
) )

View File

@@ -1,5 +1,5 @@
// 请求方法 // 请求方法
package com.example.network package com.example.myapplication.network
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
@@ -8,31 +8,24 @@ import retrofit2.http.*
interface ApiService { interface ApiService {
// GET 示例:/users/{id} // GET 示例:/users/{id}
@GET("users/{id}") // @GET("users/{id}")
suspend fun getUser( // suspend fun getUser(
@Path("id") id: String // @Path("id") id: String
): ApiResponse<User> // ): ApiResponse<User>
// GET 示例:带查询参数 /users?page=1&pageSize=20 // GET 示例:带查询参数 /users?page=1&pageSize=20
@GET("users") // @GET("users")
suspend fun getUsers( // suspend fun getUsers(
@Query("page") page: Int, // @Query("page") page: Int,
@Query("pageSize") pageSize: Int // @Query("pageSize") pageSize: Int
): ApiResponse<List<User>> // ): ApiResponse<List<User>>
// POST JSON 示例Body 为 JSON{"username": "...", "password": "..."} // POST JSON 示例Body 为 JSON{"username": "...", "password": "..."}
@POST("auth/login") @POST("user/login")
suspend fun login( suspend fun login(
@Body body: LoginRequest @Body body: LoginRequest
): ApiResponse<LoginResponse> ): ApiResponse<LoginResponse>
// POST 表单示例x-www-form-urlencoded
@FormUrlEncoded
@POST("auth/loginForm")
suspend fun loginForm(
@Field("username") username: String,
@Field("password") password: String
): ApiResponse<LoginResponse>
// zip 文件下载(或其它大文件)——必须 @Streaming // zip 文件下载(或其它大文件)——必须 @Streaming
@Streaming @Streaming

View File

@@ -1,5 +1,5 @@
// zip 文件下载器 // zip 文件下载器
package com.example.network package com.example.myapplication.network
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log

View File

@@ -1,5 +1,5 @@
// 定义请求 & 响应拦截器 // 定义请求 & 响应拦截器
package com.example.network package com.example.myapplication.network
import android.util.Log import android.util.Log
import okhttp3.Interceptor import okhttp3.Interceptor
@@ -11,17 +11,56 @@ import okhttp3.ResponseBody.Companion.toResponseBody
*/ */
val requestInterceptor = Interceptor { chain -> val requestInterceptor = Interceptor { chain ->
val original = chain.request() val original = chain.request()
val token = "your_token" // 这里换成你自己的 token
val token = "" // 你的 token
val newRequest = original.newBuilder() val newRequest = original.newBuilder()
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
// 这里加你自己的 token如果没有就注释掉
.addHeader("Authorization", "Bearer $token") .addHeader("Authorization", "Bearer $token")
.addHeader("Accept-Language", "lang")
.build() .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() ?: "" val bodyString = rawBody?.string() ?: ""
Log.d( Log.d(
"HTTP", "1314520-HTTP",
"⬇⬇⬇\n" + "⬇⬇⬇\n" +
"URL : ${request.url}\n" + "URL : ${request.url}\n" +
"Method: ${request.method}\n" + "Method: ${request.method}\n" +

View File

@@ -0,0 +1,6 @@
package com.example.myapplication.network
interface LlmStreamCallback {
fun onEvent(type: String, data: String?)
fun onError(t: Throwable)
}

View File

@@ -1,5 +1,5 @@
// Models.kt // Models.kt
package com.example.network package com.example.myapplication.network
data class User( data class User(
val id: String, val id: String,
@@ -7,8 +7,9 @@ data class User(
val age: Int val age: Int
) )
// 登录
data class LoginRequest( data class LoginRequest(
val username: String, val mail: String,
val password: String val password: String
) )

View File

@@ -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 的 OkHttpClientreadTimeout = 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
}
}

View File

@@ -1,5 +1,5 @@
// RetrofitClient.kt // RetrofitClient.kt
package com.example.network package com.example.myapplication.network
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit
object RetrofitClient { 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 { private val loggingInterceptor = HttpLoggingInterceptor().apply {

View File

@@ -24,24 +24,12 @@ object ThemeManager {
/** 主题根目录:/Android/data/<package>/files/keyboard_themes */ /** 主题根目录:/Android/data/<package>/files/keyboard_themes */
private fun getThemeRootDir(context: Context): File = private fun getThemeRootDir(context: Context): File =
File(context.getExternalFilesDir(null), "keyboard_themes") File(context.filesDir, "keyboard_themes")
/** 某个具体主题目录:/Android/.../keyboard_themes/<themeName> */ /** 某个具体主题目录:/Android/.../keyboard_themes/<themeName> */
private fun getThemeDir(context: Context, themeName: String): File = private fun getThemeDir(context: Context, themeName: String): File =
File(getThemeRootDir(context), themeName) File(getThemeRootDir(context), themeName)
// ==================== 内置主题拷贝assets -> 外部目录) ====================
/**
* 确保 APK 自带的主题(assets/keyboard_themes/...) 已经复制到
* /Android/data/.../files/keyboard_themes 目录下。
*
* 行为:
* - 如果主题目录不存在:整套拷贝过去。
* - 如果主题目录已经存在:只复制“新增文件”,不会覆盖已有文件。
*
* 建议在 IME 的 onCreate() 里调用一次。
*/
fun ensureBuiltInThemesInstalled(context: Context) { fun ensureBuiltInThemesInstalled(context: Context) {
val am = context.assets val am = context.assets
val rootName = "keyboard_themes" val rootName = "keyboard_themes"

View File

@@ -2,23 +2,31 @@ package com.example.myapplication.ui.login
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.EditText import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import com.example.myapplication.R 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 com.google.android.material.button.MaterialButton
import android.widget.Toast
import kotlinx.coroutines.launch
class LoginFragment : Fragment() { class LoginFragment : Fragment() {
private lateinit var passwordEditText: EditText private lateinit var passwordEditText: EditText
private lateinit var toggleImageView: ImageView 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 private var isPasswordVisible = false
@@ -47,8 +55,10 @@ class LoginFragment : Fragment() {
} }
// 绑定控件id 必须和 xml 里的一样) // 绑定控件id 必须和 xml 里的一样)
passwordEditText = view.findViewById(R.id.et_password) passwordEditText = view.findViewById(R.id.et_password)
emailEditText = view.findViewById(R.id.et_email)
toggleImageView = view.findViewById(R.id.iv_toggle) toggleImageView = view.findViewById(R.id.iv_toggle)
// loginButton = view.findViewById(R.id.btn_login) // 如果没有这个按钮就把这一行和变量删了 loginButton = view.findViewById(R.id.btn_login)
// 初始是隐藏密码状态 // 初始是隐藏密码状态
passwordEditText.inputType = passwordEditText.inputType =
@@ -73,10 +83,30 @@ class LoginFragment : Fragment() {
passwordEditText.setSelection(passwordEditText.text?.length ?: 0) passwordEditText.setSelection(passwordEditText.text?.length ?: 0)
} }
// // 登录按钮逻辑你自己填 // // 登录按钮逻辑
// loginButton.setOnClickListener { loginButton.setOnClickListener {
// val pwd = passwordEditText.text?.toString().orEmpty() val pwd = passwordEditText.text?.toString().orEmpty()
// // TODO: 登录处理 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()
}
}
}
}
} }
} }

View File

@@ -4,28 +4,61 @@ import java.io.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream 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 -> ZipInputStream(BufferedInputStream(zipInputStream)).use { zis ->
var entry: ZipEntry? = zis.nextEntry
val buffer = ByteArray(4096) val buffer = ByteArray(4096)
while (entry != null) { while (true) {
val file = File(targetDir, entry.name) 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) { if (entry.isDirectory) {
file.mkdirs() outFile.mkdirs()
} else { } else {
file.parentFile?.mkdirs() outFile.parentFile?.mkdirs()
FileOutputStream(file).use { fos ->
var count: Int // 覆盖策略:默认不覆盖(存在就跳过)
while (zis.read(buffer).also { count = it } != -1) { 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.write(buffer, 0, count)
} }
fos.flush()
} }
} }
zis.closeEntry() zis.closeEntry()
entry = zis.nextEntry
} }
} }
} }

View File

@@ -6,12 +6,12 @@
<translate <translate
android:fromYDelta="20%" android:fromYDelta="20%"
android:toYDelta="0" android:toYDelta="0"
android:duration="250" /> android:duration="300" />
<!-- 透明度动画:从透明到不透明 --> <!-- 透明度动画:从透明到不透明 -->
<alpha <alpha
android:fromAlpha="0" android:fromAlpha="0"
android:toAlpha="1" android:toAlpha="1"
android:duration="250" /> android:duration="300" />
</set> </set>

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#EDEDED"/>
<corners android:radius="100dp"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#8002BEAC"/>
<corners android:radius="50dp"/>
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -33,6 +33,17 @@
android:rotation="180" android:rotation="180"
android:scaleType="fitCenter" /> android:scaleType="fitCenter" />
</FrameLayout> </FrameLayout>
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="49dp"
android:gravity="center"
android:textStyle="bold"
android:text="Key of love"
android:textColor="#1B1F1A"
android:textSize="16sp" />
</LinearLayout> </LinearLayout>
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
@@ -52,6 +63,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="vertical"> android:orientation="vertical">
<!-- 头像 --> <!-- 头像 -->
@@ -127,32 +139,61 @@
android:id="@+id/bottom_panel" android:id="@+id/bottom_panel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" 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:orientation="horizontal"
android:padding="16dp"
android:layout_gravity="bottom"> android:layout_gravity="bottom">
<EditText <FrameLayout
android:id="@+id/input_message" android:layout_width="0dp"
android:layout_width="200dp" android:layout_height="34dp"
android:layout_height="52dp" android:layout_weight="1">
android:background="@drawable/input_box_bg"
android:padding="15dp" <EditText
android:hint="Please enter your content" android:id="@+id/input_message"
android:textColorHint="#CBCBCB" android:layout_width="match_parent"
android:textSize="14sp" android:layout_height="match_parent"
android:textColor="#CBCBCB" android:background="#00000000"
android:focusable="true" android:focusable="true"
android:focusableInTouchMode="true" android:focusableInTouchMode="true"
android:cursorVisible="true" android:cursorVisible="true"
android:imeOptions="actionSend" android:imeOptions="actionSend"
android:inputType="text"/> android:textColor="#CBCBCB"
<Button android:textSize="12sp"
android:inputType="text"/>
<LinearLayout
android:id="@+id/hintLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="20dp"
android:layout_height="19dp"
android:src="@drawable/input_icon"/>
<TextView
android:id="@+id/hint_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="Please enter your content"
android:textSize="12sp"
android:textColor="#CBCBCB"/>
</LinearLayout>
</FrameLayout>
<ImageView
android:id="@+id/btn_send" android:id="@+id/btn_send"
android:layout_width="60dp" android:layout_width="34dp"
android:layout_height="52dp" android:layout_height="34dp"
android:text="Send" android:layout_marginStart="8dp"
android:textColor="#FFFFFF" android:src="@drawable/send_icon"
android:textSize="14sp"
android:imeOptions="actionSend" android:imeOptions="actionSend"
android:inputType="text"/> android:inputType="text"/>
</LinearLayout> </LinearLayout>

View File

@@ -5,6 +5,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:id="@+id/background" android:id="@+id/background"
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="52dp"
@@ -30,31 +31,32 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" /> app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:id="@+id/keyboard_row_1" android:id="@+id/keyboard_row_1"
android:paddingStart="4dp" android:paddingStart="4dp"
android:paddingEnd="4dp" android:paddingEnd="4dp"
android:orientation="horizontal"> android:orientation="vertical">
<!-- 粘贴框和人设 --> <!-- 粘贴框-->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical"> android:orientation="horizontal">
<!-- 粘贴框 --> <!-- 粘贴框 -->
<HorizontalScrollView <HorizontalScrollView
android:id="@+id/completion_scroll" android:id="@+id/completion_scroll"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="41dp" android:layout_height="41dp"
android:layout_weight="1"
android:overScrollMode="never" android:overScrollMode="never"
android:scrollbars="none" android:scrollbars="none"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingEnd="8dp" android:paddingEnd="8dp"
android:background="@drawable/keyboard_button_bg3" android:background="@drawable/keyboard_button_bg3"
android:fillViewport="false"> android:fillViewport="false">
<LinearLayout <LinearLayout
android:id="@+id/completion_container" android:id="@+id/completion_container"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -76,9 +78,35 @@
</LinearLayout> </LinearLayout>
</HorizontalScrollView> </HorizontalScrollView>
<!-- 人设 --> <LinearLayout
android:id="@+id/keyboard_button_Paste"
android:layout_width="60dp"
android:layout_height="41dp"
android:layout_marginStart="4dp"
android:gravity="center"
android:orientation="horizontal"
android:background="@drawable/keyboard_button_bg1">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Paste"
android:textColor="#FFFFFF"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- 人设和操作 -->
<LinearLayout
android:id="@+id/ai_persona"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="horizontal">
<ScrollView <ScrollView
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_weight="1"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_height="131dp" android:layout_height="131dp"
android:scrollbars="none"> android:scrollbars="none">
@@ -89,9 +117,9 @@
app:flexDirection="row" app:flexDirection="row"
app:flexWrap="wrap" app:flexWrap="wrap"
app:justifyContent="space_between"> app:justifyContent="space_between">
<!-- 卡片 --> <!-- 卡片 -->
<LinearLayout <LinearLayout
android:id="@+id/card"
android:layout_width="90dp" android:layout_width="90dp"
android:layout_height="41dp" android:layout_height="41dp"
android:layout_marginBottom="4dp" android:layout_marginBottom="4dp"
@@ -115,35 +143,16 @@
android:textColor="#1B1F1A" android:textColor="#1B1F1A"
android:textSize="13sp"/> android:textSize="13sp"/>
</LinearLayout> </LinearLayout>
<!-- ```````````````` -->
</com.google.android.flexbox.FlexboxLayout> </com.google.android.flexbox.FlexboxLayout>
</ScrollView> </ScrollView>
</LinearLayout>
<!-- 操作 -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:id="@+id/keyboard_button_1" android:id="@+id/keyboard_button_1"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout
android:id="@+id/keyboard_button_Paste"
android:layout_width="60dp"
android:layout_height="41dp"
android:gravity="center"
android:orientation="horizontal"
android:background="@drawable/keyboard_button_bg1">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Paste"
android:textColor="#FFFFFF"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/keyboard_button_Delete" android:id="@+id/keyboard_button_Delete"
android:layout_width="60dp" android:layout_width="60dp"
@@ -190,5 +199,41 @@
android:textSize="13sp" /> android:textSize="13sp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout>
<!-- `````````````````````````````````````````````````````````````````````````````````````` -->
<LinearLayout
android:id="@+id/ai_output"
android:layout_width="match_parent"
android:layout_height="131dp"
android:layout_marginTop="4dp"
android:background="@drawable/keyboard_button_bg3"
android:orientation="horizontal">
<ScrollView
android:id="@+id/scroll_messages"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/container_messages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"/>
</ScrollView>
<!-- 切换按钮 -->
<TextView
android:id="@+id/Return_keyboard"
android:layout_width="50dp"
android:layout_height="30dp"
android:layout_marginStart="-55dp"
android:layout_marginTop="5dp"
android:background="@drawable/keyboard_button_bg4"
android:gravity="center"
android:text="Return"
android:textColor="#FFFFFF"
android:textSize="10sp" />
</LinearLayout>
<!-- `````````````````````````````````````````````````````````````````````````````````````` -->
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -99,7 +99,7 @@
<!-- 输入框 --> <!-- 输入框 -->
<EditText <EditText
android:id="@+id/et_username" android:id="@+id/et_email"
android:layout_width="315dp" android:layout_width="315dp"
android:layout_height="52dp" android:layout_height="52dp"
android:layout_marginTop="20dp" android:layout_marginTop="20dp"

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="4dp">
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:background="#f5f5f5"
android:padding="10dp" />
</LinearLayout>

View File

@@ -3,7 +3,8 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="40dp" android:layout_marginTop="30dp"
android:layout_marginBottom="10dp"
android:gravity="end" android:gravity="end"
android:orientation="vertical"> android:orientation="vertical">
<!-- 头像 --> <!-- 头像 -->

View File

@@ -3,7 +3,8 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="40dp" android:layout_marginTop="30dp"
android:layout_marginBottom="10dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="vertical"> android:orientation="vertical">
<!-- 头像 --> <!-- 头像 -->

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.2.21</domain>
</domain-config>
</network-security-config>