优化plus
This commit is contained in:
@@ -77,6 +77,7 @@ 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
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ 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() {
|
||||||
@@ -37,6 +40,7 @@ class BigramPredictor(
|
|||||||
word2id = map
|
word2id = map
|
||||||
|
|
||||||
id2word = m.vocab
|
id2word = m.vocab
|
||||||
|
topUnigrams = buildTopUnigrams(m, unigramCacheSize)
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
// 保持静默,允许无模型运行(仅 Trie 起作用)
|
// 保持静默,允许无模型运行(仅 Trie 起作用)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,19 +93,34 @@ class BigramPredictor(
|
|||||||
return topKByScore(candidates, topK)
|
return topKByScore(candidates, topK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) 兜底:用 unigram + 前缀过滤
|
// 3) 兜底:用预计算的 unigram Top-N + 前缀过滤
|
||||||
val heap = topKHeap(topK)
|
if (topK <= 0) return emptyList()
|
||||||
|
|
||||||
for (i in m.vocab.indices) {
|
val cachedUnigrams = getTopUnigrams(m)
|
||||||
val w = m.vocab[i]
|
if (pfx.isEmpty()) {
|
||||||
|
return cachedUnigrams.take(topK)
|
||||||
|
}
|
||||||
|
|
||||||
if (pfx.isEmpty() || w.startsWith(pfx, ignoreCase = true)) {
|
val results = ArrayList<String>(topK)
|
||||||
heap.offer(w to m.uniLogp[i])
|
if (cachedUnigrams.isNotEmpty()) {
|
||||||
|
for (w in cachedUnigrams) {
|
||||||
if (heap.size > topK) heap.poll()
|
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()
|
if (prefix.isEmpty()) return emptyList()
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
trie.startsWith(prefix).take(topK)
|
trie.startsWith(prefix, 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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -28,6 +29,8 @@ 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,
|
||||||
@@ -360,32 +363,58 @@ 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
|
||||||
|
|
||||||
fm.beginTransaction()
|
val transaction = fm.beginTransaction()
|
||||||
.setReorderingAllowed(true)
|
.setReorderingAllowed(true)
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
transaction
|
||||||
.hide(homeHost)
|
.hide(homeHost)
|
||||||
.hide(shopHost)
|
.hide(shopHost)
|
||||||
.hide(mineHost)
|
.hide(mineHost)
|
||||||
.also { ft ->
|
.show(targetHost)
|
||||||
when (targetTag) {
|
} else if (currentHost != targetHost) {
|
||||||
TAB_SHOP -> ft.show(shopHost)
|
transaction
|
||||||
TAB_MINE -> ft.show(mineHost)
|
.hide(currentHost)
|
||||||
else -> ft.show(homeHost)
|
.show(targetHost)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.commit()
|
|
||||||
|
|
||||||
// ✅ 关键:hide/show 切 tab 不会触发 destinationChanged,所以手动刷新
|
transaction
|
||||||
bottomNav.post { updateBottomNavVisibility() }
|
.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()
|
||||||
|
|
||||||
// ✅ 新增:切 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等) */
|
||||||
|
|||||||
@@ -218,10 +218,12 @@ 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 {
|
||||||
@@ -932,15 +934,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
if (fromBi.isNotEmpty()) {
|
if (fromBi.isNotEmpty()) {
|
||||||
fromBi.filter { it != prefix }
|
fromBi.filter { it != prefix }
|
||||||
} else {
|
} else {
|
||||||
wordDictionary.wordTrie.startsWith(prefix)
|
wordDictionary.wordTrie.startsWith(prefix, 20)
|
||||||
.take(20)
|
|
||||||
.filter { it != prefix }
|
.filter { it != prefix }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
if (prefix.isNotEmpty()) {
|
if (prefix.isNotEmpty()) {
|
||||||
wordDictionary.wordTrie.startsWith(prefix)
|
wordDictionary.wordTrie.startsWith(prefix, 20)
|
||||||
.take(20)
|
|
||||||
.filterNot { it == prefix }
|
.filterNot { it == prefix }
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
@@ -1102,6 +1102,8 @@ 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
|
||||||
@@ -1142,6 +1144,8 @@ 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
|
||||||
@@ -1285,7 +1289,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).take(topK)
|
wordDictionary.wordTrie.startsWith(prefix, topK)
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
@@ -1332,7 +1336,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
|
|
||||||
// —— 无上文 或 无出边 ——
|
// —— 无上文 或 无出边 ——
|
||||||
return if (pf.isNotEmpty()) {
|
return if (pf.isNotEmpty()) {
|
||||||
wordDictionary.wordTrie.startsWith(pf).take(topK)
|
wordDictionary.wordTrie.startsWith(pf, topK)
|
||||||
} else {
|
} else {
|
||||||
unigramTopKFiltered(topK)
|
unigramTopKFiltered(topK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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(
|
||||||
@@ -32,29 +34,38 @@ 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
|
||||||
|
|
||||||
for (char in prefix.lowercase()) {
|
val normalized = prefix.lowercase()
|
||||||
|
for (char in normalized) {
|
||||||
current = current.children[char] ?: return emptyList()
|
current = current.children[char] ?: return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
return getAllWordsFromNode(current, prefix)
|
val max = if (limit < 0) 0 else limit
|
||||||
}
|
if (max == 0) return emptyList()
|
||||||
|
|
||||||
//从给定节点开始递归查找所有以该节点为起点的单词。
|
val results = ArrayList<String>(minOf(max, 16))
|
||||||
private fun getAllWordsFromNode(node: TrieNode, prefix: String): List<String> {
|
val stack = ArrayDeque<Pair<TrieNode, String>>()
|
||||||
val words = mutableListOf<String>()
|
stack.addLast(current to prefix)
|
||||||
|
|
||||||
|
while (stack.isNotEmpty() && results.size < max) {
|
||||||
|
val (node, word) = stack.removeLast()
|
||||||
if (node.isEndOfWord) {
|
if (node.isEndOfWord) {
|
||||||
words.add(prefix)
|
results.add(word)
|
||||||
|
if (results.size >= max) break
|
||||||
}
|
}
|
||||||
|
|
||||||
for ((char, child) in node.children) {
|
for ((char, child) in node.children) {
|
||||||
words.addAll(getAllWordsFromNode(child, prefix + char))
|
stack.addLast(child to (word + char))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return words
|
return results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ 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>),与二元矩阵索引对齐
|
||||||
@@ -30,39 +38,104 @@ object LanguageModelLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun readInt32(context: Context, name: String): IntArray {
|
private fun readInt32(context: Context, name: String): IntArray {
|
||||||
context.assets.open(name).use { input ->
|
try {
|
||||||
val bytes = input.readBytes()
|
context.assets.openFd(name).use { afd ->
|
||||||
val n = bytes.size / 4
|
FileInputStream(afd.fileDescriptor).channel.use { channel ->
|
||||||
val out = IntArray(n)
|
return readInt32Channel(channel, afd.startOffset, afd.length)
|
||||||
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
|
}
|
||||||
|
} 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 {
|
private fun readFloat32(context: Context, name: String): FloatArray {
|
||||||
context.assets.open(name).use { input ->
|
try {
|
||||||
val bytes = input.readBytes()
|
context.assets.openFd(name).use { afd ->
|
||||||
val n = bytes.size / 4
|
FileInputStream(afd.fileDescriptor).channel.use { channel ->
|
||||||
val out = FloatArray(n)
|
return readFloat32Channel(channel, afd.startOffset, afd.length)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} 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
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ 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
|
||||||
@@ -63,13 +60,15 @@ 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)
|
||||||
rootView.findViewById(id)
|
val view = if (id != 0) rootView.findViewById<View?>(id) else null
|
||||||
|
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)
|
||||||
rootView.findViewById(id)
|
val view = if (id != 0) rootView.findViewById<View?>(id) else null
|
||||||
|
view as? ScrollView ?: ScrollView(env.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentAssistantTextView: TextView? = null
|
private var currentAssistantTextView: TextView? = null
|
||||||
@@ -84,15 +83,26 @@ 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 = inflater.inflate(layoutId, messagesContainer, false) as LinearLayout
|
val itemView = if (layoutId != 0) {
|
||||||
val tv = itemView.findViewById<TextView>(
|
inflater.inflate(layoutId, messagesContainer, false)
|
||||||
res.getIdentifier("tv_content", "id", env.ctx.packageName)
|
} else {
|
||||||
)
|
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 = tv.text?.toString().orEmpty()
|
val text = textView.text?.toString().orEmpty()
|
||||||
if (text.isNotBlank()) {
|
if (text.isNotBlank()) {
|
||||||
fillToEditorOverwriteLast(text)
|
fillToEditorOverwriteLast(text)
|
||||||
BehaviorReporter.report(
|
BehaviorReporter.report(
|
||||||
@@ -106,7 +116,7 @@ class AiKeyboard(
|
|||||||
|
|
||||||
messagesContainer.addView(itemView)
|
messagesContainer.addView(itemView)
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
return tv
|
return textView
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scrollToBottom() {
|
private fun scrollToBottom() {
|
||||||
@@ -149,6 +159,7 @@ class AiKeyboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onLlmDone() {
|
private fun onLlmDone() {
|
||||||
|
cancelAiStream()
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
streamBuffer.clear()
|
streamBuffer.clear()
|
||||||
currentAssistantTextView = null
|
currentAssistantTextView = null
|
||||||
@@ -180,6 +191,7 @@ 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 {
|
||||||
@@ -529,36 +541,13 @@ 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 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") {
|
v.background = drawable
|
||||||
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() {
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
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
|
||||||
@@ -25,10 +20,15 @@ class MainKeyboard(
|
|||||||
private val onToggleShift: () -> Boolean
|
private val onToggleShift: () -> Boolean
|
||||||
) : BaseKeyboard(env) {
|
) : BaseKeyboard(env) {
|
||||||
|
|
||||||
override val rootView: View = env.layoutInflater.inflate(
|
override val rootView: View = run {
|
||||||
env.ctx.resources.getIdentifier("keyboard", "layout", env.ctx.packageName),
|
val res = env.ctx.resources
|
||||||
null
|
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 isShiftOn: Boolean = false
|
||||||
private var keyPreviewPopup: PopupWindow? = null
|
private var keyPreviewPopup: PopupWindow? = null
|
||||||
@@ -61,24 +61,13 @@ 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 rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
|
val drawable = if (viewIdName == "background") {
|
||||||
|
ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f)
|
||||||
if (viewIdName == "background") {
|
|
||||||
v.background = scaleDrawableToHeight(rawDrawable, 243f)
|
|
||||||
} else {
|
} else {
|
||||||
v.background = rawDrawable
|
ThemeManager.getDrawableForKey(env.ctx, keyName)
|
||||||
}
|
} ?: return
|
||||||
}
|
|
||||||
|
|
||||||
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
|
v.background = 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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- 实现主题刷新 --------------------
|
// -------------------- 实现主题刷新 --------------------
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
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
|
||||||
@@ -21,10 +16,15 @@ class NumberKeyboard(
|
|||||||
env: KeyboardEnvironment
|
env: KeyboardEnvironment
|
||||||
) : BaseKeyboard(env) {
|
) : BaseKeyboard(env) {
|
||||||
|
|
||||||
override val rootView: View = env.layoutInflater.inflate(
|
override val rootView: View = run {
|
||||||
env.ctx.resources.getIdentifier("number_keyboard", "layout", env.ctx.packageName),
|
val res = env.ctx.resources
|
||||||
null
|
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
|
private var keyPreviewPopup: PopupWindow? = null
|
||||||
|
|
||||||
@@ -57,24 +57,13 @@ 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 rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
|
val drawable = if (viewIdName == "background") {
|
||||||
|
ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f)
|
||||||
if (viewIdName == "background") {
|
|
||||||
v.background = scaleDrawableToHeight(rawDrawable, 243f)
|
|
||||||
} else {
|
} else {
|
||||||
v.background = rawDrawable
|
ThemeManager.getDrawableForKey(env.ctx, keyName)
|
||||||
}
|
} ?: return
|
||||||
}
|
|
||||||
|
|
||||||
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
|
v.background = 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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- 实现主题刷新 --------------------
|
// -------------------- 实现主题刷新 --------------------
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
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
|
||||||
@@ -21,10 +16,15 @@ class SymbolKeyboard(
|
|||||||
env: KeyboardEnvironment
|
env: KeyboardEnvironment
|
||||||
) : BaseKeyboard(env) {
|
) : BaseKeyboard(env) {
|
||||||
|
|
||||||
override val rootView: View = env.layoutInflater.inflate(
|
override val rootView: View = run {
|
||||||
env.ctx.resources.getIdentifier("symbol_keyboard", "layout", env.ctx.packageName),
|
val res = env.ctx.resources
|
||||||
null
|
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
|
private var keyPreviewPopup: PopupWindow? = null
|
||||||
|
|
||||||
@@ -58,24 +58,13 @@ 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 rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
|
val drawable = if (viewIdName == "background") {
|
||||||
|
ThemeManager.getScaledDrawableForKey(env.ctx, keyName, 243f)
|
||||||
if (viewIdName == "background") {
|
|
||||||
v.background = scaleDrawableToHeight(rawDrawable, 243f)
|
|
||||||
} else {
|
} else {
|
||||||
v.background = rawDrawable
|
ThemeManager.getDrawableForKey(env.ctx, keyName)
|
||||||
}
|
} ?: return
|
||||||
}
|
|
||||||
|
|
||||||
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
|
v.background = 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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- 实现主题刷新 --------------------
|
// -------------------- 实现主题刷新 --------------------
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ 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()
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ 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
|
||||||
@@ -336,7 +337,16 @@ 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()) {
|
||||||
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
|
eventName = null
|
||||||
@@ -349,6 +359,7 @@ object NetworkClient {
|
|||||||
|
|
||||||
if (line.isEmpty()) {
|
if (line.isEmpty()) {
|
||||||
dispatch()
|
dispatch()
|
||||||
|
if (stop) break
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (line.startsWith(":")) continue
|
if (line.startsWith(":")) continue
|
||||||
@@ -366,10 +377,16 @@ object NetworkClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!stop) {
|
||||||
dispatch()
|
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 trimmed = rawData.trim()
|
||||||
val looksLikeJson = trimmed.startsWith("{") && trimmed.endsWith("}")
|
val looksLikeJson = trimmed.startsWith("{") && trimmed.endsWith("}")
|
||||||
|
|
||||||
@@ -382,14 +399,22 @@ object NetworkClient {
|
|||||||
|
|
||||||
if (type.isNotBlank()) {
|
if (type.isNotBlank()) {
|
||||||
callback.onEvent(type, dataStr)
|
callback.onEvent(type, dataStr)
|
||||||
return
|
return type.equals("done", ignoreCase = true)
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
// fallthrough to text
|
// 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 {
|
private fun peekOrReadBody(response: Response): String {
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ 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"
|
||||||
@@ -18,11 +25,15 @@ object ThemeManager {
|
|||||||
|
|
||||||
// 缓存:规范化后的 keyName(lowercase) -> Drawable
|
// 缓存:规范化后的 keyName(lowercase) -> Drawable
|
||||||
@Volatile
|
@Volatile
|
||||||
private var drawableCache: MutableMap<String, Drawable> = mutableMapOf()
|
private var drawableCache: MutableMap<String, Drawable> = ConcurrentHashMap()
|
||||||
|
@Volatile
|
||||||
|
private var filePathCache: MutableMap<String, File> = ConcurrentHashMap()
|
||||||
|
@Volatile
|
||||||
|
private var scaledBitmapCache: MutableMap<String, Bitmap> = ConcurrentHashMap()
|
||||||
|
|
||||||
// ==================== 外部目录相关 ====================
|
// ==================== 外部目录相关 ====================
|
||||||
//通知主题更新
|
//通知主题更新
|
||||||
private val listeners = mutableSetOf<() -> Unit>()
|
private val listeners = CopyOnWriteArraySet<() -> Unit>()
|
||||||
|
|
||||||
fun addThemeChangeListener(listener: () -> Unit) {
|
fun addThemeChangeListener(listener: () -> Unit) {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
@@ -124,9 +135,22 @@ object ThemeManager {
|
|||||||
.putString(KEY_CURRENT_THEME, themeName)
|
.putString(KEY_CURRENT_THEME, themeName)
|
||||||
.apply()
|
.apply()
|
||||||
|
|
||||||
drawableCache = loadThemeDrawables(context, themeName)
|
val newFilePathCache: MutableMap<String, File> = ConcurrentHashMap()
|
||||||
|
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
|
||||||
@@ -139,14 +163,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 loadThemeDrawables(
|
private fun indexThemeFiles(
|
||||||
context: Context,
|
context: Context,
|
||||||
themeName: String
|
themeName: String,
|
||||||
): MutableMap<String, Drawable> {
|
targetMap: MutableMap<String, File>
|
||||||
val map = mutableMapOf<String, Drawable>()
|
) {
|
||||||
val dir = getThemeDir(context, themeName)
|
val dir = getThemeDir(context, themeName)
|
||||||
|
|
||||||
if (!dir.exists() || !dir.isDirectory) return map
|
if (!dir.exists() || !dir.isDirectory) return
|
||||||
|
|
||||||
dir.listFiles()?.forEach { file ->
|
dir.listFiles()?.forEach { file ->
|
||||||
if (!file.isFile) return@forEach
|
if (!file.isFile) return@forEach
|
||||||
@@ -161,12 +185,8 @@ 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(".")
|
||||||
val bmp = BitmapFactory.decodeFile(file.absolutePath) ?: return@forEach
|
targetMap[key] = file
|
||||||
val d = BitmapDrawable(context.resources, bmp)
|
|
||||||
map[key] = d
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 对外:按 keyName 取 Drawable ====================
|
// ==================== 对外:按 keyName 取 Drawable ====================
|
||||||
@@ -188,10 +208,6 @@ 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()
|
||||||
|
|
||||||
@@ -202,6 +218,20 @@ 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"),
|
||||||
@@ -213,6 +243,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -222,6 +253,36 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 可选:列出所有已安装主题 ====================
|
// ==================== 可选:列出所有已安装主题 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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
|
||||||
@@ -19,6 +18,8 @@ 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
|
||||||
@@ -27,19 +28,11 @@ 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.network.FileDownloader
|
import com.example.myapplication.work.ThemeDownloadWorker
|
||||||
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
|
||||||
@@ -380,7 +373,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
|
||||||
@@ -388,108 +381,38 @@ class KeyboardDetailFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 恢复已删除的主题
|
val ctx = context ?: return
|
||||||
val restoreResponse = setrestoreTheme(themeId)
|
val downloadUrl = themeDetailResp?.themeDownloadUrl
|
||||||
if (restoreResponse?.code != 0) {
|
val themeName = downloadUrl?.let { extractZipNameFromUrl(it) }
|
||||||
// 恢复失败,显示错误信息并返回
|
|
||||||
Log.e("1314520-KeyboardDetailFragment", "恢复主题失败: ${restoreResponse?.message ?: "未知错误"}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示下载进度
|
|
||||||
showDownloadProgress()
|
showDownloadProgress()
|
||||||
|
|
||||||
try {
|
val workName = ThemeDownloadWorker.enqueue(ctx, themeId, downloadUrl, themeName)
|
||||||
// 获取主题详情
|
val workManager = WorkManager.getInstance(ctx)
|
||||||
val themeDetailResp = getThemeDetail(themeId)?.data
|
val liveData = workManager.getWorkInfosForUniqueWorkLiveData(workName)
|
||||||
if (themeDetailResp == null) {
|
liveData.observe(viewLifecycleOwner) { infos ->
|
||||||
|
val info = infos.firstOrNull() ?: return@observe
|
||||||
|
when (info.state) {
|
||||||
|
WorkInfo.State.SUCCEEDED -> {
|
||||||
|
liveData.removeObservers(viewLifecycleOwner)
|
||||||
hideDownloadProgress()
|
hideDownloadProgress()
|
||||||
return
|
if (isAdded) {
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
Log.d("1314520-KeyboardDetailFragment", "Downloading theme $themeName from $downloadUrl")
|
WorkInfo.State.CANCELLED -> {
|
||||||
|
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 {
|
}
|
||||||
hideDownloadProgress()
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示下载进度
|
|
||||||
*/
|
|
||||||
private fun showDownloadProgress() {
|
private fun showDownloadProgress() {
|
||||||
// 在主线程中更新UI
|
// 在主线程中更新UI
|
||||||
view?.post {
|
view?.post {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -15,20 +16,29 @@ 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) =
|
||||||
EncryptedSharedPreferences.create(
|
cachedPrefs ?: synchronized(this) {
|
||||||
context,
|
cachedPrefs ?: run {
|
||||||
SHARED_PREFS_NAME,
|
val appContext = context.applicationContext
|
||||||
MasterKey.Builder(context)
|
val masterKey = cachedMasterKey ?: MasterKey.Builder(appContext)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build(),
|
.build()
|
||||||
|
.also { cachedMasterKey = it }
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
appContext,
|
||||||
|
SHARED_PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
)
|
).also { cachedPrefs = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存储任意对象(会转为 JSON 字符串保存)
|
* 存储任意对象(会转为 JSON 字符串保存)
|
||||||
|
|||||||
@@ -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)
|
||||||
throw e
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
if (tempOut.exists()) tempOut.deleteRecursively()
|
if (tempOut.exists()) tempOut.deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user