From 673b4491d792440bea9ab5fc49c8c63c0bf7e66f Mon Sep 17 00:00:00 2001 From: pengxiaolong <15716207+pengxiaolong711@user.noreply.gitee.com> Date: Thu, 15 Jan 2026 21:32:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96plus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../example/myapplication/BigramPredictor.kt | 60 +++++-- .../com/example/myapplication/MainActivity.kt | 67 +++++-- .../myapplication/MyInputMethodService.kt | 32 ++-- .../java/com/example/myapplication/Trie.kt | 41 +++-- .../myapplication/data/LanguageModelLoader.kt | 127 ++++++++++--- .../myapplication/keyboard/AiKeyboard.kt | 71 ++++---- .../myapplication/keyboard/MainKeyboard.kt | 39 ++-- .../myapplication/keyboard/NumberKeyboard.kt | 39 ++-- .../myapplication/keyboard/SymbolKeyboard.kt | 39 ++-- .../myapplication/network/FileDownloader.kt | 4 +- .../myapplication/network/NetworkClient.kt | 35 +++- .../myapplication/theme/ThemeManager.kt | 97 ++++++++-- .../ui/keyboard/KeyboardDetailFragment.kt | 141 ++++----------- .../utils/EncryptedSharedPreferences.kt | 28 ++- .../example/myapplication/utils/unzipToDir.kt | 6 +- .../myapplication/work/ThemeDownloadWorker.kt | 168 ++++++++++++++++++ 17 files changed, 649 insertions(+), 346 deletions(-) create mode 100644 app/src/main/java/com/example/myapplication/work/ThemeDownloadWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0ee4b1..ca21e3d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { // lifecycle implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0") + implementation("androidx.work:work-runtime-ktx:2.9.0") // 加密 SharedPreferences implementation("androidx.security:security-crypto:1.1.0-alpha06") // Glide for image loading diff --git a/app/src/main/java/com/example/myapplication/BigramPredictor.kt b/app/src/main/java/com/example/myapplication/BigramPredictor.kt index bad6d26..010786f 100644 --- a/app/src/main/java/com/example/myapplication/BigramPredictor.kt +++ b/app/src/main/java/com/example/myapplication/BigramPredictor.kt @@ -18,6 +18,9 @@ class BigramPredictor( @Volatile private var word2id: Map = emptyMap() @Volatile private var id2word: List = emptyList() + @Volatile private var topUnigrams: List = emptyList() + + private val unigramCacheSize = 2000 //预先加载语言模型,并构建词到ID和ID到词的双向映射。 fun preload() { @@ -37,6 +40,7 @@ class BigramPredictor( word2id = map id2word = m.vocab + topUnigrams = buildTopUnigrams(m, unigramCacheSize) } catch (_: Throwable) { // 保持静默,允许无模型运行(仅 Trie 起作用) } finally { @@ -89,19 +93,34 @@ class BigramPredictor( return topKByScore(candidates, topK) } - // 3) 兜底:用 unigram + 前缀过滤 - val heap = topKHeap(topK) + // 3) 兜底:用预计算的 unigram Top-N + 前缀过滤 + if (topK <= 0) return emptyList() - for (i in m.vocab.indices) { - val w = m.vocab[i] + val cachedUnigrams = getTopUnigrams(m) + if (pfx.isEmpty()) { + return cachedUnigrams.take(topK) + } - if (pfx.isEmpty() || w.startsWith(pfx, ignoreCase = true)) { - heap.offer(w to m.uniLogp[i]) - - if (heap.size > topK) heap.poll() + val results = ArrayList(topK) + if (cachedUnigrams.isNotEmpty()) { + for (w in cachedUnigrams) { + if (w.startsWith(pfx, ignoreCase = true)) { + results.add(w) + if (results.size >= topK) return results + } } } - return heap.toSortedListDescending() + + if (results.size < topK) { + val fromTrie = safeTriePrefix(pfx, topK) + for (w in fromTrie) { + if (w !in results) { + results.add(w) + if (results.size >= topK) break + } + } + } + return results } //供上层在用户选中词时更新“上文”状态 @@ -115,12 +134,33 @@ class BigramPredictor( if (prefix.isEmpty()) return emptyList() return try { - trie.startsWith(prefix).take(topK) + trie.startsWith(prefix, topK) } catch (_: Throwable) { emptyList() } } + private fun getTopUnigrams(model: BigramModel): List { + 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 { + 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个词 private fun topKByScore(pairs: List>, k: Int): List { val heap = topKHeap(k) diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt index d47bb31..74b63a2 100644 --- a/app/src/main/java/com/example/myapplication/MainActivity.kt +++ b/app/src/main/java/com/example/myapplication/MainActivity.kt @@ -6,6 +6,7 @@ import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment @@ -28,6 +29,8 @@ class MainActivity : AppCompatActivity() { private var currentTabTag = TAB_HOME private var pendingTabAfterLogin: String? = null + private var isSwitchingTab = false + private var pendingTabSwitchTag: String? = null private val protectedTabs = setOf( R.id.shop_graph, @@ -360,32 +363,58 @@ class MainActivity : AppCompatActivity() { val fm = supportFragmentManager 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 + isSwitchingTab = true - fm.beginTransaction() + val transaction = fm.beginTransaction() .setReorderingAllowed(true) - .hide(homeHost) - .hide(shopHost) - .hide(mineHost) - .also { ft -> - when (targetTag) { - TAB_SHOP -> ft.show(shopHost) - TAB_MINE -> ft.show(mineHost) - else -> ft.show(homeHost) + + if (force) { + transaction + .hide(homeHost) + .hide(shopHost) + .hide(mineHost) + .show(targetHost) + } else if (currentHost != targetHost) { + transaction + .hide(currentHost) + .show(targetHost) + } + + transaction + .setMaxLifecycle(homeHost, if (targetHost == homeHost) Lifecycle.State.RESUMED else Lifecycle.State.STARTED) + .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() + + if (!force) { + currentTabNavController.currentDestination?.id?.let { destId -> + reportPageView(source = "switch_tab", destId = destId) + } + } + + val pendingTag = pendingTabSwitchTag + pendingTabSwitchTag = null + if (pendingTag != null && pendingTag != currentTabTag) { + switchTab(pendingTag) } } .commit() - - // ✅ 关键:hide/show 切 tab 不会触发 destinationChanged,所以手动刷新 - bottomNav.post { updateBottomNavVisibility() } - - // ✅ 新增:切 tab 后补一次路由上报(不改变其它逻辑) - if (!force) { - currentTabNavController.currentDestination?.id?.let { destId -> - reportPageView(source = "switch_tab", destId = destId) - } - } } /** 打开全局页(login/recharge等) */ diff --git a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt index 6832e27..0d7484e 100644 --- a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt +++ b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt @@ -218,11 +218,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { override fun onCreate() { super.onCreate() - ThemeManager.ensureBuiltInThemesInstalled(this) - ThemeManager.init(this) - ThemeManager.addThemeChangeListener(themeListener) + Thread { + ThemeManager.ensureBuiltInThemesInstalled(this) + ThemeManager.init(this) + }.start() + // 异步加载词典与 bigram 模型 Thread { // 1) Trie 词典 @@ -932,15 +934,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { if (fromBi.isNotEmpty()) { fromBi.filter { it != prefix } } else { - wordDictionary.wordTrie.startsWith(prefix) - .take(20) + wordDictionary.wordTrie.startsWith(prefix, 20) .filter { it != prefix } } } } catch (_: Throwable) { if (prefix.isNotEmpty()) { - wordDictionary.wordTrie.startsWith(prefix) - .take(20) + wordDictionary.wordTrie.startsWith(prefix, 20) .filterNot { it == prefix } } else { emptyList() @@ -1098,11 +1098,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { if (!pendingSwipeClear) { if (-dy >= triggerUp) { pendingSwipeClear = true - + // 如果此时正在连删(长按已触发),记录一下,方便取消时恢复 resumeDeletingAfterCancel = isDeleting stopRepeatDelete() - + view.cancelLongPress() + view.isPressed = false + showSwipeClearHint(view, "Clear") return@setOnTouchListener true } @@ -1134,15 +1136,17 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // 如果我们处于“准备清空”,才由我们接管结束逻辑 if (pendingSwipeClear) { stopRepeatDelete() - + if (event.actionMasked == android.view.MotionEvent.ACTION_UP) { clearAllAndBackup() } - + pendingSwipeClear = false resumeDeletingAfterCancel = false dismissSwipeClearHint() - + view.cancelLongPress() + view.isPressed = false + // 消费 UP,避免 click/longclick 再触发 return@setOnTouchListener true } @@ -1285,7 +1289,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { val m = bigramModel if (m == null || !bigramReady) { return if (prefix.isNotEmpty()) { - wordDictionary.wordTrie.startsWith(prefix).take(topK) + wordDictionary.wordTrie.startsWith(prefix, topK) } else { emptyList() } @@ -1332,7 +1336,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // —— 无上文 或 无出边 —— return if (pf.isNotEmpty()) { - wordDictionary.wordTrie.startsWith(pf).take(topK) + wordDictionary.wordTrie.startsWith(pf, topK) } else { unigramTopKFiltered(topK) } diff --git a/app/src/main/java/com/example/myapplication/Trie.kt b/app/src/main/java/com/example/myapplication/Trie.kt index 3243080..f7ad9e3 100644 --- a/app/src/main/java/com/example/myapplication/Trie.kt +++ b/app/src/main/java/com/example/myapplication/Trie.kt @@ -1,5 +1,7 @@ package com.example.myapplication +import java.util.ArrayDeque + class Trie { //表示Trie数据结构中的一个节点,该节点可以存储其子节点,并且可以标记是否是一个完整单词的结尾 private data class TrieNode( @@ -32,29 +34,38 @@ class Trie { return current.isEndOfWord } - //查找以prefix为前缀的所有单词。通过遍历prefix的每个字符,找到相应的节点,然后从该节点开始递归查找所有以该节点为起点的单词。 + //查找以prefix为前缀的所有单词。通过遍历prefix的每个字符,找到相应的节点,然后从该节点开始迭代搜索所有以该节点为起点的单词。 fun startsWith(prefix: String): List { + return startsWith(prefix, Int.MAX_VALUE) + } + + fun startsWith(prefix: String, limit: Int): List { var current = root - for (char in prefix.lowercase()) { + val normalized = prefix.lowercase() + for (char in normalized) { current = current.children[char] ?: return emptyList() } - - return getAllWordsFromNode(current, prefix) - } - //从给定节点开始递归查找所有以该节点为起点的单词。 - private fun getAllWordsFromNode(node: TrieNode, prefix: String): List { - val words = mutableListOf() + val max = if (limit < 0) 0 else limit + if (max == 0) return emptyList() - if (node.isEndOfWord) { - words.add(prefix) + val results = ArrayList(minOf(max, 16)) + val stack = ArrayDeque>() + stack.addLast(current to prefix) + + while (stack.isNotEmpty() && results.size < max) { + val (node, word) = stack.removeLast() + if (node.isEndOfWord) { + results.add(word) + if (results.size >= max) break + } + + for ((char, child) in node.children) { + stack.addLast(child to (word + char)) + } } - for ((char, child) in node.children) { - words.addAll(getAllWordsFromNode(child, prefix + char)) - } - - return words + return results } } diff --git a/app/src/main/java/com/example/myapplication/data/LanguageModelLoader.kt b/app/src/main/java/com/example/myapplication/data/LanguageModelLoader.kt index b816051..7a8a016 100644 --- a/app/src/main/java/com/example/myapplication/data/LanguageModelLoader.kt +++ b/app/src/main/java/com/example/myapplication/data/LanguageModelLoader.kt @@ -2,7 +2,15 @@ package com.example.myapplication.data import android.content.Context import java.io.BufferedReader +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.InputStream 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( val vocab: List, // 保留全部词(含 , , ),与二元矩阵索引对齐 @@ -30,39 +38,104 @@ object LanguageModelLoader { } private fun readInt32(context: Context, name: String): IntArray { - context.assets.open(name).use { 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 + try { + context.assets.openFd(name).use { afd -> + FileInputStream(afd.fileDescriptor).channel.use { channel -> + return readInt32Channel(channel, afd.startOffset, afd.length) + } } - return out + } catch (e: FileNotFoundException) { + // Compressed assets do not support openFd; fall back to streaming. + } + + context.assets.open(name).use { input -> + return readInt32Stream(input) } } private fun readFloat32(context: Context, name: String): FloatArray { - context.assets.open(name).use { 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 + try { + context.assets.openFd(name).use { afd -> + FileInputStream(afd.fileDescriptor).channel.use { channel -> + return readFloat32Channel(channel, afd.startOffset, afd.length) + } } - return out + } catch (e: FileNotFoundException) { + // Compressed assets do not support openFd; fall back to streaming. + } + + context.assets.open(name).use { input -> + return readFloat32Stream(input) } } + + 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 + } + + 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) + } } diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt index 8430982..6d0a9b1 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt @@ -7,11 +7,8 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.Typeface -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.os.Handler import android.os.Looper -import android.util.TypedValue import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -63,13 +60,15 @@ class AiKeyboard( private val messagesContainer: LinearLayout by lazy { val res = env.ctx.resources val id = res.getIdentifier("container_messages", "id", env.ctx.packageName) - rootView.findViewById(id) + val view = if (id != 0) rootView.findViewById(id) else null + view as? LinearLayout ?: LinearLayout(env.ctx) } private val messagesScrollView: ScrollView by lazy { val res = env.ctx.resources val id = res.getIdentifier("scroll_messages", "id", env.ctx.packageName) - rootView.findViewById(id) + val view = if (id != 0) rootView.findViewById(id) else null + view as? ScrollView ?: ScrollView(env.ctx) } private var currentAssistantTextView: TextView? = null @@ -84,15 +83,26 @@ class AiKeyboard( val res = env.ctx.resources val layoutId = res.getIdentifier("item_ai_message", "layout", env.ctx.packageName) - val itemView = inflater.inflate(layoutId, messagesContainer, false) as LinearLayout - val tv = itemView.findViewById( - res.getIdentifier("tv_content", "id", env.ctx.packageName) - ) - tv.text = initialText + val itemView = if (layoutId != 0) { + inflater.inflate(layoutId, messagesContainer, false) + } else { + LinearLayout(env.ctx) + } + val tvId = res.getIdentifier("tv_content", "id", env.ctx.packageName) + val tv = if (tvId != 0) { + itemView.findViewById(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 { - val text = tv.text?.toString().orEmpty() + val text = textView.text?.toString().orEmpty() if (text.isNotBlank()) { fillToEditorOverwriteLast(text) BehaviorReporter.report( @@ -106,7 +116,7 @@ class AiKeyboard( messagesContainer.addView(itemView) scrollToBottom() - return tv + return textView } private fun scrollToBottom() { @@ -149,6 +159,7 @@ class AiKeyboard( } private fun onLlmDone() { + cancelAiStream() mainHandler.post { streamBuffer.clear() currentAssistantTextView = null @@ -180,6 +191,7 @@ class AiKeyboard( } override fun onError(t: Throwable) { + cancelAiStream() // 尝试解析JSON错误响应 val errorResponse = try { val errorJson = t.message?.let { @@ -529,36 +541,13 @@ class AiKeyboard( val v = root.findViewById(viewId) ?: return val keyName = drawableName ?: viewIdName - val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return + val drawable = if (viewIdName == "background") { + ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f) + } else { + ThemeManager.getDrawableForKey(env.ctx, keyName) + } ?: return - 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) - } + v.background = drawable } private fun navigateToRechargeFragment() { diff --git a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt index e129254..5afccff 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt @@ -1,15 +1,10 @@ package com.example.myapplication.keyboard -import android.content.Context -import android.graphics.Bitmap import android.graphics.Color -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.VibrationEffect import android.os.Vibrator -import android.util.TypedValue import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -25,10 +20,15 @@ class MainKeyboard( private val onToggleShift: () -> Boolean ) : BaseKeyboard(env) { - override val rootView: View = env.layoutInflater.inflate( - env.ctx.resources.getIdentifier("keyboard", "layout", env.ctx.packageName), - null - ) + override val rootView: View = run { + val res = env.ctx.resources + val layoutId = res.getIdentifier("keyboard", "layout", env.ctx.packageName) + if (layoutId != 0) { + env.layoutInflater.inflate(layoutId, null) + } else { + View(env.ctx) + } + } private var isShiftOn: Boolean = false private var keyPreviewPopup: PopupWindow? = null @@ -61,24 +61,13 @@ class MainKeyboard( val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val v = root.findViewById(viewId) ?: return val keyName = drawableName ?: viewIdName - val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return - - if (viewIdName == "background") { - v.background = scaleDrawableToHeight(rawDrawable, 243f) + val drawable = if (viewIdName == "background") { + ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f) } else { - v.background = rawDrawable - } - } + ThemeManager.getDrawableForKey(env.ctx, keyName) + } ?: return - 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) } + v.background = drawable } // -------------------- 实现主题刷新 -------------------- diff --git a/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt index 1e78b33..d06a2a2 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt @@ -1,15 +1,10 @@ package com.example.myapplication.keyboard -import android.content.Context -import android.graphics.Bitmap import android.graphics.Color -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.VibrationEffect import android.os.Vibrator -import android.util.TypedValue import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -21,10 +16,15 @@ class NumberKeyboard( env: KeyboardEnvironment ) : BaseKeyboard(env) { - override val rootView: View = env.layoutInflater.inflate( - env.ctx.resources.getIdentifier("number_keyboard", "layout", env.ctx.packageName), - null - ) + override val rootView: View = run { + val res = env.ctx.resources + val layoutId = res.getIdentifier("number_keyboard", "layout", env.ctx.packageName) + if (layoutId != 0) { + env.layoutInflater.inflate(layoutId, null) + } else { + View(env.ctx) + } + } private var keyPreviewPopup: PopupWindow? = null @@ -57,24 +57,13 @@ class NumberKeyboard( val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val v = root.findViewById(viewId) ?: return val keyName = drawableName ?: viewIdName - val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return - - if (viewIdName == "background") { - v.background = scaleDrawableToHeight(rawDrawable, 243f) + val drawable = if (viewIdName == "background") { + ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f) } else { - v.background = rawDrawable - } - } + ThemeManager.getDrawableForKey(env.ctx, keyName) + } ?: return - 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) } + v.background = drawable } // -------------------- 实现主题刷新 -------------------- diff --git a/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt index 6697ca5..e2373c0 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt @@ -1,15 +1,10 @@ package com.example.myapplication.keyboard -import android.content.Context -import android.graphics.Bitmap import android.graphics.Color -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.VibrationEffect import android.os.Vibrator -import android.util.TypedValue import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -21,10 +16,15 @@ class SymbolKeyboard( env: KeyboardEnvironment ) : BaseKeyboard(env) { - override val rootView: View = env.layoutInflater.inflate( - env.ctx.resources.getIdentifier("symbol_keyboard", "layout", env.ctx.packageName), - null - ) + override val rootView: View = run { + val res = env.ctx.resources + val layoutId = res.getIdentifier("symbol_keyboard", "layout", env.ctx.packageName) + if (layoutId != 0) { + env.layoutInflater.inflate(layoutId, null) + } else { + View(env.ctx) + } + } private var keyPreviewPopup: PopupWindow? = null @@ -58,24 +58,13 @@ class SymbolKeyboard( val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val v = root.findViewById(viewId) ?: return val keyName = drawableName ?: viewIdName - val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return - - if (viewIdName == "background") { - v.background = scaleDrawableToHeight(rawDrawable, 243f) + val drawable = if (viewIdName == "background") { + ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f) } else { - v.background = rawDrawable - } - } + ThemeManager.getDrawableForKey(env.ctx, keyName) + } ?: return - 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) } + v.background = drawable } // -------------------- 实现主题刷新 -------------------- diff --git a/app/src/main/java/com/example/myapplication/network/FileDownloader.kt b/app/src/main/java/com/example/myapplication/network/FileDownloader.kt index 9145e3b..60efd83 100644 --- a/app/src/main/java/com/example/myapplication/network/FileDownloader.kt +++ b/app/src/main/java/com/example/myapplication/network/FileDownloader.kt @@ -81,7 +81,9 @@ object FileDownloader { downloadedBytes += read // 需要的话可以在这里回调进度 - val progress = downloadedBytes * 100 / totalBytes + if (totalBytes > 0) { + val progress = downloadedBytes * 100 / totalBytes + } } outputStream.flush() } finally { diff --git a/app/src/main/java/com/example/myapplication/network/NetworkClient.kt b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt index d2d25f9..b7613ed 100644 --- a/app/src/main/java/com/example/myapplication/network/NetworkClient.kt +++ b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt @@ -328,6 +328,7 @@ object NetworkClient { ) { var eventName: String? = null val dataLines = mutableListOf() + var stop = false fun dispatch() { if (eventName == null && dataLines.isEmpty()) return @@ -336,7 +337,16 @@ object NetworkClient { Log.d("999-SSE_TALK-event", "event=${eventName ?: "(null)"} rawData=[${rawData.take(500)}]") if (rawData.isNotEmpty()) { - handlePayload(eventName, rawData, callback) + if (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 @@ -349,6 +359,7 @@ object NetworkClient { if (line.isEmpty()) { dispatch() + if (stop) break continue } if (line.startsWith(":")) continue @@ -366,10 +377,16 @@ object NetworkClient { } } - dispatch() + if (!stop) { + dispatch() + } } - private fun handlePayload(eventName: String?, rawData: String, callback: LlmStreamCallback) { + private fun handlePayload( + eventName: String?, + rawData: String, + callback: LlmStreamCallback + ): Boolean { val trimmed = rawData.trim() val looksLikeJson = trimmed.startsWith("{") && trimmed.endsWith("}") @@ -382,14 +399,22 @@ object NetworkClient { if (type.isNotBlank()) { callback.onEvent(type, dataStr) - return + return type.equals("done", ignoreCase = true) } } catch (_: Throwable) { // fallthrough to text } } - callback.onEvent(eventName ?: "text_chunk", rawData) + val normalized = trimmed.lowercase() + 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 { diff --git a/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt b/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt index e03c6ca..f1a0a0a 100644 --- a/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt +++ b/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt @@ -2,11 +2,18 @@ package com.example.myapplication.theme import android.content.Context import android.content.res.AssetManager +import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import android.util.TypedValue import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArraySet object ThemeManager { + private val mainHandler = Handler(Looper.getMainLooper()) // SharedPreferences 保存当前主题名 private const val PREF_NAME = "ime_theme_prefs" @@ -18,11 +25,15 @@ object ThemeManager { // 缓存:规范化后的 keyName(lowercase) -> Drawable @Volatile - private var drawableCache: MutableMap = mutableMapOf() + private var drawableCache: MutableMap = ConcurrentHashMap() + @Volatile + private var filePathCache: MutableMap = ConcurrentHashMap() + @Volatile + private var scaledBitmapCache: MutableMap = ConcurrentHashMap() // ==================== 外部目录相关 ==================== //通知主题更新 - private val listeners = mutableSetOf<() -> Unit>() + private val listeners = CopyOnWriteArraySet<() -> Unit>() fun addThemeChangeListener(listener: () -> Unit) { listeners.add(listener) @@ -124,9 +135,22 @@ object ThemeManager { .putString(KEY_CURRENT_THEME, themeName) .apply() - drawableCache = loadThemeDrawables(context, themeName) + val newFilePathCache: MutableMap = ConcurrentHashMap() + drawableCache = ConcurrentHashMap() + filePathCache = newFilePathCache + scaledBitmapCache = ConcurrentHashMap() - listeners.forEach { it.invoke() } + if (Looper.myLooper() == Looper.getMainLooper()) { + Thread { + indexThemeFiles(context, themeName, newFilePathCache) + }.start() + listeners.forEach { it.invoke() } + } else { + indexThemeFiles(context, themeName, newFilePathCache) + mainHandler.post { + listeners.forEach { it.invoke() } + } + } } fun getCurrentThemeName(): String? = currentThemeName @@ -139,14 +163,14 @@ object ThemeManager { * /.../keyboard_themes/default/key_a.png -> "key_a" * /.../keyboard_themes/default/key_a_up.PNG -> "key_a_up" */ - private fun loadThemeDrawables( + private fun indexThemeFiles( context: Context, - themeName: String - ): MutableMap { - val map = mutableMapOf() + themeName: String, + targetMap: MutableMap + ) { val dir = getThemeDir(context, themeName) - if (!dir.exists() || !dir.isDirectory) return map + if (!dir.exists() || !dir.isDirectory) return dir.listFiles()?.forEach { file -> if (!file.isFile) return@forEach @@ -161,12 +185,8 @@ object ThemeManager { // 统一小写作为 key,比如 key_a_up.png -> "key_a_up" val key = lowerName.substringBeforeLast(".") - val bmp = BitmapFactory.decodeFile(file.absolutePath) ?: return@forEach - val d = BitmapDrawable(context.resources, bmp) - map[key] = d + targetMap[key] = file } - - return map } // ==================== 对外:按 keyName 取 Drawable ==================== @@ -188,10 +208,6 @@ object ThemeManager { * 内部统一用 keyName.lowercase() 做匹配,不区分大小写。 */ fun getDrawableForKey(context: Context, keyName: String): Drawable? { - if (currentThemeName == null) { - init(context) - } - // 统一小写,避免大小写差异 val norm = keyName.lowercase() @@ -202,6 +218,20 @@ object ThemeManager { val theme = currentThemeName ?: return null 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( File(dir, "$norm.png"), File(dir, "$norm.webp"), @@ -213,6 +243,7 @@ object ThemeManager { if (f.exists() && f.isFile) { val bmp = BitmapFactory.decodeFile(f.absolutePath) ?: continue val d = BitmapDrawable(context.resources, bmp) + filePathCache[norm] = f drawableCache[norm] = d return d } @@ -222,6 +253,36 @@ object ThemeManager { 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) + } + } + // ==================== 可选:列出所有已安装主题 ==================== /** diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt index d9c53ca..c87d03c 100644 --- a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt @@ -9,7 +9,6 @@ import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView @@ -19,6 +18,8 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.work.WorkInfo +import androidx.work.WorkManager import com.bumptech.glide.Glide import com.example.myapplication.R import com.example.myapplication.network.ApiResponse @@ -27,19 +28,11 @@ import com.example.myapplication.network.SubjectTag import com.example.myapplication.network.themeDetail import com.example.myapplication.network.purchaseThemeRequest import com.example.myapplication.ui.shop.ThemeCardAdapter -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.imageview.ShapeableImageView import kotlinx.coroutines.launch import com.example.myapplication.GuideActivity import com.example.myapplication.network.themeStyle -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.work.ThemeDownloadWorker import com.example.myapplication.ui.shop.ShopEvent import com.example.myapplication.ui.shop.ShopEventBus import com.example.myapplication.network.BehaviorReporter @@ -380,116 +373,46 @@ class KeyboardDetailFragment : Fragment() { } /** - * 启用主题:下载、解压并设置主题 + * 启用主题:后台下载并应用主题 */ private suspend fun enableTheme() { val themeId = arguments?.getInt("themeId", 0) ?: 0 if (themeId == 0) { return } - - // 恢复已删除的主题 - val restoreResponse = setrestoreTheme(themeId) - if (restoreResponse?.code != 0) { - // 恢复失败,显示错误信息并返回 - Log.e("1314520-KeyboardDetailFragment", "恢复主题失败: ${restoreResponse?.message ?: "未知错误"}") - return - } - - // 显示下载进度 - showDownloadProgress() - - try { - // 获取主题详情 - val themeDetailResp = getThemeDetail(themeId)?.data - if (themeDetailResp == null) { - hideDownloadProgress() - 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) - startActivity(intent) - return - } - - // 主动下载主题 - Log.d("1314520-KeyboardDetailFragment", "Downloading theme $themeName from $downloadUrl") - - // 下载 zip 文件 - val downloadedFile = FileDownloader.downloadZipFile( - context = context, - remoteFileName = downloadUrl, - localFileName = "$themeName.zip" - ) - - if (downloadedFile == null) { - showErrorMessage("下载主题失败") - 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 - ) + val ctx = context ?: return + val downloadUrl = themeDetailResp?.themeDownloadUrl + val themeName = downloadUrl?.let { extractZipNameFromUrl(it) } + + showDownloadProgress() + + val workName = ThemeDownloadWorker.enqueue(ctx, themeId, downloadUrl, themeName) + val workManager = WorkManager.getInstance(ctx) + val liveData = workManager.getWorkInfosForUniqueWorkLiveData(workName) + liveData.observe(viewLifecycleOwner) { infos -> + val info = infos.firstOrNull() ?: return@observe + when (info.state) { + WorkInfo.State.SUCCEEDED -> { + liveData.removeObservers(viewLifecycleOwner) + hideDownloadProgress() + if (isAdded) { + val intent = Intent(requireContext(), GuideActivity::class.java) + startActivity(intent) + } } - 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() + WorkInfo.State.FAILED, + WorkInfo.State.CANCELLED -> { + liveData.removeObservers(viewLifecycleOwner) + hideDownloadProgress() + showErrorMessage("启用主题失败") + } + else -> Unit } - - } catch (e: Exception) { - showErrorMessage("启用主题失败") - } finally { - hideDownloadProgress() } } - - /** - * 显示下载进度 - */ + + private fun showDownloadProgress() { // 在主线程中更新UI view?.post { diff --git a/app/src/main/java/com/example/myapplication/utils/EncryptedSharedPreferences.kt b/app/src/main/java/com/example/myapplication/utils/EncryptedSharedPreferences.kt index 2e32039..6628c51 100644 --- a/app/src/main/java/com/example/myapplication/utils/EncryptedSharedPreferences.kt +++ b/app/src/main/java/com/example/myapplication/utils/EncryptedSharedPreferences.kt @@ -1,6 +1,7 @@ package com.example.myapplication.utils import android.content.Context +import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.google.gson.Gson @@ -15,20 +16,29 @@ object EncryptedSharedPreferencesUtil { private const val SHARED_PREFS_NAME = "secure_prefs" private val gson by lazy { Gson() } + @Volatile private var cachedPrefs: SharedPreferences? = null + @Volatile private var cachedMasterKey: MasterKey? = null /** * 获取加密的 SharedPreferences(实际类型是 SharedPreferences) */ private fun prefs(context: Context) = - EncryptedSharedPreferences.create( - context, - SHARED_PREFS_NAME, - MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build(), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + 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( + appContext, + SHARED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ).also { cachedPrefs = it } + } + } /** * 存储任意对象(会转为 JSON 字符串保存) diff --git a/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt b/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt index dd9f8de..09c944e 100644 --- a/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt +++ b/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt @@ -134,7 +134,7 @@ fun unzipThemeSmart( zipFile: File, themeId: Int, targetBaseDir: File = File(context.filesDir, "keyboard_themes") -): String { +): String? { // 👉 检测嵌套 zip val innerZipName = findSingleInnerZip(zipFile) if (innerZipName != null) { @@ -174,7 +174,7 @@ fun unzipThemeFromFileOverwrite_ZIS( zipFile: File, themeId: Int, targetBaseDir: File -): String { +): String? { val tempOut = File(context.cacheDir, "tmp_theme_out").apply { if (exists()) deleteRecursively() @@ -269,7 +269,7 @@ fun unzipThemeFromFileOverwrite_ZIS( } catch (e: Exception) { logZipEntries(zipFile) Log.e(TAG_UNZIP, "解压失败: ${e.message}", e) - throw e + return null } finally { if (tempOut.exists()) tempOut.deleteRecursively() } diff --git a/app/src/main/java/com/example/myapplication/work/ThemeDownloadWorker.kt b/app/src/main/java/com/example/myapplication/work/ThemeDownloadWorker.kt new file mode 100644 index 0000000..1b31d40 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/work/ThemeDownloadWorker.kt @@ -0,0 +1,168 @@ +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() + .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 + } + } +}