2025-11-28 16:44:12 +08:00
|
|
|
|
package com.example.myapplication.theme
|
|
|
|
|
|
|
|
|
|
|
|
import android.content.Context
|
|
|
|
|
|
import android.content.res.AssetManager
|
|
|
|
|
|
import android.graphics.BitmapFactory
|
|
|
|
|
|
import android.graphics.drawable.BitmapDrawable
|
|
|
|
|
|
import android.graphics.drawable.Drawable
|
|
|
|
|
|
import java.io.File
|
|
|
|
|
|
object ThemeManager {
|
|
|
|
|
|
|
|
|
|
|
|
// SharedPreferences 保存当前主题名
|
|
|
|
|
|
private const val PREF_NAME = "ime_theme_prefs"
|
|
|
|
|
|
private const val KEY_CURRENT_THEME = "current_theme"
|
|
|
|
|
|
|
|
|
|
|
|
// 当前主题名(如 "default")
|
|
|
|
|
|
@Volatile
|
|
|
|
|
|
private var currentThemeName: String? = null
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存:规范化后的 keyName(lowercase) -> Drawable
|
|
|
|
|
|
@Volatile
|
|
|
|
|
|
private var drawableCache: MutableMap<String, Drawable> = mutableMapOf()
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 外部目录相关 ====================
|
2025-12-26 22:01:04 +08:00
|
|
|
|
//通知主题更新
|
|
|
|
|
|
private val listeners = mutableSetOf<() -> Unit>()
|
|
|
|
|
|
|
|
|
|
|
|
fun addThemeChangeListener(listener: () -> Unit) {
|
|
|
|
|
|
listeners.add(listener)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun removeThemeChangeListener(listener: () -> Unit) {
|
|
|
|
|
|
listeners.remove(listener)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-28 16:44:12 +08:00
|
|
|
|
|
|
|
|
|
|
/** 主题根目录:/Android/data/<package>/files/keyboard_themes */
|
|
|
|
|
|
private fun getThemeRootDir(context: Context): File =
|
2025-12-26 22:01:04 +08:00
|
|
|
|
File(context.filesDir, "keyboard_themes")
|
2025-11-28 16:44:12 +08:00
|
|
|
|
|
|
|
|
|
|
/** 某个具体主题目录:/Android/.../keyboard_themes/<themeName> */
|
|
|
|
|
|
private fun getThemeDir(context: Context, themeName: String): File =
|
|
|
|
|
|
File(getThemeRootDir(context), themeName)
|
|
|
|
|
|
|
|
|
|
|
|
fun ensureBuiltInThemesInstalled(context: Context) {
|
|
|
|
|
|
val am = context.assets
|
|
|
|
|
|
val rootName = "keyboard_themes"
|
|
|
|
|
|
val themeRootDir = getThemeRootDir(context)
|
|
|
|
|
|
if (!themeRootDir.exists()) themeRootDir.mkdirs()
|
|
|
|
|
|
|
|
|
|
|
|
// 列出 assets/keyboard_themes 下的所有子目录,比如 default、dark...
|
|
|
|
|
|
val themeNames = am.list(rootName) ?: return
|
|
|
|
|
|
|
|
|
|
|
|
for (themeName in themeNames) {
|
|
|
|
|
|
val assetThemePath = "$rootName/$themeName" // 如 keyboard_themes/default
|
|
|
|
|
|
val targetThemeDir = getThemeDir(context, themeName)
|
|
|
|
|
|
copyAssetsDirToDir(am, assetThemePath, targetThemeDir)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 递归地将 assets 中的某个目录合并复制到目标 File 目录下。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 规则:
|
|
|
|
|
|
* - 如果目标目录不存在,则创建。
|
|
|
|
|
|
* - 如果目标文件已存在,则跳过(不覆盖用户改过的图)。
|
|
|
|
|
|
* - 如果是新文件,则复制过去(补充新图)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private fun copyAssetsDirToDir(
|
|
|
|
|
|
assetManager: AssetManager,
|
|
|
|
|
|
assetDir: String,
|
|
|
|
|
|
targetDir: File
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!targetDir.exists()) targetDir.mkdirs()
|
|
|
|
|
|
|
|
|
|
|
|
val children = assetManager.list(assetDir) ?: return
|
|
|
|
|
|
for (child in children) {
|
|
|
|
|
|
val childAssetPath = "$assetDir/$child"
|
|
|
|
|
|
val outFile = File(targetDir, child)
|
|
|
|
|
|
|
|
|
|
|
|
val grandChildren = assetManager.list(childAssetPath)
|
|
|
|
|
|
if (grandChildren?.isNotEmpty() == true) {
|
|
|
|
|
|
// 子目录,递归
|
|
|
|
|
|
copyAssetsDirToDir(assetManager, childAssetPath, outFile)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 文件:如果已经存在就不覆盖;不存在才复制(补充新图)
|
|
|
|
|
|
if (outFile.exists()) continue
|
|
|
|
|
|
|
|
|
|
|
|
assetManager.open(childAssetPath).use { input ->
|
|
|
|
|
|
outFile.outputStream().use { output ->
|
|
|
|
|
|
input.copyTo(output)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 主题初始化 / 切换 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 初始化主题系统:
|
|
|
|
|
|
* - 读取 SharedPreferences 中上次使用的主题名
|
|
|
|
|
|
* - 默认使用 "default"
|
|
|
|
|
|
* - 加载该主题的所有图片到缓存
|
|
|
|
|
|
*/
|
|
|
|
|
|
fun init(context: Context) {
|
|
|
|
|
|
if (currentThemeName != null) return
|
|
|
|
|
|
|
|
|
|
|
|
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
|
|
|
|
|
val name = prefs.getString(KEY_CURRENT_THEME, "default") ?: "default"
|
|
|
|
|
|
setCurrentTheme(context, name)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换当前主题:
|
|
|
|
|
|
* - 更新 currentThemeName
|
|
|
|
|
|
* - 写 SharedPreferences
|
|
|
|
|
|
* - 重新加载该主题的全部图片到缓存
|
|
|
|
|
|
*/
|
|
|
|
|
|
fun setCurrentTheme(context: Context, themeName: String) {
|
|
|
|
|
|
currentThemeName = themeName
|
|
|
|
|
|
|
|
|
|
|
|
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
|
|
|
|
|
.edit()
|
|
|
|
|
|
.putString(KEY_CURRENT_THEME, themeName)
|
|
|
|
|
|
.apply()
|
|
|
|
|
|
|
|
|
|
|
|
drawableCache = loadThemeDrawables(context, themeName)
|
2025-12-26 22:01:04 +08:00
|
|
|
|
|
|
|
|
|
|
listeners.forEach { it.invoke() }
|
2025-11-28 16:44:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun getCurrentThemeName(): String? = currentThemeName
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 扫描某个主题目录下的所有 png/jpg/webp 文件,
|
|
|
|
|
|
* 用“文件名(去掉扩展名,小写)”作为 keyName,构造缓存。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 例如:
|
|
|
|
|
|
* /.../keyboard_themes/default/key_a.png -> "key_a"
|
|
|
|
|
|
* /.../keyboard_themes/default/key_a_up.PNG -> "key_a_up"
|
|
|
|
|
|
*/
|
|
|
|
|
|
private fun loadThemeDrawables(
|
|
|
|
|
|
context: Context,
|
|
|
|
|
|
themeName: String
|
|
|
|
|
|
): MutableMap<String, Drawable> {
|
|
|
|
|
|
val map = mutableMapOf<String, Drawable>()
|
|
|
|
|
|
val dir = getThemeDir(context, themeName)
|
|
|
|
|
|
|
|
|
|
|
|
if (!dir.exists() || !dir.isDirectory) return map
|
|
|
|
|
|
|
|
|
|
|
|
dir.listFiles()?.forEach { file ->
|
|
|
|
|
|
if (!file.isFile) return@forEach
|
|
|
|
|
|
|
|
|
|
|
|
val lowerName = file.name.lowercase()
|
|
|
|
|
|
if (
|
|
|
|
|
|
!(lowerName.endsWith(".png") ||
|
|
|
|
|
|
lowerName.endsWith(".jpg") ||
|
|
|
|
|
|
lowerName.endsWith(".jpeg") ||
|
|
|
|
|
|
lowerName.endsWith(".webp"))
|
|
|
|
|
|
) return@forEach
|
|
|
|
|
|
|
|
|
|
|
|
// 统一小写作为 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return map
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 对外:按 keyName 取 Drawable ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据 keyName 获取对应的 Drawable。
|
|
|
|
|
|
*
|
|
|
|
|
|
* keyName 一般就是 view 的 idName:
|
|
|
|
|
|
* - "key_a"
|
|
|
|
|
|
* - "key_a_up"
|
|
|
|
|
|
* - "key_1"
|
|
|
|
|
|
* - "key_space"
|
|
|
|
|
|
* - "key_send"
|
|
|
|
|
|
* ...
|
|
|
|
|
|
*
|
|
|
|
|
|
* 对应文件:
|
|
|
|
|
|
* /Android/data/.../files/keyboard_themes/<当前主题>/key_a.png
|
|
|
|
|
|
*
|
|
|
|
|
|
* 内部统一用 keyName.lowercase() 做匹配,不区分大小写。
|
|
|
|
|
|
*/
|
|
|
|
|
|
fun getDrawableForKey(context: Context, keyName: String): Drawable? {
|
|
|
|
|
|
if (currentThemeName == null) {
|
|
|
|
|
|
init(context)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 统一小写,避免大小写差异
|
|
|
|
|
|
val norm = keyName.lowercase()
|
|
|
|
|
|
|
|
|
|
|
|
// 1) 缓存里有就直接返回
|
|
|
|
|
|
drawableCache[norm]?.let { return it }
|
|
|
|
|
|
|
|
|
|
|
|
// 2) 缓存里没有:尝试从当前主题目录里单独加载一遍(兼容运行时新增图片)
|
|
|
|
|
|
val theme = currentThemeName ?: return null
|
|
|
|
|
|
val dir = getThemeDir(context, theme)
|
|
|
|
|
|
|
|
|
|
|
|
val candidates = listOf(
|
|
|
|
|
|
File(dir, "$norm.png"),
|
|
|
|
|
|
File(dir, "$norm.webp"),
|
|
|
|
|
|
File(dir, "$norm.jpg"),
|
|
|
|
|
|
File(dir, "$norm.jpeg")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for (f in candidates) {
|
|
|
|
|
|
if (f.exists() && f.isFile) {
|
|
|
|
|
|
val bmp = BitmapFactory.decodeFile(f.absolutePath) ?: continue
|
|
|
|
|
|
val d = BitmapDrawable(context.resources, bmp)
|
|
|
|
|
|
drawableCache[norm] = d
|
|
|
|
|
|
return d
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3) 找不到就返回 null(调用方自己决定是否兜底)
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 可选:列出所有已安装主题 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 返回当前外部目录下所有已经存在的主题名列表,
|
|
|
|
|
|
* 例如 ["default", "dark", "cute_pink"]。
|
|
|
|
|
|
*/
|
|
|
|
|
|
fun listAvailableThemes(context: Context): List<String> {
|
|
|
|
|
|
val root = getThemeRootDir(context)
|
|
|
|
|
|
if (!root.exists() || !root.isDirectory) return emptyList()
|
|
|
|
|
|
|
|
|
|
|
|
return root.listFiles()
|
|
|
|
|
|
?.filter { it.isDirectory }
|
|
|
|
|
|
?.map { it.name }
|
|
|
|
|
|
?.sorted()
|
|
|
|
|
|
?: emptyList()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|