Compare commits
2 Commits
e381b45a0b
...
673b4491d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
673b4491d7 | ||
|
|
a1fbc6417f |
@@ -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
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,9 @@ class BigramPredictor(
|
||||
@Volatile private var word2id: Map<String, Int> = emptyMap()
|
||||
|
||||
@Volatile private var id2word: List<String> = emptyList()
|
||||
@Volatile private var topUnigrams: List<String> = 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<String>(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<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个词
|
||||
private fun topKByScore(pairs: List<Pair<String, Float>>, k: Int): List<String> {
|
||||
val heap = topKHeap(k)
|
||||
|
||||
@@ -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等) */
|
||||
|
||||
@@ -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()
|
||||
@@ -1102,6 +1102,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
// 如果此时正在连删(长按已触发),记录一下,方便取消时恢复
|
||||
resumeDeletingAfterCancel = isDeleting
|
||||
stopRepeatDelete()
|
||||
view.cancelLongPress()
|
||||
view.isPressed = false
|
||||
|
||||
showSwipeClearHint(view, "Clear")
|
||||
return@setOnTouchListener true
|
||||
@@ -1142,6 +1144,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
return startsWith(prefix, Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
fun startsWith(prefix: String, limit: Int): List<String> {
|
||||
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)
|
||||
}
|
||||
val max = if (limit < 0) 0 else limit
|
||||
if (max == 0) return emptyList()
|
||||
|
||||
//从给定节点开始递归查找所有以该节点为起点的单词。
|
||||
private fun getAllWordsFromNode(node: TrieNode, prefix: String): List<String> {
|
||||
val words = mutableListOf<String>()
|
||||
val results = ArrayList<String>(minOf(max, 16))
|
||||
val stack = ArrayDeque<Pair<TrieNode, String>>()
|
||||
stack.addLast(current to prefix)
|
||||
|
||||
if (node.isEndOfWord) {
|
||||
words.add(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>, // 保留全部词(含 <unk>, <s>, </s>),与二元矩阵索引对齐
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<View?>(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<View?>(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<TextView>(
|
||||
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<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 {
|
||||
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<View?>(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() {
|
||||
|
||||
@@ -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<View?>(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
|
||||
}
|
||||
|
||||
// -------------------- 实现主题刷新 --------------------
|
||||
|
||||
@@ -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<View?>(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
|
||||
}
|
||||
|
||||
// -------------------- 实现主题刷新 --------------------
|
||||
|
||||
@@ -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<View?>(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
|
||||
}
|
||||
|
||||
// -------------------- 实现主题刷新 --------------------
|
||||
|
||||
@@ -81,7 +81,9 @@ object FileDownloader {
|
||||
downloadedBytes += read
|
||||
|
||||
// 需要的话可以在这里回调进度
|
||||
val progress = downloadedBytes * 100 / totalBytes
|
||||
if (totalBytes > 0) {
|
||||
val progress = downloadedBytes * 100 / totalBytes
|
||||
}
|
||||
}
|
||||
outputStream.flush()
|
||||
} finally {
|
||||
|
||||
@@ -328,6 +328,7 @@ object NetworkClient {
|
||||
) {
|
||||
var eventName: String? = null
|
||||
val dataLines = mutableListOf<String>()
|
||||
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 {
|
||||
|
||||
@@ -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<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) {
|
||||
listeners.add(listener)
|
||||
@@ -124,9 +135,22 @@ object ThemeManager {
|
||||
.putString(KEY_CURRENT_THEME, themeName)
|
||||
.apply()
|
||||
|
||||
drawableCache = loadThemeDrawables(context, themeName)
|
||||
val newFilePathCache: MutableMap<String, File> = 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<String, Drawable> {
|
||||
val map = mutableMapOf<String, Drawable>()
|
||||
themeName: String,
|
||||
targetMap: MutableMap<String, File>
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 可选:列出所有已安装主题 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,7 +373,7 @@ class KeyboardDetailFragment : Fragment() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用主题:下载、解压并设置主题
|
||||
* 启用主题:后台下载并应用主题
|
||||
*/
|
||||
private suspend fun enableTheme() {
|
||||
val themeId = arguments?.getInt("themeId", 0) ?: 0
|
||||
@@ -388,108 +381,38 @@ class KeyboardDetailFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
|
||||
// 恢复已删除的主题
|
||||
val restoreResponse = setrestoreTheme(themeId)
|
||||
if (restoreResponse?.code != 0) {
|
||||
// 恢复失败,显示错误信息并返回
|
||||
Log.e("1314520-KeyboardDetailFragment", "恢复主题失败: ${restoreResponse?.message ?: "未知错误"}")
|
||||
return
|
||||
}
|
||||
val ctx = context ?: return
|
||||
val downloadUrl = themeDetailResp?.themeDownloadUrl
|
||||
val themeName = downloadUrl?.let { extractZipNameFromUrl(it) }
|
||||
|
||||
// 显示下载进度
|
||||
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 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 {
|
||||
|
||||
@@ -3,20 +3,19 @@ package com.example.myapplication.ui.mine.myotherpages
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -26,14 +25,14 @@ import com.example.myapplication.R
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.AuthEvent
|
||||
import com.example.myapplication.network.AuthEventBus
|
||||
import com.example.myapplication.network.BehaviorReporter
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.User
|
||||
import com.example.myapplication.network.updateInfoRequest
|
||||
import com.example.myapplication.ui.common.LoadingOverlay
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import de.hdodenhof.circleimageview.CircleImageView
|
||||
import kotlinx.coroutines.launch
|
||||
import com.example.myapplication.network.updateInfoRequest
|
||||
import android.util.Log
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
@@ -41,7 +40,7 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import com.example.myapplication.network.BehaviorReporter
|
||||
import android.util.Log
|
||||
|
||||
class PersonalSettings : BottomSheetDialogFragment() {
|
||||
|
||||
@@ -53,16 +52,23 @@ class PersonalSettings : BottomSheetDialogFragment() {
|
||||
private lateinit var tvUserId: TextView
|
||||
private lateinit var loadingOverlay: LoadingOverlay
|
||||
|
||||
// ActivityResultLauncher for image selection - restrict to image types
|
||||
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri?.let { handleImageResult(it) }
|
||||
}
|
||||
|
||||
private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean ->
|
||||
if (success) {
|
||||
cameraImageUri?.let { handleImageResult(it) }
|
||||
/**
|
||||
* ✅ Android Photo Picker
|
||||
* - Android 13+:系统原生 Photo Picker
|
||||
* - 低版本:会自动 fallback 到系统/兼容实现
|
||||
* - 不需要 READ/WRITE_EXTERNAL_STORAGE
|
||||
*/
|
||||
private val galleryLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
|
||||
uri?.let { handleImageResult(it) }
|
||||
}
|
||||
|
||||
private val cameraLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean ->
|
||||
if (success) {
|
||||
cameraImageUri?.let { handleImageResult(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cameraImageUri: Uri? = null
|
||||
|
||||
@@ -97,52 +103,52 @@ class PersonalSettings : BottomSheetDialogFragment() {
|
||||
|
||||
// ===================== FragmentResult listeners =====================
|
||||
|
||||
// 昵称保存回传
|
||||
parentFragmentManager.setFragmentResultListener(
|
||||
NicknameEditSheet.REQ_KEY,
|
||||
viewLifecycleOwner
|
||||
) { _, bundle ->
|
||||
val newName = bundle.getString(NicknameEditSheet.BUNDLE_KEY_NICKNAME).orEmpty()
|
||||
if (newName.isBlank()) return@setFragmentResultListener
|
||||
lifecycleScope.launch {
|
||||
loadingOverlay.show()
|
||||
try {
|
||||
val ReturnValue = setupdateUserInfo(updateInfoRequest(nickName = newName))
|
||||
Log.d("1314520-PersonalSettings", "setupdateUserInfo: $ReturnValue")
|
||||
if(ReturnValue?.code == 0){
|
||||
tvNickname.text = newName
|
||||
// 昵称保存回传
|
||||
parentFragmentManager.setFragmentResultListener(
|
||||
NicknameEditSheet.REQ_KEY,
|
||||
viewLifecycleOwner
|
||||
) { _, bundle ->
|
||||
val newName = bundle.getString(NicknameEditSheet.BUNDLE_KEY_NICKNAME).orEmpty()
|
||||
if (newName.isBlank()) return@setFragmentResultListener
|
||||
lifecycleScope.launch {
|
||||
loadingOverlay.show()
|
||||
try {
|
||||
val returnValue = setupdateUserInfo(updateInfoRequest(nickName = newName))
|
||||
Log.d("PersonalSettings", "setupdateUserInfo: $returnValue")
|
||||
if (returnValue?.code == 0) {
|
||||
tvNickname.text = newName
|
||||
}
|
||||
user = user?.copy(nickName = newName)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PersonalSettings", "Failed to update nickname", e)
|
||||
} finally {
|
||||
loadingOverlay.hide()
|
||||
}
|
||||
user = user?.copy(nickName = newName)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PersonalSettings", "Failed to update nickname", e)
|
||||
} finally {
|
||||
loadingOverlay.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 性别保存回传
|
||||
parentFragmentManager.setFragmentResultListener(
|
||||
GenderSelectSheet.REQ_KEY,
|
||||
viewLifecycleOwner
|
||||
) { _, bundle ->
|
||||
val newGender = bundle.getInt(GenderSelectSheet.BUNDLE_KEY_GENDER, 0)
|
||||
// 性别保存回传
|
||||
parentFragmentManager.setFragmentResultListener(
|
||||
GenderSelectSheet.REQ_KEY,
|
||||
viewLifecycleOwner
|
||||
) { _, bundle ->
|
||||
val newGender = bundle.getInt(GenderSelectSheet.BUNDLE_KEY_GENDER, 0)
|
||||
|
||||
lifecycleScope.launch {
|
||||
loadingOverlay.show()
|
||||
try {
|
||||
val ReturnValue = setupdateUserInfo(updateInfoRequest(gender = newGender))
|
||||
if(ReturnValue?.code == 0){
|
||||
tvGender.text = genderText(newGender)
|
||||
lifecycleScope.launch {
|
||||
loadingOverlay.show()
|
||||
try {
|
||||
val returnValue = setupdateUserInfo(updateInfoRequest(gender = newGender))
|
||||
if (returnValue?.code == 0) {
|
||||
tvGender.text = genderText(newGender)
|
||||
}
|
||||
user = user?.copy(gender = newGender)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PersonalSettings", "Failed to update gender", e)
|
||||
} finally {
|
||||
loadingOverlay.hide()
|
||||
}
|
||||
user = user?.copy(gender = newGender)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PersonalSettings", "Failed to update gender", e)
|
||||
} finally {
|
||||
loadingOverlay.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== row click =====================
|
||||
|
||||
@@ -240,7 +246,12 @@ class PersonalSettings : BottomSheetDialogFragment() {
|
||||
.setTitle(R.string.change_avatar)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> galleryLauncher.launch("image/png,image/jpeg")
|
||||
0 -> {
|
||||
// ✅ Photo Picker: ImageOnly
|
||||
galleryLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
}
|
||||
1 -> {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
@@ -278,86 +289,93 @@ class PersonalSettings : BottomSheetDialogFragment() {
|
||||
|
||||
loadingOverlay.show()
|
||||
try {
|
||||
val resolver = requireContext().contentResolver
|
||||
|
||||
// Get MIME type to determine image format
|
||||
val mimeType = requireContext().contentResolver.getType(uri)
|
||||
val mimeType = resolver.getType(uri)
|
||||
val isPng = mimeType?.equals("image/png", ignoreCase = true) == true
|
||||
|
||||
// Determine file extension and compression format based on MIME type
|
||||
// Determine file extension and media type based on MIME type
|
||||
val fileExtension = if (isPng) ".png" else ".jpg"
|
||||
val compressFormat = if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
|
||||
val mediaType = if (isPng) "image/png" else "image/jpeg"
|
||||
|
||||
val inputStream = requireContext().contentResolver.openInputStream(uri) ?: return
|
||||
// Temp file in app-private external files dir (no storage permission needed)
|
||||
val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val tempFile = File.createTempFile(
|
||||
"UPLOAD_${timeStamp}_",
|
||||
fileExtension,
|
||||
storageDir
|
||||
)
|
||||
val tempFile = File.createTempFile("UPLOAD_${timeStamp}_", fileExtension, storageDir)
|
||||
|
||||
// Read and compress image if needed
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeStream(inputStream, null, options)
|
||||
inputStream.close()
|
||||
// 先读取尺寸信息(inJustDecodeBounds)
|
||||
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri)?.use { ins ->
|
||||
BitmapFactory.decodeStream(ins, null, boundsOptions)
|
||||
} ?: return
|
||||
|
||||
// Calculate inSampleSize
|
||||
// Calculate inSampleSize (粗略控制内存)
|
||||
var inSampleSize = 1
|
||||
val maxSize = 5 * 1024 * 1024 // 5MB
|
||||
if (options.outHeight * options.outWidth * 4 > maxSize) {
|
||||
val halfHeight = options.outHeight / 2
|
||||
val halfWidth = options.outWidth / 2
|
||||
while (halfHeight / inSampleSize >= 1024 && halfWidth / inSampleSize >= 1024) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
}
|
||||
|
||||
// Decode with inSampleSize
|
||||
options.inJustDecodeBounds = false
|
||||
options.inSampleSize = inSampleSize
|
||||
val inputStream2 = requireContext().contentResolver.openInputStream(uri) ?: return
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream2, null, options)
|
||||
inputStream2.close()
|
||||
|
||||
// Compress to file
|
||||
tempFile.outputStream().use { output ->
|
||||
bitmap?.let { bmp ->
|
||||
if (isPng) {
|
||||
// PNG compression (quality parameter is ignored for PNG)
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
} else {
|
||||
// JPEG compression with quality adjustment
|
||||
var quality = 90
|
||||
do {
|
||||
output.channel.truncate(0)
|
||||
bmp.compress(Bitmap.CompressFormat.JPEG, quality, output)
|
||||
quality -= 10
|
||||
} while (tempFile.length() > maxSize && quality > 10)
|
||||
val maxSizeBytes = 5 * 1024 * 1024 // 5MB 目标
|
||||
// 简单估算:w*h*4
|
||||
if (boundsOptions.outHeight > 0 && boundsOptions.outWidth > 0) {
|
||||
val estimated = boundsOptions.outHeight.toLong() * boundsOptions.outWidth.toLong() * 4L
|
||||
if (estimated > maxSizeBytes) {
|
||||
val halfHeight = boundsOptions.outHeight / 2
|
||||
val halfWidth = boundsOptions.outWidth / 2
|
||||
while (halfHeight / inSampleSize >= 1024 && halfWidth / inSampleSize >= 1024) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
} ?: run {
|
||||
Toast.makeText(requireContext(), "Failed to decode image", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (tempFile.length() > maxSize) {
|
||||
Toast.makeText(requireContext(), "Image is too large after compression", Toast.LENGTH_SHORT).show()
|
||||
// Decode bitmap with inSampleSize
|
||||
val decodeOptions = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = false
|
||||
this.inSampleSize = inSampleSize
|
||||
}
|
||||
|
||||
val bitmap = resolver.openInputStream(uri)?.use { ins ->
|
||||
BitmapFactory.decodeStream(ins, null, decodeOptions)
|
||||
}
|
||||
|
||||
if (bitmap == null) {
|
||||
Toast.makeText(requireContext(), "Failed to decode image", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val requestFile = RequestBody.create(mediaType.toMediaTypeOrNull(), tempFile)
|
||||
// Compress to tempFile
|
||||
// 注意:用 outputStream 反复 compress 时不要在同一个 stream 上 truncate,
|
||||
// 最稳是每次重新打开 stream 写入。
|
||||
if (isPng) {
|
||||
tempFile.outputStream().use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
}
|
||||
} else {
|
||||
var quality = 90
|
||||
do {
|
||||
tempFile.outputStream().use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||
}
|
||||
quality -= 10
|
||||
} while (tempFile.length() > maxSizeBytes && quality > 10)
|
||||
}
|
||||
|
||||
if (tempFile.length() > maxSizeBytes) {
|
||||
Toast.makeText(requireContext(), "Image is too large after compression", Toast.LENGTH_SHORT).show()
|
||||
bitmap.recycle()
|
||||
tempFile.delete()
|
||||
return
|
||||
}
|
||||
|
||||
val requestFile: RequestBody = RequestBody.create(mediaType.toMediaTypeOrNull(), tempFile)
|
||||
val body = MultipartBody.Part.createFormData("file", tempFile.name, requestFile)
|
||||
|
||||
val response = RetrofitClient.createFileUploadService()
|
||||
.uploadFile("avatar", body)
|
||||
|
||||
// Clean up
|
||||
bitmap?.recycle()
|
||||
bitmap.recycle()
|
||||
tempFile.delete()
|
||||
|
||||
if (response?.code == 0) {
|
||||
val ReturnValue = setupdateUserInfo(updateInfoRequest(avatarUrl = response.data))
|
||||
setupdateUserInfo(updateInfoRequest(avatarUrl = response.data))
|
||||
Toast.makeText(requireContext(), R.string.avatar_updated, Toast.LENGTH_SHORT).show()
|
||||
user = user?.copy(avatarUrl = response.data)
|
||||
} else {
|
||||
|
||||
@@ -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 字符串保存)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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