Skip to content
junxi8686Public

About

琉璃 —— 用蓝牙聊天的 Android 应用。不用服务器、不用流量、不用账号。

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

琉璃 · Liuli

一个用蓝牙聊天的 Android 应用:不用服务器、不用流量、不用账号,两台手机配对后直接收发文字、语音、图片和视频。界面是微信式的设计 —— 白色背景、内缩发丝线、绿色气泡 —— 顶栏/底栏/弹层带高斯模糊的磨砂质感,可以按需关掉。支持跟随系统的深色模式。

  • 包名:com.liuli.btchat
  • 最低系统:Android 12(API 31)
  • 目标系统:Android 16(API 36),compileSdk 37
  • 产物:dist/琉璃-1.0.apk(约 4.6 MB,已签名)

界面

这里没有截图,是刻意的。 早期版本放了几张真机截图,但它们拍的是真实使用中的 手机:里面的联系人名字、蓝牙 MAC 地址、以及本人的个人资料都是真数据,公开出去 等于把这些信息一起发布了。截图已从仓库和全部历史中移除。 界面长什么样,跑起来看就行 —— 或者自己截一张。

底部四个标签(会话 / 通讯录 / 我的 / 设置)是原地切换的,不是二级页面;聊天页、聊天信息、查找记录、对方主页、我的收藏这些才是二级页面,系统返回键会逐级退回,从二级页面按返回不会退回桌面。

位置 交互
会话列表 搜索、置顶、免打扰、未读角标、左滑/长按操作
聊天页右上「…」 进入聊天信息页:查找聊天记录 / 聊天背景 / 新消息提醒 / 消息免打扰 / 置顶聊天 / 清空聊天记录(群聊还有群名称、群成员、退出群聊)
长按消息 复制 / 引用 / 转发 / 收藏 / 多选 / 删除;自己发的 2 分钟内还有撤回
删除 分「仅我可见」和「双方删除」两种
点对方头像 进入对方主页:设置备注、免打扰、置顶、删除好友、清空聊天记录、拉黑(本地标记)
我的 我的资料(头像/昵称/状态签名)、我的收藏
设置 聊天与提醒、存储统计、运行蓝牙自检、关于与连接诊断

关于"液态玻璃"这个名字 —— 它被做出来过,然后被去掉了。

项目最初的目标就是液态玻璃。实际能做出的是磨砂玻璃(高斯模糊 + 边缘高光),不是 Apple 那种带折射的液态玻璃:折射要实时采样背景做位移贴图,列表里每行都做会直接掉帧。做过三档(液态玻璃 / 高斯模糊 / 全部关闭)+ 强度滑杆,真机跑下来有两个问题:

  1. 模糊和"看得清"是一对矛盾。20dp 模糊配高白度,结果是"字还看得清、又看不出模糊";把白度提到 0.97 又变成一块灰板。最后找到的平衡是大半径 + 中白度(顶栏底栏 56dp / 0.78,弹层 48dp / 0.84)—— 字彻底糊掉,同时一眼能看出是磨砂。
  2. 更实际的是,微信本身就是白底 + 发丝线 + 绿色气泡,任何位置的模糊都会让界面离"像微信"更远。

所以最终三档效果被整体移除,effectMode 在读取时被强制为 NONE。现在所有表面都是不透明白色,glassSurface 永远走不透明分支、不创建任何 RenderEffect(顺带把性能也提上去了)。代码里那套玻璃材质仍然完整保留着,改一行就能回来。

那段历史里最值得记的两个坑留在这里:

  • "全部关闭"必须真的完全不透明。 半透明的菜单会让下面的列表透出来,那看起来像渲染故障,不像设计。
  • blur() 在 TileMode.Clamp 下不给采样窗口加 padding,于是形状边缘采样不到东西,材质从边上开始化掉 —— 真机上的表现是"越靠屏幕上下越没有模糊"。必须用 TileMode.Decal。

功能

聊天

  • 文字消息,带发送中 / 已发送 / 已送达 / 已读状态
  • 语音消息:输入栏左侧「按住说话」,上滑取消,松手发送;气泡宽度随时长变化,波形是从音频里真解码出来的振幅(解不开时退化为确定性装饰波形,并有 decoded 标志可判别,绝不冒充)
  • 引用回复、撤回、双向删除、收藏、转发、多选批量操作
  • 图片:自动压缩到长边 1600px、JPEG q80 后发送,气泡里显示缩略图,点击全屏查看
  • 视频:原样传输,气泡里是抽帧缩略图 + 时长,点击用 ExoPlayer 播放
  • 文件:任意类型
  • 会话内搜索聊天记录,命中词高亮
  • 对方正在输入提示
  • 每个会话可以单独换聊天背景(8 个预设 + 从相册选图),并且跟随深色模式
  • 每个会话可以单独开关新消息提醒,收到消息时会按「本会话开关 + 该会话免打扰 + 全局提示音/震动」三层判断后响铃震动

联系人与群组

  • 蓝牙发现附近设备,也列出已配对设备
  • 添加联系人、改备注、对方主页、本地拉黑标记
  • 发起群聊、群成员管理、修改群名、退出群聊

外观

  • 深色模式跟随系统,没有开关可调;深色下状态栏与导航条图标会自动切换明暗
  • 界面是纯色平面(不发光的白底 + 发丝线 + 绿色气泡),没有模糊——原因见上面的「关于液态玻璃这个名字」

传输

  • 每个附件都有进度、速率提示和预计耗时
  • 可以取消传输
  • 收到的媒体可以手动接收或自动接收

怎么用

  1. 两台手机都装上这个 APK
  2. 都打开蓝牙,首次启动时允许「附近设备」权限
  3. A 手机:通讯录 → 右上角蓝牙图标 → 扫描 → 点中 B 手机
  4. 连上后自动进入聊天页,直接发消息即可
  5. 群聊:会话页右下角 + → 发起群聊 → 勾选联系人。群主手机负责中转,其他成员的消息都经由群主转发

两台手机都需要装着这个 App 才能互聊 —— 它用的是自己定义的协议,不是系统短信。

改名会同步:在设置里改昵称/状态后会立刻推给在线的对方,不需要重新连接。


编译

编译工具链在桌面的 Android-Toolchain 里,无需全局安装任何东西:

cd Liuli
build.cmd assembleDebug      :: 调试包
build.cmd assembleRelease    :: 签名发布包
build.cmd :app:publishDist   :: 签名发布包 + 复制到 dist/琉璃-<版本>.apk
build.cmd testDebugUnitTest  :: 单元测试(注意:没有 testReleaseUnitTest)

产物在 app/build/outputs/apk/release/app-release.apk(用 -PliuliBuildDir=<dir> 可以改输出目录,方便并行构建)。

版本号

只维护根目录的 version.properties 一个文件:

versionCode=1
versionName=1.0

versionCode 每次发布必须递增 —— Android 拒绝用同一个 code 覆盖安装,卡住的话只能先卸载,那会丢掉聊天记录。versionName 会出现在三处:APK 文件名(dist/琉璃-1.0.apk)、系统「应用信息」页、以及 App 内「我的」页底部与「设置 → 关于」。

build.cmd :app:publishDist -PliuliBuildDir=build-r2   :: 出一版并落到 dist/琉璃-1.0.apk

发布包开启 R8 混淆与资源裁剪 —— 54 MB 的未裁剪包会缩到约 4.6 MB。

签名

仓库里没有签名密钥。 clone 下来直接 build.cmd :app:assembleRelease 会得到一个未签名的 APK,装不上;要出可安装的包,自备一份密钥:

keytool -genkeypair -v -keystore keystore\liuli-release.jks -alias liuli ^
        -keyalg RSA -keysize 2048 -validity 10000

然后在 keystore/keystore.properties 写下口令(这两个文件都在 .gitignore 里):

storePassword=<你的口令>
keyAlias=liuli
keyPassword=<你的口令>

app/build.gradle.kts 两者齐备就签名、缺任一个就产出未签名包 —— 构建不会因此失败,只是拿不到能装的 APK。

它们曾经是提交进来的,理由是可复现性。但公开仓库里放签名私钥,等于把「谁能发布能覆盖安装的琉璃更新」交出去:同一个包名,只有用同一把密钥签的包才装得上去。演示项目的可复现性不值这个价。

技术环境

组件 版本
AGP 9.4.1(内置 Kotlin,不要再加 org.jetbrains.kotlin.android)
Gradle 9.6.0
Kotlin 2.3.21(+ compose / serialization 插件)
JDK Temurin 21
Compose 1.11.4 / Material3 1.4.0
视频 androidx.media3 1.11.1
图片 Coil 2.7.0

lintVitalAnalyzeRelease 已关闭:AGP 9.4.1 自带的 lint 在 Kotlin 2.3.21 的 FIR 符号上会内部崩溃(KaFirKotlinPropertyKtParameterBasedSymbol),那是工具 bug 不是代码问题。

从零克隆构建

build.cmd 会把 JAVA_HOME / ANDROID_HOME / GRADLE_USER_HOME / ANDROID_USER_HOME 全部指向桌面的 Android-Toolchain,不依赖全局安装。换机器时改 build.ps1 顶部的几个路径即可。

local.properties 不在版本库里(它是本机 SDK 的绝对路径),首次构建时如果 Gradle 抱怨找不到 SDK,让 Android Studio 生成一份,或者手动写一行 sdk.dir=...。

关于仓库里的密钥

见上面的「签名」一节:密钥和口令都不在仓库里。这一点是刻意的,不是遗漏。


代码结构

app/src/main/java/com/liuli/btchat/
├── core/          冻结契约:数据模型 / 蓝牙线协议 / 服务接口 / 服务定位器
├── bt/            RFCOMM 传输、设备发现、群组中继、附件收发、前台服务、协议自测
├── data/          SQLite(会话/消息/联系人/成员)+ SharedPreferences 设置 + 媒体仓库
├── media/         图片压缩与 EXIF、视频抽帧、缩略图缓存、录音与播放、波形解码
└── ui/
    ├── glass/     设计系统:浅/深色调色板、玻璃材质与规格、页面背景、控件、栏、头像
    ├── screens/   会话/通讯录/我的/设置/发现/建群 + 聊天/聊天信息/查找/转发/主页/收藏/视频
    ├── components/ 气泡、语音气泡、录音条、输入栏、列表行、长按菜单
    ├── Screen.kt  导航图
    └── LiuliRoot.kt 应用外壳(Tab + 详情栈 + 预测式返回手势)

几个关键设计

分层依赖是单向的。 ui 只读 ChatStore、只通过 ChatEngine 写;bt、data、media 之间不互相引用实现类,全部通过 core/Contracts.kt 的接口通话。唯一的装配点是 Di.kt。

引擎不依赖蓝牙。 协议跑在抽象的 Duplex(一对输入输出流)上,所以同一套 Connection / Router / TransferManager 可以在 localhost 的两个 socket 之间完整跑通 —— bt/SelfTest.kt 正是这么验证的:握手 → 发文本 → 传 1MB 随机文件并校验 sha256 → 建群 → 群消息中继 → 群文件扇出 → 断链感知。只需要一台手机就能证明协议是对的。

群聊是星形拓扑。 群主手机维护到每个成员的 RFCOMM 链路;广播消息带 hop 计数,群主收到后转发给其他成员并对 hop 加一,hop > 1 丢弃以防环回。这样 N 个成员只需要 N 条链路,而不是 N²。

控制帧走 JSON,媒体帧走裸字节。 每帧是 magic + type + 长度 + 负载。控制帧是 kotlinx-serialization 的 JSON;媒体分片是 [idLen][transferId][seq][裸数据],一个 16KB 分片不经过任何编码。

身份必须能区分。 早期版本把默认昵称写死成「我」,结果两台新装的手机都叫「我」,对方显示成了你自己、消息看起来发给了自己。现在默认昵称取蓝牙适配器名(如「刘峻熙」),取不到就用「琉璃用户1234」;「我」只是 UI 里对本机用户的称呼,永远不上线。另外握手完成前建的会话是以 MAC 为键的,握手后会迁移到设备 ID,避免同一台设备产生两个会话。

性能上的取舍。 玻璃质感是用帧率换来的:LayerBackdrop 会把子树画两遍,每个 glassSurface 都要一层离屏缓冲加一次模糊。实测设置页滑动从 p99 200ms / janky 27% 降到 p99 9ms / janky 0.96%,代价是列表行里一处玻璃都不留 —— 列表、气泡、卡片全是平涂的白色和发丝线,只有顶栏、底栏和弹层带模糊。

转场按 iOS 的规则走。 二级页面从右边缘整屏滑入,返回时整屏滑回右边(哪里来的回哪里去),被覆盖的页面只左移三分之一,两层读起来是一叠。手势返回用 PredictiveBackHandler:跟手时页面跟着手指走,松手后先把剩下的行程滑完再 pop —— 早期版本松手就直接删掉页面,看着像在半空闪没了。

深色模式的做法。 LiuliColors 的每个成员都是 @Composable get(),背后挂一个 LocalLiuliPalette。这样全 App 几百个 LiuliColors.TextPrimary 调用点一行都不用改就跟着主题走,切换只是一次 CompositionLocalProvider。


已知限制

验证到什么程度

项目里没有自动化 UI 测试,下面这些是真机(Galaxy S24 Ultra / Android 16)实跑的结果。 原始截图不随仓库发布(里面是真实联系人名字、蓝牙地址和个人资料),只保留结论:

  • 协议自检 39/39 全部通过(应用内「设置 → 实验室 → 运行蓝牙自检」,跑在内存管道上,不需要第二台手机)。覆盖握手、单聊落库、回执、打字指示、1 MB 文件 sha256 校验、群通话扇出、通话帧抢占、以及离线中转的完整链路(A 发出 → B 保管 → A 离线、C 连上 B → C 收到 A 的原消息)。

  • 真机双向收发:对方发来的消息正常到达并落库,一张真实照片在消息流里正常渲染。

  • 白屏修复:故障态是界面完全组合、布局正确、点击生效,但一个像素都没有(原因是二级页容器的不透明底在栈为空时盖住了整个 tab 层);修复后恢复正常。

  • 单元测试 121 个,见 app/src/test/。

  • 传输速度受蓝牙限制,实测约 100–200 KB/s。一张压缩后的照片 ≈ 2 秒,一段 30 秒的视频可能要几分钟。发之前会给出预估。

  • 视频不转码(工程里没有 ffmpeg)。超过 50MB 的视频会被拦下来,需要自己先裁。

  • 两台设备必须都装有本应用。

  • 没有「拉黑」功能,这是刻意的。 早期做过一版:本机丢包、对方显示发送失败。但「配对即好友」之后它绕得过去 —— 对方在系统蓝牙里解除配对再重新配对,就又成了好友。一个绕得过去的开关比没有这个开关更糟,用户会以为拉黑了。真正有效的手段是解除配对,所以「删除好友」会把人送到系统蓝牙设置去。

  • 群信息页的群管理入口(公告 / 禁言 / 管理员 / 转让群主 / 踢人)没有在真机上打开过 —— 代码和接口都完成、编译通过,但那一屏缺少实机验证。

  • 平板双栏(宽度 ≥ 720dp 时左列表 + 右内容)代码完成、编译通过,同样没有实机截图验证。

  • 单条转发到多个会话还没做:多选转发目前只转发第一条。


许可

MIT。

About

琉璃 —— 用蓝牙聊天的 Android 应用。不用服务器、不用流量、不用账号。

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages