Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,界面适配是本次的主要功能性改动。
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ HelloRename 就是干这件事的:
8. **跟随系统的深色 / 浅色模式**:只保留白底黑字、黑底白字两套默认配色;顶部 56dp 应用栏与页面同色,不提供自定义颜色入口。
9. **切换深浅色/字体/语言不丢状态**:Activity 重建后恢复已选文件、输入框内容、扩展名锁定和复制进度;从桌面重进不会清空当前文件。
10. **分享面板不再列出本应用**:除了底部警示语,chooser 还通过 `EXTRA_EXCLUDE_COMPONENTS` 排除 HelloRename,进一步避免自循环。
11. **文件校验值**:复制完成后对缓存文件做一次只读回读,显示 MD5 / SHA-1 / SHA-256,支持逐行选择或一键复制全部,方便和源文件对比。

## 设计取舍(为什么这么实现)

Expand Down Expand Up @@ -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,方便人工复核。

Expand Down Expand Up @@ -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 门禁
```

Expand All @@ -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 的验证边界)。

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 45 additions & 5 deletions app/src/androidTest/java/moe/hellorename/MainActivityFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 仍能识别。
Expand Down Expand Up @@ -143,6 +146,43 @@ class MainActivityFlowTest {
}
}

@Test
fun checksums_areShown_andSurviveRecreate() {
val source = newSource()
val originalIntent = sendIntent(uriFor(source))
ActivityScenario.launch<MainActivity>(originalIntent).use { scenario ->
waitUntilCopied(scenario)
assertChecksums(scenario)

scenario.recreate()

assertChecksums(scenario)
onView(withId(R.id.sendButton)).check(matches(isEnabled()))
}
}

private fun assertChecksums(scenario: ActivityScenario<MainActivity>) {
scenario.onActivity { activity ->
assertEquals(
"checksum section should be visible after copy",
View.VISIBLE,
activity.findViewById<View>(R.id.checksumSection).visibility,
)
val md5 = activity.findViewById<TextView>(R.id.checksumMd5).text.toString()
val sha1 = activity.findViewById<TextView>(R.id.checksumSha1).text.toString()
val sha256 = activity.findViewById<TextView>(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()
Expand Down
84 changes: 84 additions & 0 deletions app/src/main/java/moe/hellorename/FileDigestUtils.kt
Original file line number Diff line number Diff line change
@@ -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 = toHex(md5.digest()),
sha1 = toHex(sha1.digest()),
sha256 = toHex(sha256.digest()),
)
}

/** 小写十六进制,长度固定为字节数的两倍。 */
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])
}
}
}
Loading
Loading