键盘主题设置

This commit is contained in:
pengxiaolong
2025-11-28 16:44:12 +08:00
parent be276ae65d
commit 242d690e17
118 changed files with 1228 additions and 412 deletions

View File

@@ -0,0 +1,240 @@
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()
// ==================== 外部目录相关 ====================
/** 主题根目录:/Android/data/<package>/files/keyboard_themes */
private fun getThemeRootDir(context: Context): File =
File(context.getExternalFilesDir(null), "keyboard_themes")
/** 某个具体主题目录:/Android/.../keyboard_themes/<themeName> */
private fun getThemeDir(context: Context, themeName: String): File =
File(getThemeRootDir(context), themeName)
// ==================== 内置主题拷贝assets -> 外部目录) ====================
/**
* 确保 APK 自带的主题(assets/keyboard_themes/...) 已经复制到
* /Android/data/.../files/keyboard_themes 目录下。
*
* 行为:
* - 如果主题目录不存在:整套拷贝过去。
* - 如果主题目录已经存在:只复制“新增文件”,不会覆盖已有文件。
*
* 建议在 IME 的 onCreate() 里调用一次。
*/
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)
}
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()
}
}