Compare commits

..

1 Commits

Author SHA1 Message Date
pengxiaolong
e381b45a0b 埋点 2026-01-13 18:26:24 +08:00
24 changed files with 460 additions and 21535 deletions

View File

@@ -77,7 +77,6 @@ dependencies {
// lifecycle // lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0")
implementation("androidx.work:work-runtime-ktx:2.9.0")
// 加密 SharedPreferences // 加密 SharedPreferences
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Glide for image loading // Glide for image loading

View File

@@ -5,6 +5,8 @@
<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" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:allowBackup="true" android:allowBackup="true"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,6 @@ class BigramPredictor(
@Volatile private var word2id: Map<String, Int> = emptyMap() @Volatile private var word2id: Map<String, Int> = emptyMap()
@Volatile private var id2word: List<String> = emptyList() @Volatile private var id2word: List<String> = emptyList()
@Volatile private var topUnigrams: List<String> = emptyList()
private val unigramCacheSize = 2000
//预先加载语言模型并构建词到ID和ID到词的双向映射。 //预先加载语言模型并构建词到ID和ID到词的双向映射。
fun preload() { fun preload() {
@@ -40,7 +37,6 @@ class BigramPredictor(
word2id = map word2id = map
id2word = m.vocab id2word = m.vocab
topUnigrams = buildTopUnigrams(m, unigramCacheSize)
} catch (_: Throwable) { } catch (_: Throwable) {
// 保持静默,允许无模型运行(仅 Trie 起作用) // 保持静默,允许无模型运行(仅 Trie 起作用)
} finally { } finally {
@@ -93,34 +89,19 @@ class BigramPredictor(
return topKByScore(candidates, topK) return topKByScore(candidates, topK)
} }
// 3) 兜底:用预计算的 unigram Top-N + 前缀过滤 // 3) 兜底:用 unigram + 前缀过滤
if (topK <= 0) return emptyList() val heap = topKHeap(topK)
val cachedUnigrams = getTopUnigrams(m) for (i in m.vocab.indices) {
if (pfx.isEmpty()) { val w = m.vocab[i]
return cachedUnigrams.take(topK)
}
val results = ArrayList<String>(topK) if (pfx.isEmpty() || w.startsWith(pfx, ignoreCase = true)) {
if (cachedUnigrams.isNotEmpty()) { heap.offer(w to m.uniLogp[i])
for (w in cachedUnigrams) {
if (w.startsWith(pfx, ignoreCase = true)) {
results.add(w)
if (results.size >= topK) return results
}
}
}
if (results.size < topK) { if (heap.size > topK) heap.poll()
val fromTrie = safeTriePrefix(pfx, topK)
for (w in fromTrie) {
if (w !in results) {
results.add(w)
if (results.size >= topK) break
} }
} }
} return heap.toSortedListDescending()
return results
} }
//供上层在用户选中词时更新“上文”状态 //供上层在用户选中词时更新“上文”状态
@@ -134,33 +115,12 @@ class BigramPredictor(
if (prefix.isEmpty()) return emptyList() if (prefix.isEmpty()) return emptyList()
return try { return try {
trie.startsWith(prefix, topK) trie.startsWith(prefix).take(topK)
} catch (_: Throwable) { } catch (_: Throwable) {
emptyList() emptyList()
} }
} }
private fun getTopUnigrams(model: BigramModel): List<String> {
val cached = topUnigrams
if (cached.isNotEmpty()) return cached
val built = buildTopUnigrams(model, unigramCacheSize)
topUnigrams = built
return built
}
private fun buildTopUnigrams(model: BigramModel, limit: Int): List<String> {
if (limit <= 0) return emptyList()
val heap = topKHeap(limit)
for (i in model.vocab.indices) {
heap.offer(model.vocab[i] to model.uniLogp[i])
if (heap.size > limit) heap.poll()
}
return heap.toSortedListDescending()
}
//从给定的候选词对列表中通过一个小顶堆来过滤出评分最高的前k个词 //从给定的候选词对列表中通过一个小顶堆来过滤出评分最高的前k个词
private fun topKByScore(pairs: List<Pair<String, Float>>, k: Int): List<String> { private fun topKByScore(pairs: List<Pair<String, Float>>, k: Int): List<String> {
val heap = topKHeap(k) val heap = topKHeap(k)

View File

@@ -6,7 +6,6 @@ import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
@@ -29,8 +28,6 @@ class MainActivity : AppCompatActivity() {
private var currentTabTag = TAB_HOME private var currentTabTag = TAB_HOME
private var pendingTabAfterLogin: String? = null private var pendingTabAfterLogin: String? = null
private var isSwitchingTab = false
private var pendingTabSwitchTag: String? = null
private val protectedTabs = setOf( private val protectedTabs = setOf(
R.id.shop_graph, R.id.shop_graph,
@@ -363,58 +360,32 @@ class MainActivity : AppCompatActivity() {
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.isStateSaved) return if (fm.isStateSaved) return
if (isSwitchingTab) {
pendingTabSwitchTag = targetTag
return
}
val targetHost = when (targetTag) {
TAB_SHOP -> shopHost
TAB_MINE -> mineHost
else -> homeHost
}
val currentHost = currentTabHost
currentTabTag = targetTag currentTabTag = targetTag
isSwitchingTab = true
val transaction = fm.beginTransaction() fm.beginTransaction()
.setReorderingAllowed(true) .setReorderingAllowed(true)
if (force) {
transaction
.hide(homeHost) .hide(homeHost)
.hide(shopHost) .hide(shopHost)
.hide(mineHost) .hide(mineHost)
.show(targetHost) .also { ft ->
} else if (currentHost != targetHost) { when (targetTag) {
transaction TAB_SHOP -> ft.show(shopHost)
.hide(currentHost) TAB_MINE -> ft.show(mineHost)
.show(targetHost) else -> ft.show(homeHost)
} }
}
.commit()
transaction // ✅ 关键hide/show 切 tab 不会触发 destinationChanged所以手动刷新
.setMaxLifecycle(homeHost, if (targetHost == homeHost) Lifecycle.State.RESUMED else Lifecycle.State.STARTED) bottomNav.post { updateBottomNavVisibility() }
.setMaxLifecycle(shopHost, if (targetHost == shopHost) Lifecycle.State.RESUMED else Lifecycle.State.STARTED)
.setMaxLifecycle(mineHost, if (targetHost == mineHost) Lifecycle.State.RESUMED else Lifecycle.State.STARTED)
.setPrimaryNavigationFragment(targetHost)
.runOnCommit {
isSwitchingTab = false
updateBottomNavVisibility()
// ✅ 新增:切 tab 后补一次路由上报(不改变其它逻辑)
if (!force) { if (!force) {
currentTabNavController.currentDestination?.id?.let { destId -> currentTabNavController.currentDestination?.id?.let { destId ->
reportPageView(source = "switch_tab", destId = destId) reportPageView(source = "switch_tab", destId = destId)
} }
} }
val pendingTag = pendingTabSwitchTag
pendingTabSwitchTag = null
if (pendingTag != null && pendingTag != currentTabTag) {
switchTab(pendingTag)
}
}
.commit()
} }
/** 打开全局页login/recharge等 */ /** 打开全局页login/recharge等 */

View File

@@ -218,12 +218,10 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ThemeManager.addThemeChangeListener(themeListener)
Thread {
ThemeManager.ensureBuiltInThemesInstalled(this) ThemeManager.ensureBuiltInThemesInstalled(this)
ThemeManager.init(this) ThemeManager.init(this)
}.start()
ThemeManager.addThemeChangeListener(themeListener)
// 异步加载词典与 bigram 模型 // 异步加载词典与 bigram 模型
Thread { Thread {
@@ -934,13 +932,15 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
if (fromBi.isNotEmpty()) { if (fromBi.isNotEmpty()) {
fromBi.filter { it != prefix } fromBi.filter { it != prefix }
} else { } else {
wordDictionary.wordTrie.startsWith(prefix, 20) wordDictionary.wordTrie.startsWith(prefix)
.take(20)
.filter { it != prefix } .filter { it != prefix }
} }
} }
} catch (_: Throwable) { } catch (_: Throwable) {
if (prefix.isNotEmpty()) { if (prefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(prefix, 20) wordDictionary.wordTrie.startsWith(prefix)
.take(20)
.filterNot { it == prefix } .filterNot { it == prefix }
} else { } else {
emptyList() emptyList()
@@ -1102,8 +1102,6 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 如果此时正在连删(长按已触发),记录一下,方便取消时恢复 // 如果此时正在连删(长按已触发),记录一下,方便取消时恢复
resumeDeletingAfterCancel = isDeleting resumeDeletingAfterCancel = isDeleting
stopRepeatDelete() stopRepeatDelete()
view.cancelLongPress()
view.isPressed = false
showSwipeClearHint(view, "Clear") showSwipeClearHint(view, "Clear")
return@setOnTouchListener true return@setOnTouchListener true
@@ -1144,8 +1142,6 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
pendingSwipeClear = false pendingSwipeClear = false
resumeDeletingAfterCancel = false resumeDeletingAfterCancel = false
dismissSwipeClearHint() dismissSwipeClearHint()
view.cancelLongPress()
view.isPressed = false
// 消费 UP避免 click/longclick 再触发 // 消费 UP避免 click/longclick 再触发
return@setOnTouchListener true return@setOnTouchListener true
@@ -1289,7 +1285,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
val m = bigramModel val m = bigramModel
if (m == null || !bigramReady) { if (m == null || !bigramReady) {
return if (prefix.isNotEmpty()) { return if (prefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(prefix, topK) wordDictionary.wordTrie.startsWith(prefix).take(topK)
} else { } else {
emptyList() emptyList()
} }
@@ -1336,7 +1332,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// —— 无上文 或 无出边 —— // —— 无上文 或 无出边 ——
return if (pf.isNotEmpty()) { return if (pf.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(pf, topK) wordDictionary.wordTrie.startsWith(pf).take(topK)
} else { } else {
unigramTopKFiltered(topK) unigramTopKFiltered(topK)
} }

View File

@@ -1,7 +1,5 @@
package com.example.myapplication package com.example.myapplication
import java.util.ArrayDeque
class Trie { class Trie {
//表示Trie数据结构中的一个节点该节点可以存储其子节点并且可以标记是否是一个完整单词的结尾 //表示Trie数据结构中的一个节点该节点可以存储其子节点并且可以标记是否是一个完整单词的结尾
private data class TrieNode( private data class TrieNode(
@@ -34,38 +32,29 @@ class Trie {
return current.isEndOfWord return current.isEndOfWord
} }
//查找以prefix为前缀的所有单词。通过遍历prefix的每个字符找到相应的节点然后从该节点开始迭代搜索所有以该节点为起点的单词。 //查找以prefix为前缀的所有单词。通过遍历prefix的每个字符找到相应的节点然后从该节点开始递归查找所有以该节点为起点的单词。
fun startsWith(prefix: String): List<String> { fun startsWith(prefix: String): List<String> {
return startsWith(prefix, Int.MAX_VALUE)
}
fun startsWith(prefix: String, limit: Int): List<String> {
var current = root var current = root
val normalized = prefix.lowercase() for (char in prefix.lowercase()) {
for (char in normalized) {
current = current.children[char] ?: return emptyList() current = current.children[char] ?: return emptyList()
} }
val max = if (limit < 0) 0 else limit return getAllWordsFromNode(current, prefix)
if (max == 0) return emptyList() }
val results = ArrayList<String>(minOf(max, 16)) //从给定节点开始递归查找所有以该节点为起点的单词。
val stack = ArrayDeque<Pair<TrieNode, String>>() private fun getAllWordsFromNode(node: TrieNode, prefix: String): List<String> {
stack.addLast(current to prefix) val words = mutableListOf<String>()
while (stack.isNotEmpty() && results.size < max) {
val (node, word) = stack.removeLast()
if (node.isEndOfWord) { if (node.isEndOfWord) {
results.add(word) words.add(prefix)
if (results.size >= max) break
} }
for ((char, child) in node.children) { for ((char, child) in node.children) {
stack.addLast(child to (word + char)) words.addAll(getAllWordsFromNode(child, prefix + char))
}
} }
return results return words
} }
} }

View File

@@ -2,15 +2,7 @@ package com.example.myapplication.data
import android.content.Context import android.content.Context
import java.io.BufferedReader import java.io.BufferedReader
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.channels.Channels
import java.nio.channels.FileChannel
import kotlin.math.max
data class BigramModel( data class BigramModel(
val vocab: List<String>, // 保留全部词(含 <unk>, <s>, </s>),与二元矩阵索引对齐 val vocab: List<String>, // 保留全部词(含 <unk>, <s>, </s>),与二元矩阵索引对齐
@@ -38,104 +30,39 @@ object LanguageModelLoader {
} }
private fun readInt32(context: Context, name: String): IntArray { private fun readInt32(context: Context, name: String): IntArray {
try {
context.assets.openFd(name).use { afd ->
FileInputStream(afd.fileDescriptor).channel.use { channel ->
return readInt32Channel(channel, afd.startOffset, afd.length)
}
}
} catch (e: FileNotFoundException) {
// Compressed assets do not support openFd; fall back to streaming.
}
context.assets.open(name).use { input -> context.assets.open(name).use { input ->
return readInt32Stream(input) val bytes = input.readBytes()
val n = bytes.size / 4
val out = IntArray(n)
var i = 0; var j = 0
while (i < n) {
// 小端序
val v = (bytes[j].toInt() and 0xFF) or
((bytes[j+1].toInt() and 0xFF) shl 8) or
((bytes[j+2].toInt() and 0xFF) shl 16) or
((bytes[j+3].toInt() and 0xFF) shl 24)
out[i++] = v
j += 4
}
return out
} }
} }
private fun readFloat32(context: Context, name: String): FloatArray { private fun readFloat32(context: Context, name: String): FloatArray {
try {
context.assets.openFd(name).use { afd ->
FileInputStream(afd.fileDescriptor).channel.use { channel ->
return readFloat32Channel(channel, afd.startOffset, afd.length)
}
}
} catch (e: FileNotFoundException) {
// Compressed assets do not support openFd; fall back to streaming.
}
context.assets.open(name).use { input -> context.assets.open(name).use { input ->
return readFloat32Stream(input) val bytes = input.readBytes()
val n = bytes.size / 4
val out = FloatArray(n)
var i = 0; var j = 0
while (i < n) {
val bits = (bytes[j].toInt() and 0xFF) or
((bytes[j+1].toInt() and 0xFF) shl 8) or
((bytes[j+2].toInt() and 0xFF) shl 16) or
((bytes[j+3].toInt() and 0xFF) shl 24)
out[i++] = Float.fromBits(bits)
j += 4
} }
}
private fun readInt32Channel(channel: FileChannel, offset: Long, length: Long): IntArray {
require(length % 4L == 0L) { "int32 length invalid: $length" }
require(length <= Int.MAX_VALUE.toLong()) { "int32 asset too large: $length" }
val count = (length / 4L).toInt()
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, offset, length)
mapped.order(ByteOrder.LITTLE_ENDIAN)
val out = IntArray(count)
mapped.asIntBuffer().get(out)
return out return out
} }
private fun readFloat32Channel(channel: FileChannel, offset: Long, length: Long): FloatArray {
require(length % 4L == 0L) { "float32 length invalid: $length" }
require(length <= Int.MAX_VALUE.toLong()) { "float32 asset too large: $length" }
val count = (length / 4L).toInt()
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, offset, length)
mapped.order(ByteOrder.LITTLE_ENDIAN)
val out = FloatArray(count)
mapped.asFloatBuffer().get(out)
return out
}
private fun readInt32Stream(input: InputStream): IntArray {
val initialSize = max(1024, input.available() / 4)
var out = IntArray(initialSize)
var count = 0
val buffer = ByteBuffer.allocateDirect(64 * 1024)
buffer.order(ByteOrder.LITTLE_ENDIAN)
Channels.newChannel(input).use { channel ->
while (true) {
val read = channel.read(buffer)
if (read == -1) break
if (read == 0) continue
buffer.flip()
while (buffer.remaining() >= 4) {
if (count == out.size) out = out.copyOf(out.size * 2)
out[count++] = buffer.getInt()
}
buffer.compact()
}
}
buffer.flip()
check(buffer.remaining() == 0) { "truncated int32 stream" }
return out.copyOf(count)
}
private fun readFloat32Stream(input: InputStream): FloatArray {
val initialSize = max(1024, input.available() / 4)
var out = FloatArray(initialSize)
var count = 0
val buffer = ByteBuffer.allocateDirect(64 * 1024)
buffer.order(ByteOrder.LITTLE_ENDIAN)
Channels.newChannel(input).use { channel ->
while (true) {
val read = channel.read(buffer)
if (read == -1) break
if (read == 0) continue
buffer.flip()
while (buffer.remaining() >= 4) {
if (count == out.size) out = out.copyOf(out.size * 2)
out[count++] = buffer.getFloat()
}
buffer.compact()
}
}
buffer.flip()
check(buffer.remaining() == 0) { "truncated float32 stream" }
return out.copyOf(count)
} }
} }

View File

@@ -7,8 +7,11 @@ import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -60,15 +63,13 @@ class AiKeyboard(
private val messagesContainer: LinearLayout by lazy { private val messagesContainer: LinearLayout by lazy {
val res = env.ctx.resources val res = env.ctx.resources
val id = res.getIdentifier("container_messages", "id", env.ctx.packageName) val id = res.getIdentifier("container_messages", "id", env.ctx.packageName)
val view = if (id != 0) rootView.findViewById<View?>(id) else null rootView.findViewById(id)
view as? LinearLayout ?: LinearLayout(env.ctx)
} }
private val messagesScrollView: ScrollView by lazy { private val messagesScrollView: ScrollView by lazy {
val res = env.ctx.resources val res = env.ctx.resources
val id = res.getIdentifier("scroll_messages", "id", env.ctx.packageName) val id = res.getIdentifier("scroll_messages", "id", env.ctx.packageName)
val view = if (id != 0) rootView.findViewById<View?>(id) else null rootView.findViewById(id)
view as? ScrollView ?: ScrollView(env.ctx)
} }
private var currentAssistantTextView: TextView? = null private var currentAssistantTextView: TextView? = null
@@ -83,26 +84,15 @@ class AiKeyboard(
val res = env.ctx.resources val res = env.ctx.resources
val layoutId = res.getIdentifier("item_ai_message", "layout", env.ctx.packageName) val layoutId = res.getIdentifier("item_ai_message", "layout", env.ctx.packageName)
val itemView = if (layoutId != 0) { val itemView = inflater.inflate(layoutId, messagesContainer, false) as LinearLayout
inflater.inflate(layoutId, messagesContainer, false) val tv = itemView.findViewById<TextView>(
} else { res.getIdentifier("tv_content", "id", env.ctx.packageName)
LinearLayout(env.ctx) )
} tv.text = initialText
val tvId = res.getIdentifier("tv_content", "id", env.ctx.packageName)
val tv = if (tvId != 0) {
itemView.findViewById<View?>(tvId) as? TextView
} else {
null
}
val textView = tv ?: TextView(env.ctx)
textView.text = initialText
if (tv == null && itemView is ViewGroup) {
itemView.addView(textView)
}
// ✅ 点击整张卡片:把当前卡片内容填入输入框,并覆盖上次填入内容 // ✅ 点击整张卡片:把当前卡片内容填入输入框,并覆盖上次填入内容
itemView.setOnClickListener { itemView.setOnClickListener {
val text = textView.text?.toString().orEmpty() val text = tv.text?.toString().orEmpty()
if (text.isNotBlank()) { if (text.isNotBlank()) {
fillToEditorOverwriteLast(text) fillToEditorOverwriteLast(text)
BehaviorReporter.report( BehaviorReporter.report(
@@ -116,7 +106,7 @@ class AiKeyboard(
messagesContainer.addView(itemView) messagesContainer.addView(itemView)
scrollToBottom() scrollToBottom()
return textView return tv
} }
private fun scrollToBottom() { private fun scrollToBottom() {
@@ -159,7 +149,6 @@ class AiKeyboard(
} }
private fun onLlmDone() { private fun onLlmDone() {
cancelAiStream()
mainHandler.post { mainHandler.post {
streamBuffer.clear() streamBuffer.clear()
currentAssistantTextView = null currentAssistantTextView = null
@@ -191,7 +180,6 @@ class AiKeyboard(
} }
override fun onError(t: Throwable) { override fun onError(t: Throwable) {
cancelAiStream()
// 尝试解析JSON错误响应 // 尝试解析JSON错误响应
val errorResponse = try { val errorResponse = try {
val errorJson = t.message?.let { val errorJson = t.message?.let {
@@ -541,13 +529,36 @@ class AiKeyboard(
val v = root.findViewById<View?>(viewId) ?: return val v = root.findViewById<View?>(viewId) ?: return
val keyName = drawableName ?: viewIdName val keyName = drawableName ?: viewIdName
val drawable = if (viewIdName == "background") { val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f)
} else {
ThemeManager.getDrawableForKey(env.ctx, keyName)
} ?: return
v.background = drawable if (viewIdName == "background") {
val scaled = scaleDrawableToHeight(rawDrawable, 243f)
v.background = scaled
return
}
v.background = rawDrawable
}
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
val res = env.ctx.resources
val dm = res.displayMetrics
val targetHeightPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
targetDp,
dm
).toInt()
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
val w = bitmap.width
val h = bitmap.height
val ratio = targetHeightPx.toFloat() / h
val targetWidthPx = (w * ratio).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
return BitmapDrawable(res, scaled).apply {
setBounds(0, 0, targetWidthPx, targetHeightPx)
}
} }
private fun navigateToRechargeFragment() { private fun navigateToRechargeFragment() {

View File

@@ -1,10 +1,15 @@
package com.example.myapplication.keyboard package com.example.myapplication.keyboard
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@@ -20,15 +25,10 @@ class MainKeyboard(
private val onToggleShift: () -> Boolean private val onToggleShift: () -> Boolean
) : BaseKeyboard(env) { ) : BaseKeyboard(env) {
override val rootView: View = run { override val rootView: View = env.layoutInflater.inflate(
val res = env.ctx.resources env.ctx.resources.getIdentifier("keyboard", "layout", env.ctx.packageName),
val layoutId = res.getIdentifier("keyboard", "layout", env.ctx.packageName) null
if (layoutId != 0) { )
env.layoutInflater.inflate(layoutId, null)
} else {
View(env.ctx)
}
}
private var isShiftOn: Boolean = false private var isShiftOn: Boolean = false
private var keyPreviewPopup: PopupWindow? = null private var keyPreviewPopup: PopupWindow? = null
@@ -61,13 +61,24 @@ class MainKeyboard(
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
val v = root.findViewById<View?>(viewId) ?: return val v = root.findViewById<View?>(viewId) ?: return
val keyName = drawableName ?: viewIdName val keyName = drawableName ?: viewIdName
val drawable = if (viewIdName == "background") { val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f)
} else {
ThemeManager.getDrawableForKey(env.ctx, keyName)
} ?: return
v.background = drawable if (viewIdName == "background") {
v.background = scaleDrawableToHeight(rawDrawable, 243f)
} else {
v.background = rawDrawable
}
}
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
val res = env.ctx.resources
val dm = res.displayMetrics
val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
val ratio = targetHeightPx.toFloat() / bitmap.height
val targetWidthPx = (bitmap.width * ratio).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) }
} }
// -------------------- 实现主题刷新 -------------------- // -------------------- 实现主题刷新 --------------------

View File

@@ -1,10 +1,15 @@
package com.example.myapplication.keyboard package com.example.myapplication.keyboard
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@@ -16,15 +21,10 @@ class NumberKeyboard(
env: KeyboardEnvironment env: KeyboardEnvironment
) : BaseKeyboard(env) { ) : BaseKeyboard(env) {
override val rootView: View = run { override val rootView: View = env.layoutInflater.inflate(
val res = env.ctx.resources env.ctx.resources.getIdentifier("number_keyboard", "layout", env.ctx.packageName),
val layoutId = res.getIdentifier("number_keyboard", "layout", env.ctx.packageName) null
if (layoutId != 0) { )
env.layoutInflater.inflate(layoutId, null)
} else {
View(env.ctx)
}
}
private var keyPreviewPopup: PopupWindow? = null private var keyPreviewPopup: PopupWindow? = null
@@ -57,13 +57,24 @@ class NumberKeyboard(
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
val v = root.findViewById<View?>(viewId) ?: return val v = root.findViewById<View?>(viewId) ?: return
val keyName = drawableName ?: viewIdName val keyName = drawableName ?: viewIdName
val drawable = if (viewIdName == "background") { val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f)
} else {
ThemeManager.getDrawableForKey(env.ctx, keyName)
} ?: return
v.background = drawable if (viewIdName == "background") {
v.background = scaleDrawableToHeight(rawDrawable, 243f)
} else {
v.background = rawDrawable
}
}
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
val res = env.ctx.resources
val dm = res.displayMetrics
val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
val ratio = targetHeightPx.toFloat() / bitmap.height
val targetWidthPx = (bitmap.width * ratio).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) }
} }
// -------------------- 实现主题刷新 -------------------- // -------------------- 实现主题刷新 --------------------

View File

@@ -1,10 +1,15 @@
package com.example.myapplication.keyboard package com.example.myapplication.keyboard
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@@ -16,15 +21,10 @@ class SymbolKeyboard(
env: KeyboardEnvironment env: KeyboardEnvironment
) : BaseKeyboard(env) { ) : BaseKeyboard(env) {
override val rootView: View = run { override val rootView: View = env.layoutInflater.inflate(
val res = env.ctx.resources env.ctx.resources.getIdentifier("symbol_keyboard", "layout", env.ctx.packageName),
val layoutId = res.getIdentifier("symbol_keyboard", "layout", env.ctx.packageName) null
if (layoutId != 0) { )
env.layoutInflater.inflate(layoutId, null)
} else {
View(env.ctx)
}
}
private var keyPreviewPopup: PopupWindow? = null private var keyPreviewPopup: PopupWindow? = null
@@ -58,13 +58,24 @@ class SymbolKeyboard(
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
val v = root.findViewById<View?>(viewId) ?: return val v = root.findViewById<View?>(viewId) ?: return
val keyName = drawableName ?: viewIdName val keyName = drawableName ?: viewIdName
val drawable = if (viewIdName == "background") { val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f)
} else {
ThemeManager.getDrawableForKey(env.ctx, keyName)
} ?: return
v.background = drawable if (viewIdName == "background") {
v.background = scaleDrawableToHeight(rawDrawable, 243f)
} else {
v.background = rawDrawable
}
}
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
val res = env.ctx.resources
val dm = res.displayMetrics
val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
val ratio = targetHeightPx.toFloat() / bitmap.height
val targetWidthPx = (bitmap.width * ratio).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) }
} }
// -------------------- 实现主题刷新 -------------------- // -------------------- 实现主题刷新 --------------------

View File

@@ -81,10 +81,8 @@ object FileDownloader {
downloadedBytes += read downloadedBytes += read
// 需要的话可以在这里回调进度 // 需要的话可以在这里回调进度
if (totalBytes > 0) {
val progress = downloadedBytes * 100 / totalBytes val progress = downloadedBytes * 100 / totalBytes
} }
}
outputStream.flush() outputStream.flush()
} finally { } finally {
inputStream?.close() inputStream?.close()

View File

@@ -328,7 +328,6 @@ object NetworkClient {
) { ) {
var eventName: String? = null var eventName: String? = null
val dataLines = mutableListOf<String>() val dataLines = mutableListOf<String>()
var stop = false
fun dispatch() { fun dispatch() {
if (eventName == null && dataLines.isEmpty()) return if (eventName == null && dataLines.isEmpty()) return
@@ -337,16 +336,7 @@ object NetworkClient {
Log.d("999-SSE_TALK-event", "event=${eventName ?: "(null)"} rawData=[${rawData.take(500)}]") Log.d("999-SSE_TALK-event", "event=${eventName ?: "(null)"} rawData=[${rawData.take(500)}]")
if (rawData.isNotEmpty()) { if (rawData.isNotEmpty()) {
if (handlePayload(eventName, rawData, callback)) { handlePayload(eventName, rawData, callback)
stop = true
call.cancel()
}
} else if (eventName != null) {
callback.onEvent(eventName!!, null)
if (eventName.equals("done", ignoreCase = true)) {
stop = true
call.cancel()
}
} }
eventName = null eventName = null
@@ -359,7 +349,6 @@ object NetworkClient {
if (line.isEmpty()) { if (line.isEmpty()) {
dispatch() dispatch()
if (stop) break
continue continue
} }
if (line.startsWith(":")) continue if (line.startsWith(":")) continue
@@ -377,16 +366,10 @@ object NetworkClient {
} }
} }
if (!stop) {
dispatch() dispatch()
} }
}
private fun handlePayload( private fun handlePayload(eventName: String?, rawData: String, callback: LlmStreamCallback) {
eventName: String?,
rawData: String,
callback: LlmStreamCallback
): Boolean {
val trimmed = rawData.trim() val trimmed = rawData.trim()
val looksLikeJson = trimmed.startsWith("{") && trimmed.endsWith("}") val looksLikeJson = trimmed.startsWith("{") && trimmed.endsWith("}")
@@ -399,22 +382,14 @@ object NetworkClient {
if (type.isNotBlank()) { if (type.isNotBlank()) {
callback.onEvent(type, dataStr) callback.onEvent(type, dataStr)
return type.equals("done", ignoreCase = true) return
} }
} catch (_: Throwable) { } catch (_: Throwable) {
// fallthrough to text // fallthrough to text
} }
} }
val normalized = trimmed.lowercase() callback.onEvent(eventName ?: "text_chunk", rawData)
if (normalized == "[done]" || normalized == "done") {
callback.onEvent("done", null)
return true
}
val fallbackType = eventName ?: "text_chunk"
callback.onEvent(fallbackType, rawData)
return fallbackType.equals("done", ignoreCase = true)
} }
private fun peekOrReadBody(response: Response): String { private fun peekOrReadBody(response: Response): String {

View File

@@ -2,18 +2,11 @@ package com.example.myapplication.theme
import android.content.Context import android.content.Context
import android.content.res.AssetManager import android.content.res.AssetManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import java.io.File import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
object ThemeManager { object ThemeManager {
private val mainHandler = Handler(Looper.getMainLooper())
// SharedPreferences 保存当前主题名 // SharedPreferences 保存当前主题名
private const val PREF_NAME = "ime_theme_prefs" private const val PREF_NAME = "ime_theme_prefs"
@@ -25,15 +18,11 @@ object ThemeManager {
// 缓存:规范化后的 keyName(lowercase) -> Drawable // 缓存:规范化后的 keyName(lowercase) -> Drawable
@Volatile @Volatile
private var drawableCache: MutableMap<String, Drawable> = ConcurrentHashMap() private var drawableCache: MutableMap<String, Drawable> = mutableMapOf()
@Volatile
private var filePathCache: MutableMap<String, File> = ConcurrentHashMap()
@Volatile
private var scaledBitmapCache: MutableMap<String, Bitmap> = ConcurrentHashMap()
// ==================== 外部目录相关 ==================== // ==================== 外部目录相关 ====================
//通知主题更新 //通知主题更新
private val listeners = CopyOnWriteArraySet<() -> Unit>() private val listeners = mutableSetOf<() -> Unit>()
fun addThemeChangeListener(listener: () -> Unit) { fun addThemeChangeListener(listener: () -> Unit) {
listeners.add(listener) listeners.add(listener)
@@ -135,22 +124,9 @@ object ThemeManager {
.putString(KEY_CURRENT_THEME, themeName) .putString(KEY_CURRENT_THEME, themeName)
.apply() .apply()
val newFilePathCache: MutableMap<String, File> = ConcurrentHashMap() drawableCache = loadThemeDrawables(context, themeName)
drawableCache = ConcurrentHashMap()
filePathCache = newFilePathCache
scaledBitmapCache = ConcurrentHashMap()
if (Looper.myLooper() == Looper.getMainLooper()) {
Thread {
indexThemeFiles(context, themeName, newFilePathCache)
}.start()
listeners.forEach { it.invoke() } listeners.forEach { it.invoke() }
} else {
indexThemeFiles(context, themeName, newFilePathCache)
mainHandler.post {
listeners.forEach { it.invoke() }
}
}
} }
fun getCurrentThemeName(): String? = currentThemeName fun getCurrentThemeName(): String? = currentThemeName
@@ -163,14 +139,14 @@ object ThemeManager {
* /.../keyboard_themes/default/key_a.png -> "key_a" * /.../keyboard_themes/default/key_a.png -> "key_a"
* /.../keyboard_themes/default/key_a_up.PNG -> "key_a_up" * /.../keyboard_themes/default/key_a_up.PNG -> "key_a_up"
*/ */
private fun indexThemeFiles( private fun loadThemeDrawables(
context: Context, context: Context,
themeName: String, themeName: String
targetMap: MutableMap<String, File> ): MutableMap<String, Drawable> {
) { val map = mutableMapOf<String, Drawable>()
val dir = getThemeDir(context, themeName) val dir = getThemeDir(context, themeName)
if (!dir.exists() || !dir.isDirectory) return if (!dir.exists() || !dir.isDirectory) return map
dir.listFiles()?.forEach { file -> dir.listFiles()?.forEach { file ->
if (!file.isFile) return@forEach if (!file.isFile) return@forEach
@@ -185,8 +161,12 @@ object ThemeManager {
// 统一小写作为 key比如 key_a_up.png -> "key_a_up" // 统一小写作为 key比如 key_a_up.png -> "key_a_up"
val key = lowerName.substringBeforeLast(".") val key = lowerName.substringBeforeLast(".")
targetMap[key] = file val bmp = BitmapFactory.decodeFile(file.absolutePath) ?: return@forEach
val d = BitmapDrawable(context.resources, bmp)
map[key] = d
} }
return map
} }
// ==================== 对外:按 keyName 取 Drawable ==================== // ==================== 对外:按 keyName 取 Drawable ====================
@@ -208,6 +188,10 @@ object ThemeManager {
* 内部统一用 keyName.lowercase() 做匹配,不区分大小写。 * 内部统一用 keyName.lowercase() 做匹配,不区分大小写。
*/ */
fun getDrawableForKey(context: Context, keyName: String): Drawable? { fun getDrawableForKey(context: Context, keyName: String): Drawable? {
if (currentThemeName == null) {
init(context)
}
// 统一小写,避免大小写差异 // 统一小写,避免大小写差异
val norm = keyName.lowercase() val norm = keyName.lowercase()
@@ -218,20 +202,6 @@ object ThemeManager {
val theme = currentThemeName ?: return null val theme = currentThemeName ?: return null
val dir = getThemeDir(context, theme) val dir = getThemeDir(context, theme)
val cachedFile = filePathCache[norm]
if (cachedFile != null) {
if (cachedFile.exists() && cachedFile.isFile) {
val bmp = BitmapFactory.decodeFile(cachedFile.absolutePath)
if (bmp != null) {
val d = BitmapDrawable(context.resources, bmp)
drawableCache[norm] = d
return d
}
} else {
filePathCache.remove(norm)
}
}
val candidates = listOf( val candidates = listOf(
File(dir, "$norm.png"), File(dir, "$norm.png"),
File(dir, "$norm.webp"), File(dir, "$norm.webp"),
@@ -243,7 +213,6 @@ object ThemeManager {
if (f.exists() && f.isFile) { if (f.exists() && f.isFile) {
val bmp = BitmapFactory.decodeFile(f.absolutePath) ?: continue val bmp = BitmapFactory.decodeFile(f.absolutePath) ?: continue
val d = BitmapDrawable(context.resources, bmp) val d = BitmapDrawable(context.resources, bmp)
filePathCache[norm] = f
drawableCache[norm] = d drawableCache[norm] = d
return d return d
} }
@@ -253,36 +222,6 @@ object ThemeManager {
return null return null
} }
fun getScaledDrawableForKey(context: Context, keyName: String, targetDp: Float): Drawable? {
val raw = getDrawableForKey(context, keyName) ?: return null
val bitmap = (raw as? BitmapDrawable)?.bitmap ?: return raw
val targetHeightPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
targetDp,
context.resources.displayMetrics
).toInt()
if (targetHeightPx <= 0 || bitmap.height <= 0) return raw
val ratio = targetHeightPx.toFloat() / bitmap.height
val targetWidthPx = (bitmap.width * ratio).toInt().coerceAtLeast(1)
val theme = currentThemeName ?: "default"
val cacheKey = "${theme}|${keyName.lowercase()}|${targetHeightPx}"
val cached = scaledBitmapCache[cacheKey]
val scaled = if (cached != null && !cached.isRecycled) {
cached
} else {
val newBitmap = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
scaledBitmapCache[cacheKey] = newBitmap
newBitmap
}
return BitmapDrawable(context.resources, scaled).apply {
setBounds(0, 0, scaled.width, scaled.height)
}
}
// ==================== 可选:列出所有已安装主题 ==================== // ==================== 可选:列出所有已安装主题 ====================
/** /**

View File

@@ -9,6 +9,7 @@ import android.view.Gravity
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.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@@ -18,8 +19,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.example.myapplication.R import com.example.myapplication.R
import com.example.myapplication.network.ApiResponse import com.example.myapplication.network.ApiResponse
@@ -28,11 +27,19 @@ import com.example.myapplication.network.SubjectTag
import com.example.myapplication.network.themeDetail import com.example.myapplication.network.themeDetail
import com.example.myapplication.network.purchaseThemeRequest import com.example.myapplication.network.purchaseThemeRequest
import com.example.myapplication.ui.shop.ThemeCardAdapter import com.example.myapplication.ui.shop.ThemeCardAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.example.myapplication.GuideActivity import com.example.myapplication.GuideActivity
import com.example.myapplication.network.themeStyle import com.example.myapplication.network.themeStyle
import com.example.myapplication.work.ThemeDownloadWorker import com.example.myapplication.network.FileDownloader
import com.example.myapplication.theme.ThemeManager
import com.example.myapplication.utils.unzipThemeSmart
import com.example.myapplication.utils.logZipEntries
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.FileInputStream
import com.example.myapplication.ui.shop.ShopEvent import com.example.myapplication.ui.shop.ShopEvent
import com.example.myapplication.ui.shop.ShopEventBus import com.example.myapplication.ui.shop.ShopEventBus
import com.example.myapplication.network.BehaviorReporter import com.example.myapplication.network.BehaviorReporter
@@ -373,7 +380,7 @@ class KeyboardDetailFragment : Fragment() {
} }
/** /**
* 启用主题:后台下载并应用主题 * 启用主题:下载、解压并设置主题
*/ */
private suspend fun enableTheme() { private suspend fun enableTheme() {
val themeId = arguments?.getInt("themeId", 0) ?: 0 val themeId = arguments?.getInt("themeId", 0) ?: 0
@@ -381,38 +388,108 @@ class KeyboardDetailFragment : Fragment() {
return return
} }
val ctx = context ?: return // 恢复已删除的主题
val downloadUrl = themeDetailResp?.themeDownloadUrl val restoreResponse = setrestoreTheme(themeId)
val themeName = downloadUrl?.let { extractZipNameFromUrl(it) } if (restoreResponse?.code != 0) {
// 恢复失败,显示错误信息并返回
Log.e("1314520-KeyboardDetailFragment", "恢复主题失败: ${restoreResponse?.message ?: "未知错误"}")
return
}
// 显示下载进度
showDownloadProgress() showDownloadProgress()
val workName = ThemeDownloadWorker.enqueue(ctx, themeId, downloadUrl, themeName) try {
val workManager = WorkManager.getInstance(ctx) // 获取主题详情
val liveData = workManager.getWorkInfosForUniqueWorkLiveData(workName) val themeDetailResp = getThemeDetail(themeId)?.data
liveData.observe(viewLifecycleOwner) { infos -> if (themeDetailResp == null) {
val info = infos.firstOrNull() ?: return@observe
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
liveData.removeObservers(viewLifecycleOwner)
hideDownloadProgress() hideDownloadProgress()
if (isAdded) { return
}
val downloadUrl = themeDetailResp.themeDownloadUrl
if (downloadUrl.isNullOrEmpty()) {
hideDownloadProgress()
return
}
// 从下载 URL 中提取 zip 包名作为主题名称
val themeName = extractZipNameFromUrl(downloadUrl)
val context = requireContext()
// 检查主题是否已存在
val availableThemes = ThemeManager.listAvailableThemes(context)
if (availableThemes.contains(themeId.toString())) {
ThemeManager.setCurrentTheme(context, themeId.toString())
showSuccessMessage("主题已启用")
hideDownloadProgress()
// 跳转到GuideActivity
val intent = Intent(requireContext(), GuideActivity::class.java) val intent = Intent(requireContext(), GuideActivity::class.java)
startActivity(intent) startActivity(intent)
return
} }
}
WorkInfo.State.FAILED, // 主动下载主题
WorkInfo.State.CANCELLED -> { Log.d("1314520-KeyboardDetailFragment", "Downloading theme $themeName from $downloadUrl")
liveData.removeObservers(viewLifecycleOwner)
// 下载 zip 文件
val downloadedFile = FileDownloader.downloadZipFile(
context = context,
remoteFileName = downloadUrl,
localFileName = "$themeName.zip"
)
if (downloadedFile == null) {
showErrorMessage("下载主题失败")
hideDownloadProgress() hideDownloadProgress()
return
}
Log.d("1314520-zip", "path=${downloadedFile.absolutePath}")
Log.d("1314520-zip", "size=${downloadedFile.length()} bytes")
// 打印前16字节确认PK头/或者错误文本)
FileInputStream(downloadedFile).use { fis ->
val head = ByteArray(16)
val n = fis.read(head)
Log.d("1314520-zip", "head16=${head.take(n).joinToString { b -> "%02X".format(b) }}")
}
// 解压到主题目录
try {
val installedThemeName: String = withContext(Dispatchers.IO) {
unzipThemeSmart(
context = context,
zipFile = downloadedFile,
themeId = themeId
)
}
ThemeManager.setCurrentTheme(context, installedThemeName)
// 删除临时下载文件
downloadedFile.delete()
showSuccessMessage("主题启用成功")
// 跳转到GuideActivity
val intent = Intent(requireContext(), GuideActivity::class.java)
startActivity(intent)
} catch (e: Exception) {
showErrorMessage("解压主题失败:${e.message}")
// 清理临时文件
downloadedFile.delete()
}
} catch (e: Exception) {
showErrorMessage("启用主题失败") showErrorMessage("启用主题失败")
} } finally {
else -> Unit hideDownloadProgress()
}
} }
} }
/**
* 显示下载进度
*/
private fun showDownloadProgress() { private fun showDownloadProgress() {
// 在主线程中更新UI // 在主线程中更新UI
view?.post { view?.post {

View File

@@ -3,19 +3,20 @@ package com.example.myapplication.ui.mine.myotherpages
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.MediaStore
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.FrameLayout import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@@ -25,14 +26,14 @@ import com.example.myapplication.R
import com.example.myapplication.network.ApiResponse import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.AuthEvent import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.network.RetrofitClient import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.User import com.example.myapplication.network.User
import com.example.myapplication.network.updateInfoRequest
import com.example.myapplication.ui.common.LoadingOverlay import com.example.myapplication.ui.common.LoadingOverlay
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import de.hdodenhof.circleimageview.CircleImageView import de.hdodenhof.circleimageview.CircleImageView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.example.myapplication.network.updateInfoRequest
import android.util.Log
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
@@ -40,7 +41,7 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import android.util.Log import com.example.myapplication.network.BehaviorReporter
class PersonalSettings : BottomSheetDialogFragment() { class PersonalSettings : BottomSheetDialogFragment() {
@@ -52,19 +53,12 @@ class PersonalSettings : BottomSheetDialogFragment() {
private lateinit var tvUserId: TextView private lateinit var tvUserId: TextView
private lateinit var loadingOverlay: LoadingOverlay private lateinit var loadingOverlay: LoadingOverlay
/** // ActivityResultLauncher for image selection - restrict to image types
* ✅ Android Photo Picker private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
* - Android 13+:系统原生 Photo Picker
* - 低版本:会自动 fallback 到系统/兼容实现
* - 不需要 READ/WRITE_EXTERNAL_STORAGE
*/
private val galleryLauncher =
registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
uri?.let { handleImageResult(it) } uri?.let { handleImageResult(it) }
} }
private val cameraLauncher = private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean ->
registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean ->
if (success) { if (success) {
cameraImageUri?.let { handleImageResult(it) } cameraImageUri?.let { handleImageResult(it) }
} }
@@ -113,9 +107,9 @@ class PersonalSettings : BottomSheetDialogFragment() {
lifecycleScope.launch { lifecycleScope.launch {
loadingOverlay.show() loadingOverlay.show()
try { try {
val returnValue = setupdateUserInfo(updateInfoRequest(nickName = newName)) val ReturnValue = setupdateUserInfo(updateInfoRequest(nickName = newName))
Log.d("PersonalSettings", "setupdateUserInfo: $returnValue") Log.d("1314520-PersonalSettings", "setupdateUserInfo: $ReturnValue")
if (returnValue?.code == 0) { if(ReturnValue?.code == 0){
tvNickname.text = newName tvNickname.text = newName
} }
user = user?.copy(nickName = newName) user = user?.copy(nickName = newName)
@@ -137,8 +131,8 @@ class PersonalSettings : BottomSheetDialogFragment() {
lifecycleScope.launch { lifecycleScope.launch {
loadingOverlay.show() loadingOverlay.show()
try { try {
val returnValue = setupdateUserInfo(updateInfoRequest(gender = newGender)) val ReturnValue = setupdateUserInfo(updateInfoRequest(gender = newGender))
if (returnValue?.code == 0) { if(ReturnValue?.code == 0){
tvGender.text = genderText(newGender) tvGender.text = genderText(newGender)
} }
user = user?.copy(gender = newGender) user = user?.copy(gender = newGender)
@@ -246,12 +240,7 @@ class PersonalSettings : BottomSheetDialogFragment() {
.setTitle(R.string.change_avatar) .setTitle(R.string.change_avatar)
.setItems(options) { _, which -> .setItems(options) { _, which ->
when (which) { when (which) {
0 -> { 0 -> galleryLauncher.launch("image/png,image/jpeg")
// ✅ Photo Picker: ImageOnly
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
1 -> { 1 -> {
if (ContextCompat.checkSelfPermission( if (ContextCompat.checkSelfPermission(
requireContext(), requireContext(),
@@ -289,93 +278,86 @@ class PersonalSettings : BottomSheetDialogFragment() {
loadingOverlay.show() loadingOverlay.show()
try { try {
val resolver = requireContext().contentResolver
// Get MIME type to determine image format // Get MIME type to determine image format
val mimeType = resolver.getType(uri) val mimeType = requireContext().contentResolver.getType(uri)
val isPng = mimeType?.equals("image/png", ignoreCase = true) == true val isPng = mimeType?.equals("image/png", ignoreCase = true) == true
// Determine file extension and media type based on MIME type // Determine file extension and compression format based on MIME type
val fileExtension = if (isPng) ".png" else ".jpg" val fileExtension = if (isPng) ".png" else ".jpg"
val compressFormat = if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
val mediaType = if (isPng) "image/png" else "image/jpeg" val mediaType = if (isPng) "image/png" else "image/jpeg"
// Temp file in app-private external files dir (no storage permission needed) val inputStream = requireContext().contentResolver.openInputStream(uri) ?: return
val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES) val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val tempFile = File.createTempFile("UPLOAD_${timeStamp}_", fileExtension, storageDir) val tempFile = File.createTempFile(
"UPLOAD_${timeStamp}_",
fileExtension,
storageDir
)
// 先读取尺寸信息inJustDecodeBounds // Read and compress image if needed
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } val options = BitmapFactory.Options()
resolver.openInputStream(uri)?.use { ins -> options.inJustDecodeBounds = true
BitmapFactory.decodeStream(ins, null, boundsOptions) BitmapFactory.decodeStream(inputStream, null, options)
} ?: return inputStream.close()
// Calculate inSampleSize (粗略控制内存) // Calculate inSampleSize
var inSampleSize = 1 var inSampleSize = 1
val maxSizeBytes = 5 * 1024 * 1024 // 5MB 目标 val maxSize = 5 * 1024 * 1024 // 5MB
// 简单估算w*h*4 if (options.outHeight * options.outWidth * 4 > maxSize) {
if (boundsOptions.outHeight > 0 && boundsOptions.outWidth > 0) { val halfHeight = options.outHeight / 2
val estimated = boundsOptions.outHeight.toLong() * boundsOptions.outWidth.toLong() * 4L val halfWidth = options.outWidth / 2
if (estimated > maxSizeBytes) {
val halfHeight = boundsOptions.outHeight / 2
val halfWidth = boundsOptions.outWidth / 2
while (halfHeight / inSampleSize >= 1024 && halfWidth / inSampleSize >= 1024) { while (halfHeight / inSampleSize >= 1024 && halfWidth / inSampleSize >= 1024) {
inSampleSize *= 2 inSampleSize *= 2
} }
} }
}
// Decode bitmap with inSampleSize // Decode with inSampleSize
val decodeOptions = BitmapFactory.Options().apply { options.inJustDecodeBounds = false
inJustDecodeBounds = false options.inSampleSize = inSampleSize
this.inSampleSize = inSampleSize val inputStream2 = requireContext().contentResolver.openInputStream(uri) ?: return
} val bitmap = BitmapFactory.decodeStream(inputStream2, null, options)
inputStream2.close()
val bitmap = resolver.openInputStream(uri)?.use { ins -> // Compress to file
BitmapFactory.decodeStream(ins, null, decodeOptions) tempFile.outputStream().use { output ->
bitmap?.let { bmp ->
if (isPng) {
// PNG compression (quality parameter is ignored for PNG)
bmp.compress(Bitmap.CompressFormat.PNG, 100, output)
} else {
// JPEG compression with quality adjustment
var quality = 90
do {
output.channel.truncate(0)
bmp.compress(Bitmap.CompressFormat.JPEG, quality, output)
quality -= 10
} while (tempFile.length() > maxSize && quality > 10)
} }
} ?: run {
if (bitmap == null) {
Toast.makeText(requireContext(), "Failed to decode image", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Failed to decode image", Toast.LENGTH_SHORT).show()
return return
} }
// Compress to tempFile
// 注意:用 outputStream 反复 compress 时不要在同一个 stream 上 truncate
// 最稳是每次重新打开 stream 写入。
if (isPng) {
tempFile.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
} else {
var quality = 90
do {
tempFile.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
}
quality -= 10
} while (tempFile.length() > maxSizeBytes && quality > 10)
} }
if (tempFile.length() > maxSizeBytes) { if (tempFile.length() > maxSize) {
Toast.makeText(requireContext(), "Image is too large after compression", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Image is too large after compression", Toast.LENGTH_SHORT).show()
bitmap.recycle()
tempFile.delete()
return return
} }
val requestFile: RequestBody = RequestBody.create(mediaType.toMediaTypeOrNull(), tempFile) val requestFile = RequestBody.create(mediaType.toMediaTypeOrNull(), tempFile)
val body = MultipartBody.Part.createFormData("file", tempFile.name, requestFile) val body = MultipartBody.Part.createFormData("file", tempFile.name, requestFile)
val response = RetrofitClient.createFileUploadService() val response = RetrofitClient.createFileUploadService()
.uploadFile("avatar", body) .uploadFile("avatar", body)
// Clean up // Clean up
bitmap.recycle() bitmap?.recycle()
tempFile.delete() tempFile.delete()
if (response?.code == 0) { if (response?.code == 0) {
setupdateUserInfo(updateInfoRequest(avatarUrl = response.data)) val ReturnValue = setupdateUserInfo(updateInfoRequest(avatarUrl = response.data))
Toast.makeText(requireContext(), R.string.avatar_updated, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.avatar_updated, Toast.LENGTH_SHORT).show()
user = user?.copy(avatarUrl = response.data) user = user?.copy(avatarUrl = response.data)
} else { } else {

View File

@@ -1,7 +1,6 @@
package com.example.myapplication.utils package com.example.myapplication.utils
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.google.gson.Gson import com.google.gson.Gson
@@ -16,29 +15,20 @@ object EncryptedSharedPreferencesUtil {
private const val SHARED_PREFS_NAME = "secure_prefs" private const val SHARED_PREFS_NAME = "secure_prefs"
private val gson by lazy { Gson() } private val gson by lazy { Gson() }
@Volatile private var cachedPrefs: SharedPreferences? = null
@Volatile private var cachedMasterKey: MasterKey? = null
/** /**
* 获取加密的 SharedPreferences实际类型是 SharedPreferences * 获取加密的 SharedPreferences实际类型是 SharedPreferences
*/ */
private fun prefs(context: Context) = private fun prefs(context: Context) =
cachedPrefs ?: synchronized(this) {
cachedPrefs ?: run {
val appContext = context.applicationContext
val masterKey = cachedMasterKey ?: MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
.also { cachedMasterKey = it }
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
appContext, context,
SHARED_PREFS_NAME, SHARED_PREFS_NAME,
masterKey, MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
).also { cachedPrefs = it } )
}
}
/** /**
* 存储任意对象(会转为 JSON 字符串保存) * 存储任意对象(会转为 JSON 字符串保存)

View File

@@ -134,7 +134,7 @@ fun unzipThemeSmart(
zipFile: File, zipFile: File,
themeId: Int, themeId: Int,
targetBaseDir: File = File(context.filesDir, "keyboard_themes") targetBaseDir: File = File(context.filesDir, "keyboard_themes")
): String? { ): String {
// 👉 检测嵌套 zip // 👉 检测嵌套 zip
val innerZipName = findSingleInnerZip(zipFile) val innerZipName = findSingleInnerZip(zipFile)
if (innerZipName != null) { if (innerZipName != null) {
@@ -174,7 +174,7 @@ fun unzipThemeFromFileOverwrite_ZIS(
zipFile: File, zipFile: File,
themeId: Int, themeId: Int,
targetBaseDir: File targetBaseDir: File
): String? { ): String {
val tempOut = File(context.cacheDir, "tmp_theme_out").apply { val tempOut = File(context.cacheDir, "tmp_theme_out").apply {
if (exists()) deleteRecursively() if (exists()) deleteRecursively()
@@ -269,7 +269,7 @@ fun unzipThemeFromFileOverwrite_ZIS(
} catch (e: Exception) { } catch (e: Exception) {
logZipEntries(zipFile) logZipEntries(zipFile)
Log.e(TAG_UNZIP, "解压失败: ${e.message}", e) Log.e(TAG_UNZIP, "解压失败: ${e.message}", e)
return null throw e
} finally { } finally {
if (tempOut.exists()) tempOut.deleteRecursively() if (tempOut.exists()) tempOut.deleteRecursively()
} }

View File

@@ -1,168 +0,0 @@
package com.example.myapplication.work
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.example.myapplication.network.FileDownloader
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.theme.ThemeManager
import com.example.myapplication.utils.unzipThemeSmart
import java.io.File
class ThemeDownloadWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
private val mainHandler = Handler(Looper.getMainLooper())
override suspend fun doWork(): Result {
val themeId = inputData.getInt(KEY_THEME_ID, 0)
var downloadUrl = inputData.getString(KEY_DOWNLOAD_URL)
var themeName = inputData.getString(KEY_THEME_NAME)
if (themeId == 0) {
Log.e(TAG, "invalid themeId")
return Result.failure()
}
val context = applicationContext
if (ThemeManager.listAvailableThemes(context).contains(themeId.toString())) {
ThemeManager.setCurrentTheme(context, themeId.toString())
notifySuccess(context)
return Result.success()
}
if (downloadUrl.isNullOrBlank()) {
try {
val detail = RetrofitClient.apiService.themeDetail(themeId)
if (detail.code != 0) {
Log.e(TAG, "themeDetail failed: code=${detail.code} msg=${detail.message}")
return Result.failure()
}
downloadUrl = detail.data?.themeDownloadUrl
if (downloadUrl.isNullOrBlank()) {
Log.e(TAG, "themeDetail missing downloadUrl")
return Result.failure()
}
if (themeName.isNullOrBlank()) {
themeName = extractZipNameFromUrl(downloadUrl!!)
}
} catch (e: Exception) {
Log.e(TAG, "themeDetail exception: ${e.message}", e)
return Result.retry()
}
} else if (themeName.isNullOrBlank()) {
themeName = extractZipNameFromUrl(downloadUrl!!)
}
try {
val restoreResp = RetrofitClient.apiService.restoreTheme(themeId)
if (restoreResp.code != 0) {
Log.e(TAG, "restoreTheme failed: code=${restoreResp.code} msg=${restoreResp.message}")
return Result.failure()
}
} catch (e: Exception) {
Log.e(TAG, "restoreTheme exception: ${e.message}", e)
return Result.retry()
}
val localName = if (!themeName.isNullOrBlank()) {
"${themeName}.zip"
} else {
"theme_$themeId.zip"
}
var downloadedFile: File? = null
try {
downloadedFile = FileDownloader.downloadZipFile(
context = context,
remoteFileName = downloadUrl!!,
localFileName = localName
)
if (downloadedFile == null) return Result.retry()
val installedThemeName = unzipThemeSmart(
context = context,
zipFile = downloadedFile,
themeId = themeId
)
if (installedThemeName.isNullOrBlank()) return Result.failure()
ThemeManager.setCurrentTheme(context, installedThemeName)
notifySuccess(context)
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "download/unzip exception: ${e.message}", e)
return Result.failure()
} finally {
downloadedFile?.delete()
}
}
private fun extractZipNameFromUrl(url: String): String {
val fileName = if (url.contains('?')) {
url.substring(url.lastIndexOf('/') + 1, url.indexOf('?'))
} else {
url.substring(url.lastIndexOf('/') + 1)
}
return if (fileName.endsWith(".zip")) {
fileName.substring(0, fileName.length - 4)
} else {
fileName
}
}
private fun notifySuccess(context: Context) {
mainHandler.post {
Toast.makeText(context, "主题应用成功", Toast.LENGTH_SHORT).show()
}
}
companion object {
private const val TAG = "ThemeDownloadWorker"
private const val KEY_THEME_ID = "theme_id"
private const val KEY_DOWNLOAD_URL = "download_url"
private const val KEY_THEME_NAME = "theme_name"
private const val UNIQUE_WORK_PREFIX = "download_theme_"
fun enqueue(context: Context, themeId: Int, downloadUrl: String?, themeName: String?): String {
val data = Data.Builder()
.putInt(KEY_THEME_ID, themeId)
.putString(KEY_DOWNLOAD_URL, downloadUrl)
.putString(KEY_THEME_NAME, themeName)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<ThemeDownloadWorker>()
.setInputData(data)
.setConstraints(constraints)
.addTag("theme_download")
.addTag("theme_download_$themeId")
.build()
val workName = "$UNIQUE_WORK_PREFIX$themeId"
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(
workName,
ExistingWorkPolicy.KEEP,
request
)
return workName
}
}
}