334 lines
12 KiB
Kotlin
334 lines
12 KiB
Kotlin
package com.digitalperson.util
|
||
|
||
import android.content.ContentUris
|
||
import android.content.Context
|
||
import android.provider.MediaStore
|
||
import android.util.Log
|
||
import com.digitalperson.config.AppConfig
|
||
import java.io.File
|
||
import java.io.FileOutputStream
|
||
|
||
object FileHelper {
|
||
private const val TAG = AppConfig.TAG
|
||
|
||
fun assetExists(context: Context, path: String): Boolean {
|
||
return try {
|
||
context.assets.open(path).close()
|
||
true
|
||
} catch (_: Throwable) {
|
||
false
|
||
}
|
||
}
|
||
|
||
fun copyAssetsToInternal(context: Context, assetDir: String, targetDir: File, files: Array<String>): File {
|
||
if (!targetDir.exists()) targetDir.mkdirs()
|
||
|
||
for (name in files) {
|
||
val assetPath = "$assetDir/$name"
|
||
val outFile = File(targetDir, name)
|
||
if (outFile.exists() && outFile.length() > 0) continue
|
||
try {
|
||
context.assets.open(assetPath).use { input ->
|
||
FileOutputStream(outFile).use { output ->
|
||
input.copyTo(output)
|
||
}
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Failed to copy asset $assetPath: ${e.message}")
|
||
}
|
||
}
|
||
return targetDir
|
||
}
|
||
|
||
fun copySenseVoiceAssets(context: Context): File {
|
||
val outDir = File(context.filesDir, AppConfig.Asr.MODEL_DIR)
|
||
val files = arrayOf(
|
||
"am.mvn",
|
||
"chn_jpn_yue_eng_ko_spectok.bpe.model",
|
||
"embedding.npy",
|
||
"sense-voice-encoder.rknn"
|
||
)
|
||
return copyAssetsToInternal(context, AppConfig.Asr.MODEL_DIR, outDir, files)
|
||
}
|
||
|
||
@JvmStatic
|
||
fun copyRetinaFaceAssets(context: Context): File {
|
||
val outDir = File(context.filesDir, AppConfig.Face.MODEL_DIR)
|
||
val files = arrayOf(AppConfig.Face.MODEL_NAME)
|
||
return copyAssetsToInternal(context, AppConfig.Face.MODEL_DIR, outDir, files)
|
||
}
|
||
|
||
@JvmStatic
|
||
fun copyInsightFaceAssets(context: Context): File {
|
||
val outDir = File(context.filesDir, AppConfig.FaceRecognition.MODEL_DIR)
|
||
val files = arrayOf(AppConfig.FaceRecognition.MODEL_NAME)
|
||
return copyAssetsToInternal(context, AppConfig.FaceRecognition.MODEL_DIR, outDir, files)
|
||
}
|
||
|
||
fun ensureDir(dir: File): File {
|
||
if (!dir.exists()) {
|
||
val created = dir.mkdirs()
|
||
if (!created) {
|
||
Log.e(TAG, "Failed to create directory: ${dir.absolutePath}")
|
||
// 如果创建失败,使用应用内部存储
|
||
return File("/data/data/${dir.parentFile?.parentFile?.name}/files/llm")
|
||
}
|
||
}
|
||
return dir
|
||
}
|
||
|
||
fun getAsrAudioDir(context: Context): File {
|
||
return ensureDir(File(context.filesDir, "asr_audio"))
|
||
}
|
||
|
||
// @JvmStatic
|
||
// 当前使用的模型文件名
|
||
private val MODEL_FILE_NAME = com.digitalperson.config.AppConfig.LLM.MODEL_FILE_NAME
|
||
|
||
fun getLLMModelPath(context: Context): String {
|
||
Log.d(TAG, "=== getLLMModelPath START ===")
|
||
|
||
// 从应用内部存储目录加载模型
|
||
val llmDir = ensureDir(File(context.filesDir, AppConfig.LLM.MODEL_DIR))
|
||
|
||
Log.d(TAG, "Loading models from: ${llmDir.absolutePath}")
|
||
|
||
// 检查文件是否存在
|
||
val rkllmFile = File(llmDir, MODEL_FILE_NAME)
|
||
|
||
if (!rkllmFile.exists()) {
|
||
Log.e(TAG, "RKLLM model not found: ${rkllmFile.absolutePath}")
|
||
} else {
|
||
Log.i(TAG, "RKLLM model exists, size: ${rkllmFile.length() / (1024*1024)} MB")
|
||
}
|
||
|
||
val modelPath = rkllmFile.absolutePath
|
||
Log.i(TAG, "Using RKLLM model path: $modelPath")
|
||
Log.d(TAG, "=== getLLMModelPath END ===")
|
||
return modelPath
|
||
}
|
||
|
||
/**
|
||
* 异步下载模型文件,带进度回调
|
||
* @param context 上下文
|
||
* @param onProgress 进度回调 (currentFile, downloadedBytes, totalBytes, progressPercent)
|
||
* @param onComplete 完成回调 (success, message)
|
||
*/
|
||
@JvmStatic
|
||
fun downloadModelFilesWithProgress(
|
||
context: Context,
|
||
onProgress: (String, Long, Long, Int) -> Unit,
|
||
onComplete: (Boolean, String) -> Unit
|
||
) {
|
||
Log.d(TAG, "=== downloadModelFilesWithProgress START ===")
|
||
|
||
val llmDir = ensureDir(File(context.filesDir, AppConfig.LLM.MODEL_DIR))
|
||
|
||
// 模型文件列表 - 使用 DeepSeek-R1-Distill-Qwen-1.5B 模型
|
||
val modelFiles = listOf(
|
||
MODEL_FILE_NAME
|
||
)
|
||
|
||
// 在后台线程下载
|
||
Thread {
|
||
try {
|
||
var allSuccess = true
|
||
var totalDownloaded: Long = 0
|
||
var totalSize: Long = 0
|
||
|
||
// 首先计算总大小
|
||
val downloadUrl = AppConfig.LLM.DOWNLOAD_SERVER + AppConfig.LLM.DOWNLOAD_PATH
|
||
Log.i(TAG, "Using download server: ${AppConfig.LLM.DOWNLOAD_SERVER}")
|
||
|
||
for (fileName in modelFiles) {
|
||
val modelFile = File(llmDir, fileName)
|
||
if (!modelFile.exists() || modelFile.length() == 0L) {
|
||
val size = getFileSizeFromServer("$downloadUrl/$fileName")
|
||
if (size > 0) {
|
||
totalSize += size
|
||
} else {
|
||
// 如果无法获取文件大小,使用估计值
|
||
val estimatedSize = AppConfig.LLM.MODEL_SIZE_ESTIMATE
|
||
totalSize += estimatedSize
|
||
Log.i(TAG, "Using estimated size for $fileName: ${estimatedSize / (1024*1024)} MB")
|
||
}
|
||
}
|
||
}
|
||
|
||
for (fileName in modelFiles) {
|
||
val modelFile = File(llmDir, fileName)
|
||
if (!modelFile.exists() || modelFile.length() == 0L) {
|
||
Log.i(TAG, "Downloading model file: $fileName")
|
||
try {
|
||
downloadFileWithProgress(
|
||
"$downloadUrl/$fileName",
|
||
modelFile
|
||
) { downloaded, total ->
|
||
val progress = if (totalSize > 0) {
|
||
((totalDownloaded + downloaded) * 100 / totalSize).toInt()
|
||
} else 0
|
||
onProgress(fileName, downloaded, total, progress)
|
||
}
|
||
totalDownloaded += modelFile.length()
|
||
Log.i(TAG, "Downloaded model file: $fileName, size: ${modelFile.length() / (1024*1024)} MB")
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Failed to download model file $fileName: ${e.message}")
|
||
allSuccess = false
|
||
}
|
||
} else {
|
||
totalDownloaded += modelFile.length()
|
||
Log.i(TAG, "Model file exists: $fileName, size: ${modelFile.length() / (1024*1024)} MB")
|
||
}
|
||
}
|
||
Log.d(TAG, "=== downloadModelFilesWithProgress END ===")
|
||
if (allSuccess) {
|
||
onComplete(true, "模型下载完成")
|
||
} else {
|
||
onComplete(false, "部分模型下载失败")
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Download failed: ${e.message}")
|
||
onComplete(false, "下载失败: ${e.message}")
|
||
}
|
||
}.start()
|
||
}
|
||
|
||
/**
|
||
* 从服务器获取文件大小
|
||
*/
|
||
private fun getFileSizeFromServer(url: String): Long {
|
||
return try {
|
||
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
|
||
connection.requestMethod = "HEAD"
|
||
connection.connectTimeout = AppConfig.LLM.DOWNLOAD_CONNECT_TIMEOUT
|
||
connection.readTimeout = AppConfig.LLM.DOWNLOAD_READ_TIMEOUT
|
||
|
||
// 从响应头获取 Content-Length,避免 int 溢出
|
||
val contentLengthStr = connection.getHeaderField("Content-Length")
|
||
var size = 0L
|
||
|
||
if (contentLengthStr != null) {
|
||
try {
|
||
size = contentLengthStr.toLong()
|
||
if (size < 0) {
|
||
Log.w(TAG, "Invalid Content-Length value: $size")
|
||
size = 0
|
||
}
|
||
} catch (e: NumberFormatException) {
|
||
Log.w(TAG, "Invalid Content-Length format: $contentLengthStr")
|
||
size = 0
|
||
}
|
||
} else {
|
||
val contentLength = connection.contentLength
|
||
if (contentLength > 0) {
|
||
size = contentLength.toLong()
|
||
} else {
|
||
Log.w(TAG, "Content-Length not available or invalid: $contentLength")
|
||
size = 0
|
||
}
|
||
}
|
||
|
||
connection.disconnect()
|
||
Log.i(TAG, "File size for $url: $size bytes")
|
||
size
|
||
} catch (e: Exception) {
|
||
Log.w(TAG, "Failed to get file size: ${e.message}")
|
||
0
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从网络下载文件,带进度回调
|
||
*/
|
||
private fun downloadFileWithProgress(
|
||
url: String,
|
||
destination: File,
|
||
onProgress: (Long, Long) -> Unit
|
||
) {
|
||
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
|
||
connection.connectTimeout = AppConfig.LLM.DOWNLOAD_CONNECT_TIMEOUT
|
||
connection.readTimeout = AppConfig.LLM.DOWNLOAD_READ_TIMEOUT
|
||
|
||
// 从响应头获取 Content-Length,避免 int 溢出
|
||
val contentLengthStr = connection.getHeaderField("Content-Length")
|
||
val totalSize = if (contentLengthStr != null) {
|
||
try {
|
||
contentLengthStr.toLong()
|
||
} catch (e: NumberFormatException) {
|
||
Log.w(TAG, "Invalid Content-Length format: $contentLengthStr")
|
||
0
|
||
}
|
||
} else {
|
||
connection.contentLength.toLong()
|
||
}
|
||
Log.i(TAG, "Downloading file $url, size: $totalSize bytes")
|
||
|
||
try {
|
||
connection.inputStream.use { input ->
|
||
FileOutputStream(destination).use { output ->
|
||
val buffer = ByteArray(8192)
|
||
var downloaded: Long = 0
|
||
var bytesRead: Int
|
||
|
||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||
output.write(buffer, 0, bytesRead)
|
||
downloaded += bytesRead
|
||
onProgress(downloaded, totalSize)
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
connection.disconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查本地 LLM 模型是否可用
|
||
*/
|
||
@JvmStatic
|
||
fun isLocalLLMAvailable(context: Context): Boolean {
|
||
val llmDir = File(context.filesDir, AppConfig.LLM.MODEL_DIR)
|
||
|
||
val rkllmFile = File(llmDir, MODEL_FILE_NAME)
|
||
|
||
val rkllmExists = rkllmFile.exists() && rkllmFile.length() > 0
|
||
|
||
Log.i(TAG, "LLM model check: rkllm=$rkllmExists")
|
||
Log.i(TAG, "RKLLM file: ${rkllmFile.absolutePath}, size: ${if (rkllmFile.exists()) rkllmFile.length() / (1024*1024) else 0} MB")
|
||
|
||
return rkllmExists
|
||
}
|
||
|
||
/**
|
||
* 从网络下载文件
|
||
*/
|
||
private fun downloadFile(url: String, destination: File) {
|
||
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
|
||
connection.connectTimeout = 30000 // 30秒超时
|
||
connection.readTimeout = 60000 // 60秒读取超时
|
||
|
||
try {
|
||
connection.inputStream.use { input ->
|
||
FileOutputStream(destination).use { output ->
|
||
input.copyTo(output)
|
||
}
|
||
}
|
||
} finally {
|
||
connection.disconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载测试图片
|
||
*/
|
||
fun downloadTestImage(url: String, destination: File): Boolean {
|
||
return try {
|
||
downloadFile(url, destination)
|
||
true
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Failed to download test image: ${e.message}", e)
|
||
false
|
||
}
|
||
}
|
||
}
|