优化plus

This commit is contained in:
pengxiaolong
2026-01-15 21:32:32 +08:00
parent a1fbc6417f
commit 673b4491d7
17 changed files with 649 additions and 346 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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等 */

View File

@@ -218,11 +218,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
override fun onCreate() {
super.onCreate()
ThemeManager.ensureBuiltInThemesInstalled(this)
ThemeManager.init(this)
ThemeManager.addThemeChangeListener(themeListener)
Thread {
ThemeManager.ensureBuiltInThemesInstalled(this)
ThemeManager.init(this)
}.start()
// 异步加载词典与 bigram 模型
Thread {
// 1) Trie 词典
@@ -932,15 +934,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
if (fromBi.isNotEmpty()) {
fromBi.filter { it != prefix }
} else {
wordDictionary.wordTrie.startsWith(prefix)
.take(20)
wordDictionary.wordTrie.startsWith(prefix, 20)
.filter { it != prefix }
}
}
} catch (_: Throwable) {
if (prefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(prefix)
.take(20)
wordDictionary.wordTrie.startsWith(prefix, 20)
.filterNot { it == prefix }
} else {
emptyList()
@@ -1098,11 +1098,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
if (!pendingSwipeClear) {
if (-dy >= triggerUp) {
pendingSwipeClear = true
// 如果此时正在连删(长按已触发),记录一下,方便取消时恢复
resumeDeletingAfterCancel = isDeleting
stopRepeatDelete()
view.cancelLongPress()
view.isPressed = false
showSwipeClearHint(view, "Clear")
return@setOnTouchListener true
}
@@ -1134,15 +1136,17 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 如果我们处于“准备清空”,才由我们接管结束逻辑
if (pendingSwipeClear) {
stopRepeatDelete()
if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
clearAllAndBackup()
}
pendingSwipeClear = false
resumeDeletingAfterCancel = false
dismissSwipeClearHint()
view.cancelLongPress()
view.isPressed = false
// 消费 UP避免 click/longclick 再触发
return@setOnTouchListener true
}
@@ -1285,7 +1289,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
val m = bigramModel
if (m == null || !bigramReady) {
return if (prefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(prefix).take(topK)
wordDictionary.wordTrie.startsWith(prefix, topK)
} else {
emptyList()
}
@@ -1332,7 +1336,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// —— 无上文 或 无出边 ——
return if (pf.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(pf).take(topK)
wordDictionary.wordTrie.startsWith(pf, topK)
} else {
unigramTopKFiltered(topK)
}

View File

@@ -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)
}
//从给定节点开始递归查找所有以该节点为起点的单词。
private fun getAllWordsFromNode(node: TrieNode, prefix: String): List<String> {
val words = mutableListOf<String>()
val max = if (limit < 0) 0 else limit
if (max == 0) return emptyList()
if (node.isEndOfWord) {
words.add(prefix)
val results = ArrayList<String>(minOf(max, 16))
val stack = ArrayDeque<Pair<TrieNode, String>>()
stack.addLast(current to prefix)
while (stack.isNotEmpty() && results.size < max) {
val (node, word) = stack.removeLast()
if (node.isEndOfWord) {
results.add(word)
if (results.size >= max) break
}
for ((char, child) in node.children) {
stack.addLast(child to (word + char))
}
}
for ((char, child) in node.children) {
words.addAll(getAllWordsFromNode(child, prefix + char))
}
return words
return results
}
}

View File

@@ -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)
}
}

View File

@@ -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() {

View File

@@ -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
}
// -------------------- 实现主题刷新 --------------------

View File

@@ -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
}
// -------------------- 实现主题刷新 --------------------

View File

@@ -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
}
// -------------------- 实现主题刷新 --------------------

View File

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

View File

@@ -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 {

View File

@@ -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)
}
}
// ==================== 可选:列出所有已安装主题 ====================
/**

View File

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

View File

@@ -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 字符串保存)

View File

@@ -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()
}

View File

@@ -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
}
}
}