分享 → 改名 → 再分享。 一个极简的 Android 小工具:接收其他 App 分享过来的文件,改好名字后,再把文件分享给别的 App。
中文系统下,应用名显示为 改名助手(英文/其他语系显示
HelloRename)。
Android 系统分享面板本身没有“改名后再分享”的能力:接收方拿到的 content:// URI 是临时且不可转发的授权,而且没有任何 API 能改别人的文件名(改名本质上是换个名字重新提供同一份数据)。
HelloRename 就是干这件事的:
- 出现在系统分享面板里,接收任意类型的文件(
ACTION_SEND/ACTION_SEND_MULTIPLE)。 - 上半部分显示旧文件名.扩展名(可长按复制)。
- 下半部分是一个自动换行的大输入框,直接改新文件名。
- 扩展名默认锁定,且不显示在输入框里(连灰色后缀也不放,由输入框下方的提示行写明保留的扩展名);勾选最底部的“允许修改扩展名”后,扩展名才拼进输入框一起编辑。
- 输入框下方并排两个按钮:发送(丢进系统分享面板再分享)和保存到下载(Android 10+ 走 MediaStore 直写、Android 7–9 走 SAF 选择器,同样零权限)。
- 发送或保存成功后 Toast 提示并自动关闭主界面,避免用户在分享面板里又把文件选回本应用。
- 底部固定一条红色警示语,提醒发送后在分享面板选择其它应用接收、勿再选本应用,防止无限循环。
- 跟随系统的深色 / 浅色模式:只保留白底黑字、黑底白字两套默认配色;顶部 56dp 应用栏与页面同色,不提供自定义颜色入口。
- 切换深浅色/字体/语言不丢状态:Activity 重建后恢复已选文件、输入框内容、扩展名锁定和复制进度;从桌面重进不会清空当前文件。
- 分享面板不再列出本应用:除了底部警示语,chooser 还通过
EXTRA_EXCLUDE_COMPONENTS排除 HelloRename,进一步避免自循环。 - 文件校验值:复制完成后对缓存文件做一次只读回读,显示 MD5 / SHA-1 / SHA-256,支持逐行选择或一键复制全部,方便和源文件对比。
| 决策 | 原因 |
|---|---|
| 标准前台 Activity,不用悬浮窗、不占通知、不申请任何系统权限 | 悬浮窗在国产 ROM 上要额外授权、易被拦截;SYSTEM_ALERT_WINDOW 权限一旦被回收功能就废了。前台 Activity 走系统分享入口,行为最可预期。 |
收到文件后立刻复制到 cacheDir/shared/<会话>/_incoming.<ext> |
源 URI 的读授权是一次性的、不可传递的,且源 App 随时可能被系统杀掉。越早复制越可靠,之后改名、分享都不再依赖源 App。 |
| 改名时只是同目录 rename,不再复制第二份 | 一份数据从头到尾只存在一份:_incoming.png → 我的新名字.png 是同一文件系统内的瞬时操作,零字节拷贝。 |
| 用自己的 FileProvider 对外提供文件 | cacheDir/shared/<会话>/<新文件名> 的真实磁盘文件名就是对外报出的 DISPLAY_NAME,不需要写自定义 ContentProvider 去伪造名字。 |
| 锁定时完全隐藏扩展名,勾选后才把扩展名拼进输入框(全程不用 TextWatcher 硬顶) | 未勾选时输入框里只有“基础名”,改名焦点完全放在名字上;保留的扩展名由下方提示行写明(锁定为 .png)。用户仍可以在名字里随便用点号(my.file.v2 不会被误切),勾选后扩展名变成普通可编辑文本。 |
| 保存到下载:10+ 走 MediaStore、7–9 走 SAF,全程零权限 | 写自己新建进 Downloads 的文件在 scoped storage 下不需要任何权限,保住“零权限”卖点;老系统退回系统选择器,只多一次确认,也不用申请 WRITE_EXTERNAL_STORAGE(那会让 QA 体检的权限门禁失败)。 |
只有能读到文件大小时才走 FileChannel.transferTo,并按字节数校验;拿不到大小(pipe 型 Provider)或校验不符就退回 64 KiB 流复制 |
transferTo 允许“少传”甚至返回 0,只有在 statSize 可校验时才敢用零拷贝;pipe 型 Provider 走流复制,两条路都不会把不完整的文件当成成功。 |
| 启动分享面板之前先写交付标记,写失败就不分享 | 旧实现先弹面板再写标记:进程在中间被杀(或磁盘已满)时,目标 App 拿到的 URI 没有跨进程保护,下一次清理就会把它变成死链。写不进去时宁可不发,也不把死链交给对方。代价是用户在分享面板里取消时,这份缓存仍会按已交付保温 24 小时。 |
| 未知大小的流最多复制 2GB,并且不超过“可写空间 - 动态预留” | 导出的 Activity 可以被任意 Provider 触发;没有上限的 pipe 流可以把设备存储填满。已知大小仍按实际大小预检,未知大小才用这个兜底上限。 |
会,只留这一份:源文件在对方 App 里(或对方进程的内存/管道里),你不可能原地改名,也不可能把临时授权转给下一个 App,所以复制到自己的缓存是唯一稳妥的做法。
理论上还有“不复制”的方案——写一个自定义 ContentProvider,query() 报新名字、openFile() 现场把源 URI 的数据流转发出去。但它依赖源 App 的进程一直活着、授权一直有效,在国产 ROM 的后台清理下非常容易失败;而且分享面板弹出后你的 Activity 可能已经退栈,失败就发生在用户看不到的地方。所以本项目不做这个方案。
缓存文件放在 cacheDir/shared/<会话>/,系统可以回收;App 自己也会清理,规则按“有没有交出去”分成两种:
| 会话状态 | 什么时候删 | 为什么 |
|---|---|---|
| 未交付(取消、直接杀进程、复制失败) | 用户离开界面 30 分钟后才开始按 10 分钟 TTL 计算 | 没有任何第三方握有它的 URI,留着只会白占空间;30 分钟宽限让用户切出去再回来还能继续用,超时后 10 分钟 TTL 负责收尾 |
| 已交付(发送过 / 保存过) | 从交付时刻起 24 小时 | 对方的 content:// URI 在它自己的任务存活期间一直有效,可能还在异步上传,用户也可能稍后再次转发 |
清理全程在 IO 线程(老版本在主线程同步删,攒几个大文件就会卡冷启动),并且绝不会碰当前界面正在用的会话:进程内的活会话登记 + cacheDir/shared-state/ 里的交付标记(跨进程持久化)一起把这件事钉死。交付标记用“临时文件 + fsync + rename”原子写入;清理只删除本次真正列到过、且确实已经删掉的会话标记,所以清理期间新完成的分享不会被误删。触发点是冷启动(强制)+ 复用界面时收到新分享 + 新建复制会话,后两者按 10 分钟节流。
从后台回来时,如果发现缓存文件已经被 30 分钟宽限后的清理收走,会自动用原 URI 重试复制;源 URI 授权已经过期时会明确提示“请重新分享”,不会静默变成空界面。
最新的 APK 在 Releases 页面(release 变体,已用项目自己的密钥签名,可直接安装)。
版本号约定:versionName 来自 tag(去掉 v),versionCode 用 CI 的 run number(单调递增,可直接覆盖安装升级)。
任意 App → 分享 → HelloRename → 改名字 → 发送 → 系统分享面板 → 目标 App
- 从启动器直接打开时,可以点“选择一个文件”(走 SAF),方便自测。
- 支持多文件分享,但当前版本只处理第 1 个(界面会提示收到几个)。
- 只收到纯文本(
EXTRA_TEXT)而不是文件时会明确提示,不会假装成功。
- 推到
main/ 开 PR →QAworkflow:单元测试 → Lint 门禁 → 构建 release APK → APK 出厂体检;同时并行跑云端模拟器 UI 验证。QA 产物带.debug包名后缀(moe.hellorename.debug),可以和正式包同时装在手机上。 - 打
v*tag →Release APKworkflow:先跑云端模拟器 UI 验证 → 要求签名 Secrets → 构建 → 体检 → 创建 Release(附 APK 与SHA256SUMS.txt,说明自动取自CHANGELOG.md)。
.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 起,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,方便人工复核。
每次 CI 都会对产出的 APK 做一遍硬性检查,任何一项失败就不发版:
- 包名
moe.hellorename、版本号与 tag 一致、minSdk 24、targetSdk 36 - 除了 androidx 自用的
DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION(signature级、不向外部应用授权)外没有任何权限,一旦多出INTERNET/存储之类就失败 application-debuggable不存在MainActivityexported=true,注册了MAIN/LAUNCHER、SEND、SEND_MULTIPLE,launchMode=singleTopFileProviderexported=false+grantUriPermissions=true+ authority 与file_paths.xml到位- APK Signature v2 / v3 校验通过,且证书 SHA-256 等于发布密钥(不是 Android Debug 证书)
- APK 内不得残留
*.jks / keystore.properties / local.properties
本地也能跑:
python3 tools/qa_check.py app/build/outputs/apk/release/app-release.apk \
--expect-cert-sha256 3682cce6d8fbf859b9b0a90ef8818acc747c82bb5f9d97418a4e6749cb6ccaca(没装 build-tools 时会跳过依赖 aapt2/apksigner 的检查并给出 WARN。)
签名密钥不在仓库里,通过 GitHub Actions Secrets 注入:
| Secret | 内容 |
|---|---|
SIGNING_KEYSTORE_BASE64 |
密钥库文件的 base64(单行) |
SIGNING_STORE_PASSWORD |
密钥库口令 |
SIGNING_KEY_ALIAS |
key alias |
SIGNING_KEY_PASSWORD |
key 口令 |
生成密钥库:
keytool -genkeypair -v -keystore hellorename-release.jks -alias hellorename \
-keyalg RSA -keysize 4096 -validity 10950 -storetype PKCS12 \
-storepass '<PASS>' -keypass '<PASS>' \
-dname "CN=hellorename, OU=Android, O=hellorename, C=CN"
base64 -w0 hellorename-release.jks # 粘到 SIGNING_KEYSTORE_BASE64未配置这些 Secrets 时,release 会自动退回 debug 签名,CI 依旧能出包(仅供自测)。
cp keystore.properties.example keystore.properties # 填自己的路径与口令,已被 gitignore
gradle assembleRelease # 或 ./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/app-release.apk环境:JDK 17、Gradle 9.6.0、AGP 9.4.1、Kotlin 2.4.21、compileSdk 36、targetSdk 36、minSdk 24。单元测试:
gradle test # 78 条 JVM 单测(文件名/缓存/流复制/操作代际/校验值)
gradle connectedDebugAndroidTest # 需要模拟器或真机;API 36 门禁- 没有网络代码,也没有申请任何系统权限(
INTERNET、存储、悬浮窗等一概没有)。APK 里只剩 androidx 自带的moe.hellorename.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION,它是signature级的应用内自用权限,不会授予外部应用,也不出现在安装权限列表里。 - 文件只在本地缓存目录里复制、改名;除了你自己选的目标 App,不会发给任何第三方。
- 改扩展名会同时改变对外声明的 MIME type,部分 App 会因此拒收(界面有提示)。
- 多文件分享只处理第一个。
- 文件名中的
/ : * ? " < > |等非法字符、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 的验证边界)。