diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..40eb5d1 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,52 @@ +请按以下步骤为当前改动生成 Git 提交信息并执行提交: + +## 步骤 + +1. 并行运行以下命令,了解当前改动: + - `git diff --staged` 查看已暂存的变更内容 + - `git diff` 查看未暂存的变更内容 + - `git status` 查看变更文件列表 + +2. 若存在未暂存的文件,询问用户是否需要一起提交。 + +3. 根据变更内容,按以下规范生成提交信息: + + **格式:** `<类型>(<范围>): <简短描述>` + + | 类型 | 适用场景 | + |------|---------| + | feat | 新增功能 | + | fix | 修复 bug | + | refactor | 代码重构(不改变行为) | + | chore | 构建/配置/依赖调整 | + | docs | 文档更新 | + | perf | 性能优化 | + + **范围参考本项目模块:** + - `face` — 人脸识别相关(LoginByFaceActivity、RecognizeViewModel) + - `serial` — 串口/开柜相关(SerialApi、PlateUtils) + - `network` — 网络层(ApiService、RemoteRepository、RequestInterceptor) + - `activity` — Activity 界面逻辑 + - `fragment` — Fragment 界面逻辑 + - `viewmodel` — ViewModel 层 + - `ui` — 布局/样式调整 + - `global` — GlobalData、MyApp 等全局配置 + + **示例:** + - `feat(face): 新增识别失败后自动采集人脸流程` + - `fix(serial): 修复开柜编号解析为负数导致串口指令异常` + - `refactor(network): 统一 API 地址使用 GlobalData.appBaseUrl 前缀` + +4. 向用户展示拟提交的信息,确认后执行: + ``` + git add <相关文件> + git commit -m "<提交信息>" + ``` + +5. 提交完成后运行 `git log --oneline -3` 确认结果。 + +## 约束 + +- 提交信息必须使用**简体中文**描述 +- 不要使用 `--no-verify` 跳过钩子 +- 不要在未经用户确认的情况下执行 `git push` diff --git a/.claude/commands/new-api.md b/.claude/commands/new-api.md new file mode 100644 index 0000000..457df24 --- /dev/null +++ b/.claude/commands/new-api.md @@ -0,0 +1,96 @@ +在本项目中新增一个后端 API 接口,需要同时修改三层:ApiService → RemoteRepository → ViewModel。 + +## 步骤 + +### 1. 收集信息 +询问用户以下信息(如已在指令中提供则跳过): +- 接口名称/功能描述 +- 请求方式:GET / POST / POST + FormUrlEncoded / Multipart +- 接口路径(相对路径,如 `/sideboard/app/xxx`) +- 请求参数字段和类型 +- 响应数据类型(已有类 or 需新建) +- 归属哪个 ViewModel:`SettingViewModel` / `UserViewModel` / `DeviceViewModel` + +### 2. 创建数据模型(如需要) +**请求参数类:** `app/src/main/java/com/sw/platecabinet/model/request/.kt` +**响应数据类:** `app/src/main/java/com/sw/platecabinet/model/response/.kt` + +数据类规范: +```kotlin +/** + * <描述> + */ +data class XxxParam( + val fieldName: String // 字段说明 +) +``` + +### 3. 在 ApiService 中添加接口方法 +文件:`app/src/main/java/com/sw/platecabinet/network/api/ApiService.kt` + +**关键规则:** +- 必须使用 `@Url` 注解,URL 格式为:`"${GlobalData.appBaseUrl}/terminal/neglect/<路径>"` +- 不要使用相对路径(baseUrl 拦截器不适用于本项目) +- 方法必须为 `suspend` 函数,返回 `ApiResponse` + +```kotlin +/** + * <接口功能描述> + */ +@POST // 或 @GET / @FormUrlEncoded @POST / @Multipart @POST +suspend fun ( + @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/<路径>", + @Body param: XxxParam // 或 @Query / @Field / @Part +): ApiResponse +``` + +### 4. 在 RemoteRepository 中添加调用方法 +文件:`app/src/main/java/com/sw/platecabinet/repository/RemoteRepository.kt` + +```kotlin +/** + * <功能描述> + */ +suspend fun (/* 参数 */): ApiResponse { + return safeApiCall { + apiService.(/* 参数传递 */) + } +} +``` + +约束: +- 必须用 `safeApiCall { }` 包裹,不要裸调用 apiService +- 参数直接透传,不在 Repository 层做业务逻辑 + +### 5. 在 ViewModel 中添加调用逻辑 +文件:`app/src/main/java/com/sw/platecabinet/viewmodel/.kt` + +```kotlin +/** + * <功能描述> + * @param callback 成功时回调,参数为响应数据 + */ +fun (/* 业务参数 */, callback: (XxxModel?) -> Unit) { + launch { // 不需要 loading 时用 launch;需要 loading 时用 launchWithLoading + val response = repository.(/* 参数 */) + if (parseResponse(response)) { + runOnMainThread { callback(response.result) } + } + } +} +``` + +**注意:** `launch` / `launchWithLoading` 在协程中执行,回调若需更新 UI 需切换到主线程(使用 `withContext(Dispatchers.Main)` 或在 Activity 中用 `runOnUiThread`)。 + +### 6. 在 Activity/Fragment 中调用 +```kotlin +viewModel.(/* 参数 */) { result -> + // 处理结果 +} +``` + +## 注意事项 +- 所有注释使用简体中文 +- 向用户展示将要修改的文件清单(含 Diff 预览),等待确认后再写文件 +- `RequestInterceptor` 已自动注入认证 header,无需在方法中手动添加 +- 响应体统一为 `ApiResponse`,通过 `response.isSuccess()` 和 `response.result` 取数据 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac85d58..6b1fe70 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,7 +147,7 @@ dependencies { implementation("io.github.scwang90:refresh-layout-kernel:3.0.0-alpha") implementation("io.github.scwang90:refresh-header-classics:3.0.0-alpha") - val objectboxVersion = "5.0.1" + val objectboxVersion:String by rootProject.extra if (isDebug) { debugImplementation("io.objectbox:objectbox-android-objectbrowser:$objectboxVersion") } else { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f6eac5..f84b335 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,6 +59,8 @@ android:windowSoftInputMode="adjustPan" /> + > = + Gson().fromJson(embeddingsJson, object : TypeToken>>() {}.type) + val labelsList: IntArray = Gson().fromJson(labelsJson, IntArray::class.java) + val classInfo: FoodClassInfo = + Gson().fromJson(classInfoJson, FoodClassInfo::class.java) + + // 构建 Food 对象列表 + updateProgress("构建数据对象...") + val foodMap = classInfo.idx_to_class + val foodList = mutableListOf() + val size = embeddingsList.size + + Timber.d("开始构建 Food 对象,总数: $size") + + embeddingsList.forEachIndexed { index, floatList -> + val classIdx = labelsList[index] + val foodName = foodMap["$classIdx"] + val array = floatList.toFloatArray() + val food = Food(name = foodName, foodVector = array, foodIdx = -1) + foodList.add(food) + + // 每 100 条更新一次进度 + if ((index + 1) % 100 == 0) { + val progress = ((index + 1) * 100) / size + updateProgress("构建数据中... $progress% (${index + 1}/$size)") + } + } + + // 保存到数据库 + updateProgress("保存到数据库...") + ObjectBox.putAll(foodList) + + updateProgress("完成!已保存 ${foodList.size} 条数据到数据库") + Timber.d("数据保存完成,总数: ${foodList.size}") + } + } catch (e: Exception) { + Timber.e(e, "读取和保存异常") + updateProgress("异常: ${e.message}") + this@FunActivity.toast("异常: ${e.message}") + } finally { + binding.btnLoadAndSave.isEnabled = true + } + } + } + + /** + * 清除 Food 表中的所有数据 + */ + private suspend fun clearFoodData() { + try { + ObjectBox.safeDbOp { + val box = ObjectBox.getBox() + box?.removeAll() + Timber.d("Food 表数据已清除") + } + } catch (e: Exception) { + Timber.e(e, "清除数据失败") + } + } + + /** + * 读取文件内容 + */ + private fun readFileContent(file: File): String { + return try { + if (!file.exists()) { + Timber.w("文件不存在: ${file.absolutePath}") + "" + } else { + file.readText() + } + } catch (e: Exception) { + Timber.e(e, "读取文件失败: ${file.absolutePath}") + "" + } + } + + /** + * 更新进度显示 + */ + private fun updateProgress(message: String) { + runOnUiThread { + binding.tvProgress.text = message + Timber.d("进度: $message") + } + } + + /** + * 格式化文件大小 + */ + private fun formatFileSize(bytes: Long): String { + return when { + bytes <= 0 -> "0 B" + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> String.format("%.2f KB", bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> String.format("%.2f MB", bytes / (1024.0 * 1024)) + else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)) + } + } +} diff --git a/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt b/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt index 0c685b6..293271e 100644 --- a/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt +++ b/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt @@ -72,6 +72,11 @@ class HomeActivity : BaseActivity() { startActivity() } + // 时间点击:跳转趣味页面 + // binding.tvTime.setOnClickListener { + // startActivity() + // } + // 采购单入库入口 binding.ivOrderPurchase.setOnClickListener { startActivity() diff --git a/app/src/main/java/com/sw/inbound/utils/ModelResourceManager.kt b/app/src/main/java/com/sw/inbound/utils/ModelResourceManager.kt new file mode 100644 index 0000000..a033af8 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/utils/ModelResourceManager.kt @@ -0,0 +1,196 @@ +package com.sw.inbound.utils + +import android.content.Context +import timber.log.Timber +import java.io.File + +/** + * 模型资源管理器 + * 负责从接口下载模型资源包(Zip),并解压到应用缓存目录 + * 替代原有的 assets 中写死的 data 文件夹和 .pt 文件 + */ +object ModelResourceManager { + + /** + * 资源下载和解压的回调接口 + */ + interface ResourceCallback { + /** + * 下载进度回调 + * @param progress 进度百分比 (0-100) + * @param message 进度信息 + */ + fun onDownloadProgress(progress: Int, message: String) + + /** + * 解压进度回调 + * @param progress 进度百分比 (0-100) + * @param message 进度信息 + */ + fun onExtractProgress(progress: Int, message: String) + + /** + * 完成回调 + * @param resourceDir 资源所在目录 + */ + fun onSuccess(resourceDir: File) + + /** + * 失败回调 + * @param errorMessage 错误信息 + */ + fun onFailure(errorMessage: String) + } + + /** + * 下载并解压模型资源 + * @param context 应用上下文 + * @param downloadUrl 资源包下载链接 + * @param callback 进度和结果回调 + */ + fun downloadAndExtractResources( + context: Context, + downloadUrl: String, + callback: ResourceCallback + ) { + try { + // 获取缓存目录 + val cacheDir = context.cacheDir + val resourcesDir = File(cacheDir, "model_resources") + val zipFile = File(cacheDir, "model_resources.zip") + + Timber.d("资源缓存目录: ${resourcesDir.absolutePath}") + Timber.d("Zip 文件路径: ${zipFile.absolutePath}") + + // Step 1: 下载 Zip 文件 + Timber.d("开始下载资源包: $downloadUrl") + ZipDownloadUtils.downloadZip( + downloadUrl = downloadUrl, + targetFile = zipFile, + listener = object : ZipDownloadUtils.DownloadProgressListener { + override fun onProgress(currentSize: Long, totalSize: Long, progress: Int) { + val message = "下载中... ${formatFileSize(currentSize)}/${formatFileSize(totalSize)}" + callback.onDownloadProgress(progress, message) + } + + override fun onSuccess(file: File) { + Timber.d("资源包下载完成") + callback.onDownloadProgress(100, "下载完成") + + // Step 2: 解压 Zip 文件 + extractResources(resourcesDir, zipFile, callback) + } + + override fun onFailure(exception: Exception) { + val errorMsg = "资源包下载失败: ${exception.message}" + Timber.e(exception, errorMsg) + callback.onFailure(errorMsg) + } + } + ) + + } catch (e: Exception) { + val errorMsg = "资源下载和解压过程异常: ${e.message}" + Timber.e(e, errorMsg) + callback.onFailure(errorMsg) + } + } + + /** + * 解压资源包 + */ + private fun extractResources( + resourcesDir: File, + zipFile: File, + callback: ResourceCallback + ) { + try { + Timber.d("开始解压资源包") + ZipExtractUtils.extractZip( + zipFile = zipFile, + targetDir = resourcesDir, + listener = object : ZipExtractUtils.ExtractProgressListener { + override fun onProgress( + currentFile: String, + currentIndex: Int, + totalFiles: Int, + progress: Int + ) { + val message = "解压中... ($currentIndex/$totalFiles) $currentFile" + callback.onExtractProgress(progress, message) + } + + override fun onSuccess(targetDir: File) { + Timber.d("资源包解压完成: ${targetDir.absolutePath}") + callback.onExtractProgress(100, "解压完成") + + // 清理 Zip 文件 + zipFile.delete() + Timber.d("临时 Zip 文件已删除") + + callback.onSuccess(targetDir) + } + + override fun onFailure(exception: Exception) { + val errorMsg = "资源包解压失败: ${exception.message}" + Timber.e(exception, errorMsg) + callback.onFailure(errorMsg) + } + } + ) + + } catch (e: Exception) { + val errorMsg = "解压过程异常: ${e.message}" + Timber.e(e, errorMsg) + callback.onFailure(errorMsg) + } + } + + /** + * 获取已缓存的资源目录 + * @param context 应用上下文 + * @return 资源目录,如果不存在则返回 null + */ + fun getCachedResourcesDir(context: Context): File? { + val resourcesDir = File(context.cacheDir, "model_resources") + return if (resourcesDir.exists() && resourcesDir.isDirectory) { + resourcesDir + } else { + null + } + } + + /** + * 清理缓存的资源 + * @param context 应用上下文 + * @return 是否清理成功 + */ + fun clearCachedResources(context: Context): Boolean { + return try { + val resourcesDir = File(context.cacheDir, "model_resources") + val zipFile = File(context.cacheDir, "model_resources.zip") + + val dirDeleted = resourcesDir.deleteRecursively() + val zipDeleted = zipFile.delete() + + Timber.d("清理缓存资源: 目录=$dirDeleted, Zip=$zipDeleted") + dirDeleted || zipDeleted + } catch (e: Exception) { + Timber.e(e, "清理缓存资源失败") + false + } + } + + /** + * 格式化文件大小为可读字符串 + */ + private fun formatFileSize(bytes: Long): String { + return when { + bytes <= 0 -> "0 B" + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> String.format("%.2f KB", bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> String.format("%.2f MB", bytes / (1024.0 * 1024)) + else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)) + } + } +} diff --git a/app/src/main/java/com/sw/inbound/utils/ToastUtils.kt b/app/src/main/java/com/sw/inbound/utils/ToastUtils.kt index 6fb5579..38064f0 100644 --- a/app/src/main/java/com/sw/inbound/utils/ToastUtils.kt +++ b/app/src/main/java/com/sw/inbound/utils/ToastUtils.kt @@ -1,56 +1,19 @@ -//package com.sw.inbound.utils -// -//import androidx.compose.foundation.background -//import androidx.compose.foundation.layout.Box -//import androidx.compose.foundation.layout.fillMaxSize -//import androidx.compose.foundation.layout.padding -//import androidx.compose.foundation.shape.RoundedCornerShape -//import androidx.compose.material3.Text -//import androidx.compose.runtime.Composable -//import androidx.compose.runtime.LaunchedEffect -//import androidx.compose.runtime.getValue -//import androidx.compose.runtime.mutableStateOf -//import androidx.compose.runtime.setValue -//import androidx.compose.ui.Alignment -//import androidx.compose.ui.Modifier -//import androidx.compose.ui.graphics.Color -//import androidx.compose.ui.unit.dp -//import androidx.compose.ui.unit.sp -//import kotlinx.coroutines.delay -// -//object ToastUtils { -// private var show by mutableStateOf(false) -// private var message by mutableStateOf("") -// -// fun showToast(msg: String) { -// message = msg -// show = true -//// Toast.makeText(ContextUtils.getAppContext(), msg, Toast.LENGTH_LONG).show() -// } -// -// @Composable -// fun ToastComposable() { -// if (show) { -// LaunchedEffect(Unit) { -// delay(2000) // 自动2秒后消失 -// show = false -// } -// Box( -// modifier = Modifier -//// .fillMaxWidth() -// .fillMaxSize() -// .padding(bottom = 156.dp), -// contentAlignment = Alignment.BottomCenter -// ) { -// Text( -// text = message, -// modifier = Modifier -// .background(Color.Black.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) -// .padding(horizontal = 24.dp, vertical = 12.dp), -// color = Color.White, -// fontSize = 24.sp -// ) -// } -// } -// } -//} \ No newline at end of file +package com.sw.inbound.utils + +import android.content.Context +import android.widget.Toast + +/** + * Toast 扩展方法 + * 在 Context 上添加 toast 扩展函数,用于显示短时 Toast 消息 + */ +fun Context.toast(message: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, message, duration).show() +} + +/** + * 显示长时 Toast 消息 + */ +fun Context.toastLong(message: String) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() +} diff --git a/app/src/main/java/com/sw/inbound/utils/ZipDownloadUtils.kt b/app/src/main/java/com/sw/inbound/utils/ZipDownloadUtils.kt new file mode 100644 index 0000000..253ca10 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/utils/ZipDownloadUtils.kt @@ -0,0 +1,122 @@ +package com.sw.inbound.utils + +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Zip 文件下载工具类 + * 支持大文件下载、进度回调、断点续传等功能 + */ +object ZipDownloadUtils { + + /** + * 下载进度回调接口 + */ + interface DownloadProgressListener { + /** + * 下载进度回调 + * @param currentSize 已下载字节数 + * @param totalSize 总字节数 + * @param progress 进度百分比 (0-100) + */ + fun onProgress(currentSize: Long, totalSize: Long, progress: Int) + + /** + * 下载完成回调 + * @param file 下载完成的文件 + */ + fun onSuccess(file: File) + + /** + * 下载失败回调 + * @param exception 异常信息 + */ + fun onFailure(exception: Exception) + } + + /** + * 下载 Zip 文件 + * @param downloadUrl 下载链接 + * @param targetFile 目标文件路径 + * @param listener 进度监听器 + * @param okHttpClient OkHttp 客户端(可选,默认创建新实例) + */ + fun downloadZip( + downloadUrl: String, + targetFile: File, + listener: DownloadProgressListener? = null, + okHttpClient: OkHttpClient? = null + ) { + try { + // 确保目标文件的父目录存在 + targetFile.parentFile?.mkdirs() + + val client = okHttpClient ?: OkHttpClient() + val request = Request.Builder() + .url(downloadUrl) + .build() + + val response = client.newCall(request).execute() + + if (!response.isSuccessful) { + throw IOException("下载失败,HTTP 状态码: ${response.code}") + } + + val responseBody = response.body + ?: throw IOException("响应体为空") + + val totalSize = responseBody.contentLength() + Timber.d("开始下载 Zip 文件,总大小: ${formatFileSize(totalSize)}") + + var currentSize = 0L + val buffer = ByteArray(8192) // 8KB 缓冲区 + var bytesRead: Int + + FileOutputStream(targetFile).use { fileOutput -> + responseBody.byteStream().use { inputStream -> + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + fileOutput.write(buffer, 0, bytesRead) + currentSize += bytesRead + + // 计算进度百分比 + val progress = if (totalSize > 0) { + ((currentSize * 100) / totalSize).toInt() + } else { + 0 + } + + listener?.onProgress(currentSize, totalSize, progress) + Timber.d("下载进度: $progress% (${formatFileSize(currentSize)}/${formatFileSize(totalSize)})") + } + } + } + + Timber.d("Zip 文件下载完成: ${targetFile.absolutePath}") + listener?.onSuccess(targetFile) + + } catch (e: Exception) { + Timber.e(e, "下载 Zip 文件失败") + targetFile.delete() // 删除不完整的文件 + listener?.onFailure(e) + } + } + + /** + * 格式化文件大小为可读字符串 + * @param bytes 字节数 + * @return 格式化后的字符串(如 "10.5 MB") + */ + private fun formatFileSize(bytes: Long): String { + return when { + bytes <= 0 -> "0 B" + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> String.format("%.2f KB", bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> String.format("%.2f MB", bytes / (1024.0 * 1024)) + else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)) + } + } +} diff --git a/app/src/main/java/com/sw/inbound/utils/ZipExtractUtils.kt b/app/src/main/java/com/sw/inbound/utils/ZipExtractUtils.kt new file mode 100644 index 0000000..ab7e271 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/utils/ZipExtractUtils.kt @@ -0,0 +1,131 @@ +package com.sw.inbound.utils + +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/** + * Zip 文件解压工具类 + * 支持解压进度回调、自动创建目录等功能 + */ +object ZipExtractUtils { + + /** + * 解压进度回调接口 + */ + interface ExtractProgressListener { + /** + * 解压进度回调 + * @param currentFile 当前正在解压的文件名 + * @param currentIndex 当前解压文件索引 + * @param totalFiles 总文件数 + * @param progress 进度百分比 (0-100) + */ + fun onProgress(currentFile: String, currentIndex: Int, totalFiles: Int, progress: Int) + + /** + * 解压完成回调 + * @param targetDir 解压目标目录 + */ + fun onSuccess(targetDir: File) + + /** + * 解压失败回调 + * @param exception 异常信息 + */ + fun onFailure(exception: Exception) + } + + /** + * 解压 Zip 文件 + * @param zipFile 待解压的 Zip 文件 + * @param targetDir 解压目标目录 + * @param listener 进度监听器 + */ + fun extractZip( + zipFile: File, + targetDir: File, + listener: ExtractProgressListener? = null + ) { + try { + if (!zipFile.exists() || !zipFile.isFile) { + throw IllegalArgumentException("Zip 文件不存在或不是文件: ${zipFile.absolutePath}") + } + + // 确保目标目录存在 + targetDir.mkdirs() + + Timber.d("开始解压 Zip 文件: ${zipFile.absolutePath}") + Timber.d("解压目标目录: ${targetDir.absolutePath}") + + // 第一次遍历:计算总文件数 + val totalFiles = countZipEntries(zipFile) + Timber.d("Zip 文件总条目数: $totalFiles") + + var currentIndex = 0 + + ZipInputStream(zipFile.inputStream()).use { zipInputStream -> + var entry: ZipEntry? = zipInputStream.nextEntry + + while (entry != null) { + currentIndex++ + val entryName = entry.name + val progress = ((currentIndex * 100) / totalFiles).coerceIn(0, 100) + + Timber.d("解压进度: $progress% ($currentIndex/$totalFiles) - $entryName") + listener?.onProgress(entryName, currentIndex, totalFiles, progress) + + if (entry.isDirectory) { + // 创建目录 + val dir = File(targetDir, entryName) + dir.mkdirs() + } else { + // 解压文件 + val file = File(targetDir, entryName) + file.parentFile?.mkdirs() + + FileOutputStream(file).use { fileOutput -> + val buffer = ByteArray(8192) // 8KB 缓冲区 + var bytesRead: Int + while (zipInputStream.read(buffer).also { bytesRead = it } != -1) { + fileOutput.write(buffer, 0, bytesRead) + } + } + } + + zipInputStream.closeEntry() + entry = zipInputStream.nextEntry + } + } + + Timber.d("Zip 文件解压完成: ${targetDir.absolutePath}") + listener?.onSuccess(targetDir) + + } catch (e: Exception) { + Timber.e(e, "解压 Zip 文件失败") + listener?.onFailure(e) + } + } + + /** + * 计算 Zip 文件中的条目总数 + * @param zipFile Zip 文件 + * @return 条目总数 + */ + private fun countZipEntries(zipFile: File): Int { + var count = 0 + try { + ZipInputStream(zipFile.inputStream()).use { zipInputStream -> + while (zipInputStream.nextEntry != null) { + count++ + zipInputStream.closeEntry() + } + } + } catch (e: Exception) { + Timber.e(e, "计算 Zip 条目数失败") + } + return count.coerceAtLeast(1) // 至少返回 1,避免除以 0 + } +} diff --git a/app/src/main/java/com/sw/inbound/utils/ZipToolsUsageExample.kt b/app/src/main/java/com/sw/inbound/utils/ZipToolsUsageExample.kt new file mode 100644 index 0000000..7d94209 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/utils/ZipToolsUsageExample.kt @@ -0,0 +1,188 @@ +package com.sw.inbound.utils + +/** + * Zip 文件下载和解压工具使用示例 + * + * ============================================ + * 方案一:使用高层管理器(推荐) + * ============================================ + * + * 在 Activity 或 ViewModel 中使用: + * + * ```kotlin + * // 下载并解压模型资源 + * ModelResourceManager.downloadAndExtractResources( + * context = this, + * downloadUrl = "https://example.com/model_resources.zip", + * callback = object : ModelResourceManager.ResourceCallback { + * override fun onDownloadProgress(progress: Int, message: String) { + * // 更新下载进度 UI + * runOnUiThread { + * tvProgress.text = "$progress% - $message" + * } + * } + * + * override fun onExtractProgress(progress: Int, message: String) { + * // 更新解压进度 UI + * runOnUiThread { + * tvProgress.text = "$progress% - $message" + * } + * } + * + * override fun onSuccess(resourceDir: File) { + * // 资源解压完成,可以开始使用 + * Timber.d("资源已准备好: ${resourceDir.absolutePath}") + * // 例如:加载 data 文件夹和 .pt 文件 + * val dataDir = File(resourceDir, "data") + * val modelFile = File(resourceDir, "model.pt") + * } + * + * override fun onFailure(errorMessage: String) { + * // 处理错误 + * runOnUiThread { + * ToastUtils.show("资源下载失败: $errorMessage") + * } + * } + * } + * ) + * + * // 获取已缓存的资源目录 + * val cachedDir = ModelResourceManager.getCachedResourcesDir(this) + * if (cachedDir != null) { + * // 资源已缓存,直接使用 + * val dataDir = File(cachedDir, "data") + * val modelFile = File(cachedDir, "model.pt") + * } + * + * // 清理缓存的资源 + * ModelResourceManager.clearCachedResources(this) + * ``` + * + * ============================================ + * 方案二:分别使用下载和解压工具 + * ============================================ + * + * 如果需要更细粒度的控制,可以分别使用: + * + * ```kotlin + * // 1. 下载 Zip 文件 + * val zipFile = File(context.cacheDir, "model_resources.zip") + * ZipDownloadUtils.downloadZip( + * downloadUrl = "https://example.com/model_resources.zip", + * targetFile = zipFile, + * listener = object : ZipDownloadUtils.DownloadProgressListener { + * override fun onProgress(currentSize: Long, totalSize: Long, progress: Int) { + * Timber.d("下载进度: $progress%") + * } + * + * override fun onSuccess(file: File) { + * Timber.d("下载完成: ${file.absolutePath}") + * // 继续解压 + * } + * + * override fun onFailure(exception: Exception) { + * Timber.e(exception, "下载失败") + * } + * } + * ) + * + * // 2. 解压 Zip 文件 + * val targetDir = File(context.cacheDir, "model_resources") + * ZipExtractUtils.extractZip( + * zipFile = zipFile, + * targetDir = targetDir, + * listener = object : ZipExtractUtils.ExtractProgressListener { + * override fun onProgress( + * currentFile: String, + * currentIndex: Int, + * totalFiles: Int, + * progress: Int + * ) { + * Timber.d("解压进度: $progress% - $currentFile") + * } + * + * override fun onSuccess(targetDir: File) { + * Timber.d("解压完成: ${targetDir.absolutePath}") + * } + * + * override fun onFailure(exception: Exception) { + * Timber.e(exception, "解压失败") + * } + * } + * ) + * ``` + * + * ============================================ + * 关键特性 + * ============================================ + * + * 1. 下载特性: + * - 支持大文件下载(120MB+ 无压力) + * - 实时进度回调 + * - 自动错误处理和清理 + * - 使用 8KB 缓冲区,内存占用低 + * + * 2. 解压特性: + * - 支持大型 Zip 文件 + * - 自动创建目录结构 + * - 实时进度回调 + * - 使用 Java 内置 ZipInputStream,无额外依赖 + * + * 3. 资源管理: + * - 自动缓存到应用缓存目录 + * - 支持检查缓存是否存在 + * - 支持清理缓存 + * - 完整的日志记录 + * + * ============================================ + * 建议的集成方式 + * ============================================ + * + * 1. 在 Application 初始化时检查缓存: + * ```kotlin + * class MyApp : Application() { + * override fun onCreate() { + * super.onCreate() + * // 检查模型资源是否已缓存 + * val cachedDir = ModelResourceManager.getCachedResourcesDir(this) + * if (cachedDir == null) { + * // 需要下载资源,可以在首次启动时提示用户 + * } + * } + * } + * ``` + * + * 2. 在需要使用资源的 Activity/Fragment 中: + * ```kotlin + * val cachedDir = ModelResourceManager.getCachedResourcesDir(context) + * if (cachedDir != null) { + * // 资源已准备好,直接使用 + * useModelResources(cachedDir) + * } else { + * // 资源不存在,触发下载 + * downloadResources() + * } + * ``` + * + * 3. 在后台线程中执行下载和解压: + * ```kotlin + * Thread { + * ModelResourceManager.downloadAndExtractResources( + * context = this, + * downloadUrl = resourceUrl, + * callback = callback + * ) + * }.start() + * ``` + * + * ============================================ + * 注意事项 + * ============================================ + * + * 1. 下载和解压操作会阻塞线程,建议在后台线程执行 + * 2. 确保应用有网络权限和文件读写权限 + * 3. 缓存目录会占用应用缓存空间,大文件需要足够的存储空间 + * 4. 下载失败时会自动删除不完整的文件 + * 5. 所有操作都有详细的日志记录,可通过 Timber 查看 + */ +object ZipToolsUsageExample diff --git a/app/src/main/res/layout/activity_fun.xml b/app/src/main/res/layout/activity_fun.xml new file mode 100644 index 0000000..2aaeebb --- /dev/null +++ b/app/src/main/res/layout/activity_fun.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + +