From e5743b38ef01442c63da36ef4f8911257feed704 Mon Sep 17 00:00:00 2001 From: llllllllqq <12707622+llllllllqq@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:29:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A4=8D=E5=88=B6=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=90=8E=E6=98=BE=E7=A4=BA=20MD5/SHA-1/SHA-256?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 4 +- CHANGELOG.md | 19 +++ README.md | 6 +- app/build.gradle.kts | 4 +- .../moe/hellorename/MainActivityFlowTest.kt | 50 ++++++- .../java/moe/hellorename/FileDigestUtils.kt | 84 +++++++++++ .../main/java/moe/hellorename/MainActivity.kt | 136 +++++++++++++++++- app/src/main/res/layout/activity_main.xml | 72 ++++++++++ app/src/main/res/values-zh/strings.xml | 11 ++ app/src/main/res/values/strings.xml | 11 ++ .../moe/hellorename/FileDigestUtilsTest.kt | 94 ++++++++++++ 11 files changed, 476 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/moe/hellorename/FileDigestUtils.kt create mode 100644 app/src/test/java/moe/hellorename/FileDigestUtilsTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba00b99..5822cc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,9 +69,9 @@ jobs: else NAME="0.0.1-dev.${GITHUB_RUN_NUMBER}" fi - # v1.3.2 的 versionCode 是 11;v1.4.0 必须能覆盖安装。 + # v1.4.0 的 versionCode 是 12;v1.5.0 必须能覆盖安装。 CODE="${GITHUB_RUN_NUMBER}" - if [ "$CODE" -lt 12 ]; then CODE=12; fi + if [ "$CODE" -lt 13 ]; then CODE=13; fi echo "name=$NAME" >> "$GITHUB_OUTPUT" echo "code=$CODE" >> "$GITHUB_OUTPUT" echo "构建版本: $NAME (versionCode=$CODE)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7275df2..a32e717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/);每个 `v*` tag 会由 GitHub Actions 自动构建并发布 APK。 +## [1.5.0] - 2026-10-28 + +复制完成后显示文件校验值,方便用户自行和源文件对比,验证传输/写盘后内容是否一致。 + +### Added +- **MD5 / SHA-1 / SHA-256 展示**:文件复制完成后,在输入框/按钮下方显示三个小写十六进制校验值;支持长按选择,以及“Copy all”一键复制三行。 +- **复制后回读计算**:校验值是对 `cacheDir/shared/<会话>/` 里的最终缓存文件做一次只读回读得到的,而不是边复制边算。这样显示的是真正会被分享/保存的那份文件,写盘阶段若发生损坏也能通过和源文件对比发现。 +- **计算进度**:回读期间复用现有进度条,显示“Calculating checksums… %”;期间禁用发送/保存,避免在拿到校验值前交付。 +- **状态恢复**:MD5/SHA-1/SHA-256 进入 `onSaveInstanceState`,深浅色/字体/语言重建后保留;新分享/重置/缓存失效时清空。 +- **失败可重试**:回读失败显示“Could not calculate checksums. Tap Send to retry.”,不展示半套值,也不会把未完成校验的文件当成 ready。 + +### Changed +- **版本号**:`versionName` 1.5.0,`versionCode` 最低 13(可覆盖 v1.4.0 的 12)。 +- **测试**:新增 `FileDigestUtilsTest`(已知向量、空文件、二进制、进度、取消、十六进制填充),JVM 单测增加到 78 条;`MainActivityFlowTest` 增加“校验值展示 + 重建后保留”用例,API 36 模拟器门禁共 8 条 instrumentation。 + +### Notes +- 回读是一次额外的完整读盘;大文件期间界面仍可响应,但发送/保存要等校验值完成。 +- MD5/SHA-1 仅用于兼容旧工具和快速对比,完整性/安全性优先看 SHA-256。 + ## [1.4.0] - 2026-10-18 构建工具链升级到 AGP 9 / Gradle 9 / Kotlin 2.4,并把 `compileSdk` / `targetSdk` 升到 **36(Android 16)**。这是一个 minor 版本:targetSdk 36 带来强制 edge-to-edge,界面适配是本次的主要功能性改动。 diff --git a/README.md b/README.md index e0f4823..c9d7780 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ HelloRename 就是干这件事的: 8. **跟随系统的深色 / 浅色模式**:只保留白底黑字、黑底白字两套默认配色;顶部 56dp 应用栏与页面同色,不提供自定义颜色入口。 9. **切换深浅色/字体/语言不丢状态**:Activity 重建后恢复已选文件、输入框内容、扩展名锁定和复制进度;从桌面重进不会清空当前文件。 10. **分享面板不再列出本应用**:除了底部警示语,chooser 还通过 `EXTRA_EXCLUDE_COMPONENTS` 排除 HelloRename,进一步避免自循环。 +11. **文件校验值**:复制完成后对缓存文件做一次只读回读,显示 MD5 / SHA-1 / SHA-256,支持逐行选择或一键复制全部,方便和源文件对比。 ## 设计取舍(为什么这么实现) @@ -87,7 +88,7 @@ HelloRename 就是干这件事的: `.github/workflows/emulator-ui.yml` 在 GitHub Actions 的 API 36 模拟器上执行 `connectedDebugAndroidTest`: - **主题门禁**:系统 ActionBar 必须关闭,全屏只有一个应用名标题(防止出现 v1.3.0 那样上下两个标题);顶栏高度恰好 **56dp**,Android 15+ 强制 edge-to-edge 时由根布局让出状态栏/导航栏/IME inset;浅色模式白底黑字、深色模式黑底白字。 -- **主流程门禁**(v1.3.2 起):分享文件 → 等待复制 → 输入新名字 → `ActivityScenario.recreate()` 后输入/旧文件名/复制状态仍在;`ACTION_MAIN` 重进不清空;异常 `EXTRA_STREAM` 不崩溃;`Parcelable[]` 形式仍能识别。 +- **主流程门禁**(v1.3.2 起,v1.5.0 增加校验值):分享文件 → 等待复制和校验值计算 → 输入新名字 → `ActivityScenario.recreate()` 后输入/旧文件名/复制状态/MD5/SHA-1/SHA-256 仍在;`ACTION_MAIN` 重进不清空;异常 `EXTRA_STREAM` 不崩溃;`Parcelable[]` 形式仍能识别。 Release workflow 的 `release` job 通过 `needs: emulator-verify` 依赖这项验证;模拟器不通过就不会构建发布包,更不会创建 Release。测试结束后会上传测试报告、浅色 / 深色截图与 UI 层级作为 Actions artifact,方便人工复核。 @@ -146,7 +147,7 @@ gradle assembleRelease # 或 ./gradlew assembleRel 环境:JDK 17、Gradle 9.6.0、AGP 9.4.1、Kotlin 2.4.21、compileSdk 36、targetSdk 36、minSdk 24。单元测试: ```bash -gradle test # 71 条 JVM 单测(文件名/缓存/流复制/操作代际) +gradle test # 78 条 JVM 单测(文件名/缓存/流复制/操作代际/校验值) gradle connectedDebugAndroidTest # 需要模拟器或真机;API 36 门禁 ``` @@ -162,6 +163,7 @@ gradle connectedDebugAndroidTest # 需要模拟器或真机;API 36 门禁 - 文件名中的 `/ : * ? " < > |` 等非法字符、C0/C1 控制字符会被替换成 `_`,双向控制符(bidi)会被移除,结尾的点会被去掉(避免 `../` 之类的路径穿越和文件名伪装)。 - 新文件名按 **code point + UTF-8 字节数**截断(上限 240 字节,中文约 78 字,扩展名始终完整保留),因为 `ext4/f2fs` 的单个文件名上限是 255 字节而不是 255 个字符;emoji 不会被劈成半个。 - 文案默认英文,中文放在 `values-zh`(所有中文语系都会命中)。 +- 校验值是在复制完成后对缓存文件做一次完整回读计算的,因此大文件会额外花一次读盘时间;计算期间发送/保存按钮保持禁用并显示进度。 - 分享面板弹出后你没法知道对方何时读完文件,所以临时文件已交付的按交付时刻保温 24 小时;未交付的在离开界面 30 分钟后按 10 分钟 TTL 回收。 - SAF 保存路径只在 Android 7–9 使用。API 36 模拟器门禁走 MediaStore;`wt` 回退、截断和取消清理目前靠代码审查 + API 28 人工验证,没有把 API 28 放进自动矩阵(这是 v1.4.0 的验证边界)。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 311c7c0..9791125 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,8 +32,8 @@ val hasReleaseSigning = sigStoreFile != null && sigStorePassword != null && // 版本号 / applicationId 可由 CI 覆盖: // -PversionName=1.2.3 -PversionCode=42 [-PapplicationIdSuffix=.debug] -val appVersionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "1.4.0" -val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 12 +val appVersionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "1.5.0" +val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 13 val appIdSuffix = (project.findProperty("applicationIdSuffix") as String?)?.takeIf { it.isNotBlank() } ?: "" android { diff --git a/app/src/androidTest/java/moe/hellorename/MainActivityFlowTest.kt b/app/src/androidTest/java/moe/hellorename/MainActivityFlowTest.kt index 7e8d934..60ad0da 100644 --- a/app/src/androidTest/java/moe/hellorename/MainActivityFlowTest.kt +++ b/app/src/androidTest/java/moe/hellorename/MainActivityFlowTest.kt @@ -3,7 +3,9 @@ package moe.hellorename import android.content.Intent import android.net.Uri import android.os.Bundle +import android.view.View import android.widget.Button +import android.widget.TextView import androidx.core.content.FileProvider import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView @@ -17,18 +19,19 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.hamcrest.CoreMatchers.not +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.io.File /** - * API 34 模拟器上的主流程 / 生命周期测试(v1.3.2 起由 QA 与 Release 的模拟器门禁执行)。 + * API 36 模拟器上的主流程 / 生命周期测试(由 QA 与 Release 的模拟器门禁执行)。 * - * 用 file:// URI 提供测试源文件:测试与被测应用同 UID,ContentResolver 能直接打开, - * 不需要额外声明测试 Provider。覆盖: - * - 分享进入 → 复制完成; - * - 配置重建后输入/旧文件名/复制状态不丢(H1); + * 用 FileProvider content:// URI 提供测试源文件,覆盖: + * - 分享进入 → 复制完成 → 显示 MD5/SHA-1/SHA-256; + * - 配置重建后输入/旧文件名/复制状态/校验值不丢(H1); * - ACTION_MAIN 重进不清空当前文件(L6); * - 异常 EXTRA_STREAM 不崩溃(L5); * - Parcelable[] 形式的 EXTRA_STREAM 仍能识别。 @@ -143,6 +146,43 @@ class MainActivityFlowTest { } } + @Test + fun checksums_areShown_andSurviveRecreate() { + val source = newSource() + val originalIntent = sendIntent(uriFor(source)) + ActivityScenario.launch(originalIntent).use { scenario -> + waitUntilCopied(scenario) + assertChecksums(scenario) + + scenario.recreate() + + assertChecksums(scenario) + onView(withId(R.id.sendButton)).check(matches(isEnabled())) + } + } + + private fun assertChecksums(scenario: ActivityScenario) { + scenario.onActivity { activity -> + assertEquals( + "checksum section should be visible after copy", + View.VISIBLE, + activity.findViewById(R.id.checksumSection).visibility, + ) + val md5 = activity.findViewById(R.id.checksumMd5).text.toString() + val sha1 = activity.findViewById(R.id.checksumSha1).text.toString() + val sha256 = activity.findViewById(R.id.checksumSha256).text.toString() + assertTrue(md5, md5.contains("00184e6135af969e09086d0058d53385")) + assertTrue(sha1, sha1.contains("325955adedc149e2f90962bb2b30374b6973023b")) + assertTrue( + sha256, + sha256.contains( + "854f0a004368659f7ac3df08e942c3e5" + + "d0a31ad534d2deb8f602f11ecd2c302f", + ), + ) + } + } + private fun newSource(): File { val dir = File(context.cacheDir, CacheStore.SHARED_DIR) dir.mkdirs() diff --git a/app/src/main/java/moe/hellorename/FileDigestUtils.kt b/app/src/main/java/moe/hellorename/FileDigestUtils.kt new file mode 100644 index 0000000..25fa211 --- /dev/null +++ b/app/src/main/java/moe/hellorename/FileDigestUtils.kt @@ -0,0 +1,84 @@ +package moe.hellorename + +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.security.MessageDigest + +/** 一份文件的三个校验值,全部是小写十六进制。 */ +data class FileChecksums( + val md5: String, + val sha1: String, + val sha256: String, +) + +/** + * 文件校验值计算。刻意不依赖 Android API,方便 JVM 单测。 + * + * 只在 IO 线程调用。一次顺序读同时更新 MD5 / SHA-1 / SHA-256,避免为了三个值读三遍。 + * 计算的是**缓存里的最终文件**:如果写盘阶段发生损坏,用户拿这里的值和源文件对比就能发现。 + */ +object FileDigestUtils { + + private const val BUFFER_SIZE = 64 * 1024 + + /** 非阻塞/异常流连续返回 0 的保护;普通 FileInputStream 不会触发 */ + private const val MAX_ZERO_READS = 100 + + private val HEX = "0123456789abcdef".toCharArray() + + /** + * @return 计算结果;[cancelled] 返回 true 时返回 null(调用方按“已取消”处理,不更新 UI)。 + * @throws IOException 读文件失败;调用方应把它当作校验失败,而不是显示半套值。 + */ + fun hashFile( + file: File, + cancelled: () -> Boolean = { false }, + onProgress: (Long, Long) -> Unit = { _, _ -> }, + ): FileChecksums? { + val md5 = MessageDigest.getInstance("MD5") + val sha1 = MessageDigest.getInstance("SHA-1") + val sha256 = MessageDigest.getInstance("SHA-256") + val total = file.length() + var readTotal = 0L + var zeroReads = 0 + + FileInputStream(file).use { input -> + val buffer = ByteArray(BUFFER_SIZE) + while (true) { + if (cancelled()) return null + val read = input.read(buffer) + if (read < 0) break + if (read == 0) { + zeroReads++ + if (zeroReads > MAX_ZERO_READS) { + throw IOException("file read stalled: ${file.absolutePath}") + } + Thread.yield() + continue + } + zeroReads = 0 + md5.update(buffer, 0, read) + sha1.update(buffer, 0, read) + sha256.update(buffer, 0, read) + readTotal += read + onProgress(readTotal, total) + } + } + if (cancelled()) return null + return FileChecksums( + md5 = md5.digest().toHex(), + sha1 = sha1.digest().toHex(), + sha256 = sha256.digest().toHex(), + ) + } + + /** 小写十六进制,长度固定为字节数的两倍。 */ + fun toHex(bytes: ByteArray): String = buildString(bytes.size * 2) { + for (byte in bytes) { + val value = byte.toInt() and 0xFF + append(HEX[value ushr 4]) + append(HEX[value and 0x0F]) + } + } +} diff --git a/app/src/main/java/moe/hellorename/MainActivity.kt b/app/src/main/java/moe/hellorename/MainActivity.kt index 6510623..1d0a12b 100644 --- a/app/src/main/java/moe/hellorename/MainActivity.kt +++ b/app/src/main/java/moe/hellorename/MainActivity.kt @@ -3,6 +3,7 @@ package moe.hellorename import android.app.Activity import android.content.ActivityNotFoundException import android.content.ClipData +import android.content.ClipboardManager import android.content.ComponentName import android.content.ContentValues import android.content.Intent @@ -97,11 +98,19 @@ class MainActivity : AppCompatActivity() { private var backgroundJob: Job? = null private var copyDone = false + /** 复制完成后对缓存文件回读计算的校验值;null = 尚未算出/已失效 */ + private var checksums: FileChecksums? = null + /** 已经交给系统分享面板的绝对路径,不允许再被 rename/删除 */ private val handedOutPaths = mutableSetOf() private val lastPercent = AtomicInteger(-1) private val lastReportedBytes = AtomicLong(0L) + private val hashPercent = AtomicInteger(-1) + + /** 哈希阶段已结束的代际:迟到的哈希进度不能再覆盖“完成/失败”状态 */ + @Volatile + private var hashFinishedGeneration = -1L /** 操作代际:新分享/重置/重建都会换代 */ private val operations = OperationRegistry() @@ -242,6 +251,7 @@ class MainActivity : AppCompatActivity() { binding.saveButton.setOnClickListener { onSaveClicked() } binding.pickButton.setOnClickListener { pickFile.launch(arrayOf("*/*")) } binding.allowExtCheck.setOnCheckedChangeListener { _, isChecked -> applyExtLock(!isChecked) } + binding.copyChecksumsButton.setOnClickListener { copyChecksumsToClipboard() } if (savedInstanceState != null) { // 配置变更/进程重建:优先恢复,不再无条件重放原始 Intent。 @@ -308,6 +318,9 @@ class MainActivity : AppCompatActivity() { outState.putString(KEY_SESSION_DIR, sessionDir?.absolutePath) outState.putString(KEY_CURRENT_FILE, currentFile?.absolutePath) outState.putBoolean(KEY_COPY_DONE, copyDone) + outState.putString(KEY_MD5, checksums?.md5) + outState.putString(KEY_SHA1, checksums?.sha1) + outState.putString(KEY_SHA256, checksums?.sha256) outState.putString(KEY_INPUT, binding.nameInput.text?.toString().orEmpty()) outState.putBoolean(KEY_EXT_LOCKED, lockedExt != null) outState.putString(KEY_LOCKED_EXT, lockedExt.orEmpty()) @@ -432,11 +445,13 @@ class MainActivity : AppCompatActivity() { sourceSize = -1L currentFile = null copyDone = false + checksums = null preparingFile = false wasSaving = false savePendingFile = null handedOutPaths.clear() + hideChecksums() binding.oldNameText.text = getString(R.string.old_name_loading) binding.pickButton.visibility = View.GONE binding.allowExtCheck.setOnCheckedChangeListener(null) @@ -550,6 +565,7 @@ class MainActivity : AppCompatActivity() { originalExt = state.getString(KEY_ORIGINAL_EXT).orEmpty() sourceSize = state.getLong(KEY_SOURCE_SIZE, -1L) copyDone = state.getBoolean(KEY_COPY_DONE, false) + checksums = readChecksums(state) val savedDir = state.getString(KEY_SESSION_DIR)?.let(::File) sessionDir = savedDir?.takeIf { isSessionDir(it) } @@ -558,7 +574,7 @@ class MainActivity : AppCompatActivity() { if (sessionDir == null) { sessionDir = currentFile?.parentFile?.takeIf { isSessionDir(it) } } - if (currentFile == null) copyDone = false + if (currentFile == null || checksums == null) copyDone = false lockedExt = if (state.getBoolean(KEY_EXT_LOCKED, false)) { state.getString(KEY_LOCKED_EXT).orEmpty() @@ -583,6 +599,7 @@ class MainActivity : AppCompatActivity() { binding.progress.visibility = View.GONE lastPercent.set(-1) lastReportedBytes.set(0L) + if (copyDone && checksums != null) showChecksums(checksums!!) else hideChecksums() if (savePendingFile != null) { // SAF 选择器还开着(或结果还没回来):恢复待保存文件,等回调。 @@ -646,6 +663,9 @@ class MainActivity : AppCompatActivity() { releaseSessionDir() startCleanup(force = false) loadingMeta = false + checksums = null + hashFinishedGeneration = -1L + hideChecksums() // 目录名带时间戳 + 会话号 + UUID:时钟回拨/进程重启/同毫秒都不会复用旧目录。 var dir: File @@ -668,6 +688,8 @@ class MainActivity : AppCompatActivity() { sessionDir = dir currentFile = target copyDone = false + checksums = null + hideChecksums() finishedGeneration = -1L lastPercent.set(-1) lastReportedBytes.set(0L) @@ -699,11 +721,59 @@ class MainActivity : AppCompatActivity() { // 会话已换代或本协程被取消:什么都不许碰,避免把新状态覆盖掉 if (!isActive || !operations.isCurrent(generation)) return@launch finishedGeneration = generation + + if (outcome == CopyOutcome.OK) { + // 复制成功后再回读缓存文件算校验值:这是真正会被分享/保存的那份文件。 + // 如果写盘时发生损坏,用户拿这里的值和源文件对比就能发现。 + binding.progress.isIndeterminate = true + binding.progress.progress = 0 + binding.progress.visibility = View.VISIBLE + lastPercent.set(-1) + hashPercent.set(-1) + hashFinishedGeneration = -1L + setStatus(getString(R.string.status_hashing), isError = false) + + val hashes = try { + withContext(Dispatchers.IO) { + FileDigestUtils.hashFile( + file = target, + cancelled = { !operations.isCurrent(generation) }, + ) { read, total -> onHashProgress(generation, read, total) } + } + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + null + } + + if (!isActive || !operations.isCurrent(generation)) return@launch + hashFinishedGeneration = generation + if (hashes == null) { + target.delete() + currentFile = null + releaseSessionDir() + copyDone = false + binding.progress.visibility = View.GONE + setButtonsEnabled(true) + setStatus(getString(R.string.status_hash_failed), isError = true) + return@launch + } + + checksums = hashes + showChecksums(hashes) + copyDone = true + binding.progress.visibility = View.GONE + setButtonsEnabled(true) + setStatus(getString(R.string.status_ready), isError = false) + return@launch + } + + // 复制失败:不保留半套校验值,按钮保持可点以便重试。 binding.progress.visibility = View.GONE - copyDone = outcome == CopyOutcome.OK + copyDone = false + hideChecksums() setButtonsEnabled(true) when (outcome) { - CopyOutcome.OK -> setStatus(getString(R.string.status_ready), isError = false) CopyOutcome.NO_SPACE -> { currentFile = null releaseSessionDir() @@ -719,7 +789,7 @@ class MainActivity : AppCompatActivity() { releaseSessionDir() setStatus(getString(R.string.status_source_unavailable), isError = true) } - CopyOutcome.FAILED -> { + CopyOutcome.FAILED, CopyOutcome.OK -> { currentFile = null releaseSessionDir() setStatus(getString(R.string.status_copy_failed), isError = true) @@ -755,10 +825,63 @@ class MainActivity : AppCompatActivity() { } } + /** 回读文件的校验值进度;与复制进度分开,避免 finishedGeneration 把哈希进度丢掉。 */ + private fun onHashProgress(generation: Long, read: Long, total: Long) { + if (total <= 0L || !operations.isCurrent(generation)) return + val percent = ((read * 100) / total).toInt().coerceIn(0, 100) + if (hashPercent.getAndSet(percent) == percent) return + runOnUiThread { + if (!operations.isCurrent(generation) || generation == hashFinishedGeneration) { + return@runOnUiThread + } + binding.progress.isIndeterminate = false + binding.progress.progress = percent + setStatus(getString(R.string.status_hashing_percent, percent), isError = false) + } + } + /** 旧代际(已被取代/取消)或已经出过最终结果的代际:迟到的进度更新一律丢弃 */ private fun isStale(generation: Long): Boolean = !operations.isCurrent(generation) || generation == finishedGeneration + private fun showChecksums(value: FileChecksums) { + binding.checksumMd5.text = getString(R.string.checksum_md5_format, value.md5) + binding.checksumSha1.text = getString(R.string.checksum_sha1_format, value.sha1) + binding.checksumSha256.text = getString(R.string.checksum_sha256_format, value.sha256) + binding.checksumSection.visibility = View.VISIBLE + } + + private fun hideChecksums() { + binding.checksumSection.visibility = View.GONE + binding.checksumMd5.text = "" + binding.checksumSha1.text = "" + binding.checksumSha256.text = "" + } + + private fun readChecksums(state: Bundle): FileChecksums? { + val md5 = state.getString(KEY_MD5) + val sha1 = state.getString(KEY_SHA1) + val sha256 = state.getString(KEY_SHA256) + if (md5 == null || sha1 == null || sha256 == null) return null + if (md5.length != 32 || sha1.length != 40 || sha256.length != 64) return null + return FileChecksums(md5, sha1, sha256) + } + + private fun copyChecksumsToClipboard() { + val value = checksums ?: return + val text = getString( + R.string.checksum_copy_all_format, + value.md5, + value.sha1, + value.sha256, + ) + val clipboard = getSystemService(ClipboardManager::class.java) ?: return + clipboard.setPrimaryClip( + ClipData.newPlainText(getString(R.string.checksum_title), text), + ) + Toast.makeText(applicationContext, R.string.toast_checksums_copied, Toast.LENGTH_SHORT).show() + } + /** 复制结果:只有 [OK] 才允许继续交付 */ private enum class CopyOutcome { OK, NO_SPACE, TOO_LARGE, SOURCE_UNAVAILABLE, FAILED } @@ -1651,6 +1774,7 @@ class MainActivity : AppCompatActivity() { currentFile = null releaseSessionDir() copyDone = false + checksums = null handedOutPaths.clear() lockedExt = "" savePendingFile = null @@ -1660,6 +1784,7 @@ class MainActivity : AppCompatActivity() { lastPercent.set(-1) lastReportedBytes.set(0L) + hideChecksums() binding.oldNameText.text = getString(R.string.old_name_none) setInput("") binding.allowExtCheck.setOnCheckedChangeListener(null) @@ -1807,6 +1932,9 @@ class MainActivity : AppCompatActivity() { const val KEY_SESSION_DIR = "session_dir" const val KEY_CURRENT_FILE = "current_file" const val KEY_COPY_DONE = "copy_done" + const val KEY_MD5 = "checksum_md5" + const val KEY_SHA1 = "checksum_sha1" + const val KEY_SHA256 = "checksum_sha256" const val KEY_INPUT = "input" const val KEY_EXT_LOCKED = "ext_locked" const val KEY_LOCKED_EXT = "locked_ext" diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c7ac3af..1ebf945 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -125,6 +125,78 @@ android:max="100" android:visibility="gone" /> + + + + + + + + + + + + + + + + 发送 选择一个文件 保存到下载 + 复制全部 允许修改扩展名 扩展名锁定为 %1$s(不显示在输入框里),勾选上面的选项即可修改。 @@ -23,6 +24,9 @@ 正在复制文件… %1$d%% 正在复制文件… %1$s 复制完成,可以改名后发送了。 + 复制完成,正在计算校验值… + 正在计算校验值… %1$d%% + 校验值计算失败,点“发送”可重试。 复制失败,点“发送”可重试。 存储空间不足,无法把文件复制进缓存;清理出空间后点“发送”重试。 文件过大,无法复制进缓存。 @@ -43,5 +47,12 @@ 请选择其它应用接收文件,勿再选「%1$s」。 已保存到下载:%1$s 保存文件失败。 + 文件校验值 + 可与源文件对比。推荐使用 SHA-256;MD5/SHA-1 仅用于兼容旧工具。 + MD5\n%1$s + SHA-1\n%1$s + SHA-256\n%1$s + MD5:%1$s\nSHA-1:%2$s\nSHA-256:%3$s + 校验值已复制。 点“发送”后,请在系统分享面板里选择其它应用接收文件;请勿再次选择本应用,否则文件会被反复转手、无限循环。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8cdc092..42aa18b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Send Pick a file Save to Downloads + Copy all Allow editing the extension Extension is locked to %1$s and hidden from the box. Tick the box above to change it. @@ -23,6 +24,9 @@ Copying file… %1$d%% Copying file… %1$s Copy finished. Rename it and hit Send. + Copy finished. Calculating checksums… + Calculating checksums… %1$d%% + Could not calculate checksums. Tap Send to retry. Copy failed. Tap Send to retry. Not enough free space to copy the file. Free up space, then tap Send to retry. This file is too large to copy into the cache. @@ -44,5 +48,12 @@ Pick a different app to receive the file; don\'t pick %1$s again. Saved to Downloads: %1$s Could not save the file. + File checksums + Compare with the source file. SHA-256 is recommended; MD5/SHA-1 are for compatibility. + MD5\n%1$s + SHA-1\n%1$s + SHA-256\n%1$s + MD5: %1$s\nSHA-1: %2$s\nSHA-256: %3$s + Checksums copied. After sending, choose a DIFFERENT app in the share sheet to receive the file. Selecting this app again creates an endless loop. diff --git a/app/src/test/java/moe/hellorename/FileDigestUtilsTest.kt b/app/src/test/java/moe/hellorename/FileDigestUtilsTest.kt new file mode 100644 index 0000000..de251a8 --- /dev/null +++ b/app/src/test/java/moe/hellorename/FileDigestUtilsTest.kt @@ -0,0 +1,94 @@ +package moe.hellorename + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException + +class FileDigestUtilsTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `empty file has the well known hashes`() { + val file = tmp.newFile("empty.bin") + val checksums = FileDigestUtils.hashFile(file)!! + assertEquals("d41d8cd98f00b204e9800998ecf8427e", checksums.md5) + assertEquals("da39a3ee5e6b4b0d3255bfef95601890afd80709", checksums.sha1) + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb924" + + "27ae41e4649b934ca495991b7852b855", + checksums.sha256, + ) + } + + @Test + fun `abc has the well known hashes`() { + val file = tmp.newFile("abc.txt") + file.writeText("abc") + val checksums = FileDigestUtils.hashFile(file)!! + assertEquals("900150983cd24fb0d6963f7d28e17f72", checksums.md5) + assertEquals("a9993e364706816aba3e25717850c26c9cd0d89d", checksums.sha1) + assertEquals( + "ba7816bf8f01cfea414140de5dae2223" + + "b00361a396177a9cb410ff61f20015ad", + checksums.sha256, + ) + } + + @Test + fun `all three values are lowercase hex of the right length`() { + val file = tmp.newFile("binary.bin") + file.writeBytes(ByteArray(4096) { (it * 31 and 0xFF).toByte() }) + val checksums = FileDigestUtils.hashFile(file)!! + assertEquals(32, checksums.md5.length) + assertEquals(40, checksums.sha1.length) + assertEquals(64, checksums.sha256.length) + assertTrue(checksums.md5.all { it in "0123456789abcdef" }) + assertTrue(checksums.sha1.all { it in "0123456789abcdef" }) + assertTrue(checksums.sha256.all { it in "0123456789abcdef" }) + } + + @Test + fun `progress reaches the file length`() { + val file = tmp.newFile("progress.bin") + file.writeBytes(ByteArray(200_000) { 7 }) + var lastRead = 0L + var lastTotal = -1L + val checksums = FileDigestUtils.hashFile(file) { read, total -> + lastRead = read + lastTotal = total + }!! + assertEquals(200_000L, lastRead) + assertEquals(200_000L, lastTotal) + assertTrue(checksums.sha256.isNotEmpty()) + } + + @Test + fun `cancelled hash returns null`() { + val file = tmp.newFile("cancel.bin") + file.writeBytes(ByteArray(200_000) { 3 }) + assertNull(FileDigestUtils.hashFile(file, cancelled = { true })) + } + + @Test + fun `toHex pads bytes correctly`() { + assertEquals("00010f10ff", FileDigestUtils.toHex(byteArrayOf(0, 1, 15, 16, -1))) + } + + @Test + fun `missing file throws IOException`() { + val missing = File(tmp.root, "missing.bin") + try { + FileDigestUtils.hashFile(missing) + throw AssertionError("expected IOException") + } catch (_: IOException) { + // expected + } + } +} From 90e1eb5f40ffd958f6f49a94ee09dd8b4d8ec350 Mon Sep 17 00:00:00 2001 From: llllllllqq <12707622+llllllllqq@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:33:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20toHex=20?= =?UTF-8?q?=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/moe/hellorename/FileDigestUtils.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/moe/hellorename/FileDigestUtils.kt b/app/src/main/java/moe/hellorename/FileDigestUtils.kt index 25fa211..f8263ff 100644 --- a/app/src/main/java/moe/hellorename/FileDigestUtils.kt +++ b/app/src/main/java/moe/hellorename/FileDigestUtils.kt @@ -67,9 +67,9 @@ object FileDigestUtils { } if (cancelled()) return null return FileChecksums( - md5 = md5.digest().toHex(), - sha1 = sha1.digest().toHex(), - sha256 = sha256.digest().toHex(), + md5 = toHex(md5.digest()), + sha1 = toHex(sha1.digest()), + sha256 = toHex(sha256.digest()), ) }