DAO MESSAGE — Vibecoding 建造指南
使用说明: 将本文档全文粘贴给 AI,说"请按照这份指南从零构建工程"即可。
本文由三部分组成:架构原则 + SDK API 参考(机器可读) + 接线示例
Part A: 架构原则(10 条铁律)
DAO MESSAGE 不是一个 App,而是一套 AI 时代的隐私通讯协议 + 中继网络即服务。 你正在帮用户构建的,是一个基于 DAO MESSAGE 协议的专属聊天客户端。
SDK 安装
npm install @daomessage_sdk/sdkSDK 已发布到 npm 公共仓库,直接
npm install即可。无需本地 link、submodule 或 monorepo 配置。 源码仓库:https://github.com/daomessage/securechat-sdk-typescript
| # | 铁律 | 约束 |
|---|---|---|
| 1 | SDK 是黑盒 | @daomessage_sdk/sdk 处理所有加密、密钥、WebSocket、IndexedDB。App 只做 UI 壳。不要重复实现 SDK 已有的任何功能。 |
| 2 | 协议至上 | 所有客户端共享同一套 WS 帧格式和加密协议,你不能自创私有协议 |
| 3 | 绝对零信任 | 服务端只转发密文,App 层不得发送任何明文到服务端 |
| 4 | API 已硬编码 | SecureChatClient 构造函数无参数,API 地址 https://relay.daomessage.com 硬编码在 SDK 内,不可传参、不可覆盖 |
| 5 | 命名二元规则 | HTTP 接口返回 snake_case(如 alias_id, friendship_id);IndexedDB 存储 camelCase(如 conversationId, theirAliasId)。不得混用 |
| 6 | 只用 SDK 导出的 API | 不要猜测或发明 SDK 不存在的方法。所有可用方法见下方 Part B 的 TypeScript 声明 |
| 7 | 事件驱动 | 用 client.on('message' | 'status_change' | 'network_state' | 'typing' | 'goaway', handler) 订阅事件,返回 unsubscribe 函数 |
| 8 | 异步安全 | 所有 SDK 异步调用必须 try/catch,UI 层禁止 Silent Failure。按钮必须有 loading/disabled 状态防止重复点击 |
| 9 | 全面出海 | 面向海外用户,UI 默认英文 |
| 10 | Vibe Coding 生态 | 你生成的 App 必须通过 SDK 接入官方中继,与其他开发者的 App 互通 |
| 11 | 强制双边核对(不可绕过) | 每对会话必须双方都完成密钥核对(trustState='verified')才能聊天。未核对前:(a) client.messages.send() 会抛 UNVERIFIED_SESSION 异常;(b) 收到的密文进 quarantine 队列不解密;(c) 服务端拒绝转发。UI 必须实现三状态可视化:unverified(黄色硬遮罩 + "开始核对"按钮)/ my_side_verified(蓝色软遮罩 + "等对方核对"提示)/ verified(解锁聊天 + 绿色已核验图标)。详见 Part C.9。禁止绕过:不要让用户跳过核对,不要让 trustState 直接从 unverified 跳到 verified。 |
技术栈(强制)
| 层 | 技术 | 版本 |
|---|---|---|
| 构建工具 | Vite | 6+ |
| 前端框架 | React | 19+ |
| 样式方案 | TailwindCSS + @tailwindcss/vite 插件 | v4(不是 v3) |
| 状态管理 | Zustand | 5+ |
| 路由 | React Router | 7+ |
| 语言 | TypeScript(strict mode) | 5.5+ |
构建优先级
按以下顺序实现,每个阶段完成可运行后再进入下一阶段。所有 7 个阶段都是必须交付的,不可跳过任何一个。
- 工程骨架 — Vite 初始化 + TailwindCSS v4 + PWA 配置(manifest + Service Worker) + 路由 + SDK 单例
- 注册/登录 — 助记词生成 + 备份确认 + 注册 + 靓号选购(可跳过) + 恢复会话
- 通讯录 — 好友列表 + 添加好友 + 好友请求处理 + 安全码核验 + 我的二维码 + 扫一扫加好友
- 文字聊天 — 消息收发 + 历史记录 + 已读回执 + 正在输入
- 多媒体 — 图片/文件/语音消息(含 MediaRecorder 录音 UI + VoiceBubble 播放组件)
- 频道 — 频道列表 + 订阅 + 发帖
- 音视频通话 — 完整的 CallScreen 来电/通话/挂断 UI + WebRTC 信令 + STUN/TURN(⚠️ 必须实现,不可跳过)。TURN 后端中立:relay-server
GET /api/v1/calls/ice-config返回标准 WebRTC iceServers JSON,默认接入 Cloudflare Realtime TURN,兼容自建 coturn。详见sdk-typescript/docs/calls.md。
Part B: SDK TypeScript 类型声明(机器可读合约)
以下是
@daomessage_sdk/sdk的完整.d.ts类型声明文件。 AI 必须严格按照这些类型签名调用 SDK,不存在的方法不能调用。 这是单一真相源——如果本文任何其他描述与此.d.ts冲突,以.d.ts为准。
// ═══════════════════════════════════════════════
// @daomessage_sdk/sdk — 完整类型声明
// 自动生成自 sdk-typescript/dist/index.d.ts
// ═══════════════════════════════════════════════
/**
* keys/index.ts - SDK 密钥体系
*
* 架构设计 §1.3.1 HD 派生路径规范:
* - m/44'/0'/0'/0/0 → Ed25519(身份认证/签名)
* - m/44'/1'/0'/0/0 → X25519(ECDH 消息加密)
*
* 依赖:
* - @scure/bip39:助记词生成/验证
* - @noble/curves/ed25519:Ed25519 签名
* - @noble/curves/x25519:X25519 ECDH
* - @noble/hashes/sha512:PBKDF KDF
*/
interface KeyPair {
privateKey: Uint8Array;
publicKey: Uint8Array;
}
interface Identity {
mnemonic: string;
/** Ed25519 身份密钥,用于 Challenge-Response 认证 */
signingKey: KeyPair;
/** X25519 ECDH 密钥,用于消息会话密钥协商 */
ecdhKey: KeyPair;
}
/** 生成 12 词英文助记词 */
declare function newMnemonic(): string;
/** 验证助记词是否合法(12 词,BIP-39 词库)*/
declare function validateMnemonicWords(mnemonic: string): boolean;
/**
* 从助记词完整派生 Identity(包含两对密钥)
*/
declare function deriveIdentity(mnemonic: string): Identity;
/**
* ECDH 密钥协商:根据己方私钥和对方 X25519 公钥计算 SharedSecret
* SharedSecret 再经 KDF 得到 32 字节 AES-256 会话密钥
*/
declare function computeSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array;
/**
* 计算 60 字符安全码
* 算法:SHA-256(min(pubA, pubB) ‖ max(pubA, pubB))[0..30] → hex
* 双方使用相同的确定性拼接顺序,MITM 无法伪造一致结果
*/
declare function computeSecurityCode(myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): string;
/**
* formatSecurityCode — 把 60 hex 字符切成 5 字符 × 12 组的展示格式
*
* 用户看到的形式: "a3f2c 8b91d 4567a ..." (12 组用空格分隔)
* 用于"高级模式: 完整 60 位指纹"展示, 主流程已改用 directional code(见下)。
*/
declare function formatSecurityCode(hex60: string): string;
/**
* normalizeSecurityCode — 比对前归一化用户输入
*
* 用户从外部粘贴的码可能含: 空格 / 换行 / Tab / 中文括号 / 标点污染。
* 归一化只保留 hex 字符 (0-9 a-f), 转小写。用于完整 60 位指纹模式。
*/
declare function normalizeSecurityCode(input: string): string;
/**
* 计算单方向核对码 (Base32 编码 80 bits, 16 字符 4-4-4-4 分组显示)
*
* @param sharedSecret X25519 ECDH 共享密钥 (32 字节)
* @param fromPub 发送方 X25519 公钥 (32 字节)
* @param toPub 接收方 X25519 公钥 (32 字节)
* @returns 例如 "5KQF-2T3X-NMRE-W8YA"
*/
declare function computeDirectionalCode(sharedSecret: Uint8Array, fromPub: Uint8Array, toPub: Uint8Array): string;
/**
* 归一化用户输入的 directional code (去除空格/横线/转大写, 只保留 base32 字符)
* 用于核对前的输入清洗。
*/
declare function normalizeDirectionalCode(input: string): string;
/**
* toBase64 — 安全 Base64 编码
*
* 老实现 `btoa(String.fromCharCode(...bytes))` 在大 Uint8Array(>~100KB)上
* 会触发 "Maximum call stack size exceeded"(spread 参数过多)。
* 新实现分片处理 + 合并,支持任意大小。
*/
declare function toBase64(bytes: Uint8Array): string;
/**
* fromBase64 — 严格 Base64 解码
*
* 校验输入是合法 base64(字符集 + 长度是 4 的倍数)。非法输入会抛出
* 而不是静默产生 NaN 字节(老实现对非法字符会 produce Uint8Array with NaN)。
*/
declare function fromBase64(b64: string): Uint8Array;
declare function toHex(bytes: Uint8Array): string;
declare function fromHex(hex: string): Uint8Array;
/**
* keys/store.ts - T-012 IndexedDB 三存储持久化
* 关键数据结构:identity / sessions / offlineInbox
*/
interface StoredIdentity {
uuid: string;
aliasId: string;
nickname: string;
mnemonic: string;
signingPublicKey: string;
ecdhPublicKey: string;
}
/**
* trustState 三档 (1.0.36 强制双边密钥核对):
* - 'unverified': 默认 / 冷启动 / reset 后, 用户从未本地核对成功 (或 IDB 已被清)
* - 'my_side_verified': 用户已在 KeyVerificationModal 比对成功并调过 verify-mark, 但对方还没核对
* - 'verified': 双方都已完成核对 (服务端 NATS friend_verified 触发或 syncFriends 看到 verified_at NOT NULL)
*
* 状态机约束: unverified → verified 必须经过 my_side_verified
* 单独的 NATS 通知不能让 unverified 跳到 verified (防服务端 root 伪造)
*
* SDK gate:
* - unverified / my_side_verified: sendMessage 抛 UNVERIFIED_SESSION, handleIncomingMsg 入 quarantine
* - verified: 正常发收
*/
type SessionTrustState = 'unverified' | 'my_side_verified' | 'verified';
interface SessionRecord {
conversationId: string;
theirAliasId: string;
theirEcdhPublicKey: string;
theirEd25519PublicKey?: string;
sessionKeyBase64: string;
trustState: SessionTrustState;
friendshipId?: number;
createdAt: number;
}
/**
* QuarantinedMessage — 未核对会话收到的密文, 等核对完成后批量重新解密。
* 关键: 不立即解密展示, 因为如果服务端 MITM, 密文是用假 sessionKey 加密的, 内容不可信。
*/
interface QuarantinedMessage {
conversationId: string;
msgId: string;
seq: number;
fromAliasId: string;
payloadEncrypted: string;
envelope: string;
receivedAt: number;
}
declare function loadIdentity(): Promise<StoredIdentity | undefined>;
declare function clearIdentity(): Promise<void>;
declare function loadSession(conversationId: string): Promise<SessionRecord | undefined>;
declare function listSessions(): Promise<SessionRecord[]>;
declare function deleteSession(conversationId: string): Promise<void>;
declare function markSessionVerified(conversationId: string): Promise<void>;
/**
* 用户在 KeyVerificationModal 本地比对成功后调。
* 注: 不直接升 verified, 必须等服务端 NATS friend_verified 通知才升级。
*/
declare function markSessionMyVerified(conversationId: string): Promise<void>;
/**
* 服务端 NATS friend_verified 通知或 syncFriends 看到 verified_at NOT NULL 时调。
* 关键约束: 必须本地已是 my_side_verified 才能升 verified, 否则忽略 (防服务端 root 伪造)。
* 返回是否真的升级了。
*/
declare function maybeMarkSessionVerified(conversationId: string): Promise<boolean>;
/**
* Reset 会话到未核对状态。
* 用户主动重新核对或服务端 NATS friend_verify_reset 触发。
* quarantine 保留 (重新核对成功后还能解密老密文)。
*/
declare function resetSessionTrust(conversationId: string): Promise<void>;
/**
* 用户在 KeyVerificationModal 本地比对成功后调。
* 1) 本地 trustState 升级到 my_side_verified (写 IDB)
* 2) 通知服务端 verify-mark
*
* 注: 不直接升 verified, 必须等服务端 NATS friend_verified 才升级。
* 这是 spec §4.5.2 状态机约束。
*
* 抛错场景: 服务端返回非 2xx (比如 friendship 不在 accepted 状态、被踢出参与者等)
* 本地 IDB 写入失败
*/
declare function markMyVerified(apiBase: string, token: string, friendshipId: number, conversationId: string): Promise<{
myVerifiedAt?: string;
verifiedAt?: string | null;
}>;
/**
* 重置核对状态 (用户主动重新核对)。
* 1) 调服务端 verify-reset
* 2) 本地 trustState 重置为 unverified
*
* 服务端会通过 NATS friend_verify_reset 通知对端, 对端 SDK 也会 reset 本地 trustState。
*/
declare function verifyReset(apiBase: string, token: string, friendshipId: number, conversationId: string): Promise<void>;
interface StoredMessage {
id: string;
conversationId: string;
text: string;
isMe: boolean;
time: number;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
msgType?: string;
mediaUrl?: string;
caption?: string;
seq?: number;
fromAliasId?: string;
replyToId?: string;
}
interface OutboxIntent {
internalId: string;
conversationId: string;
toAliasId: string;
text: string;
addedAt: number;
replyToId?: string;
}
type NetworkState = 'connected' | 'connecting' | 'disconnected';
type NetworkListener = (state: NetworkState) => void;
declare class RobustWSTransport implements WSTransport {
private ws;
isConnected: boolean;
private messageHandlers;
private openHandlers;
private closeHandlers;
private networkListeners;
private goawayListeners;
private reconnectAttempts;
private reconnectTimer;
private heartbeatTimer;
private intentionalClose;
lastUrl: string;
private connectingPromise;
constructor();
onNetworkStateChange(fn: NetworkListener): () => void;
/** 监听 GOAWAY 帧(被其他设备踢下线) */
onGoaway(fn: (reason: string) => void): () => void;
private emitNetworkState;
private urlProvider;
connect(urlOrProvider: string | (() => Promise<string>)): void;
private _doConnect;
private _scheduleReconnect;
private _startHeartbeat;
private _stopHeartbeat;
send(data: string): void;
onMessage(handler: (data: string) => void): void;
onOpen(handler: () => void): void;
onClose(handler: () => void): void;
disconnect(): void;
}
/**
* sdk-typescript/src/messaging/index.ts — T-100+T-101
* MessageModule:完整的消息收发封装 + 离线同步引擎 + 强制本地持久化 (Vibe Coding Refactor)
*/
interface OutgoingMessage {
conversationId: string;
toAliasId: string;
text: string;
replyToId?: string;
}
interface MessageStatus {
id: string;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
}
interface WSTransport {
send(data: string): void;
onMessage(handler: (data: string) => void): void;
onOpen(handler: () => void): void;
onClose(handler: () => void): void;
isConnected: boolean;
}
declare class MessageModule {
private transport;
onMessage?: (msg: StoredMessage) => void;
onStatusChange?: (status: MessageStatus) => void;
onChannelPost?: (data: any) => void;
/** 对方正在输入通知 */
onTyping?: (data: {
fromAliasId: string;
conversationId: string;
}) => void;
/** 链上支付确认通知(pay-worker 确认后 WS 推送,由 VanityModule 订阅)*/
onPaymentConfirmed?: (data: {
type: string;
order_id: string;
ref_id: string;
}) => void;
/** P0-C(2026-04-26): 通讯录变更通知 — 收到好友请求 / 对方同意你的请求时触发,UI 应重新拉好友列表。
* 服务端在 friends/handler.go 用 PublishCore("msg.notify.{uuid}", ...) 推送,
* gateway 用 NATS 订阅转成 WS 帧 {type:'friend_request'|'friend_accepted', ...}。
* 之前两端用 10s 轮询掩盖这个事件链断点,现在补齐。*/
onContactsChange?: (data: {
type: 'friend_request' | 'friend_accepted';
from?: string;
by?: string;
conv_id?: string;
}) => void;
/**
* 1.0.38: trustState 变化通知 — 三态版。客户端 UI 用此触发"聊天页解锁/阻塞"切换。
* - trustState='verified': 双方都完成核对, UI 解锁聊天
* - trustState='my_side_verified': 本端调 markMyVerified 后, UI 切到"等对方"视图
* - trustState='unverified': 一方 reset, UI 切回阻塞 + 提示重新核对
*
* 历史: 1.0.36/1.0.37 这里只声明了 'verified' | 'unverified', 缺 my_side_verified —
* 但运行时实际并没有从这里发 my_side_verified, UI 端只能自行 loadSession() 兜底。
* 1.0.38 起 markMyVerified 也会触发本回调, 类型补齐。
*/
onTrustStateChange?: (data: {
conversationId: string;
trustState: SessionTrustState;
}) => void;
/**
* 1.0.38: 由独立函数 markMyVerified() 调用, 在本地 trustState 升 my_side_verified
* 后通知 UI。也可由 verifyReset 调用通知 UI 降 unverified。
* 公开方法避免反射 hack — 之前 PWA/Android 用 (client.messages as any).inner.onTrustStateChange
* 都改用这个稳定 API。
*/
notifyTrustStateChange(conversationId: string, trustState: SessionTrustState): void;
constructor(transport: WSTransport);
send(msg: OutgoingMessage): Promise<string>;
private _trySendIntent;
sendDelivered(convId: string, seq: number, toAliasId: string): void;
sendRead(convId: string, seq: number, toAliasId: string): void;
sendTyping(toAliasId: string, convId: string): void;
/** 发送消息撤回帧(架构 §4.2 V1.1 新增) */
sendRetract(messageId: string, toAliasId: string, convId: string): void;
private handleFrame;
/**
* 1.0.36: 处理 quarantine 队列。
* 在双方都核对完成后调用, 把之前缓存的密文重新走 handleIncomingMsg 流程解密。
* 完成后清空 quarantine。
*/
private processQuarantine;
private handleIncomingMsg;
private handleStatusChange;
/**
* 处理 delivered/read 回执帧(基于 conv_id 批量更新)
* 回执帧格式:{type:'delivered'|'read', conv_id, seq, to}
* 不含消息 id,所以需要按 conv_id 查找自己发出的消息并更新
*/
private handleReceiptByConvId;
private onConnected;
private handleRetract;
}
declare class HttpClient {
private apiBase;
private token;
constructor(apiBase?: string);
setApiBase(apiBase: string): void;
getApiBase(): string;
setToken(token: string | null): void;
getToken(): string | null;
getHeaders(customHeaders?: Record<string, string>): Record<string, string>;
get<T = any>(path: string): Promise<T>;
post<T = any>(path: string, body: any): Promise<T>;
put<T = any>(path: string, body?: any): Promise<T>;
delete<T = any>(path: string): Promise<T>;
/**
* For direct fetch calls (like Media Presigned URL PUT / GET)
*/
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}
declare class AuthModule {
private http;
private _uuid;
/**
* 串行化 restoreSession / reauthenticate,防止并发自踢:
* 多个调用点(冷启动/SW 唤醒/标签恢复)几乎同时调用时,
* await 之间的微任务窗口让两个 promise 都先读到 token == null,
* 各跑一次 authenticate,服务端 revokeOldSessions 把先到的 JTI 撤销,
* 30s 后 revalidateLoop 推 GOAWAY → 误踢。
*/
private authPromise;
constructor(http: HttpClient);
/**
* 供 SDK 内部建立 WebSocket 时调用,防误用
*/
get internalUUID(): string;
/**
* 恢复会话:从本地 IndexedDB 加载身份 -> 防伪签名挑战 -> 写入 Token
*
* 🔁 幂等保证(关键!):
* 服务端 revokeOldSessions 每次新认证都会撤销旧 JTI。
* 短时间内多处调用 restoreSession(冷启动 / SW 唤醒 / 标签切换),
* 第一个 JTI 会被第二个撤销,30 秒后 revalidateLoop 命中导致 jwt_revoked GOAWAY。
* 因此:已经有 token 时直接复用,只在第一次或主动 logout 后才重新认证。
*/
restoreSession(): Promise<{
aliasId: string;
nickname: string;
} | null>;
/**
* 强制重新认证(用于 jwt_revoked 自愈场景)
* 不复用现有 token,始终走完整 authenticate 拿新 JTI。
* 但仍要和 restoreSession 共享同一把"in-flight"锁,避免和并发的 restoreSession 互相撤 JTI。
*/
reauthenticate(): Promise<{
aliasId: string;
nickname: string;
} | null>;
/**
* 注册:执行 PoW 防刷验证 -> 计算公钥 -> /register -> /auth/challenge -> /auth/verify
* V1.4.1 方案 A:靓号在注册完成后通过 vanity.bind() 接口绑定,注册时不再传入靓号订单
*/
registerAccount(mnemonic: string, nickname: string): Promise<{
aliasId: string;
}>;
/**
* 针对给定的 UUID 和私钥执行防伪鉴权,成功后将 token 注册到 http 内部并返回
*/
performAuthChallenge(userUUID: string, signingPrivateKey: Uint8Array): Promise<string>;
}
/**
* Observable<T> — 0.3.0 响应式数据层原语
*
* 设计目标:
* - bundle size ≤ 3KB gzipped(不引入 rxjs)
* - 语义与 Kotlin StateFlow / Swift AsyncStream 对齐
* - 零依赖
*
* 语义约定:
* - 只实现 "hot" 流(BehaviorSubject 语义):新订阅者立即收到当前值
* - emit 顺序严格,背压策略 = 最新覆盖(IM 场景合适)
* - unsubscribe 幂等,多次调用无副作用
*
* 不做什么:
* - 不做 rxjs 全集(Subject 以外的各种高级操作符)
* - 不做 cold observable(所有流一经创建即 hot)
* - 不做错误信道(错误走 client.events.error 总线)
*/
interface Subscription {
unsubscribe(): void;
readonly closed: boolean;
}
interface Observer<T> {
next?: (value: T) => void;
error?: (err: Error) => void;
complete?: () => void;
}
type Subscribable<T> = Observer<T> | ((value: T) => void);
interface Observable<T> {
subscribe(observer: Subscribable<T>): Subscription;
/** BehaviorSubject 语义:当前值(订阅时立即收到) */
readonly value: T;
/** 操作符:映射 */
map<U>(fn: (value: T) => U): Observable<U>;
/** 操作符:过滤(谓词为 false 的值不发射) */
filter(predicate: (value: T) => boolean): Observable<T>;
/** 操作符:值变化才发射(浅比较) */
distinctUntilChanged(compare?: (a: T, b: T) => boolean): Observable<T>;
}
/**
* BehaviorSubject<T> — 带当前值的 Observable
* SDK 内部使用,对外一律以 Observable<T> 类型暴露(readonly)
*/
declare class BehaviorSubject<T> implements Observable<T> {
private _value;
private _listeners;
private _completed;
constructor(initial: T);
get value(): T;
/** 内部 API:推送新值 */
next(value: T): void;
/** 内部 API:终结流(之后不再发射,但已有订阅者仍持有最后一个值) */
complete(): void;
subscribe(observer: Subscribable<T>): Subscription;
map<U>(fn: (value: T) => U): Observable<U>;
filter(predicate: (value: T) => boolean): Observable<T>;
distinctUntilChanged(compare?: (a: T, b: T) => boolean): Observable<T>;
}
/**
* src/events — 0.3.0 全局事件总线
*
* 取代 0.2.x 的 client.on('message', cb) 风格,改为四个顶层 Observable:
* - client.events.network: 连接状态
* - client.events.sync: 消息补同步状态
* - client.events.error: 非致命错误
* - client.events.message: 跨会话的全局消息流(UI 通知场景)
*
* 内部实现:包装 BehaviorSubject,对外只暴露 Observable<T> 只读视图
*/
type SyncState = {
tag: 'idle';
} | {
tag: 'syncing';
progress: number;
pendingMessages: number;
} | {
tag: 'done';
catchUpDurationMs: number;
};
type SDKErrorKind = 'auth' | 'network' | 'rate_limit' | 'crypto' | 'server' | 'unknown';
interface SDKError {
kind: SDKErrorKind;
message: string;
details?: Record<string, unknown>;
at: number;
}
/**
* SDK 内部使用的事件总线,持有可写的 BehaviorSubject
* 通过 .toPublic() 产出对外视图
*/
declare class EventBus {
readonly _network: BehaviorSubject<NetworkState>;
readonly _sync: BehaviorSubject<SyncState>;
readonly _error: BehaviorSubject<SDKError | null>;
readonly _message: BehaviorSubject<StoredMessage | null>;
emitNetwork(state: NetworkState): void;
emitSync(state: SyncState): void;
emitError(err: Omit<SDKError, 'at'>): void;
emitMessage(msg: StoredMessage): void;
toPublic(): PublicEventBus;
}
/** 对外只读的事件总线视图 */
interface PublicEventBus {
/** WebSocket 连接状态 · offline | connecting | online */
readonly network: Observable<NetworkState>;
/** 消息补同步状态 · idle | syncing | done */
readonly sync: Observable<SyncState>;
/** 非致命错误流(初值 null) */
readonly error: Observable<SDKError | null>;
/** 全局消息流 · 每条到达的消息(跨会话)· 初值 null */
readonly message: Observable<StoredMessage | null>;
}
/**
* src/contacts/module.ts — 0.4.0 ContactsModule(响应式首版)
*
* 直接命名 ContactsModule, 不叫 ReactiveContactsModule。
* 产品未公开发布, 没有历史包袱, 第一版就是终态。
*/
interface FriendProfile {
friendship_id: number;
alias_id: string;
nickname: string;
status: 'pending' | 'accepted' | 'rejected';
direction: 'sent' | 'received';
conversation_id: string;
x25519_public_key: string;
ed25519_public_key: string;
created_at: string;
}
declare class ContactsModule {
private readonly http;
private readonly events?;
private _friends;
private _primed;
private _refreshPromise;
constructor(http: HttpClient, events?: EventBus | undefined);
observeFriends(): Observable<FriendProfile[]>;
observeAccepted(): Observable<FriendProfile[]>;
observePending(): Observable<FriendProfile[]>;
observePendingCount(): Observable<number>;
/** 快照读取(非订阅) */
get friends(): FriendProfile[];
lookupUser(aliasId: string): Promise<{
alias_id: string;
nickname: string;
x25519_public_key: string;
ed25519_public_key: string;
}>;
/** 发送好友请求,成功后自动 refresh */
sendRequest(toAliasId: string): Promise<void>;
/** 接受好友请求(乐观更新 + rollback),返回 conversationId */
accept(friendshipId: number): Promise<string>;
/** 拒绝好友请求(乐观更新 + rollback) */
reject(friendshipId: number): Promise<void>;
/**
* 兼容 0.2.x API · 一次性拉取好友列表快照
* 指定描述: 底层执行 refresh(), 返回最新 friends 数组
* 推荐新代码用 observeFriends().subscribe() 或 observeFriends().value
*/
/** 兼容 0.2.x API */
acceptFriendRequest(friendshipId: number): Promise<string>;
/** 兼容 0.2.x API */
sendFriendRequest(toAliasId: string): Promise<void>;
/** 兼容 0.2.x API */
rejectFriendRequest(friendshipId: number): Promise<void>;
syncFriends(): Promise<FriendProfile[]>;
/** 手动触发刷新(WS 收到好友事件时 SDK 内部调用) */
refresh(): Promise<void>;
private _refresh;
private _reportError;
}
/**
* src/messaging/module.ts — 0.4.0 MessagesModule(响应式)
*
* 底层保留 MessageModule(单数, index.ts 里的)作为 WebSocket / Outbox 引擎。
* 本类是对外 API, 暴露 observeConversations / observeMessages / send。
*/
/** 1.0.38 公开三态 trustState 回调签名 — UI 用此监听核对状态切换。 */
type TrustStateChangeData = {
conversationId: string;
trustState: SessionTrustState;
};
type TrustStateChangeListener = (data: TrustStateChangeData) => void;
interface ConversationSummary {
conversationId: string;
peerAliasId: string;
peerNickname: string;
lastMessage?: {
text: string;
at: number;
fromMe: boolean;
status?: StoredMessage['status'];
};
unreadCount: number;
}
declare class MessagesModule {
private readonly inner;
private readonly events?;
private _conversations;
private _byConvId;
private _primed;
private _trustStateListeners;
constructor(inner: MessageModule, events?: EventBus | undefined);
/**
* 1.0.38 订阅 trustState 三态变化。返回 unsubscribe 函数。
*
* 触发时机:
* - `markMyVerified()` 后 → trustState='my_side_verified'
* - 服务端 NATS friend_verified → trustState='verified'(仅在本端已 my_side_verified 时)
* - `verifyReset()` 或服务端 NATS friend_verify_reset → trustState='unverified'
*
* 用法(替代 1.0.37 及更早的反射 hack):
* ```ts
* const unsub = client.messages.onTrustStateChange(({ conversationId, trustState }) => {
* if (conversationId !== activeChatId) return
* if (trustState === 'verified') { setShowModal(false); unlockChat() }
* else if (trustState === 'my_side_verified') { switchToWaitingView() }
* else { showVerifyOverlay() }
* })
* useEffect(() => unsub, [])
* ```
*/
onTrustStateChange(listener: TrustStateChangeListener): () => void;
observeConversations(): Observable<ConversationSummary[]>;
observeMessages(conversationId: string): Observable<StoredMessage[]>;
/** 发送消息,立即写本地,订阅者立刻看到 sending 状态。返回 messageId */
send(msg: OutgoingMessage): Promise<string>;
/** 清除某会话的本地消息(测试/退出账号场景) */
clearConversation(conversationId: string): Promise<void>;
/** 向对方发送正在输入事件 */
sendTyping(convId: string, toAliasId: string): void;
/** 标记对方消息为已读 */
markAsRead(convId: string, maxSeq: number, toAliasId: string): void;
/** 撤回自己发的消息 */
retract(messageId: string, toAliasId: string, conversationId: string): void;
/** 获取本地历史消息 */
getHistory(convId: string, opts?: {
limit?: number;
before?: number;
}): Promise<StoredMessage[]>;
/** 清除指定会话的本地消息 */
clearHistory(convId: string): Promise<void>;
/** 清除全部会话的本地消息 */
clearAllConversations(): Promise<void>;
/** 导出所有会话为 Blob URL(NDJSON) */
exportAll(): Promise<string>;
private _refreshSummary;
private _buildSummary;
private _loadConversation;
private _handleIncoming;
private _handleStatusChange;
}
/**
* sdk-typescript/src/media/manager.ts — 多媒体上传/下载(零知识盲中转)
* 支持图片(压缩)、文件(原始)、语音(原始)三类
*/
declare class MediaModule$1 {
private http;
constructor(http: HttpClient);
/**
* 极简外壳:自动压缩、调用端侧加密、并获得安全返回
* 成功即返回拼接好的快捷消息内容: "[img]media_key"
*/
uploadImage(conversationId: string, file: File, maxDim?: number, quality?: number): Promise<string>;
/**
* 上传通用文件(不压缩,直接加密分片上传)
* 返回 "[file]media_key|original_name|size_bytes"
*/
uploadFile(file: File, conversationId: string): Promise<string>;
/**
* 上传语音消息(不压缩,直接加密分片上传)
* @param durationMs 录音时长(毫秒)
* 返回 "[voice]media_key|duration_ms"
*/
uploadVoice(blob: Blob, conversationId: string, durationMs: number): Promise<string>;
/**
* 分片上传加密大文件 (由于原生 AES-GCM 限制,采用基于 Chunk 的流式加密)
* 返回 "[img]media_key" (此处重用业务层格式)
*/
uploadEncryptedFile(file: File, conversationId: string, maxDim?: number, quality?: number): Promise<string>;
/**
* 通用加密分片上传(共享逻辑)
* @returns media_key
*/
private encryptAndUpload;
/**
* 下载加密的媒体文件 (裸流,仅供内部解密使用)
*/
private downloadMedia;
/**
* 下载并流式解密媒体文件
*/
downloadDecryptedMedia(mediaKey: string, conversationId: string): Promise<ArrayBuffer>;
/**
* 压缩图片到指定最大尺寸(宽/高)
*/
private compressImage;
}
/**
* src/media/module.ts — 0.4.0 MediaModule(响应式上传进度)
*
* 对 0.2.x 老 MediaModule (manager.ts) 的上层包装, 提供:
* - sendImage / sendFile / sendVoice 返回 messageId
* - observeUpload(messageId) 返回 Observable<UploadProgress>
*
* 0.5+ 会把进度回调从伪曲线改成真实 byte-level, 届时接口不变。
*/
type UploadPhase = 'encrypting' | 'uploading' | 'done' | 'failed';
interface UploadProgress {
messageId: string;
phase: UploadPhase;
loaded: number;
total: number;
error?: string;
}
type MediaKind = 'image' | 'file' | 'voice';
declare class MediaModule {
private readonly events?;
private _uploads;
private _inner;
private _messages?;
constructor(low: MediaModule$1, events?: EventBus | undefined, messages?: MessagesModule);
sendImage(conversationId: string, toAliasId: string, file: File, opts?: {
maxDim?: number;
quality?: number;
thumbnail?: string;
replyToId?: string;
}): Promise<string>;
sendFile(conversationId: string, toAliasId: string, file: File, opts?: {
replyToId?: string;
}): Promise<string>;
sendVoice(conversationId: string, toAliasId: string, blob: Blob, durationMs: number, opts?: {
replyToId?: string;
}): Promise<string>;
/** 内部:通过 messages 模块把 payload 作为 IM 消息发给对端 */
private _sendMessage;
observeUpload(messageId: string): Observable<UploadProgress>;
/** 兼容 0.2.x API · 下载并解密媒介 */
downloadDecryptedMedia(mediaKey: string, conversationId: string): Promise<ArrayBuffer>;
/** 释放已完成上传的进度对象 */
dispose(messageId: string): void;
private _upload;
}
/**
* sdk-typescript/src/calls/index.ts — T-072+T-073
* WebRTC 信令状态机 + Insertable Streams E2EE(视频帧加密)
*
* 架构 §4:通话建立流程
* Caller → call_offer(含 SDP + Ed25519 签名)
* → Relay(透明转发)
* → Callee → call_answer / call_reject
* → ICE Candidate 交换
* ← TURN 中继 ← RTP
*/
type CallState = 'idle' | 'calling' | 'ringing' | 'connecting' | 'connected' | 'hangup' | 'rejected' | 'ended';
interface CallOptions {
audio?: boolean;
video?: boolean;
}
interface SignalTransport {
send(env: unknown): void;
onMessage(handler: (env: unknown) => void): void;
}
declare class CallModule {
private transport;
private iceConfigProvider;
private pc;
private callId;
private state;
private localStream;
private remoteStream;
private pendingCandidates;
private _outboundIceBarrier;
private _answerTimer;
private _iceRestartedOnce;
private _disconnectedTimer;
private _rtpWatchdogTimer;
private _lastInboundProgressAt;
private _rtpPrevInbound;
private flushIceCandidates;
/**
* getUserMedia 带超时(不降级):
* - 主请求超过 timeoutMs 没 resolve/reject → 抛 'gUM timeout'
* - reject → 向上抛
*
* ⚠️ 严格模式:用户点"视频通话"就必须真的拿到摄像头。
* 旧版本曾在 video 失败时静默降级到音频纯模式,但后果是:
* 1. PWA 拿到的 stream 只有 audio,addTrack 也只 add 了 audio
* 2. createOffer 仍可能产生 m=video transceiver(unified plan 默认行为)
* 3. 对端 Android 看到 SDP 有 m=video → 也开摄像头并发回视频
* 4. PWA 这边没 sender,但对端却以为是视频通话 — 双方语义错位
* 5. 用户看到"PWA 没弹摄像头权限,Android 也黑屏"
* 因此现在严格行为:video 失败 = 通话失败,UI 必须告知用户"摄像头不可用"。
*
* 修复历史:某些浏览器/环境下 getUserMedia 既不 resolve 也不 reject,
* 用 timeoutMs 做兜底,避免 call() 永远卡在 await。
*/
private getUserMediaWithTimeout;
private signingPrivKey;
private signingPubKey;
private myAliasId;
onStateChange?: (state: CallState) => void;
onRemoteStream?: (stream: MediaStream) => void;
onLocalStream?: (stream: MediaStream) => void;
onIncomingCall?: (fromAlias: string, isVideo: boolean) => void;
onError?: (err: Error) => void;
getLocalStream(): MediaStream | null;
getRemoteStream(): MediaStream | null;
constructor(transport: SignalTransport, iceConfigProvider: () => Promise<RTCConfiguration>, opts: {
signingPrivKey: Uint8Array;
signingPubKey: Uint8Array;
myAliasId: string;
});
call(toAliasId: string, opts?: CallOptions): Promise<void>;
private _answering;
answer(): Promise<void>;
reject(): void;
hangup(): void;
private _callerAlias;
private _remoteAlias;
private handleSignal;
private handleOffer;
private handleAnswer;
/**
* connectionState === 'connected' 后调一次。理由:
* - 协商前 RtpSender.getParameters() 拿不到 codec_payload_type,
* 部分浏览器 setParameters 会抛 InvalidModificationError
* - 接通后 encoding 已就位,改 maxBitrate 最稳
*
* 双端 maxBitrate 必须一致,否则 BWE 取低值,改动等于失效。
* 失败静默(老浏览器 / RN WebView 不一定支持),不能因此断了通话。
*/
private applyVideoSenderParams;
private _diagTimer;
private _diagPrev;
private startDiagDump;
private stopDiagDump;
/**
* 把 SDP 中"本地有对应 track"的 m= section 强制改写为 a=sendrecv。
* 用于修复 Chrome createOffer/createAnswer 在某些场景下生成
* sendonly/recvonly/inactive 导致一端收不到画面的 bug。
*
* - 主叫端 createOffer 后,以 localStream 的 track kinds 为依据修方向
* - 接听端 createAnswer 后同理
*
* 不改 m= section 没有本地 track 的方向(那种 section 本来就该 recvonly)。
*/
private forceSendrecvOnSdp;
private armAnswerTimeout;
private clearAnswerTimeout;
private armDisconnectedTimer;
private clearDisconnectedTimer;
/**
* 接通后周期性 getStats(每 2s),监控入站 RTP 字节/帧是否在涨。
* 视频通话:看 inbound-rtp video.bytesReceived + framesDecoded
* 纯音频:看 inbound-rtp audio.bytesReceived
* 持续 15s 无增长 → cleanup('ended')。
*
* 阈值 15s:Opus DTX 静音可能 5~8s 不发包,15s 留裕度。
* 实测对端 App 被杀场景下,inbound 字节 ~5s 内停止,15s 后兜底触发,体感 OK。
*/
private startRtpWatchdog;
private stopRtpWatchdog;
private handleICE;
private createPeerConnection;
private sendSignal;
private setState;
private cleanup;
private _pubKeyCache;
private fetchPubKey;
private _sessionKeyCache;
private getSessionKey;
}
/**
* src/calls/module.ts — 0.4.0 CallsModule(响应式通话)
*
* 底层 CallModule (calls/index.ts) 保留作为 WebRTC 引擎, 本类是对外 API。
* 响应式流:
* - observeState: CallState('idle' | 'calling' | 'ringing' | 'connecting' | 'connected' | 'hangup' | 'rejected' | 'ended')
* - observeLocalStream / observeRemoteStream
*
* 命令式 API:
* - start(peerAliasId, { audio, video })
* - accept()
* - reject(reason?)
* - hangup()
*/
declare class CallsModule {
private readonly inner;
private readonly events?;
private _state;
private _localStream;
private _remoteStream;
private _extraOnStateChange;
private _extraOnLocalStream;
private _extraOnRemoteStream;
set onStateChange(cb: ((s: CallState) => void) | undefined);
get onStateChange(): ((s: CallState) => void) | undefined;
set onLocalStream(cb: ((s: MediaStream) => void) | undefined);
get onLocalStream(): ((s: MediaStream) => void) | undefined;
set onRemoteStream(cb: ((s: MediaStream) => void) | undefined);
get onRemoteStream(): ((s: MediaStream) => void) | undefined;
set onIncomingCall(cb: ((fromAlias: string, isVideo: boolean) => void) | undefined);
get onIncomingCall(): ((fromAlias: string, isVideo: boolean) => void) | undefined;
set onError(cb: ((err: Error) => void) | undefined);
get onError(): ((err: Error) => void) | undefined;
constructor(inner: CallModule, events?: EventBus | undefined);
observeState(): Observable<CallState>;
observeLocalStream(): Observable<MediaStream | null>;
observeRemoteStream(): Observable<MediaStream | null>;
get currentState(): CallState;
start(peerAliasId: string, options: CallOptions): Promise<void>;
accept(): Promise<void>;
reject(reason?: string): Promise<void>;
hangup(): Promise<void>;
/** 为 PWA 旧 API 提供的别名: start() */
call(peerAliasId: string, options?: CallOptions): Promise<void>;
/** 为 PWA 旧 API 提供的别名: accept() */
answer(): Promise<void>;
/** 获取本地流 */
getLocalStream(): MediaStream | null;
/** 获取远端流 */
getRemoteStream(): MediaStream | null;
}
/**
* security/index.ts — SecurityModule(P3-004 修复)
*
* 实现文档 §2.2.1 SecurityModule 接口设计:
* - getSecurityCode(contactId) → 60位安全码(MITM 防御)
* - verifyInputCode(contactId, code) → 输入验证(主路径)
* - markAsVerified(contactId) → 手动标记已验证(辅助路径)
* - getTrustState(contactId) → 信任状态
* - resetTrustState(contactId) → 重置信任
*
* 所有状态存储于 IndexedDB(服务器完全不知情)
* 防劫持守护:每条消息触发前调用 guardMessage() 检测公钥突变
*/
interface SecurityCode {
contactId: string;
/** 60 位十六进制字符串,每 4 字符一组,如 "AB12 · F39C · ..." */
displayCode: string;
/** 原始 hex(用于 verifyInputCode 内部比对)*/
fingerprintHex: string;
}
type TrustState = {
status: 'unverified';
} | {
status: 'verified';
verifiedAt: number;
fingerprintSnapshot: string;
};
interface SecurityViolationEvent {
type: 'security_violation';
contactId: string;
previousFingerprint: string;
currentFingerprint: string;
detectedAt: number;
message: null;
}
declare class SecurityModule {
/**
* 获取与指定联系人的安全码(60 位 hex 字符串)
* 每次加好友后全自动生成,UI 打开"加密详情"页时调用
*/
getSecurityCode(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<SecurityCode>;
/**
* 输入验证(主路径):
* 用户粘贴对方通过微信/TG 发来的 60 位安全码,SDK 自动与本地计算值比对
* 返回 true → 一致(无 MITM)→ 自动写入 verified
* 返回 false → 不一致(公钥被篡改)
*/
verifyInputCode(contactId: string, inputCode: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<boolean>;
/**
* 手动标记为"已验证"(辅助路径):
* 用户通过截图肉眼比对后,手动点击按钮调用此方法
* 服务器完全不知情(存储于 IndexedDB)
*/
markAsVerified(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<void>;
/**
* 获取指定联系人当前的信任状态
*/
getTrustState(contactId: string): Promise<TrustState>;
/**
* 重置验证状态:将 trust_state 还原为 'unverified'
* 场景:用户主动换设备/助记词后需重新核查
*/
resetTrustState(contactId: string): Promise<void>;
/**
* 防劫持守护(文档 §2.2 流程图三):
* 每条消息到达时自动调用,若检测到公钥突变,返回 SecurityViolationEvent
*
* @returns null → 验证通过,可正常解密
* @returns SecurityViolationEvent → 公钥突变,拒绝解密并上报 UI
*/
guardMessage(contactId: string, currentMyEcdh: Uint8Array, currentTheirEcdh: Uint8Array): Promise<SecurityViolationEvent | null>;
}
/**
* src/security/module.ts — 0.4.0 SecurityModule(响应式)
*
* 底层 security/index.ts 里的 SecurityModule(命令式)保留不动,
* 本文件把它包装成响应式 ContactTrust 流。
*
* 重命名:对外类名用 SecurityService 避免与底层 SecurityModule 同名冲突。
*/
declare class SecurityService {
private readonly inner;
private _states;
constructor(inner: SecurityModule);
observeTrust(contactId: string): Observable<TrustState>;
getTrust(contactId: string): Promise<TrustState>;
getSafetyCode(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<SecurityCode>;
verifyCode(contactId: string, inputCode: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<boolean>;
markVerified(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<void>;
reset(contactId: string): Promise<void>;
private _loadTrust;
private _emitCurrent;
}
interface ChannelInfo {
id: string;
name: string;
description: string;
role?: string;
is_subscribed?: boolean;
/** 频道是否处于出售状态 */
for_sale?: boolean;
/** 出售价格(USDT),仅当 for_sale=true 时有值 */
sale_price?: number;
}
interface ChannelPost {
id: string;
type: string;
content: string;
created_at: string;
author_alias_id: string;
}
/** 频道交易订单(来自 POST /api/v1/channels/{id}/buy)*/
interface ChannelTradeOrder {
order_id: string;
/** 单位 USDT */
price_usdt: number;
/** TRON 收款地址 */
pay_to: string;
/** 订单有效期(ISO 8601) */
expired_at: string;
}
declare class ChannelsModule {
private http;
constructor(http: HttpClient);
/**
* Search for public channels
*/
search(query: string): Promise<ChannelInfo[]>;
/**
* Get channels subscribed by current user
*/
getMine(): Promise<ChannelInfo[]>;
/**
* Get channel details
*/
getDetail(channelId: string): Promise<ChannelInfo>;
/**
* Create a new channel
*/
create(name: string, description: string, isPublic?: boolean): Promise<{
channel_id: string;
}>;
/**
* Subscribe to a channel
*/
subscribe(channelId: string): Promise<void>;
/**
* Unsubscribe from a channel
*/
unsubscribe(channelId: string): Promise<void>;
/**
* Post a message to a channel
*/
postMessage(channelId: string, content: string, type?: string): Promise<{
post_id: string;
}>;
/**
* Get channel post history
*/
getPosts(channelId: string): Promise<ChannelPost[]>;
/**
* Check if current user can post in the channel
*/
canPost(channelInfo: ChannelInfo | null): boolean;
/**
* 将自有频道挂牌出售(T-096,需要 JWT,必须是频道 Owner)
*
* 使用乐观锁 CAS 设置售价,挂牌后其他用户可通过 `buyChannel` 购买。
*
* @param channelId 要出售的频道 ID
* @param priceUsdt 出售价格(USDT,整数)
*
* @example
* await client.channels.listForSale('ch_abc123', 200)
*/
listForSale(channelId: string, priceUsdt: number): Promise<void>;
/**
* 购买频道 — 创建支付订单(T-096,需要 JWT)
*
* 使用乐观锁 CAS 防止超卖。返回后向用户展示 TRON 收款地址,
* 链上确认后 pay-worker 自动完成频道所有权转移,并推送 `payment_confirmed` WS 事件。
*
* @throws 409 — 频道刚被其他人抢购,请刷新后重试
* @throws 404 — 频道不存在
* @throws 400 — 试图购买自己的频道
*
* @example
* const order = await client.channels.buyChannel('ch_abc123')
* showQRCode(order.pay_to, order.price_usdt)
*/
buyChannel(channelId: string): Promise<ChannelTradeOrder>;
/**
* 购买额外频道创建配额(每次购买增加 1 个席位,固定 5 USDT)
*
* @throws 401 — 需要登录
* @example
* const order = await client.channels.buyQuota()
* showQRCode(order.pay_to, order.price_usdt)
*/
buyQuota(): Promise<ChannelTradeOrder>;
}
/**
* sdk-typescript/src/vanity/manager.ts — T-095 VanityModule
*
* 负责靓号的搜索、购买(创建支付订单)和链上确认回调订阅。
* 支付感知通过 WS `payment_confirmed` 帧 → MessageModule.onPaymentConfirmed → 本模块路由实现。
*
* V1.4.1(方案 A):靓号商店已移至注册完成后,主流程:
* 1. purchase(aliasId) — 注册后购买(需要 JWT),创建 PENDING 订单
* 2. 监听 onPaymentConfirmed() WS 推送 → 链上确认
* 3. bind(orderId) — 支付确认后绑定靓号到账户 alias_id
*
* @deprecated reserve() / orderStatus() 为旧版注册前流程遗留接口,不再使用
*/
/** 靓号列表项(来自 GET /api/v1/vanity/search)
* V1.3 规则引擎版:价格由后端 rules.go 实时评估,不再依赖预填表 */
interface VanityItem {
alias_id: string;
price_usdt: number;
/** 靓号等级:'top' | 'premium' | 'standard' */
tier: string;
is_featured: boolean;
}
/** 购买靓号返回的支付订单(来自 POST /api/v1/vanity/purchase,需 JWT)*/
interface PurchaseOrder {
order_id: string;
alias_id: string;
/** 单位 USDT */
price_usdt: number;
/** NOWPayments 托管支付页 URL(V1.5.0 新增,接入 NOWPayments 后返回)*/
payment_url: string;
/** @deprecated 原始 TRON 地址,NOWPayments 接入后不再使用 */
pay_to?: string;
/** 订单有效期(ISO 8601) */
expired_at: string;
}
/**
* 注册前预订靓号返回的订单(来自 POST /api/v1/vanity/reserve,**无需 JWT**)
* 与 PurchaseOrder 结构相同,但不绑定用户 UUID(注册前无身份)
*/
interface ReserveOrder {
order_id: string;
alias_id: string;
price_usdt: number;
/** TRON 收款地址 */
pay_to: string;
/** 订单有效期(ISO 8601) */
expired_at: string;
}
/**
* 订单状态查询结果(来自 GET /api/v1/vanity/order/{id}/status,**无需 JWT**)
* 用于注册前用户轮询支付结果
*/
interface OrderStatus {
status: 'pending' | 'confirmed' | 'expired';
alias_id: string;
}
/** WS payment_confirmed 事件(pay-worker 链上确认后推送)*/
interface PaymentConfirmedEvent {
type: 'payment_confirmed';
order_id: string;
/** 靓号购买 → alias_id;频道交易 → channel_id */
ref_id: string;
}
declare class VanityModule {
private http;
private paymentListeners;
constructor(http: HttpClient);
/**
* 搜索靓号 / 获取精选列表(T-091 公开接口,无需 JWT)
*
* - `q` 为空 → 返回精选 (is_featured=1),按价格升序
* - `q` 非空 → 按 alias_id 前缀匹配(LIKE 'q%')
*
* @example
* const featured = await client.vanity.search()
* const results = await client.vanity.search('888')
*/
search(q?: string): Promise<VanityItem[]>;
/**
* @deprecated V1.4.1 方案 A 后不再使用。旧版注册前公开预订靓号(无需 JWT)。
* 请改用 purchase()(注册后,需 JWT)+ bind() 流程。
*/
reserve(aliasId: string): Promise<ReserveOrder>;
/**
* @deprecated V1.4.1 方案 A 后不再使用。旧版注册前轮询订单状态(无需 JWT)。
* 注册后请改用 onPaymentConfirmed() WS 推送。
*/
orderStatus(orderId: string): Promise<OrderStatus>;
/**
* 购买靓号 — 创建支付订单(T-090,**需要 JWT**)
*
* V1.4.1 方案 A:注册完成后的首次引导页调用此方法。
* 使用乐观锁 CAS 占位 15 分钟。返回后向用户展示 TRON 收款地址,
* 用户链上转账后 pay-worker 自动完成确认,并通过 WS 推送 `payment_confirmed`。
* 收到推送后,调用 bind(orderId) 将靓号正式绑定到账户。
*
* @throws 409 — 靓号已被其他人抢占,请提示用户更换
* @throws 404 — 靓号不存在
*
* @example
* const order = await client.vanity.purchase('88888888')
* // 展示支付弹窗
* client.vanity.onPaymentConfirmed(async e => {
* const { alias_id } = await client.vanity.bind(e.order_id)
* store.setAliasId(alias_id)
* })
*/
purchase(aliasId: string): Promise<PurchaseOrder>;
/**
* 绑定靓号到当前账户(**V1.4.1 新增,需要 JWT**)
*
* 在 pay-worker 确认链上支付后,调用此方法将 `alias_id` 正式写入 identity 表。
* 通常在 onPaymentConfirmed() 回调内调用。
*
* @param orderId — 已确认的 `payment_order.id`
* @returns `{ alias_id }` — 绑定成功的靓号
*
* @throws 404 — 订单不存在或不属于当前用户
* @throws 409 — 订单未确认 / 靓号绑定冲突
*
* @example
* const { alias_id } = await client.vanity.bind(orderId)
* store.setAliasId(alias_id)
*/
bind(orderId: string): Promise<{
alias_id: string;
}>;
/**
* 订阅支付完成回调(链上确认后 pay-worker → WS 推送)
*
* 返回 unsubscribe 函数,可直接在 React `useEffect` 清理函数中调用。
*
* @example
* useEffect(() => {
* return client.vanity.onPaymentConfirmed(e => {
* toast(`🎉 靓号 ${e.ref_id} 已绑定到你的账号!`)
* router.push('/profile')
* })
* }, [])
*/
onPaymentConfirmed(cb: (e: PaymentConfirmedEvent) => void): () => void;
/**
* @internal SDK 内部路由入口,由 MessageModule handleFrame 在收到
* `payment_confirmed` WS 帧时调用,App 层不应直接调用此方法。
*/
_handlePaymentConfirmed(event: PaymentConfirmedEvent): void;
}
/**
* PushNotificationError — SDK push 模块的语义化错误
*
* 之前 enablePushNotifications 失败只 console.warn,UI 端拿到 generic Error
* 无法定位是哪一步出错。1.0.39 改成抛 PushNotificationError 带 kind 字段,
* UI 可以根据 kind 显示具体修复建议。
*
* kind 列表:
* - 'no_push_manager' SW 不支持 pushManager(老浏览器 / 不安全上下文)
* - 'no_vapid_key' 服务端没返回 VAPID 公钥(服务端配置缺失)
* - 'subscribe_failed' pushManager.subscribe 失败 (iOS 必须 standalone + user gesture)
* - 'permission_denied' 通知权限被拒(用户在系统 / 浏览器设置里拒了)
* - 'register_failed' 服务端 POST /push/subscribe 失败
* - 'unknown' 其他
*/
declare class PushNotificationError extends Error {
readonly kind: 'no_push_manager' | 'no_vapid_key' | 'subscribe_failed' | 'permission_denied' | 'register_failed' | 'unknown';
readonly cause?: unknown;
constructor(kind: PushNotificationError['kind'], message: string, cause?: unknown);
}
declare class PushModule {
private http;
constructor(http: HttpClient);
/**
* 浏览器申请推送凭证并向服务端注册
* 此方法需要依赖浏览器的 ServiceWorker API,仅在 Web 端有效
*
* 失败时抛 PushNotificationError 带 kind 字段,UI 可据此显示具体修复建议。
*/
enablePushNotifications(swRegistration: ServiceWorkerRegistration, vapidPublicKey?: string): Promise<void>;
/**
* 注销当前设备的推送订阅
* 对应服务端 POST /api/v1/push/disable — 清空 device_session.push_endpoint
* 对标 Android/iOS SDK: client.push.disable() / .disablePush()
*/
disablePush(): Promise<void>;
/**
* 1.0.39: 诊断当前推送链路状态, 返回 6 项检查结果。
* 用于 Settings → 推送诊断 UI, 让用户一键自查为什么收不到推送。
*
* 返回 PushDiagnostics 对象, 调用方根据 status 显示状态 + 修复建议。
*/
diagnose(swRegistration?: ServiceWorkerRegistration): Promise<PushDiagnostics>;
private urlBase64ToUint8Array;
}
/** 1.0.39: PushModule.diagnose() 返回值 */
interface PushDiagnostics {
overall: 'ok' | 'warn' | 'bad' | 'unknown';
checks: {
pushApiSupport?: {
ok: boolean;
detail: string;
};
serviceWorker?: {
ok: boolean;
detail: string;
};
permission?: {
ok: boolean;
detail: string;
};
subscription?: {
ok: boolean;
detail: string;
};
serverVapid?: {
ok: boolean;
detail: string;
};
standalone?: {
ok: boolean;
detail: string;
};
};
}
/**
* src/events/streams-ext.ts — 0.4.0 事件总线扩展流
*
* 为什么是扩展文件?
* events/index.ts 里 EventBus 已经有 4 个核心流 (network / sync / error / message)。
* PWA 实际 UI 还依赖:
* - typing 对方输入提示
* - statusChange 消息送达/已读回执
* - channelPost 公共频道新帖
* - goaway 服务端通知下线 (多设备登录被踢等)
*
* 不修改 events/index.ts 的前提下, 用一个 ExtendedEventBus 组合它 + 4 个新流。
* SecureChatClient.events 将是 PublicExtendedEventBus 类型。
*/
interface TypingEvent {
fromAliasId: string;
conversationId: string;
}
interface MessageStatusEvent {
id: string;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
}
interface ChannelPostEvent {
channelId: string;
postId: string;
fromAliasId: string;
text: string;
at: number;
[k: string]: any;
}
interface GoawayEvent {
reason: string;
at: number;
}
/** P0-C(2026-04-26): 通讯录变更事件
* - friend_request: 收到他人发来的好友请求(`from` = 对方 user_uuid)
* - friend_accepted: 自己之前发出的请求被对方接受(`by` = 对方 user_uuid, `conv_id` = 新生成的 DM 会话 id)
* UI 应在收到此事件后重新拉取好友列表/请求列表,无需轮询 */
interface ContactsChangeEvent {
type: 'friend_request' | 'friend_accepted';
from?: string;
by?: string;
conv_id?: string;
at: number;
}
interface PublicExtendedEventBus extends PublicEventBus {
/** 对方正在输入 · 初值 null */
readonly typing: Observable<TypingEvent | null>;
/** 消息状态流转(send/delivered/read/failed)· 初值 null */
readonly messageStatus: Observable<MessageStatusEvent | null>;
/** 公共频道新帖 · 初值 null */
readonly channelPost: Observable<ChannelPostEvent | null>;
/** 服务端通知下线(多设备登录被踢 / 强制登出)· 初值 null */
readonly goaway: Observable<GoawayEvent | null>;
/** P0-C: 通讯录变更(收到好友请求 / 对方同意请求)· 初值 null
* UI 应订阅此流并重新拉取好友列表,替代之前的 10s 轮询 */
readonly contactsChange: Observable<ContactsChangeEvent | null>;
}
/**
* src/client-v2.ts — 0.4.0 SecureChatClient(响应式首版, 清爽 API)
*
* ⚠️ 重要:
* 晚上应用 patch 时, 把本文件改名为 client.ts(替换老文件)。
* 本轮 session 规则限制不能直接改 client.ts, 所以并行命名为 v2。
*
* 0.4.0 终态 API:
* const client = new SecureChatClient()
* await client.auth.registerAccount(mnemonic, 'Alice')
* client.contacts.observeFriends().subscribe(handler)
* client.events.network.subscribe(handler)
*
* 删除:
* - attachReactive(client) 门面
* - client.on / client.off EventEmitter API
* - syncFriends / getConversations / 所有命令式 getter
*/
interface SecureChatClientOptions {
/** 自定义 relay URL, 留空用默认 `https://relay.daomessage.com` */
relayUrl?: string;
}
declare class SecureChatClient {
readonly transport: RobustWSTransport;
readonly http: HttpClient;
readonly auth: AuthModule;
readonly contacts: ContactsModule;
readonly messages: MessagesModule;
readonly media: MediaModule;
readonly security: SecurityService;
readonly channels: ChannelsModule;
readonly vanity: VanityModule;
readonly push: PushModule;
calls: CallsModule | null;
readonly events: PublicExtendedEventBus;
private readonly _bus;
private readonly _messageInner;
static readonly CORE_API_BASE = "https://relay.daomessage.com";
constructor(opts?: SecureChatClientOptions);
/** 手动连接 WebSocket(`registerAccount / loginWithMnemonic / restoreSession` 会自动连, 通常不需要手动调) */
/**
* 初始化通话模块(需传入身份签名密钥对)
* 内部则建一个底层 CallModule + 响应式包装 CallsModule
*/
initCalls(opts: {
signingPrivKey: Uint8Array;
signingPubKey: Uint8Array;
myAliasId: string;
alwaysRelay?: boolean;
}): void;
connect(): Promise<void>;
/** 手动断开 WS(调试 / 省电模式) */
disconnect(): void;
get isReady(): boolean;
}
export { type CallOptions, type CallState, CallsModule, type ChannelPostEvent, type ChannelTradeOrder, ChannelsModule, ContactsModule, type ConversationSummary, type FriendProfile, type GoawayEvent, type Identity, type KeyPair, type MediaKind, MediaModule, type MessageStatus, type MessageStatusEvent, MessagesModule, type NetworkState, type Observable, type Observer, type OrderStatus, type OutboxIntent, type OutgoingMessage, type PaymentConfirmedEvent, type PublicEventBus, type PublicExtendedEventBus, type PurchaseOrder, type PushDiagnostics, PushModule, PushNotificationError, type QuarantinedMessage, type ReserveOrder, type SDKError, type SDKErrorKind, SecureChatClient, type SecureChatClientOptions, type SecurityCode, SecurityService, type SessionRecord, type SessionTrustState, type StoredIdentity, type StoredMessage, type Subscribable, type Subscription, type SyncState, type TrustState, type TypingEvent, type UploadPhase, type UploadProgress, type VanityItem, VanityModule, clearIdentity, computeDirectionalCode, computeSecurityCode, computeSharedSecret, deleteSession, deriveIdentity, formatSecurityCode, fromBase64, fromHex, listSessions, loadIdentity, loadSession, markMyVerified, markSessionMyVerified, markSessionVerified, maybeMarkSessionVerified, newMnemonic, normalizeDirectionalCode, normalizeSecurityCode, resetSessionTrust, toBase64, toHex, validateMnemonicWords, verifyReset };Part C: 最小可运行接线示例
以下是将 SDK 接入 App 的关键接线代码。 AI 应以此为模式参考,扩展出完整的聊天应用。
C.1 SDK 单例(全局一个实例)
import { SecureChatClient } from '@daomessage_sdk/sdk';
// 无参实例化——API 地址已硬编码在 SDK 内
export const client = new SecureChatClient();C.2 冷启动流程
// App 启动时 → 尝试恢复会话
const session = await client.restoreSession();
if (!session) {
// 首次使用 → 进入注册流程
navigateTo('welcome');
} else {
// 已注册 → 连接 WebSocket + 同步好友
const { aliasId, nickname } = session;
client.connect().catch(console.warn); // 优先获取 WS ticket 后再建连
await client.contacts.syncFriends();
navigateTo('main');
}C.3 注册流程
import { newMnemonic } from '@daomessage_sdk/sdk';
// Step 1: 生成 12 词助记词(同步函数!)
const mnemonic = newMnemonic();
// → 展示给用户备份
// Step 2: 用户确认备份后注册
const { aliasId } = await client.auth.registerAccount(mnemonic, nickname);
// → SDK 自动完成:PoW → 密钥派生 → HTTP 注册 → JWT 获取
// Step 3: 连接
client.connect().catch(console.warn);C.4 事件订阅(React 模式)
useEffect(() => {
// 返回值是 unsubscribe 函数,可直接用于 cleanup
const unsub1 = client.on('message', (msg) => {
// msg: StoredMessage — 已解密的消息
setMessages(prev => [...prev, msg]);
});
const unsub2 = client.on('network_state', (state) => {
// state: 'connected' | 'connecting' | 'disconnected'
setNetworkState(state);
});
const unsub3 = client.on('goaway', (reason) => {
// 被其他设备踢下线 → 清除身份 → 跳转登录
client.disconnect();
clearIdentity();
navigateTo('welcome');
});
return () => { unsub1(); unsub2(); unsub3(); };
}, []);C.5 好友列表 + 分类
// syncFriends() 自动建立 ECDH 会话(不需要 App 层干预)
const friends = await client.contacts.syncFriends();
// HTTP 返回 snake_case 字段!
const pendingReceived = friends.filter(f => f.status === 'pending' && f.direction === 'received');
const pendingSent = friends.filter(f => f.status === 'pending' && f.direction === 'sent');
const accepted = friends.filter(f => f.status === 'accepted');
// 点击好友进入聊天 → 用 conversation_id(snake_case!)
setActiveChatId(friend.conversation_id);C.6 发消息 + 收消息
// 发文本
const msgId = await client.sendMessage(conversationId, toAliasId, 'Hello!');
// 发图片(SDK 自动:压缩 → 加密 → 上传 → 拼协议)
const imgId = await client.sendImage(conversationId, toAliasId, file);
// 撤回
await client.retractMessage(msgId, toAliasId, conversationId);
// 标记已读
client.markAsRead(conversationId, maxSeq, toAliasId);
// 加载历史(分页)
const history = await client.getHistory(conversationId, { limit: 50, before: oldestTimestamp });C.7 退出登录(顺序很重要)
import { clearIdentity } from '@daomessage_sdk/sdk';
client.disconnect(); // 1. 断开 WebSocket
await clearIdentity(); // 2. 清除 IndexedDB 身份
await client.clearAllHistory(); // 3. 清除消息历史
localStorage.clear(); // 4. 清除 App 层缓存
navigateTo('welcome');C.8 GOAWAY 多端踢出处理
// 必须监听!不处理会导致多设备冲突
useEffect(() => client.on('goaway', async (reason) => {
client.disconnect();
await clearIdentity();
await client.clearAllHistory();
localStorage.clear();
navigateTo('welcome');
}), []);C.9 强制双边密钥核对(铁律 11 配套实现)
三状态状态机:
unverified→my_side_verified→verified完整背景见 Developer Guide 的 Security 章节(doc.daomessage.com/en/guide/security)。 这里给出 AI 生成代码时必须遵循的接线模式。
C.9.1 计算双向核对码(用户在 modal 里看到的码)
import {
loadIdentity, deriveIdentity, loadSession, fromBase64,
computeSharedSecret, computeDirectionalCode, normalizeDirectionalCode,
computeSecurityCode, formatSecurityCode,
} from '@daomessage_sdk/sdk';
// modal 打开时计算
const stored = await loadIdentity();
const identity = deriveIdentity(stored!.mnemonic);
const myPriv = identity.ecdhKey.privateKey;
const myPub = identity.ecdhKey.publicKey;
const session = await loadSession(conversationId);
const theirPub = fromBase64(session!.theirEcdhPublicKey);
const shared = computeSharedSecret(myPriv, theirPub);
// "我的码"——给用户看 + 通过外部渠道(微信/邮件/短信)发给对方
const myCode = computeDirectionalCode(shared, myPub, theirPub);
// "对方应该发我的码"——本地比对用,不显示给用户
const expectedTheirCode = computeDirectionalCode(shared, theirPub, myPub);
// 高级模式:60 hex 完整公钥指纹(双方一致,给极客对照)
const fingerprint60 = formatSecurityCode(computeSecurityCode(myPub, theirPub));关键约束(不要踩):
- A 看到的
myCode≠ B 看到的myCode,这是故意的(方向性派生)。UI 必须告知用户"双方的码不一样是正常的"。- 比对必须用
normalizeDirectionalCode()归一化后再===,不能比较原始字符串(用户输入会带空格/横线/大小写)。
C.9.2 用户输入对方码 → 本地比对 → 标记我这边核对完
import { markMyVerified } from '@daomessage_sdk/sdk';
const ok = normalizeDirectionalCode(userInput)
=== normalizeDirectionalCode(expectedTheirCode);
if (!ok) {
// 显示错误提示。3 次失败 → 强警告(可能是 MITM 或复制错位)
return;
}
// 比对通过 → 调 SDK 标记
await markMyVerified(
'https://relay.daomessage.com',
await (client as any).http.getToken(), // 当前 JWT
friendshipId, // 从 session 或 friend 对象拿
conversationId,
);
// 此刻:本地 trustState = 'my_side_verified'
// 服务端 friendship.user_X_verified_at 已写
// 但还不能发消息——等对方也调 markMyVerifiedC.9.3 监听 trustState 变化 → 解锁聊天
// 拿到 MessageModule 内部实例(SDK 这一层未来会有官方 client.on('trust_state', ...) API)
const inner = (client as any).messages?.inner;
if (inner) {
inner.onTrustStateChange = async ({ conversationId: cId }) => {
const fresh = await loadSession(cId);
const newState = fresh?.trustState ?? 'unverified';
setTrustState(newState);
// 双方都完成核对 → 自动关闭 modal + 解锁聊天
if (newState === 'verified') {
setShowVerifyModal(false);
}
};
}C.9.4 三状态 UI 必须区分
import type { SessionTrustState } from '@daomessage_sdk/sdk';
const overlay = trustState === 'verified'
? null // 完全解锁,正常聊天 UI
: trustState === 'my_side_verified'
? <BlueOverlay text="You verified. Waiting for peer..." />
: <YellowOverlay text="Verify security code to unlock chat" actionButton="Start verification" />;| trustState | overlay | header 角标 | 输入栏 |
|---|---|---|---|
unverified | 黄色硬遮罩 + "开始核对"按钮 | ⚠ 未核验安全会话 | 禁用 |
my_side_verified | 蓝色软遮罩 + "等对方核对" + spinner | ✓ 等对方核对 | 禁用 |
verified | 无遮罩 | 绿色 ✓ 已核验 | 正常 |
C.9.5 modal 三状态视图(核心 UX)
modal 打开时根据当前 session.trustState 决定显示哪个视图,不要每次都从输入态开始(用户已经核对过的话,再开 modal 直接显示等待视图):
const view = session.trustState === 'my_side_verified' ? 'waiting' : 'input';view='input':显示我的码 + 输入对方码 + [确认核对] 按钮view='waiting':显示我的码(带"重新分享"按钮,应付对方说"没收到再发一次")+ "等待对方核对中" 提示 + [关闭] 按钮
markMyVerified 成功后不要立即关闭 modal,切换到 view='waiting',让用户明确感知"我这边已经完成了"。当 trustState 升到 verified 时(onTrustStateChange 回调中),父组件再自动关闭 modal。
C.9.6 重置核对(对方换设备 / 怀疑被攻击)
import { verifyReset } from '@daomessage_sdk/sdk';
await verifyReset(
'https://relay.daomessage.com',
await (client as any).http.getToken(),
friendshipId,
conversationId,
);
// 双方都降回 unverified,quarantine 队列**直接丢弃**(不回放,因为可能含攻击注入的伪造消息)C.9.7 send 失败处理
try {
await client.sendMessage(conversationId, toAliasId, text);
} catch (e: any) {
if (e?.kind === 'UNVERIFIED_SESSION' || /unverified/i.test(e?.message)) {
// SDK 拦截了:未核对不能发
showToast('Please verify the security code first');
setShowVerifyModal(true);
return;
}
// 其他错误...
}Part D: Android/Kotlin 实现指南
§十七 Android SDK 接入指南
本节面向 AI:基于
sdk-android/生成 Android 版 DAO MESSAGE 客户端时,必须遵守本节所有约束。 ⚠️ 所有 Phase 都是强制交付的,不可跳过任何一个。
17.1 SDK 位置与依赖
monorepo/
├── sdk-android/ ← Android Library(Kotlin)
│ ├── sdk/ ← 主模块,对标 @daomessage_sdk/sdk
│ ├── sample-app/ ← 最小示例(Compose)
│ └── README.md ← AI Vibecoding Prompt 入口接入方式(monorepo 本地依赖):
// settings.gradle.kts
includeBuild("../sdk-android")
// app/build.gradle.kts
dependencies {
implementation("space.securechat:sdk")
}17.2 初始化(Application.onCreate,一次)
// 🔒 SDK 自动创建 Room DB、HttpClient、BouncyCastle 安全提供者
SecureChatClient.init(applicationContext)
val client = SecureChatClient.getInstance()17.3 注册流程(对标 Web 端 Welcome → GenerateMnemonic → SetNickname → VanityShop)
// Step 1: 生成助记词(展示给用户备份)
val mnemonic = KeyDerivation.newMnemonic()
// Step 2: 用户确认备份后注册(PoW + 密钥派生 + JWT 全自动)
val aliasId = client.auth.registerAccount(mnemonic, nickname = "Alice")
// Step 3: 连接 WebSocket
client.connect()
// Step 4: 同步好友(建立 ECDH 会话)
val friends = client.contacts.syncFriends()
// Step 5: 进入靓号商店(可跳过)— 见 17.3.1
navigateToVanityShop()17.3.1 靓号商店 VanityShopScreen(⚠️ 仅 Onboarding 阶段,注册后不能再买)
🔴 铁律:靓号仅在注册后的 onboarding 流程中可购买,一旦进入主界面后不再提供任何购买入口。 alias_id 一旦绑定终身不可更改。用户可跳过此步骤,使用系统分配的默认 ID。
// 🔒 SDK API(来自 VanityManager)
// 搜索靓号(公开 API,无需 JWT)
val items: List<VanityItem> = client.vanity.search("888")
// VanityItem(aliasId, priceUsdt, tier, isFeatured)
// 购买下单
val order: PurchaseResult = client.vanity.purchase("88888888")
// PurchaseResult(orderId, aliasId, amountUsdt, paymentUrl, expiredAt)
// 轮询订单状态
val status: String = client.vanity.orderStatus(order.orderId)
// "PENDING" | "CONFIRMING" | "COMPLETED" | "EXPIRED"
// 绑定靓号(支付完成后)
val newAliasId: String = client.vanity.bind(order.orderId)Compose UI 示例
@Composable
fun VanityShopScreen(onSkip: () -> Unit, onBound: (String) -> Unit) {
var query by remember { mutableStateOf("") }
var items by remember { mutableStateOf<List<VanityItem>>(emptyList()) }
var loading by remember { mutableStateOf(false) }
var order by remember { mutableStateOf<PurchaseResult?>(null) }
var orderStatus by remember { mutableStateOf("") }
val client = SecureChatClient.getInstance()
val scope = rememberCoroutineScope()
// 首次加载 + 防抖搜索
LaunchedEffect(query) {
delay(400)
loading = true
items = try { client.vanity.search(query) } catch (_: Exception) { emptyList() }
loading = false
}
// 轮询支付状态
LaunchedEffect(order) {
val o = order ?: return@LaunchedEffect
while (true) {
delay(5000)
val st = try { client.vanity.orderStatus(o.orderId) } catch (_: Exception) { continue }
orderStatus = st
when (st) {
"COMPLETED" -> {
val newId = client.vanity.bind(o.orderId)
onBound(newId)
return@LaunchedEffect
}
"EXPIRED" -> { order = null; return@LaunchedEffect }
}
}
}
// ── 支付等待 UI ──
if (order != null && orderStatus != "COMPLETED") {
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) {
Text("💎 Complete Payment", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
Text("Amount: ${order!!.amountUsdt} USDT", style = MaterialTheme.typography.bodyLarge)
// 👤 App:展示 payment URL(可用 WebView 或系统浏览器打开)
SelectionContainer { Text(order!!.paymentUrl, color = Color(0xFF60A5FA)) }
Spacer(Modifier.height(16.dp))
if (orderStatus == "CONFIRMING") {
Text("⏳ Confirming on blockchain...")
} else {
Text("⏳ Waiting for payment...")
}
TextButton(onClick = { order = null }) { Text("Cancel") }
}
return
}
// ── 主界面 ──
Column(Modifier.fillMaxSize()) {
// 搜索框
OutlinedTextField(query, onValueChange = { query = it },
placeholder = { Text("Search numbers (e.g. 888, 666)") },
modifier = Modifier.fillMaxWidth().padding(16.dp))
if (loading) { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } }
LazyColumn(Modifier.weight(1f)) {
items(items) { item ->
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
if (item.isFeatured) Text("⭐ ", color = Color(0xFFFACC15))
Text(item.aliasId, style = MaterialTheme.typography.titleLarge.copy(fontFamily = FontFamily.Monospace))
Spacer(Modifier.width(8.dp))
Text(item.tier, style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.weight(1f))
Button(onClick = { scope.launch { order = client.vanity.purchase(item.aliasId) } }) {
Text("${item.priceUsdt} USDT")
}
}
}
}
// 跳过按钮
TextButton(onClick = onSkip, Modifier.fillMaxWidth().padding(16.dp)) { Text("Skip for now →") }
}
}17.4 恢复会话(每次 App 启动)
val session = client.restoreSession()
if (session == null) {
navigateToWelcome() // 首次:进入注册流程
} else {
val (aliasId, nickname) = session
client.connect()
client.contacts.syncFriends()
navigateToMain()
}17.5 消息收发
// 接收(在 Activity/Fragment 生命周期内注册)
val unsub = client.on(SecureChatClient.EVENT_MESSAGE) { msg: StoredMessage ->
// ✅ 主线程回调,直接更新 UI
adapter.addMessage(msg)
}
// onStop/onPause 中: unsub()
// 发送 E2EE 文本
val msgId = client.sendMessage(conversationId, toAliasId, "Hello!")
// 引用回复
val msgId = client.sendMessage(conversationId, toAliasId, "Indeed!", replyToId = quotedMsgId)
// 撤回
client.retractMessage(msgId, toAliasId, conversationId)
// Typing
client.sendTyping(conversationId, toAliasId)
// 已读
client.markAsRead(conversationId, maxSeq, toAliasId)17.6 退出登录
// 🔒 SDK 自动:disconnect + 清 Room DB + 清 JWT
client.logout()
navigateToWelcome()17.7 FCM 推送接入
// FirebaseMessagingService.onNewToken() 中
client.push.register(fcmToken)
// 推送 data payload(relay-server 零知识推送格式):
// { "type": "new_msg" }
// ⚠️ 零知识原则:推送不含 conv_id 等敏感信息,仅展示"新消息"通知唤醒 App17.7.5 账号二维码 & 扫一扫加好友(⚠️ 必须实现)
🔴 强制交付:联系人页面必须有「扫一扫」按钮,设置/个人页面必须有「我的二维码」入口。 二维码是加好友的核心交互方式,不可跳过。
二维码协议格式(与 Web 端统一)
dao://add/{alias_id}示例:dao://add/u12345678。解析规则:扫到以 dao://add/ 开头的内容,提取 alias_id → lookupUser → sendFriendRequest。
依赖
// app/build.gradle.kts
dependencies {
implementation("com.journeyapps:zxing-android-embedded:4.3.0") // 二维码生成 + 扫码
// 或使用 ML Kit: implementation("com.google.mlkit:barcode-scanning:17.2.0")
}「我的二维码」Compose 组件
import android.graphics.Bitmap
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
@Composable
fun MyQRCodeScreen(aliasId: String, nickname: String, onClose: () -> Unit) {
val qrBitmap = remember(aliasId) {
val writer = QRCodeWriter()
val matrix = writer.encode("dao://add/$aliasId", BarcodeFormat.QR_CODE, 512, 512)
val bmp = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
for (x in 0 until 512) {
for (y in 0 until 512) {
bmp.setPixel(x, y, if (matrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt())
}
}
bmp
}
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable { onClose() }) {
Column(
Modifier.align(Alignment.Center).background(Color(0xFF18181B), RoundedCornerShape(24.dp))
.padding(32.dp).clickable(enabled = false) {},
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(nickname, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(aliasId, fontSize = 14.sp, color = Color(0xFFA1A1AA), fontFamily = FontFamily.Monospace)
Spacer(Modifier.height(16.dp))
Image(
bitmap = qrBitmap.asImageBitmap(), contentDescription = "QR Code",
modifier = Modifier.size(220.dp).clip(RoundedCornerShape(16.dp))
.background(Color.White).padding(12.dp)
)
Spacer(Modifier.height(12.dp))
Text("Scan to add me as a friend", fontSize = 12.sp, color = Color(0xFF71717A))
}
}
}「扫一扫」Compose 组件(CameraX + ML Kit)
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
@Composable
fun ScanQRButton(onScanned: (String) -> Unit) {
val client = SecureChatClient.getInstance()
val scope = rememberCoroutineScope()
var scannedUser by remember { mutableStateOf<Pair<String, String>?>(null) } // (alias_id, nickname)
var isProcessing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// ZXing 扫码启动器
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
val text = result.contents ?: return@rememberLauncherForActivityResult
val match = Regex("^dao://add/(\\S+)$").find(text)
if (match == null) { error = "Not a valid DAO Message QR code"; return@rememberLauncherForActivityResult }
val aliasId = match.groupValues[1]
scope.launch {
try {
val user = client.contacts.lookupUser(aliasId)
scannedUser = user.alias_id to user.nickname
} catch (_: Exception) {
error = "User not found"
}
}
}
// 触发扫码的按钮(放在 ContactsTab 顶栏)
IconButton(onClick = {
scanLauncher.launch(ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("Scan DAO Message QR Code")
setCameraId(0)
setBeepEnabled(false)
})
}) {
Icon(Icons.Default.QrCodeScanner, "scan", tint = Color(0xFFA1A1AA))
}
// 扫到用户 → 确认弹窗
scannedUser?.let { (aliasId, nickname) ->
AlertDialog(
onDismissRequest = { scannedUser = null },
title = { Text("Add Friend") },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(48.dp).clip(CircleShape).background(Color(0xFF27272A)),
contentAlignment = Alignment.Center) {
Text(nickname.take(2).uppercase(), fontWeight = FontWeight.Bold, color = Color.White)
}
Spacer(Modifier.height(8.dp))
Text(nickname, fontWeight = FontWeight.Bold)
Text(aliasId, fontSize = 12.sp, color = Color.Gray, fontFamily = FontFamily.Monospace)
}
},
confirmButton = {
Button(onClick = {
isProcessing = true
scope.launch {
try {
client.contacts.sendFriendRequest(aliasId)
onScanned(aliasId)
scannedUser = null
} catch (e: Exception) {
error = if (e.message?.contains("409") == true) "Request already exists" else e.message
}
isProcessing = false
}
}, enabled = !isProcessing) { Text(if (isProcessing) "Sending..." else "Add Friend") }
},
dismissButton = { TextButton(onClick = { scannedUser = null }) { Text("Cancel") } }
)
}
error?.let {
Snackbar(modifier = Modifier.padding(16.dp)) { Text(it) }
LaunchedEffect(it) { delay(3000); error = null }
}
}在 ContactsTab 顶栏集成
// ContactsTab 顶栏
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Contacts", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Row {
// 我的二维码
IconButton(onClick = { showMyQR = true }) {
Icon(Icons.Default.QrCode, "my qr", tint = Color(0xFFA1A1AA))
}
// 扫一扫
ScanQRButton(onScanned = { loadData() })
// 手动输入 ID
IconButton(onClick = { showAddDialog = true }) {
Icon(Icons.Default.PersonAdd, "add", tint = Color(0xFFA1A1AA))
}
}
}
if (showMyQR) { MyQRCodeScreen(aliasId, nickname) { showMyQR = false } }17.8 强制双边密钥核对(⚠️ 必须实现,不可跳过 + 不可绕过)
🔴 协议级强制:每对会话必须双方都完成核对(
TrustState.Verified)才能聊天。SDK 协议层和服务端都会拦截未核对消息——UI 不阻塞,消息也发不出去。 完整算法和协议设计见 Developer Guide 的 Security 章节(doc.daomessage.com/en/guide/security)。
17.8.1 三状态机(必须区分)
// SDK 暴露 TrustState sealed class(与 PWA 的 SessionTrustState 三态一一对应)
sealed class TrustState {
object Unverified : TrustState() // 未核对
object MySideVerified : TrustState() // 我已核对,等对方
data class Verified(val verifiedAt: Long, val fingerprintSnapshot: String) : TrustState()
}
// 进入聊天时读取
var trustState by remember { mutableStateOf<TrustState>(TrustState.Unverified) }
LaunchedEffect(convId) {
trustState = client.security.getTrustState(friendAliasId)
}
// 监听 trustState 变化(服务端 NATS 通知 → SDK 升级 → 触发回调)
// SDK 暴露 MessageManager.onTrustStateChange (lambda),通过反射或公开 API 设置
LaunchedEffect(convId) {
val inner = client.messages.javaClass.getDeclaredField("inner")
.apply { isAccessible = true }.get(client.messages)
val field = inner.javaClass.getDeclaredField("onTrustStateChange")
.apply { isAccessible = true }
val handler: (String, String) -> Unit = { cId, newState ->
if (cId == convId) {
scope.launch {
trustState = client.security.getTrustState(friendAliasId)
// 🔓 双方都完成核对 → 自动关闭 dialog
if (newState == "verified") showVerifyDialog = false
}
}
}
field.set(inner, handler)
}17.8.2 三状态 overlay(区分文案 + 区分颜色)
// Header 角标三态
when (trustState) {
is TrustState.Verified -> Icon(Icons.Default.VerifiedUser, null,
tint = Color(0xFF4ADE80), modifier = Modifier.size(16.dp))
is TrustState.MySideVerified -> Row(
Modifier.clip(RoundedCornerShape(4.dp))
.background(Color(0x1A3B82F6))
.clickable { showVerifyDialog = true }
.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.VerifiedUser, null, tint = Color(0xFF60A5FA), modifier = Modifier.size(12.dp))
Spacer(Modifier.width(2.dp))
Text("Awaiting peer", color = Color(0xFF60A5FA), fontSize = 10.sp)
}
is TrustState.Unverified -> Row(
Modifier.clip(RoundedCornerShape(4.dp))
.background(Color(0x1AF59E0B))
.clickable { showVerifyDialog = true }
.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Warning, null, tint = Color(0xFFFDE047), modifier = Modifier.size(12.dp))
Spacer(Modifier.width(2.dp))
Text("Unverified", color = Color(0xFFFDE047), fontSize = 10.sp)
}
}
// 阻塞聊天界面的 overlay(非 Verified 时盖住整个聊天区,要包在 Box 里 z-index 高于 Column)
val needBlock = trustState !is TrustState.Verified
if (needBlock) {
val isMySide = trustState is TrustState.MySideVerified
Box(Modifier.fillMaxSize().background(DarkBg.copy(alpha = 0.96f)).padding(24.dp),
contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth(0.85f),
) {
Box(Modifier.size(64.dp)
.background(
if (isMySide) Color(0xFF60A5FA).copy(alpha = 0.2f)
else Color(0xFFFFD56B).copy(alpha = 0.2f),
RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
if (isMySide) Icons.Default.VerifiedUser else Icons.Default.Warning,
null,
tint = if (isMySide) Color(0xFF60A5FA) else Color(0xFFFFD56B),
modifier = Modifier.size(32.dp),
)
}
Text(
if (isMySide) "Waiting for peer" else "Verify Security Code",
color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.Bold,
)
Text(
if (isMySide)
"You verified. Waiting for @$friendAliasId to verify too."
else
"Both sides must verify each other's keys via an external channel before this chat unlocks.",
color = TextMuted, fontSize = 13.sp,
)
if (!isMySide) {
Button(
onClick = { showVerifyDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = BrandPrimary),
) { Text("Start verification", color = Color.White) }
}
TextButton(onClick = onBack) { Text("Later", color = TextMuted, fontSize = 13.sp) }
}
}
}17.8.3 KeyVerificationDialog(三视图:input / waiting)
import space.securechat.sdk.keys.KeyDerivation
private enum class VerifyView { INPUT, WAITING }
@Composable
fun KeyVerificationDialog(
conversationId: String,
theirAliasId: String,
friendshipId: Long,
myEcdhPrivateKey: ByteArray,
myEcdhPublicKey: ByteArray,
theirEcdhPublicKey: ByteArray,
initialTrustState: TrustState, // ⚠️ 关键 UX:根据当前状态决定打开时显示哪个视图
onDismiss: () -> Unit,
) {
val client = SecureChatClient.getInstance()
val scope = rememberCoroutineScope()
val ctx = LocalContext.current
// 一次性计算双向码
val (myCode, expectedTheirCode, fingerprint60) = remember(myEcdhPrivateKey, theirEcdhPublicKey) {
val shared = KeyDerivation.computeSharedSecret(myEcdhPrivateKey, theirEcdhPublicKey)
val mine = client.security.computeDirectionalCode(shared, myEcdhPublicKey, theirEcdhPublicKey)
val expected = client.security.computeDirectionalCode(shared, theirEcdhPublicKey, myEcdhPublicKey)
val fp = client.security.computeSecurityCode(myEcdhPublicKey, theirEcdhPublicKey)
Triple(mine, expected, fp)
}
// ⚠️ 关键:根据 initialTrustState 决定初始视图
var view by remember {
mutableStateOf(
if (initialTrustState is TrustState.MySideVerified) VerifyView.WAITING
else VerifyView.INPUT
)
}
var inputCode by remember { mutableStateOf("") }
var attempts by remember { mutableStateOf(0) }
var error by remember { mutableStateOf<String?>(null) }
var submitting by remember { mutableStateOf(false) }
val inputN = client.security.normalizeDirectionalCode(inputCode)
val expectN = client.security.normalizeDirectionalCode(expectedTheirCode)
val myCodeLen = client.security.normalizeDirectionalCode(myCode).length
AlertDialog(
onDismissRequest = { if (!submitting) onDismiss() },
title = {
Text(
if (view == VerifyView.WAITING) "Verified your side" else "Verify Security Code",
fontWeight = FontWeight.Bold,
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (view == VerifyView.WAITING) {
Text(
"You confirmed @$theirAliasId's code. Waiting for them to verify yours. " +
"Chat unlocks automatically once both sides complete.",
fontSize = 12.sp, color = TextMuted,
)
// 我的码(重新分享给对方用)
Box(Modifier.fillMaxWidth()
.background(Color(0xFF18181B), RoundedCornerShape(8.dp))
.padding(12.dp)) {
Text(myCode, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
}
} else {
Text(
"Send your code to @$theirAliasId via WeChat / email / SMS, " +
"then paste their code below. " +
"It is NORMAL that both sides see DIFFERENT codes (directionally derived).",
fontSize = 12.sp, color = TextMuted,
)
Text("My code (send to peer):", fontSize = 11.sp, color = TextMuted)
Box(Modifier.fillMaxWidth()
.background(Color(0xFF18181B), RoundedCornerShape(8.dp))
.padding(12.dp)) {
Text(myCode, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
}
Text("Peer's code (received from them):", fontSize = 11.sp, color = TextMuted)
OutlinedTextField(
value = inputCode,
onValueChange = {
inputCode = it
if (error == "length" || error == "mismatch") error = null
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Paste peer's code (dashes/spaces ok)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
)
when (error) {
"mismatch" -> Text("Codes don't match — check carefully ($attempts/3)",
color = Color(0xFFEF4444), fontSize = 11.sp)
"mismatch_3" -> Text("⚠️ 3 mismatches — possible MITM. Strongly consider deleting and re-adding via QR.",
color = Color(0xFFEF4444), fontSize = 11.sp)
null -> {}
else -> Text(error!!, color = Color(0xFFEF4444), fontSize = 11.sp)
}
}
}
},
confirmButton = {
if (view == VerifyView.WAITING) {
Button(onClick = onDismiss) { Text("Close") }
} else {
Button(
enabled = !submitting && inputCode.isNotEmpty(),
onClick = {
if (inputN.length != myCodeLen) { error = "length"; return@Button }
if (inputN != expectN) {
attempts += 1
error = if (attempts >= 3) "mismatch_3" else "mismatch"
return@Button
}
submitting = true
scope.launch {
try {
client.contacts.markMyVerified(
friendshipId = friendshipId,
contactId = theirAliasId,
myEcdhPublicKey = myEcdhPublicKey,
theirEcdhPublicKey = theirEcdhPublicKey,
security = client.security,
)
// 🔒 关键:成功后**不立即关闭 dialog**,切到等待视图
// 父组件 onTrustStateChange 监听 verified 时再自动关
view = VerifyView.WAITING
} catch (e: Throwable) {
error = "Network error: ${e.message}"
} finally {
submitting = false
}
}
},
) { Text(if (submitting) "Submitting..." else "Verify") }
}
},
dismissButton = {
// waiting 视图无二级按钮;INPUT 视图显示 Later
if (view == VerifyView.INPUT) {
TextButton(onClick = { if (!submitting) onDismiss() }) { Text("Later") }
}
},
)
}17.8.4 在 ChatScreen 中接线
// 拿当前用户 ECDH 私钥(从 mnemonic 派生)
var myEcdhPriv by remember { mutableStateOf<ByteArray?>(null) }
LaunchedEffect(convId) {
val mnemonic = client.getMnemonic()
if (!mnemonic.isNullOrEmpty()) {
myEcdhPriv = KeyDerivation.deriveEcdhKey(mnemonic).privateKey
}
}
if (showVerifyDialog && friendshipId != null && myEcdhPub != null && theirEcdhPub != null && myEcdhPriv != null) {
KeyVerificationDialog(
conversationId = convId,
theirAliasId = friendAliasId,
friendshipId = friendshipId!!,
myEcdhPrivateKey = myEcdhPriv!!,
myEcdhPublicKey = myEcdhPub!!,
theirEcdhPublicKey = theirEcdhPub!!,
initialTrustState = trustState, // ⚠️ 必须传,决定显示哪个视图
onDismiss = {
showVerifyDialog = false
scope.launch { trustState = client.security.getTrustState(friendAliasId) }
},
)
}17.8.5 send 错误兜底
try {
client.sendMessage(convId, friendAliasId, text)
} catch (e: Throwable) {
val msg = e.message.orEmpty()
if (msg.contains("UNVERIFIED_SESSION") || msg.contains("unverified", ignoreCase = true)) {
showVerifyDialog = true
return
}
// 其他错误...
}17.8.6 不要做的事
- ❌ 不要做"跳过核对"按钮
- ❌ 不要把双方的码做成相同(违反方案 Y 设计意图)
- ❌
markMyVerified成功后不要立即关闭 dialog(用户感知不到完成) - ❌ 不要让
TrustState直接从Unverified跳到Verified(必须经过MySideVerified中间态) - ❌ 不要比较原始字符串(必须用
client.security.normalizeDirectionalCode()归一化后再==)
17.9 语音消息录制与播放(⚠️ 必须实现)
🔒 SDK:
client.sendVoice(conversationId, toAliasId, audioBytes, durationMs)— 加密上传 + 发送 🔒 SDK:client.downloadMedia(conversationId, mediaKey)— 解密下载语音 👤 App:录音 UI(MediaRecorderAPI)+ 播放 UI
// 🔒 SDK 发送语音
val durationMs = 3500L
val audioBytes: ByteArray = ... // 从 MediaRecorder 获取
client.sendVoice(conversationId, toAliasId, audioBytes, durationMs)
// 🔒 SDK 下载解密语音
val decryptedBytes = client.downloadMedia(conversationId, mediaKey)录音交互(长按录音 + 松开发送)
@Composable
fun VoiceRecordButton(onSend: (ByteArray, Long) -> Unit) {
var isRecording by remember { mutableStateOf(false) }
var recordDuration by remember { mutableIntStateOf(0) }
val context = LocalContext.current
val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
val fileRef = remember { mutableStateOf<File?>(null) }
val timerRef = remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
// ⚠️ 需要先请求 RECORD_AUDIO 权限
val startRecording = {
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a")
val recorder = MediaRecorder(context).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(44100)
setOutputFile(file.absolutePath)
prepare()
start()
}
recorderRef.value = recorder
fileRef.value = file
isRecording = true
recordDuration = 0
timerRef.value = scope.launch {
while (isActive) { delay(1000); recordDuration++ }
}
}
val stopAndSend = {
timerRef.value?.cancel()
recorderRef.value?.apply { stop(); release() }
recorderRef.value = null
isRecording = false
val file = fileRef.value
if (file != null && recordDuration >= 1) {
val bytes = file.readBytes()
onSend(bytes, recordDuration * 1000L)
file.delete()
}
}
// 长按录音按钮
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(if (isRecording) Color(0xFFEF4444) else Color(0xFF3F3F46))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
startRecording()
tryAwaitRelease()
stopAndSend()
}
)
},
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Mic, "record", tint = Color.White,
modifier = Modifier.size(24.dp))
}
// 录音指示器
if (isRecording) {
Text("🔴 ${recordDuration}s — Release to send",
color = Color.Red, fontSize = 12.sp, modifier = Modifier.padding(start = 8.dp))
}
}语音气泡播放
@Composable
fun VoiceBubble(mediaKey: String, durationMs: Long, conversationId: String, isMe: Boolean) {
val client = SecureChatClient.getInstance()
var playing by remember { mutableStateOf(false) }
var progress by remember { mutableFloatStateOf(0f) }
val playerRef = remember { mutableStateOf<MediaPlayer?>(null) }
val context = LocalContext.current
val durationSec = (durationMs / 1000).toInt()
val startPlayback = {
LaunchedEffect(mediaKey) {
val bytes = client.downloadMedia(conversationId, mediaKey)
val tempFile = File(context.cacheDir, "voice_play_${mediaKey.hashCode()}.m4a")
tempFile.writeBytes(bytes)
val player = MediaPlayer().apply {
setDataSource(tempFile.absolutePath)
prepare()
start()
setOnCompletionListener { playing = false; progress = 0f }
}
playerRef.value = player
playing = true
}
}
Row(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(if (isMe) Color(0xFF2563EB) else Color(0xFF27272A))
.padding(horizontal = 12.dp, vertical = 8.dp)
.widthIn(min = 120.dp, max = 200.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
if (playing) { playerRef.value?.pause(); playing = false }
else startPlayback()
}, modifier = Modifier.size(32.dp)) {
Icon(if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
"play", tint = Color.White)
}
// 简化波形
Box(Modifier.weight(1f).height(24.dp)) {
LinearProgressIndicator(progress = { progress }, Modifier.fillMaxWidth().align(Alignment.Center),
color = Color.White.copy(alpha = 0.6f))
}
Text("${durationSec}s", color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.padding(start = 4.dp))
}
DisposableEffect(Unit) { onDispose { playerRef.value?.release() } }
}
// 在消息渲染中使用:
// when (inferType(msg)) {
// "voice" -> {
// val data = Json.decodeFromString<Map<String,String>>(msg.text)
// VoiceBubble(data["key"]!!, data["duration"]!!.toLong(), msg.conversationId, msg.isMe)
// }
// }17.10 端到端音视频通话(⚠️ 必须实现,不可跳过)
架构说明:Android SDK 暂无独立
CallModule类(与 TS SDK 不同),但已提供:
sendSignalFrame(frame)— 发送 Ed25519 签名 + AES-GCM 加密的信令帧(crypto_v=2)EVENT_SIGNAL— 接收解密后的信令帧fetchTurnConfig()— 获取 iceServers(调用 relay-serverGET /api/v1/calls/ice-config)TURN 契约(1.0.12+,后端中立):
fetchTurnConfig()返回标准 WebRTCRTCConfiguration.iceServers兼容 JSON:{ ttl, ice_transport_policy, ice_servers: [{urls, username?, credential?}] }任何实现此契约的后端都可替换 relay-server 默认 provider。 推荐后端 Cloudflare Realtime TURN(330+ 全球节点,$0.05/GB), relay-server 配CF_TURN_KEY_ID/CF_TURN_API_TOKEN即用。 自建 coturn 兼容,但需注意 AWS VPC 172.31 段与denied-peer-ip=172.16-31冲突等坑。👤 App 层需基于
org.webrtc库实现PeerConnection管理。 🔴 这是强制交付产物。来电视频/音频判断(1.0.12+):从
call_offer的sdp字段检测m=video行, 不依赖发起方显式声明。SDP 包含视频轨 → 视频来电;纯音频 offer → 音频来电。跨平台兼容矩阵:
- 1.0.19+:Android
call_ice用candidate=string + sdp_mid + sdp_mline三字段- 1.0.20+:信令 Ed25519 签名改为递归排序 canonical JSON(嵌套对象的签名验证修复)
- Android↔PWA 通话必须双方 SDK 均 ≥ 1.0.20,否则 verify FAILED / call_ice 不通。
🔴 已知陷阱(必踩,请提前避开):
陷阱 1:接通后没声音 → AudioManager 必须配置 WebRTC 不会自动切换系统音频路由。默认
MODE_NORMAL下远端音轨会 被路由到错误通路,表现为"通话已建立但完全没声音"。修复:
AndroidManifest.xml增加<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />- 在
PeerConnection.onConnectionChange(CONNECTED)时把am.mode = MODE_IN_COMMUNICATION、am.isSpeakerphoneOn = true、am.isMicrophoneMute = false- 在 teardown / hangup 时恢复
am.mode = MODE_NORMAL、am.isSpeakerphoneOn = false,否则会影响系统铃声和媒体音量陷阱 2:PWA 拨号 Android 不振铃 → call_offer 必须切 INCOMING 状态 SDK 1.0.20+ 起,发起方直接发
call_offer(不再单独发call_invite)。 被叫端如果只是把 SDP 缓存到pendingOfferSdp而不更新 UI 状态机, 会完全无反应。when (type == "call_offer")分支必须做:
- 若
peerConnection == null(接听前还没建 PC),缓存 SDP 到pendingOfferSdp- 同时判断当前
state == IDLE时切到INCOMING,从 SDP 读m=video行 决定Mode.AUDIO / Mode.VIDEO- UI 层(CallScreen)订阅 state 变化才会渲染响铃界面
依赖
// app/build.gradle.kts
dependencies {
implementation("io.getstream:stream-webrtc-android:1.3.5")
// 或使用 Google 官方:implementation("org.webrtc:google-webrtc:1.0.+")
}ViewModel 通话状态
class CallViewModel : ViewModel() {
sealed class CallState {
object Idle : CallState()
data class Calling(val peerAlias: String, val isVideo: Boolean) : CallState()
data class Ringing(val fromAlias: String, val isVideo: Boolean) : CallState()
data class Connecting(val peerAlias: String) : CallState()
data class Connected(val peerAlias: String, val isVideo: Boolean, val durationSec: Int = 0) : CallState()
}
var callState by mutableStateOf<CallState>(CallState.Idle)
private set
// 👤 App:在 Activity/Fragment 中监听信令
fun startListening() {
val client = SecureChatClient.getInstance()
unsubSignal = client.on(SecureChatClient.EVENT_SIGNAL) { frame: Map<String, Any?> ->
when (frame["type"]) {
"call_offer" -> {
val from = frame["from"] as? String ?: return@on
val sdp = frame["sdp"] as? String ?: return@on
val isVideo = sdp.contains("\nm=video ") || sdp.startsWith("m=video ")
// ⚠️ 必须先缓存 SDP, 再切 Ringing 状态 — 缺一不可
pendingOffer = sdp
callState = CallState.Ringing(from, isVideo)
}
"call_answer" -> {
val sdp = frame["sdp"] as? String ?: return@on
peerConnection?.setRemoteDescription(/* SessionDescription(ANSWER, sdp) */)
callState = (callState as? CallState.Calling)?.let {
CallState.Connected(it.peerAlias, it.isVideo)
} ?: callState
}
"call_ice" -> {
val candidate = frame["candidate"] as? String ?: return@on
peerConnection?.addIceCandidate(/* IceCandidate(...) */)
}
"call_hangup" -> { hangup() }
}
}
}
// 发起呼叫
fun startCall(toAliasId: String, isVideo: Boolean) {
callState = CallState.Calling(toAliasId, isVideo)
// 1. fetchTurnConfig() 获取 ICE 服务器
// 2. 创建 PeerConnection
// 3. getUserMedia(摄像头/麦克风)
// 4. createOffer → setLocalDescription
// 5. sendSignalFrame({ type: "call_offer", to, from, sdp, call_id })
}
// 接听来电
fun answerCall() {
callState = CallState.Connecting((callState as CallState.Ringing).fromAlias)
// 1. setRemoteDescription(pendingOffer)
// 2. createAnswer → setLocalDescription
// 3. sendSignalFrame({ type: "call_answer", to, from, sdp, call_id })
}
// 挂断
fun hangup() {
// sendSignalFrame({ type: "call_hangup", to, from, call_id })
peerConnection?.close()
// ⚠️ 必须恢复 AudioManager 路由, 否则会影响系统铃声/媒体音量
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.mode = AudioManager.MODE_NORMAL
am.isSpeakerphoneOn = false
callState = CallState.Idle
}
// PeerConnection.Observer.onConnectionChange 里:
// if (state == PeerConnection.PeerConnectionState.CONNECTED) {
// val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// am.mode = AudioManager.MODE_IN_COMMUNICATION
// am.isMicrophoneMute = false
// am.isSpeakerphoneOn = true // 默认外放
// }
// 静音/摄像头切换
fun toggleMute() { /* localAudioTrack.enabled = !enabled */ }
fun toggleCamera() { /* localVideoTrack.enabled = !enabled */ }
}CallScreen Compose UI
@Composable
fun CallScreen(viewModel: CallViewModel) {
val state = viewModel.callState
if (state is CallViewModel.CallState.Idle) return
Box(Modifier.fillMaxSize().background(Color(0xFF09090B))) {
// 顶部信息
Column(Modifier.align(Alignment.TopCenter).padding(top = 64.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val peerAlias = when (state) {
is CallViewModel.CallState.Calling -> state.peerAlias
is CallViewModel.CallState.Ringing -> state.fromAlias
is CallViewModel.CallState.Connecting -> state.peerAlias
is CallViewModel.CallState.Connected -> state.peerAlias
else -> ""
}
Box(Modifier.size(80.dp).clip(CircleShape).background(Color(0xFF27272A)),
contentAlignment = Alignment.Center) {
Text(peerAlias.take(2).uppercase(), fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
Spacer(Modifier.height(16.dp))
Text(peerAlias, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(when (state) {
is CallViewModel.CallState.Ringing -> "Incoming call..."
is CallViewModel.CallState.Calling -> "Calling..."
is CallViewModel.CallState.Connecting -> "Connecting..."
is CallViewModel.CallState.Connected -> String.format("%02d:%02d", state.durationSec / 60, state.durationSec % 60)
else -> ""
}, color = Color(0xFFA1A1AA), fontSize = 14.sp)
}
// 底部按钮
Row(Modifier.align(Alignment.BottomCenter).padding(bottom = 64.dp), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
when (state) {
is CallViewModel.CallState.Ringing -> {
// 拒绝
FloatingActionButton(onClick = { viewModel.hangup() }, containerColor = Color.Red) {
Icon(Icons.Default.CallEnd, "reject", tint = Color.White)
}
// 接听
FloatingActionButton(onClick = { viewModel.answerCall() }, containerColor = Color(0xFF22C55E)) {
Icon(Icons.Default.Call, "answer", tint = Color.White)
}
}
is CallViewModel.CallState.Calling, is CallViewModel.CallState.Connecting -> {
FloatingActionButton(onClick = { viewModel.hangup() }, containerColor = Color.Red) {
Icon(Icons.Default.CallEnd, "hangup", tint = Color.White)
}
}
is CallViewModel.CallState.Connected -> {
// 静音
FloatingActionButton(onClick = { viewModel.toggleMute() }, containerColor = Color(0xFF3F3F46)) {
Icon(Icons.Default.MicOff, "mute", tint = Color.White)
}
// 挂断
FloatingActionButton(onClick = { viewModel.hangup() }, containerColor = Color.Red, modifier = Modifier.size(64.dp)) {
Icon(Icons.Default.CallEnd, "hangup", tint = Color.White, modifier = Modifier.size(28.dp))
}
}
else -> {}
}
}
}
}
// 👤 App:在 Activity 的最外层 Compose 中渲染 CallScreen(全屏覆盖,不受导航控制)17.10.1 通话生命周期兜底(双端必须 1.0.29+ 强制)
🔴 强制实施:不做的话"一边挂断/被杀,另一边还在计时"的 bug 必现。
CallManager 必须在 PeerConnection 生命周期实现两组兜底,与 Web SDK 数值完全一致:
// CallConstants.kt — internal object 集中放常量,与 sdk-typescript/src/calls/constants.ts 数值一致
internal object CallConstants {
const val CALL_DISCONNECTED_TIMEOUT_MS = 8_000L // PC disconnected 8s 兜底
const val CALL_RTP_SILENCE_TIMEOUT_MS = 15_000L // 入站 RTP 静默 15s 兜底
const val CALL_RTP_WATCHDOG_INTERVAL_MS = 2_000L
const val VIDEO_TARGET_WIDTH = 1280
const val VIDEO_TARGET_HEIGHT = 720
const val VIDEO_TARGET_FPS = 30
const val VIDEO_MAX_BITRATE_BPS = 1_500_000
}
// CallManager 关键回调
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
when (newState) {
PeerConnection.PeerConnectionState.CONNECTED -> {
cancelDisconnectedJob() // 清掉 disconnected 计时器
applyVideoSenderParams() // 设 maxBitrate=1.5M
startRtpWatchdog() // 启 15s 入站静默 watchdog
}
PeerConnection.PeerConnectionState.CONNECTING -> {
cancelDisconnectedJob() // ICE restart 中间态也清(避免打架)
}
PeerConnection.PeerConnectionState.DISCONNECTED -> {
armDisconnectedJob() // 8s 后自动 hangup
}
else -> { /* CLOSED/FAILED 等沿用 onIceConnectionChange 处理 */ }
}
}
private fun armDisconnectedJob() {
cancelDisconnectedJob()
disconnectedJob = scope.launch {
delay(CallConstants.CALL_DISCONNECTED_TIMEOUT_MS)
hangup() // 触发完整挂断流程(包括发 call_hangup 信令)
}
}
private fun startRtpWatchdog() {
stopRtpWatchdog()
var lastProgress = System.currentTimeMillis()
var prevVideoBytes = 0L; var prevVideoFrames = 0L; var prevAudioBytes = 0L
rtpWatchdogJob = scope.launch {
while (isActive) {
delay(CallConstants.CALL_RTP_WATCHDOG_INTERVAL_MS)
peerConnection?.getStats { reports ->
var inVideoBytes = 0L; var inVideoFrames = 0L; var inAudioBytes = 0L
for (r in reports.statsMap.values) {
if (r.type != "inbound-rtp") continue
val kind = r.members["kind"] as? String ?: continue
when (kind) {
"video" -> {
inVideoBytes = (r.members["bytesReceived"] as? Number)?.toLong() ?: 0L
inVideoFrames = (r.members["framesDecoded"] as? Number)?.toLong() ?: 0L
}
"audio" -> {
inAudioBytes = (r.members["bytesReceived"] as? Number)?.toLong() ?: 0L
}
}
}
if (inVideoBytes > prevVideoBytes || inVideoFrames > prevVideoFrames
|| inAudioBytes > prevAudioBytes) {
lastProgress = System.currentTimeMillis()
}
prevVideoBytes = inVideoBytes
prevVideoFrames = inVideoFrames
prevAudioBytes = inAudioBytes
if (System.currentTimeMillis() - lastProgress >= CallConstants.CALL_RTP_SILENCE_TIMEOUT_MS) {
scope.launch { hangup() }
}
}
}
}
}17.10.2 通话终止 UI 必须冻结 1.5s 显示最终时长
// CallManager.finalizeCall — 统一收尾入口
private fun finalizeCall() {
teardownPeer()
_state.value = State.ENDED // ENDED 是中间态,UI 在此显示最终时长
scope.launch {
delay(1_500L)
_info.value = null
_state.value = State.IDLE
}
}
// hangup() / reject() / 收到对端 call_hangup/call_reject 都调 finalizeCall// CallScreen.kt 计时器:remember key 不要包含 state,否则 ENDED 时被 reset 0
var elapsedSec by remember(current.callId) { mutableIntStateOf(0) } // 只用 callId 作 key
LaunchedEffect(state, current.callId) {
if (state == CallManager.State.CONNECTED) {
while (true) { delay(1000); elapsedSec++ }
}
// ENDED 时 effect 退出,elapsedSec 保留供 UI 显示
}
// 状态文案
Text(when (state) {
State.OUTGOING -> "正在呼叫…"
State.CONNECTING -> "正在建立加密通道…"
State.CONNECTED -> durationStr
State.ENDED -> if (elapsedSec > 0) "$durationStr · 通话已结束" else "通话已结束"
else -> ""
})17.10.3 视频媒体质量(必须双端一致)
// 1. 采集:720p30
capturer.startCapture(
CallConstants.VIDEO_TARGET_WIDTH, // 1280
CallConstants.VIDEO_TARGET_HEIGHT, // 720
CallConstants.VIDEO_TARGET_FPS, // 30
)
// 2. 接通后给 video sender 设 maxBitrate
private fun applyVideoSenderParams() {
val pc = peerConnection ?: return
for (sender in pc.senders) {
if (sender.track()?.kind() != "video") continue
val params = sender.parameters
if (params.encodings.isEmpty()) continue
params.encodings[0].maxBitrateBps = CallConstants.VIDEO_MAX_BITRATE_BPS // 1_500_000
params.degradationPreference =
org.webrtc.RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE
sender.setParameters(params)
}
}⚠️
stream-webrtc-android 1.x未暴露setCodecPreferencesJava API。 Android 端跳过 codec 偏好,降级为DefaultVideoEncoderFactory默认协商。 PWA 端会按 VP9>H264>VP8 排序,PWA 主叫 → Android 仍能拿到 VP9。
🔑 常量必须双端一致:
maxBitratePWA 1.5M / Android 3M → BWE 取低值,你的配置静默失效。disconnectedPWA 8s / Android 30s → 单向"一边挂另一边没挂"复发。
陷阱 3:计时器在 ENDED 时被 reset 0 → UI 看不到最终时长。 remember key 用 callId,不要用 state。
陷阱 4:_state.value=ENDED 紧接 IDLE 同步设 → StateFlow 去重,UI 永远看不到 ENDED 帧。 必须
delay(1500)间隔。
陷阱 5:answer() 又调 createPeerConnection 覆盖已有 PC → SDP m-line 顺序错乱, PWA 报 "The order of m-lines in answer doesn't match order in offer"。
createPeerConnection必须先检查if (peerConnection != null) return复用已有 PC。
陷阱 6:
handleIncoming("call_hangup")不分状态都走 finalizeCall → INCOMING 状态(从未接通)走 2.5s 冻结路径浪费时间。要按_state.value分流:
INCOMING→teardownPeer + State.IDLE(从未接通无最终时长可显示)OUTGOING / CONNECTING / CONNECTED→finalizeCall- 还要校验
callId防止旧通话的延迟 hangup 信令误伤新通话。
陷阱 7:接通后到首帧渲染之间的黑屏(285ms~数秒) → 用户感知"接通了但看不到画面"。 SurfaceViewRenderer.init 注入
RendererCommon.RendererEvents.onFirstFrameRendered监听, 翻转firstFrameRendered状态。首帧前在 SurfaceViewRenderer 上层叠一层覆盖 UI: 头像 + alias + "画面加载中..." 文案。firstFrameRendered = true时自动隐藏。 remember(callId) 作 key 保证下次通话重置。
17.10.4 Android 模板 UX 必备(避免新手坑)
子页面物理返回键(BackHandler)
Compose 默认不拦截系统返回键,进入子页(聊天/频道/通话)按返回直接退 App。 所有非 root 页面必须注册 BackHandler:
@Composable
fun ChatScreen(onBack: () -> Unit) {
BackHandler { onBack() } // 物理键 → 回上一层
// ...
}
// 通话页特殊:按 state 区分
BackHandler(enabled = state != CallManager.State.ENDED) {
when (state) {
CallManager.State.INCOMING -> callManager.reject()
else -> callManager.hangup()
}
}加好友乐观更新
sendFriendRequest 是网络调用 1-2 秒。点完按钮立刻把这条插入 pending 列表, 失败再回滚 — 用户感知"按了立刻有反应":
onSendRequest = { aliasId ->
val tempId = -System.currentTimeMillis() // 临时负数 ID 占位
val optimistic = Friend(
friendshipId = tempId,
aliasId = aliasId,
// ... 其他字段填默认/搜索结果快照
friendStatus = "pending",
friendDirection = "sent",
)
allRecords = allRecords + optimistic
scope.launch {
try {
client.contacts.sendFriendRequest(aliasId)
reload() // 拿真实 friendshipId
} catch (e: Exception) {
allRecords = allRecords.filter { it.friendshipId != tempId } // 回滚
}
}
}消息列表订阅事件实时刷新
MessagesTab 不能只在 LaunchedEffect(Unit) mount 时拉一次, 要订阅事件:
LaunchedEffect(Unit) {
scope.launch { reloadAll() }
// 新消息 → 立即更新 lastText/lastTime, 让对话副标题不停留在冷启动那刻
val unsubMsg = client.on<(StoredMessage) -> Unit>(SecureChatClient.EVENT_MESSAGE) { msg ->
convMeta = convMeta + (msg.conversationId to ConvMeta(
lastText = msg.text, lastTime = msg.time,
isVerified = convMeta[msg.conversationId]?.isVerified ?: false,
))
}
// 新好友/好友变更 → 整列表重拉
val unsubContacts = client.on<(String, Map<String, Any?>) -> Unit>(
SecureChatClient.EVENT_CONTACTS_CHANGE
) { _, _ -> scope.launch { reloadAll() } }
try { awaitCancellation() } finally { unsubMsg(); unsubContacts() }
}
// 列表按最近消息时间倒序(最新跳到最上, 类似微信)
val sortedFriends = remember(friends, convMeta) {
friends.sortedByDescending { convMeta[it.conversationId]?.lastTime ?: 0L }
}17.11 SDK 责任边界(Android 对应版)
| 职责 | 🔒 SDK 自动完成 | 👤 App 实现 |
|---|---|---|
| 密钥派生 | ✅ BIP-39 + SLIP-0010 | ❌ |
| PoW 计算 | ✅ SHA-256 nonce | ❌ |
| JWT 获取/注入 | ✅ Challenge-Response | ❌ |
| ECDH 会话建立 | ✅ syncFriends() 自动完成 | ❌ |
| AES-GCM 加解密 | ✅ 收发透明加解密 | ❌ |
| 消息持久化 | ✅ Room DB | ❌ |
| WebSocket 重连 | ✅ 指数退避 8 级 | ❌ |
| FCM Token 上传 | ✅ push.register() | ❌ |
| 靓号搜索/购买/绑定 | ✅ vanity.search/purchase/bind | 👤 UI + 支付流程 |
| 安全码生成/验证 | ✅ security.getSecurityCode/verifyInputCode | 👤 核验弹窗 UI |
| 语音加密上传 | ✅ sendVoice() | 👤 录音 + 播放 UI |
| 信令加解密/转发 | ✅ sendSignalFrame/EVENT_SIGNAL | 👤 PeerConnection + 通话 UI |
| UI 路由 | ❌ | ✅ Welcome/VanityShop/Main/Chat/Call |
| 助记词备份 UI | ❌ | ✅ 12 宫格展示 |
| 消息列表渲染 | ❌ | ✅ LazyColumn |
| 本地通知展示 | ❌ | ✅ NotificationManager |
17.12 分阶段实施清单
⚠️ 所有 8 个阶段都是强制交付的,不可跳过任何一个。
| Phase | 交付产物 | 核心注意点 |
|---|---|---|
| 1 | Application + SDK 初始化 + 导航框架 | SecureChatClient.init() 在 Application.onCreate |
| 2 | Welcome + 助记词生成/备份 + 注册 + VanityShop(可跳过) + 恢复 | 注册完进靓号商店,不能直接进主界面 |
| 3 | MainLayout + MessagesTab + ContactsTab + MyQRCodeScreen + ScanQRButton | syncFriends 已自动建 ECDH;二维码+扫一扫必须实现 |
| 4 | ChatScreen + 消息收发 + 撤回 + 引用 | 安全码核验横幅必须实现 |
| 5 | 图片/文件/语音消息 + VoiceBubble + 录音按钮 | MediaRecorder 录音;sendVoice / downloadMedia |
| 6 | ChannelsTab + ChannelDetail | canPost 是本地判断 |
| 7 | SettingsTab + FCM 推送 | logout() 一步清理 |
| 8 | CallScreen + WebRTC PeerConnection | ⚠️ 必须实现!sendSignalFrame + EVENT_SIGNAL |
视觉一致性铁律(三端同款)
所有 UI 改动前必须对照
docs/DESIGN_TOKENS.md,Android 实现见app/src/main/kotlin/space/securechat/app/ui/theme/Theme.kt。
必用的 token 命名空间
import space.securechat.app.ui.theme.*
// 颜色 (不允许 Color(0xFFxxxxxx) 硬编码)
DarkBg / Surface1 / Surface2 / SurfaceHover
TextPrimary / TextSecondary / TextMutedLight / TextMuted
BrandPrimary / BrandPrimaryHover / BrandPrimaryText
Danger / Success / SuccessText / Warning
// 间距 (不允许 Modifier.padding(13.dp) 等魔数)
Spacing.s0 / s1 / s2 / s3 / s4 / s5 / s6 / s8 / s10 // = 0/4/8/12/16/20/24/32/40 dp
// 圆角
Radius.sm / md / lg / xl / xxl / xxxl / full
// 字号
TextSize.xs / sm / base / lg / xl / xl2 / xl3 / xl4
// 阴影
Elevation.sm / md / lg / xl / xxl必用的标准组件
import space.securechat.app.ui.components.*
PrimaryButton(text = "创建账户", onClick = { ... })
SecondaryButton(text = "恢复", onClick = { ... })
DangerButton(text = "挂断", onClick = { ... })
OutlineButton(text = "取消", onClick = { ... })
Avatar(text = aliasId, size = AvatarSize.MD)
NetworkBanner(state = networkState)关键尺寸(对齐 PWA / iOS)
| 元素 | dp |
|---|---|
| Primary Button height | 48 (Material 默认 40, 必须显式设) |
| Form Input height | 48 |
| TabBar height | 56 (Material NavigationBar 默认 80, 必须 Modifier.height(56.dp) 覆盖) |
| TopBar height | 56 |
| Avatar | 32 / 48 / 80 / 96 |
Welcome 页规范(P0-1)
- ❌ 不要 🔒 emoji Logo 盒
- ✅ gradient "DAO Message" 标题(Brush.linearGradient blue-400 → violet-400 → purple-400)
- ✅ 副标题"零知识端到端加密通讯 · 由你掌控的去中心化即时通讯"
- ✅ PrimaryButton "创建新账户" + SecondaryButton "恢复已有账户"
MessageBubble 圆角(P1-1)
RoundedCornerShape(
topStart = 16.dp, topEnd = 16.dp,
bottomStart = if (msg.isMe) 16.dp else 4.dp, // 对方左下尖
bottomEnd = if (msg.isMe) 4.dp else 16.dp, // 自己右下尖
)