From 7c7d891570b6e42769cc482c0301816d5e399410 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:02:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M6.44+M6.45=20=E6=94=B6=E7=BC=96=20?= =?UTF-8?q?=E2=80=94=20=E7=9C=9F=E6=9C=BA=200.8.45=20=E7=8E=B0=E5=9C=BA?= =?UTF-8?q?=E4=BF=9D=E5=85=A8(reader=E7=AB=A0=E8=8A=82/key=5Frouter?= =?UTF-8?q?=E6=8C=89=E9=94=AE=E8=B7=AF=E7=94=B1/WiFi=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D/ncm=20bench+IME=E4=BF=AE=E5=A4=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 脏工作区现场收编:devdocs/local/m6_44_45_dirty_tracked.patch(46 个已跟踪文本文件, +3090/-585, 已对 M6.43 基线干净适配) + m6_44_45_untracked_src.tar.gz 中 patch_v2/ 下 13 个新增源文件, 合成单个提交, 内容与容器工作树(R3ProII/patch_v2)逐字节一致。 - reader 章节: 新增 core/rdchapter.{h,c} — TXT 章节规范解析器, 扫描 512KB 码位对齐段文件 逐段流式识别标题行(行首缩进≤4/显示宽≤40/行内标点≤1 三重约束防误判), 输出标题+全书字符 偏移+段号/段内偏移+阿拉伯章号(中文数字归一), 首章前内容归"卷首"; book/reader/rdenc/ rdlayout/settings 接入章节跳转与进度锚点, 附 chapter_smoke/turn_repro/build_turn_repro.sh - key_router 按键路由: 新增 meow_launcherd/key_router.{h,c} — launcher-UI 页物理键统一路由 (P1 仅音量键), 把读取边沿+防抖+长按 held+ramp 推进从 5 个会话收敛为进程内单开; 各页每圈 key_router_pump() 取防抖后边沿, 动作(调音量/光标导航)仍由调用页决定, 保留 run_menu 的 meow_mode 分支; App 前台 volume_page 武装时边沿抑制; main_menu/now_playing/notif_center/settings_page/subpage 接入, 附 test_key_router_vol/test_np_keys - WiFi 连接修复: meow_netmgrd connect 有界等待 wpa_state=COMPLETED(普通 10s/隐藏网络 15s, env 可调)修设置页连接无响应; list_networks/scan_results 的 \xNN 转义还原始字节, 修中文 SSID 永不命中致新建同名 open 记录顶影; launcher netmgr_ipc 配读超时; 附 test_wifi_connect(桩 wpa_cli 驱动 static wifi_connect_ex), test_netmgrd.sh 补用例; wifi_page 同步 - ncm bench: 新增 bench/lyric_sim.c、bench/plpage_sim.c, vol_sync_sim 与 ncm core/app 同步 - SDK IME 修复: shift 大小写、K_ENTER 只提交不关、阻塞 IME 循环喂 on_tick 不被遮挡、 IME 打开 ~200ms TAP 冷却窗吞 stray-TAP; 附 sdk/test_ime_fix.c - 同期: meow_audiod 音量异步 ACK 线程(代际防 fd/槽复用竞态)、watchdog 音量收编(首选 audiod 权威, settings.conf 覆盖路径)、panel 文件搜索(F1-④)与蓝牙只读详情(F1-③)、各类 build 脚本同步 范围红线: third_party/legado-E-main 原开发者未跟踪, 本次不入库(保留 devdocs/local tar 参考); 二进制不入库(后续重编); devdocs/ 不入库。59 文件全部在 patch_v2/ 下, 无 third_party/devdocs。 验证: git apply --check 干净; 59 文件 md5 与容器 patch_v2 逐一比对 59/59 一致(含 key_router.c/rdchapter.c/meow_ui_ime.c/test_wifi_connect.c 抽查); 可执行位保守 (check_lgpl_release.sh 100755, build_turn_repro.sh 100644 与容器一致)。 --- patch_v2/meow_audiod/meow_audiod.c | 99 ++- patch_v2/meow_audiod/test_audiod_host.c | 74 ++ patch_v2/meow_launcherd/build_launcher.sh | 2 +- patch_v2/meow_launcherd/key_router.c | 142 ++++ patch_v2/meow_launcherd/key_router.h | 65 ++ patch_v2/meow_launcherd/main_menu.c | 93 +-- .../meow_launcherd/meow_launcher_internal.h | 4 + patch_v2/meow_launcherd/netmgr_ipc.c | 172 +++- patch_v2/meow_launcherd/notif_center.c | 46 +- patch_v2/meow_launcherd/now_playing.c | 145 +++- patch_v2/meow_launcherd/settings_page.c | 62 +- patch_v2/meow_launcherd/subpage.c | 66 +- patch_v2/meow_launcherd/test_key_router_vol.c | 229 ++++++ patch_v2/meow_launcherd/test_np_keys.c | 114 +++ .../meow_launcherd/test_settings_widgets.c | 54 +- .../meow_launcherd/test_subpage_widgets.c | 10 +- patch_v2/meow_launcherd/wifi_page.c | 79 +- patch_v2/meow_netmgrd/meow_netmgrd.c | 208 +++-- patch_v2/meow_netmgrd/test_netmgrd.sh | 19 + patch_v2/meow_netmgrd/test_wifi_connect.c | 168 ++++ patch_v2/meow_watchdog/build_watchdog.sh | 7 +- patch_v2/meow_watchdog/meow_watchdog.c | 128 ++- patch_v2/meowpanel/src/mp.h | 4 + patch_v2/meowpanel/src/mp_files.c | 136 ++++ patch_v2/meowpanel/src/mp_net.c | 154 ++++ patch_v2/meowpanel/src/mp_server.c | 113 ++- patch_v2/ncm_app/app/ncm_app_internal.h | 5 +- patch_v2/ncm_app/app/play/audio_out.c | 26 +- patch_v2/ncm_app/app/play/player_manager.c | 44 +- patch_v2/ncm_app/app/ui/playlist.c | 94 ++- patch_v2/ncm_app/bench/lyric_sim.c | 145 ++++ patch_v2/ncm_app/bench/plpage_sim.c | 277 +++++++ patch_v2/ncm_app/bench/vol_sync_sim.c | 47 +- patch_v2/ncm_app/build_ncm_sims.sh | 12 + patch_v2/ncm_app/core/ncm_api.c | 10 +- patch_v2/ncm_app/core/ncm_req.c | 10 +- patch_v2/ncm_app/test/test_ncm_core.c | 30 + patch_v2/reader_app/app/book.c | 735 +++++++++++++++--- patch_v2/reader_app/app/book.h | 21 + patch_v2/reader_app/app/reader.c | 520 ++++++++++++- patch_v2/reader_app/app/settings.c | 46 +- patch_v2/reader_app/build_reader.sh | 10 +- patch_v2/reader_app/core/rdchapter.c | 528 +++++++++++++ patch_v2/reader_app/core/rdchapter.h | 61 ++ patch_v2/reader_app/core/rdenc.c | 7 +- patch_v2/reader_app/core/rdlayout.c | 63 ++ patch_v2/reader_app/core/rdlayout.h | 8 + patch_v2/reader_app/pack_reader_mpk.sh | 10 +- patch_v2/reader_app/reader_internal.h | 5 + patch_v2/reader_app/test/book_smoke.c | 106 ++- patch_v2/reader_app/test/build_turn_repro.sh | 20 + patch_v2/reader_app/test/chapter_smoke.c | 275 +++++++ patch_v2/reader_app/test/test_reader_core.c | 111 +++ patch_v2/reader_app/test/turn_repro.c | 83 ++ .../reader_app/tools/check_lgpl_release.sh | 103 +++ patch_v2/sdk/meow_ui_ime.c | 20 + patch_v2/sdk/meow_ui_input.c | 23 +- patch_v2/sdk/meow_ui_internal.h | 4 +- patch_v2/sdk/test_ime_fix.c | 43 +- 59 files changed, 5305 insertions(+), 590 deletions(-) create mode 100644 patch_v2/meow_launcherd/key_router.c create mode 100644 patch_v2/meow_launcherd/key_router.h create mode 100644 patch_v2/meow_launcherd/test_key_router_vol.c create mode 100644 patch_v2/meow_launcherd/test_np_keys.c create mode 100644 patch_v2/meow_netmgrd/test_wifi_connect.c create mode 100644 patch_v2/ncm_app/bench/lyric_sim.c create mode 100644 patch_v2/ncm_app/bench/plpage_sim.c create mode 100644 patch_v2/reader_app/core/rdchapter.c create mode 100644 patch_v2/reader_app/core/rdchapter.h create mode 100644 patch_v2/reader_app/test/build_turn_repro.sh create mode 100644 patch_v2/reader_app/test/chapter_smoke.c create mode 100644 patch_v2/reader_app/test/turn_repro.c create mode 100755 patch_v2/reader_app/tools/check_lgpl_release.sh diff --git a/patch_v2/meow_audiod/meow_audiod.c b/patch_v2/meow_audiod/meow_audiod.c index 63c6529..5ae2e8d 100644 --- a/patch_v2/meow_audiod/meow_audiod.c +++ b/patch_v2/meow_audiod/meow_audiod.c @@ -8,9 +8,10 @@ * 核心设计(用户定案,勿推翻): * - 独占 + 抢占(后开流赢,旧流收 MSG_AUDIO_PREEMPTED 并被置 draining,PCM 丢弃); * 软混音已否决(MIPS32 软 SRC 伤音质),留混音器接口位(v1 不实现)。 - * - 单进程双线程(+路由线程):IO 线程(accept/读帧/解析/ring 写)、播放线程 + * - 单进程四线程:IO 线程(accept/读帧/解析/ring 写)、播放线程 * (独占 ALSA 直开,从 ring 取数写 DAC,xrun PREPARE 恢复)、路由线程 - * (轮询 headset/balance 插孔 switch 状态 → amixer cset numid=9)。 + * (轮询 headset/balance 插孔 switch 状态 → amixer cset numid=9)、 + * 音量执行线程(MSG_AUDIO_VOLUME 异步落 amixer + 持久化,末值去抖,见事项三)。 * - 格式归一 = 协商:grant_rate/ch/fmt 回客户端应产出的格式(SDK 负责 mono→stereo * 复制 / S16↔S32 定值升降位);audiod 只做「硬件最优」格式的字节管道。 * - DAC 原生率跟随(无 SRC);切率 mute-重配-恢复。 @@ -130,6 +131,7 @@ static void session_clean_locked(void); static void _audio_mark_reconfig_needed(void); static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t g_cond; +static pthread_cond_t g_vol_cond; /* 音量异步执行线程等待(CLOCK_MONOTONIC,main 中初始化) */ struct client { int used; @@ -137,10 +139,23 @@ struct client { char app_id[MEOW_APP_ID_MAX]; uint32_t sid; int state; /* CLS_* */ + uint32_t gen; /* 分配代际(音量异步 ACK 防 fd/槽复用竞态) */ + int vol_ack; /* 音量请求已执行,待 IO 线程回 ACK(vol 线程写/IO 线程清) */ + uint32_t vol_ack_req; /* 对应 req_id */ + uint32_t vol_ack_status; /* ACK status(应用后 g_vol_pct) */ uint8_t rbuf[READ_BUF_MAX]; size_t rlen; }; static struct client g_cli[CLI_MAX]; +static uint32_t g_cli_gen = 0; /* 客户端分配代际计数(cli_alloc 递增) */ + +/* ---- 音量异步化(事项三):IO 线程只入队末值,专用 vol 线程落 amixer+持久化后 + * 经 IO 线程回 ACK。驱动 wedge 时 3×popen≈4.5s 不再卡持锁的 dispatch/IO 循环。 ---- */ +static int g_vol_pending = 0; /* 有待执行音量请求(g_mu 保护) */ +static int g_vol_pending_pct = 0; /* 末值(0-100;连续请求只保留最新=去抖) */ +static struct client *g_vol_pending_cli = NULL; /* 末次请求方(ACK 用;可能已回收,靠 gen 校验) */ +static uint32_t g_vol_pending_req = 0; /* 末次请求 req_id */ +static uint32_t g_vol_pending_gen = 0; /* 末次请求方 gen(防槽复用误 ACK) */ /* 当前授权流(喂养 DAC 的唯一流) */ struct stream_info { @@ -308,15 +323,16 @@ static int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payloa } /* cond 有界等待(g_cond 为 CLOCK_MONOTONIC;调用方已持 g_mu)。 */ -static void cond_wait_tmo(int ms) +static void cond_wait_tmo_cv(pthread_cond_t *cv, int ms) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); ts.tv_sec += ms / 1000; ts.tv_nsec += (long)(ms % 1000) * 1000000L; if (ts.tv_nsec >= 1000000000L) { ts.tv_sec++; ts.tv_nsec -= 1000000000L; } - pthread_cond_timedwait(&g_cond, &g_mu, &ts); + pthread_cond_timedwait(cv, &g_mu, &ts); } +static void cond_wait_tmo(int ms) { cond_wait_tmo_cv(&g_cond, ms); } /* ---- frame 大小/位深 ---- */ static int fmt_bps(int fmt) @@ -1135,12 +1151,14 @@ static void bt_vol_full(void) } /* 音量应用(含 BT 隔离):BT → 只通知 AVRCP(best-effort),绝不动 DAC numid=1/2、不持久化 - * audio.volume(那是 DAC 音量键);DAC → 原感知锥度反转 + 持久化。 */ -static void vol_apply(int pct) + * audio.volume(那是 DAC 音量键);DAC → 原感知锥度反转 + 持久化。 + * target 为调用方在持锁下快照的 g_target;本函数不持 g_mu 执行(g_vol_pct 为 plain int + * 松散写,同 route_thread 的 g_route_cur 语义,MIPS32 int 写天然原子)。 */ +static void vol_apply(int pct, int target) { if (pct < 0) pct = 0; if (pct > 100) pct = 100; - if (g_target == AUDIO_OUT_BT) { + if (target == AUDIO_OUT_BT) { logmsg("vol -> %d%% (BT/AVRCP, DAC numid=1/2 NOT written)", pct); return; } @@ -1551,6 +1569,7 @@ static struct client *cli_alloc(int fd) freec->fd = fd; freec->state = CLS_OPEN; freec->sid = 0; + freec->gen = ++g_cli_gen; /* 代际递增:音量异步 ACK 校验用,防槽复用误发 */ return freec; } static void cli_free(struct client *c) @@ -1771,13 +1790,20 @@ static void dispatch(struct client *c, uint16_t type, uint32_t req_id, const uin int32_t pct = -1; if (len >= sizeof(int32_t)) memcpy(&pct, pl, sizeof pct); if (pct >= 0) { - /* 音量应用含 BT 隔离:target=BT 时绝不下发 DAC 反转(vol_apply 内判断) */ - vol_apply((int)pct); + /* 异步化(事项三):只入队末值,**不在持 g_mu 下 popen amixer**——驱动 wedge 时 + * 一次 set = 3×popen ≈ 4.5s,会把 OPEN/QUERY/喂流一起卡死。连续请求合并为末值 + * (去抖),由 vol 线程应用+持久化后经 IO 线程回 ACK。 */ + g_vol_pending = 1; + g_vol_pending_pct = (pct > 100) ? 100 : (int)pct; + g_vol_pending_cli = c; + g_vol_pending_req = req_id; + g_vol_pending_gen = c->gen; + pthread_cond_signal(&g_vol_cond); } else { - g_vol_pct = settings_get_volume(); + g_vol_pct = settings_get_volume(); /* 读当前:文件读非 popen,可同步 */ + m_ack_t a; a.code = MEOW_OK; a.status = (uint32_t)g_vol_pct; + send_frame(c->fd, MSG_ACK, req_id, &a, sizeof a); } - m_ack_t a; a.code = MEOW_OK; a.status = (uint32_t)g_vol_pct; - send_frame(c->fd, MSG_ACK, req_id, &a, sizeof a); break; } case MSG_AUDIO_OUTPUT: { @@ -1911,6 +1937,14 @@ static void *io_thread(void *arg) if (snaps[i].revents & (POLLHUP | POLLERR | POLLNVAL)) { cli_free(c); continue; } if (snaps[i].revents & POLLIN) cx_read_frames(c); } + /* 音量 ACK 回写(事项三):vol 线程执行完只置标记,由 IO 线程统一发送——客户端 fd + * 生命周期归 IO 线程独占,跨线程直接 send_frame 有 fd 复用竞态。 */ + for (int i = 0; i < CLI_MAX; i++) { + if (!g_cli[i].used || !g_cli[i].vol_ack) continue; + m_ack_t va; va.code = MEOW_OK; va.status = g_cli[i].vol_ack_status; + send_frame(g_cli[i].fd, MSG_ACK, g_cli[i].vol_ack_req, &va, sizeof va); + g_cli[i].vol_ack = 0; + } pthread_mutex_unlock(&g_mu); /* 路由线程与播放线程独立;此处不再做 */ } @@ -1921,6 +1955,40 @@ static void *io_thread(void *arg) return NULL; } +/* ---- 音量执行线程(事项三):异步落 amixer + 持久化,不在 IO 线程持锁 popen ---- + * 等待 g_vol_cond 取末值(连续请求合并=去抖),放锁后执行 dac_vol_set(3×popen,有界) + * + settings_set_volume(文件 IO),完成后只置 ACK 标记,由 IO 线程统一发送(防 fd 复用竞态)。 + * BT 目标只记日志不动 DAC/不持久化(AVRCP 隔离,g_vol_pct 保持不变)。 */ +static void *vol_thread(void *arg) +{ + (void)arg; + for (;;) { + pthread_mutex_lock(&g_mu); + while (!g_vol_pending && !g_stop) + cond_wait_tmo_cv(&g_vol_cond, COND_WAIT_MS); + if (g_stop) { pthread_mutex_unlock(&g_mu); break; } + int pct = g_vol_pending_pct; + int target = g_target; /* 持锁快照:vol_apply 不读全局 */ + struct client *c = g_vol_pending_cli; + uint32_t req = g_vol_pending_req; + uint32_t cgen = g_vol_pending_gen; + g_vol_pending = 0; + g_vol_pending_cli = NULL; + pthread_mutex_unlock(&g_mu); + + vol_apply(pct, target); + + pthread_mutex_lock(&g_mu); + if (c && c->used && c->gen == cgen) { /* 槽复用校验:只 ACK 原请求方 */ + c->vol_ack = 1; + c->vol_ack_req = req; + c->vol_ack_status = (uint32_t)g_vol_pct; + } + pthread_mutex_unlock(&g_mu); + } + return NULL; +} + /* ---- 路由线程主体 ---- */ static void *route_thread(void *arg) { @@ -2149,19 +2217,21 @@ int main(int argc, char **argv) logmsg("volume init = %d%% target=%s codec=%s volctl=%s", g_vol_pct, target_name(), g_bt_codec, volctl_name()); - /* g_cond 用 CLOCK_MONOTONIC(所有 timedwait 的基准;防系统时间跳变影响护栏超时) */ + /* g_cond/g_vol_cond 用 CLOCK_MONOTONIC(所有 timedwait 的基准;防系统时间跳变影响护栏超时) */ { pthread_condattr_t ca; pthread_condattr_init(&ca); pthread_condattr_setclock(&ca, CLOCK_MONOTONIC); pthread_cond_init(&g_cond, &ca); + pthread_cond_init(&g_vol_cond, &ca); pthread_condattr_destroy(&ca); } - pthread_t ti, tp, tr; + pthread_t ti, tp, tr, tv; pthread_create(&ti, NULL, io_thread, NULL); pthread_create(&tp, NULL, playback_thread, NULL); pthread_create(&tr, NULL, route_thread, NULL); + pthread_create(&tv, NULL, vol_thread, NULL); /* 主线程 = watchdog:心跳静默 >WD_SILENT_SEC 秒即告警(只告警不强杀—— * 对卡在内核的线程任何外部干预都不安全;护栏保证其它线程/连接继续服务)。 */ @@ -2191,6 +2261,7 @@ int main(int argc, char **argv) pthread_join(ti, NULL); pthread_join(tp, NULL); pthread_join(tr, NULL); + pthread_join(tv, NULL); unlink(g_sock); if (g_logfd >= 0) close(g_logfd); return 0; diff --git a/patch_v2/meow_audiod/test_audiod_host.c b/patch_v2/meow_audiod/test_audiod_host.c index 9636357..1644eaf 100644 --- a/patch_v2/meow_audiod/test_audiod_host.c +++ b/patch_v2/meow_audiod/test_audiod_host.c @@ -187,6 +187,24 @@ static int start_audiod_trace(const char *sink, const char *settings_path, const return pid; } +/* 带假 amixer 的启动(事项三异步化验证:假 amixer 每次 cset 睡 1s,模拟慢 popen)。 + * 用于断言「MSG_AUDIO_VOLUME 在 IO 线程只入队、不放锁 popen」——慢 amixer 期间 QUERY 仍即时。 */ +static int start_audiod_fake_amixer(const char *sink, const char *settings_path, const char *amixer_script) +{ + pid_t pid = fork(); + if (pid == 0) { + setenv("MEOW_AUDIO_SINK", sink, 1); + setenv("MEOW_AUDIO_OUT", TWAV, 1); + setenv("MEOW_AUDIO_SOCK", TSOCK, 1); + setenv("MEOW_AUDIO_FORCE", "1", 1); + if (settings_path) setenv("MEOW_AUDIO_SETTINGS", settings_path, 1); + if (amixer_script) setenv("MEOW_AUDIO_AMIXER", amixer_script, 1); + execl("./meow_audiod_host", "meow_audiod_host", (char *)0); + _exit(127); + } + return pid; +} + /* 直接客户端 open(带 flags:AUDIO_FLAG_DSD 用于 DoP) */ static int do_open_f(struct cli *c, int rate, int ch, int fmt, uint32_t flags, m_audio_open_ack_t *ack) { @@ -1080,6 +1098,62 @@ int main(void) } stop_audiod(); + /* ---- T17:MSG_AUDIO_VOLUME 异步化(事项三)。假 amixer 每次 cset 睡 1s(一次 set=3×cset≈3s)。 + * 断言:慢 amixer 期间 QUERY 即时返回(dispatch 不放锁 popen);应用后回 ACK + 双组写 + 持久化。 */ + printf("[T17 volume async (dispatch not blocked by slow amixer)]\n"); + unlink(TSOCK); unlink(TWAV); + const char *tconf17 = "/tmp/meow_test_settings17.conf"; + FILE *tf17 = fopen(tconf17, "w"); + if (tf17) { fprintf(tf17, "# meow-settings 1\naudio.volume = 60\n"); fclose(tf17); } + const char *fake17 = "/tmp/meow_fake_amixer17.sh"; + const char *trace17 = "/tmp/meow_fake_amixer17_trace.txt"; + unlink(trace17); + { + FILE *fs = fopen(fake17, "w"); + if (fs) { + fprintf(fs, "#!/bin/sh\necho \"cset $4 $5\" >> %s\nsleep 1\nexit 0\n", trace17); + fclose(fs); + chmod(fake17, 0755); + } + } + g_audiod_pid = start_audiod_fake_amixer("file", tconf17, fake17); + usleep(400000); + { + struct cli v17; + check(cli_connect(&v17) == 0, "connect V17"); + int32_t pct = 40; + check(sf(v17.fd, MSG_AUDIO_VOLUME, 17, &pct, sizeof pct) == 0, "send async volume 40"); + /* 慢 amixer 执行期间 QUERY 必须即时(<600ms),证明 IO 线程没被 popen 卡住 */ + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); + char q17[1024]; + int qok = do_query(&v17, q17, sizeof q17); + clock_gettime(CLOCK_MONOTONIC, &t1); + long qms = (t1.tv_sec - t0.tv_sec) * 1000L + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + check(qok == 0 && qms < 600, "QUERY immediate during slow amixer (async dispatch)"); + if (qok == 0 && qms >= 600) printf(" (query took %ldms)\n", qms); + /* 应用后回 ACK(末值 40) */ + uint8_t ab[64]; uint16_t at; + int rlen = rf_tmo(v17.fd, 4000, &at, ab, sizeof ab); + check(rlen >= 0 && at == MSG_ACK, "async volume ACK returned after apply"); + usleep(100000); + { + FILE *chk = fopen(tconf17, "r"); int has40 = 0; char l[128]; + if (chk) { while (fgets(l, sizeof l, chk)) if (strstr(l, "audio.volume = 40")) has40 = 1; fclose(chk); } + check(has40, "async volume persisted = 40"); + } + { + char trb[2048]; int trl = 0; + int fd = open(trace17, O_RDONLY); + if (fd >= 0) { trl = (int)read(fd, trb, sizeof trb - 1); close(fd); if (trl > 0) trb[trl] = 0; } + check(trl > 0 && strstr(trb, "numid=1 ") && strstr(trb, "numid=2 ") && strstr(trb, "numid=11 "), + "async dual-group write (numid=1/2/11) via fake amixer"); + } + close(v17.fd); + } + stop_audiod(); + unlink(fake17); unlink(trace17); + printf("=== SUMMARY: pass=%d fail=%d ===\n", g_pass, g_fail); return g_fail ? 1 : 0; } \ No newline at end of file diff --git a/patch_v2/meow_launcherd/build_launcher.sh b/patch_v2/meow_launcherd/build_launcher.sh index c83651d..103f343 100755 --- a/patch_v2/meow_launcherd/build_launcher.sh +++ b/patch_v2/meow_launcherd/build_launcher.sh @@ -26,7 +26,7 @@ SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf" main_menu.c statusbar.c fb_compositor.c settings_page.c power_menu.c \ notif_center.c app_launcher.c audio.c backlight.c net.c about.c \ subpage.c wifi_page.c bt_page.c netmgr_ipc.c now_playing.c bg_page.c \ - font_theme.c keys.c util.c meow_dep.c \ + font_theme.c keys.c util.c meow_dep.c key_router.c \ meow_touch.c meow_settings.c meow_notif.c meow_media.c meow_bgplay.c \ meow_bgmgr.c \ meow_ams_start.c lock_screen.c lock_screen_ui.c lock_settings.c \ diff --git a/patch_v2/meow_launcherd/key_router.c b/patch_v2/meow_launcherd/key_router.c new file mode 100644 index 0000000..fbd0113 --- /dev/null +++ b/patch_v2/meow_launcherd/key_router.c @@ -0,0 +1,142 @@ +/* key_router.c — launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY,P1:音量键)。 + * + * 本模块持有一个进程内唯一的音量键防抖/基线: + * kp_keys / kp_power(事件设备 poll 态,key_router_init 单开) + * kd_vold / kd_volu(40ms 防抖态,KEY_DEBOUNCE_MS) + * m_prev_vold / m_prev_volu(音量边沿基线) + * 各 launcher-UI 页每圈调 key_router_pump() —— 它只产「音量边沿 + 原始 held + ramp 状态」, + * 产出的边沿由「谁在跑循环」天然归属当前页;页据此决定 settings_volume_adj(±step) / + * 光标导航 / ramp 推进。这样 run_menu 的 meow_mode 分支(HibyOS overlay 音量=导航)不被破坏, + * 其余页(设置/通知中心/Now Playing/子页)用默认「音量=调音量」。 + * + * 边界(§3 P1 / §4): + * - App 前台 volume_page 武装时(meow_volpage_armed())音量边沿被抑制(防御闸;launcher-UI + * 下 sink 恒空,此闸是「不改变行为」的保险)。 + * - 播放键/电源键 P1 不路由(各页现状不动;播放键严禁 cmd=6)。 + * - 锁屏(EVIOCGRAB)/App 会话层不调用本模块 —— 边界由「调用栈里当前循环是不是 launcher-UI 页」 + * 自动强加。 + */ +#include "meow_launcher_internal.h" + +static int g_inited = 0; + +/* 进程唯一事件设备 poll 态(key_router_init 单开;各页另自开 kp_* 仅供播放/电源键用)。 */ +static struct keypoll kp_power, kp_keys; + +/* 进程唯一音量键防抖态 + 边沿基线。 */ +static struct kd_state kd_vold, kd_volu; +static int m_prev_vold = 0, m_prev_volu = 0; + +/* 当前活动页哨兵(key_router_page_enter/exit 登记;与 sdk meow_ui_activity 交叉校验用)。 */ +static int g_page_sentinel = 0; + + +void key_router_init(void) +{ + if (g_inited) return; + g_inited = 1; + memset(&kp_power, 0, sizeof kp_power); + memset(&kp_keys, 0, sizeof kp_keys); + memset(&kd_vold, 0, sizeof kd_vold); + memset(&kd_volu, 0, sizeof kd_volu); + m_prev_vold = 0; m_prev_volu = 0; + kp_open(&kp_power, DEV_POWER); + kp_open(&kp_keys, DEV_KEYS); + /* 基线对齐当前 raw:进入时已按住的音量键不产假边沿。 */ + key_router_rebase(); +} + + +void key_router_rebase(void) +{ + memset(&kd_vold, 0, sizeof kd_vold); + memset(&kd_volu, 0, sizeof kd_volu); + if (!g_inited) return; + kp_poll(&kp_power, NULL, NULL); + kp_poll(&kp_keys, NULL, NULL); + m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); + m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); +} + + +key_evt_t key_router_pump(void) +{ + key_evt_t ev; + memset(&ev, 0, sizeof ev); + if (!g_inited) key_router_init(); + + /* 设备晚就绪兜底:init 时 open 失败则此处每圈重试(open 失败快速返回,无阻塞)。 */ + if (!kp_keys.ok) kp_open(&kp_keys, DEV_KEYS); + if (!kp_power.ok) kp_open(&kp_power, DEV_POWER); + kp_poll(&kp_power, NULL, NULL); + kp_poll(&kp_keys, NULL, NULL); + + int vold = 0, volu = 0; + for (int w = 0; w < KEYBUF_WORDS; w++) { + if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1; + if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1; + } + /* 原始(未防抖)值:长按 ramp held 用(松开立即停止;同 run_menu/now_playing 现状)。 */ + ev.vol_held_dn = vold; + ev.vol_held_up = volu; + (void)kd_tick(&kd_vold, vold); + (void)kd_tick(&kd_volu, volu); + vold = kd_vold.stable; volu = kd_volu.stable; + ev.vol_st_dn = vold; ev.vol_st_up = volu; + + if (meow_volpage_armed()) { + /* 防御闸:App 前台 volume_page 武装时(launcher-UI 下恒空)不放行音量边沿。 + * 音量键在那态下由 app_launcher 的 meow_volpage_key 消费(翻页),不落系统调音。 */ + ev.vol_dn_edge = 0; + ev.vol_up_edge = 0; + } else { + ev.vol_dn_edge = (vold && !m_prev_vold); + ev.vol_up_edge = (volu && !m_prev_volu); + } + ev.ramping = (vol_ramp_dir != 0); + + m_prev_vold = vold; + m_prev_volu = volu; + return ev; +} + + +void key_router_default_volume(const key_evt_t *ev, unsigned long long *last_input) +{ + if (ev->vol_dn_edge) { + settings_volume_adj(-settings_volume_step); + if (last_input) *last_input = now_ms(); + logmsg("key_router: volume DOWN <- step=%d", settings_volume_step); + } + if (ev->vol_up_edge) { + settings_volume_adj(+settings_volume_step); + if (last_input) *last_input = now_ms(); + logmsg("key_router: volume UP <- step=%d", settings_volume_step); + } + /* 在 settings_volume_adj 之后重查 vol_ramp_dir:同圈「起 ramp」即立刻迈第一步(同现状 + * 各页「settings_volume_adj 后 if(vol_ramp_dir) vol_ramp_tick」),且长按续爬随每圈推进。 */ + if (vol_ramp_dir != 0) { + int held = (vol_ramp_dir > 0) ? ev->vol_held_up : ev->vol_held_dn; + (void)vol_ramp_tick(held); + } +} + + +void key_router_page_enter(int sentinel) +{ + g_page_sentinel = sentinel; + /* 与 sdk 活动栈交叉校验:哨兵非 0 且栈顶不一致 → 记录(不中止,防宿主/真机栈深度差异)。 */ + if (sentinel) { + int act = meow_ui_activity_active(); + if (act && act != sentinel) + logmsg("key_router: page_enter sentinel=%#x but active=%#x", sentinel, act); + } +} + + +void key_router_page_exit(int sentinel) +{ + if (g_page_sentinel == sentinel) g_page_sentinel = 0; + /* 页退出即重播基线(§6 嵌套返回风险项:返回瞬间不产假边沿)。 */ + key_router_rebase(); +} diff --git a/patch_v2/meow_launcherd/key_router.h b/patch_v2/meow_launcherd/key_router.h new file mode 100644 index 0000000..7a6302d --- /dev/null +++ b/patch_v2/meow_launcherd/key_router.h @@ -0,0 +1,65 @@ +/* key_router.h — launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY,P1:音量键)。 + * + * 设计(devdocs/plan/KEY_ROUTER_UNIFY.md §2.5 方案 C / §3 P1): + * - 进程内**唯一**的 kd_*(防抖态)+ m_prev_*(边沿基线)+ kp_*(事件设备 poll 态), + * 把「读边沿 + 防抖 + 长按 held + ramp 推进」这层从 5 个会话(run_menu / + * run_settings_page / run_notif_center / run_now_playing / meow_subpage_run) + * 收敛到一处。 + * - 每个 launcher-UI 页在其 for(;;) 每圈调一次 key_router_pump(),得到「防抖后的 + * 音量边沿 + 原始 held + ramp 状态」;**真正的动作**(音量边沿 → + * settings_volume_adj(±step),还是光标导航)由调用页决定 —— 这样 run_menu 的 + * meow_mode 分支(HibyOS overlay:音量=导航;MeowOS:音量=调音量)得以保留。 + * - 非导航页可经 key_router_default_volume()(内含 settings_volume_adj + vol_ramp_tick + + * last_input 复位)直接用;run_menu 走自己的 meow_mode 分支。 + * + * 范围与边界(§3 P1,勿越界): + * - 本模块只收敛**音量键**(KEY_VOLUMEDOWN/KEY_VOLUMEUP)。播放键/电源键 P1 不路由, + * 维持各页现状(播放键严禁改成 cmd=6;电源键单击/菜单/8s 状态机不动)。 + * - App 前台 volume_page 武装时(meow_volpage_armed())音量边沿被抑制(防御闸)。 + * - 锁屏层(EVIOCGRAB)/App 会话层从不调用 key_router_pump,天然退出本路由。 + */ +#ifndef MEOW_KEY_ROUTER_H +#define MEOW_KEY_ROUTER_H + +/* 一框音量边沿/held/ramp 输出。P1 只填音量;播放/电源字段后续期(P2/P3)再扩展。 + * 字段语义: + * vol_dn_edge / vol_up_edge —— 防抖后**稳定按下边沿**(= stable ∧ ¬上一 stable; + * 1=本圈新按下)。这是「音量边沿」的唯一权威判定。 + * vol_held_dn / vol_held_up —— **原始(未防抖)**按住态,ramp 长按 held 用 + * (松开立即停止,同 run_menu/now_playing 现状)。 + * vol_st_dn / vol_st_up —— **防抖稳定**按住态(wake_suppress「全键抬起」判定用)。 + * ramping —— vol_ramp_dir != 0(线性渐变是否在跑;repaint 用)。 + */ +typedef struct { + int vol_dn_edge; + int vol_up_edge; + int vol_held_dn; + int vol_held_up; + int vol_st_dn; + int vol_st_up; + int ramping; +} key_evt_t; + +/* 进程级单开 kp_*(事件设备)+ 初始化防抖态/基线(幂等,可安全重复调用)。 */ +void key_router_init(void); + +/* 每页 for(;;) 每圈调用一次:poll 两设备 → 防抖音量键 → 产边沿/held/ramp。 + * 不做 settings_volume_adj、不驱动 vol_ramp_tick、不复位 last_input —— 皆由调用页决定 + * (见 key_router_default_volume 与各页自己的分支)。 */ +key_evt_t key_router_pump(void); + +/* 模态/菜单/嵌套会话返回/唤醒后重播基线:kd_* 清零 + m_prev_* 对齐当前 raw + * (对齐现状各页「memset(kd_*) + kp_poll + m_prev_*=当前 raw」的语义,防假边沿)。 + * 各页在原本做该重播处调用。 */ +void key_router_rebase(void); + +/* 页进入/退出登记(记录当前哨兵;与 sdk meow_ui_activity_active 交叉校验)。 */ +void key_router_page_enter(int sentinel); +void key_router_page_exit(int sentinel); + +/* 默认「音量键=调音量」页回调(非导航页直接用):音量边沿 → settings_volume_adj(±step); + * 若有 ramp 在爬 → vol_ramp_tick(held);边沿 → 复位 *last_input。 + * run_menu 不走本函数(自行 meow_mode 分支)。 */ +void key_router_default_volume(const key_evt_t *ev, unsigned long long *last_input); + +#endif /* MEOW_KEY_ROUTER_H */ diff --git a/patch_v2/meow_launcherd/main_menu.c b/patch_v2/meow_launcherd/main_menu.c index b77e2b4..f420acb 100644 --- a/patch_v2/meow_launcherd/main_menu.c +++ b/patch_v2/meow_launcherd/main_menu.c @@ -1214,10 +1214,11 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, struct keypoll kp_power, kp_keys; kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS); - /* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动 */ - struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; - memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); + /* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动。 + * 音量键防抖/基线已上收 key_router(进程唯一);本页只保留播放/电源键自家防抖。 */ + struct kd_state kd_play, kd_pwr; memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr); + key_router_rebase(); /* 进入主菜单:音量基线对齐当前 raw(防残留边沿) */ logmsg("menu open: napps=%d state=%d cursor=%d", napps, state, cursor); @@ -1226,10 +1227,8 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, * when the menu opens (e.g. the hotkey release tail) do not fire * edges; kp.prev cannot be used for this because kp_poll() syncs it * to cur before we compare */ - int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0; + int m_prev_play = 0, m_prev_pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1; } @@ -1433,26 +1432,20 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, * does not affect it) — the launcher must NOT touch brightness/panel * or exit the menu on it. Exit is only via the BACK TO PLAYER touch * row (below) or sim-exit. */ + /* 音量键走 key_router(进程唯一防抖/基线,产边沿/held/ramp);播放/电源键仍本页自理。 */ + key_evt_t kev = key_router_pump(); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); - int vold = 0, volu = 0, play = 0, pwr = 0; + int play = 0, pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1; } if (sim_power_hold_ms > 0 && el >= sim_power_hold_ms) pwr = 1; /* sim:按住电源 */ - /* 原始(未防抖)键值:长按连续(vol_ramp held)用,避免防抖延迟让单击仍连爬多档。 */ - int rvold = vold, rvolu = volu, rplay = play, rpwr = pwr; /* 原始值:长按连续用 rvolu/rvold */ - (void)rplay; (void)rpwr; /* play/pwr 只用稳定边沿,不需原始 held */ /* M2.16 防抖:每键值须稳定 KEY_DEBOUNCE_MS(40ms) 才认可,抗 ADC 抖动("点一下跳5-6下")。 - * 稳定态 vold/volu/play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */ - (void)kd_tick(&kd_vold, vold); - (void)kd_tick(&kd_volu, volu); + * 稳定态 play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */ (void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr); - vold = kd_vold.stable; volu = kd_volu.stable; play = kd_play.stable; pwr = kd_pwr.stable; /* M2.6 电源语义(MeowOS 自持面板,无播放器拔河): * 单击(短按松开 <2s)= 息屏/亮屏;2s = 电源菜单;8s = 强制关机。 */ @@ -1497,15 +1490,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, * 难唤醒:launcher 以为屏开着,电源唤醒逻辑不触发)。电源键确认取消时 raw * 仍=按住=1,无此错配,所以只有触摸路径黑屏。此处重播 kd_* 并以当前 raw * 对齐 m_prev_*,杜绝该假边沿(真实新按键仍正常防抖/单击)。 */ + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); continue; } @@ -1530,15 +1521,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, if (lck_wake_should_lock()) { int lr = run_lock_screen(); /* 重播按键防抖基线(阻塞期间按键可能已变化,防误判新边沿) */ + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); - m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); + m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); if (lr == 1) meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */ @@ -1567,11 +1556,11 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, * M2.11 E:MeowOS 下音量键 = 调系统软件音量(settings audio.volume,步进5 clamp), * 状态栏即时显示、持久化。有播放器的覆盖态才保留音量键=菜单光标导航。 */ if (meow_mode) { - if (vold && !m_prev_vold) { + if (kev.vol_dn_edge) { any_input = 1; settings_volume_adj(-settings_volume_step); /* 起线性渐变(1% 爬) */ logmsg("volume DOWN <- ramp step=%d", settings_volume_step); - } else if (volu && !m_prev_volu) { + } else if (kev.vol_up_edge) { any_input = 1; settings_volume_adj(+settings_volume_step); logmsg("volume UP <- ramp step=%d", settings_volume_step); @@ -1579,22 +1568,23 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, } else { /* M2.36 B2b:光标范围扩展为 [0..napps](napps=「系统设置」行,位于 Exit 行上方)。 * 音量下移越过最后应用 → 设置行;设置行再下移 → 回 0。应用行仍 0..napps-1。 */ - if (vold && !m_prev_vold && napps > 0 && state == 0) { + if (kev.vol_dn_edge && napps > 0 && state == 0) { any_input = 1; cursor++; if (cursor > napps) cursor = 0; /* napps=设置行 */ logmsg("menu cursor -> %d", cursor); } - if (volu && !m_prev_volu && napps > 0 && state == 0) { + if (kev.vol_up_edge && napps > 0 && state == 0) { any_input = 1; cursor--; if (cursor < 0) cursor = napps; /* 回绕到设置行 */ logmsg("menu cursor -> %d", cursor); } } - /* M2.14 线性渐变:每 tick 推进 1% 并刷新状态栏(显示爬升中的值或最终值) */ + /* M2.14 线性渐变:每 tick 推进 1% 并刷新状态栏(显示爬升中的值或最终值)。 + * 长按 raw held 取自 key_router(kev.vol_held_*,同现状 rvolu/rvold),松开立即停止。 */ if (meow_mode && vol_ramp_dir != 0) { - int held = (vol_ramp_dir > 0) ? rvolu : rvold; /* 长按=该方向键仍按住(用原始值:松开立即停止) */ + int held = (vol_ramp_dir > 0) ? kev.vol_held_up : kev.vol_held_dn; int going = vol_ramp_tick(held); g_sys.vol = settings_volume_get(); paint_sb_right(); /* M2.16:只重画右侧信息格(VOL 变化),不动列表/时钟 */ @@ -1618,7 +1608,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, logmsg("play key: media-reserved (no media source)"); } } - m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; + m_prev_play = play; /* touch (event reads) */ if (touch_fd >= 0 && meow_screen_on) { /* 息屏时不处理触摸(goodix 已被 FBIOBLANK(4) 挂起) */ @@ -1683,15 +1673,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, click.status = NOTIF_CLICK_STATUS_NONE; run_notif_center(apps, napps, &click, 1, t_down_x, loy); /* 重播按键防抖基线(同原 top-swipe 路径),防面板内电源/音量边沿被 run_menu 误判 */ + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); last_input_ms = now_ms(); menu_arm_touch(800); @@ -1783,15 +1771,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, click.status = NOTIF_CLICK_STATUS_NONE; run_notif_center(apps, napps, &click, 0, 0, 0); /* ⑨ live_open=0 → 触发式自动展开(防御) */ /* 重播按键防抖基线,防面板内电源/音量边沿被 run_menu 误判(M2.25 同款)。 */ + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); last_input_ms = now_ms(); menu_arm_touch(800); @@ -1835,15 +1821,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, menu_arm_touch(TAP_ARM_DELAY_MS); run_now_playing(apps, napps); /* 重播按键防抖基线,防播放页内电源/音量边沿被 run_menu 误判 */ + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); last_input_ms = now_ms(); menu_arm_touch(800); @@ -1858,15 +1842,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, logmsg("menu: touch topbar bg-manager button"); menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护 */ run_bg_page(apps, napps); + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); last_input_ms = now_ms(); menu_arm_touch(800); @@ -1898,15 +1880,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, logmsg("menu: touch settings row -> settings page"); menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护:吃掉打开此行的那次触摸 */ run_settings_page(); + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); last_input_ms = now_ms(); /* 回主界面即视为输入 */ menu_arm_touch(800); /* 设置页返回后再忽略近期触摸 */ @@ -1975,15 +1955,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state, /* M2.x 锁屏:自动息屏唤醒后若需锁屏 → 弹阻塞式锁屏页;解锁才重绘,息屏留 dirty */ if (lck_wake_should_lock()) { int lr = run_lock_screen(); + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); - m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); + m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); if (lr == 1) meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */ @@ -2219,6 +2197,7 @@ int main(int argc, char **argv) load_config(); /* hotkey_seq / hotkey_seq_gap / bg_color (also run per * menu open so edits apply without a daemon restart) */ settings_load(); /* M2.11:读系统设置(audio.volume 等),供音量/状态栏使用 */ + key_router_init(); /* M6.x:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1)单开 kp_*/ /* M2.6:启动扫描一次 TF 卡 MeowOS/fonts///,填充「字体文件」下拉 preset。 * 无卡/无字库目录 → 仅默认字体(等价现状)。进设置页时 run_settings_page 会再扫一次(verify=0)。 */ diff --git a/patch_v2/meow_launcherd/meow_launcher_internal.h b/patch_v2/meow_launcherd/meow_launcher_internal.h index c2c8825..8c28eac 100644 --- a/patch_v2/meow_launcherd/meow_launcher_internal.h +++ b/patch_v2/meow_launcherd/meow_launcher_internal.h @@ -28,6 +28,7 @@ extern "C" { #include #include /* Bug 2:直连 reboot(2) syscall 用 */ #include /* M0:IPC 线程 */ +#include "key_router.h" /* M6.x:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1) */ #include /* M0:IPC→主循环唤醒(EFD_NONBLOCK) */ #include /* M0:AF_UNIX IPC */ #include /* M0:sockaddr_un */ @@ -847,6 +848,9 @@ int nm_wifi_saved(meow_net_t *out, int max); int nm_wifi_scan(meow_net_t *out, int max); void nm_wifi_scan_trigger(void); int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen); /* MEOW_OK=成功 */ +/* WiFi 设置页 bug2(2026-09-15):hidden=1 走 netmgrd connecth(scan_ssid=1 + 更长 COMPLETED 等待), + * 回退直连同步对齐;err 回填 netmgrd/直连的诚实结果文本(成功含 ip / 失败含原因)。 */ +int nm_wifi_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen); int nm_wifi_disconnect(char *err, int elen); int nm_wifi_toggle(int on); diff --git a/patch_v2/meow_launcherd/netmgr_ipc.c b/patch_v2/meow_launcherd/netmgr_ipc.c index 5c36f7b..d5045bd 100644 --- a/patch_v2/meow_launcherd/netmgr_ipc.c +++ b/patch_v2/meow_launcherd/netmgr_ipc.c @@ -15,6 +15,14 @@ #define NETMGR_SOCK_DEFAULT "/usr/data/meow_net.sock" /* NETMGR_UNREACH 定义在 meow_launcher_internal.h(测试/调用方共用) */ +/* IPC 读超时(ms)。netmgrd 单线程 poll,connect/connecth 会阻塞 ~10s/~15s 有界等待 + * wpa_state=COMPLETED(bug1),scan 内含 1.5s 触发等待(P3)——客户端 per-command 超时需 + * 覆盖这些同步等待,其余读级子命令维持 1.5s 不被卡死。 */ +#define NM_TMO_DFL 1500 +#define NM_TMO_SCAN 5000 +#define NM_TMO_CONNECT 12000 +#define NM_TMO_CONNECTH 17000 + /* 可达性缓存:netmgrd 不可达时每次 connect 都秒回 ECONNREFUSED,无成本;缓存约 1s 防热循环。 */ static int g_nm_ok = -1; /* -1 未知 / 0 down / 1 up */ static time_t g_nm_ok_ts = 0; @@ -50,7 +58,7 @@ static int nm_r_all(int fd, uint8_t *b, size_t n) return 0; } static int nm_xchg(int fd, uint16_t type, uint32_t req, const void *pl, uint32_t len, - uint8_t *rbuf, uint32_t rcap, uint16_t *rtype) + uint8_t *rbuf, uint32_t rcap, uint16_t *rtype, int tmo_ms) { uint8_t hdr[MEOW_IPC_HDR_LEN]; uint32_t magic = MEOW_IPC_MAGIC, rlen = len; @@ -60,7 +68,7 @@ static int nm_xchg(int fd, uint16_t type, uint32_t req, const void *pl, uint32_t if (nm_w_all(fd, hdr, sizeof hdr) < 0) return -1; if (len && pl && nm_w_all(fd, pl, len) < 0) return -1; struct pollfd pf = { fd, POLLIN, 0 }; - int pr = poll(&pf, 1, 1500); + int pr = poll(&pf, 1, tmo_ms); if (pr <= 0) return -1; uint8_t rhdr[MEOW_IPC_HDR_LEN]; if (nm_r_all(fd, rhdr, sizeof rhdr) < 0) return -1; @@ -81,15 +89,16 @@ static int nm_hello(int fd) snprintf(h.app_id, sizeof h.app_id, "%s", "os.meow.launcher"); h.version = 1; uint8_t rbuf[256]; uint16_t rtype = 0; - if (nm_xchg(fd, MSG_HELLO, 0x100, &h, sizeof h, rbuf, sizeof rbuf, &rtype) < 0 || rtype != MSG_ACK) + if (nm_xchg(fd, MSG_HELLO, 0x100, &h, sizeof h, rbuf, sizeof rbuf, &rtype, NM_TMO_DFL) < 0 || rtype != MSG_ACK) return -1; m_ack_t ak; memcpy(&ak, rbuf, sizeof ak); return (ak.code == MEOW_OK) ? 0 : -1; } -/* 发一条 WIFI_CTL/BT_CTL 子命令,收 RESULT。返回 netmgrd 业务 code;NETMGR_UNREACH=不可达。 */ +/* 发一条 WIFI_CTL/BT_CTL 子命令,收 RESULT。返回 netmgrd 业务 code;NETMGR_UNREACH=不可达。 + * tmo_ms:读超时(connect/connecth/scan 需覆盖 netmgrd 同步等待,见 NM_TMO_*)。 */ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const char *arg, - char *text, int tlen) + char *text, int tlen, int tmo_ms) { m_net_cmd_t q; memset(&q, 0, sizeof q); @@ -100,7 +109,7 @@ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const c if (nm_hello(fd) < 0) { close(fd); nm_cache(0); return NETMGR_UNREACH; } uint8_t rbuf[MEOW_IPC_MAX]; uint16_t rtype = 0; memset(rbuf, 0, sizeof rbuf); - if (nm_xchg(fd, ctl_type, 0x101, &q, sizeof q, rbuf, sizeof rbuf, &rtype) < 0 || rtype != res_type) { + if (nm_xchg(fd, ctl_type, 0x101, &q, sizeof q, rbuf, sizeof rbuf, &rtype, tmo_ms) < 0 || rtype != res_type) { close(fd); nm_cache(0); return NETMGR_UNREACH; } m_net_cmd_result_t res; memcpy(&res, rbuf, sizeof res); @@ -112,11 +121,14 @@ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const c return (int)res.code; } +int netmgr_wifi_tmo(const char *cmd, const char *arg, char *text, int tlen, int tmo_ms) +{ return nm_cmd(MSG_NET_WIFI_CTL, MSG_NET_WIFI_RESULT, cmd, arg, text, tlen, tmo_ms); } + int netmgr_wifi(const char *cmd, const char *arg, char *text, int tlen) -{ return nm_cmd(MSG_NET_WIFI_CTL, MSG_NET_WIFI_RESULT, cmd, arg, text, tlen); } +{ return netmgr_wifi_tmo(cmd, arg, text, tlen, NM_TMO_DFL); } int netmgr_bt(const char *cmd, const char *arg, char *text, int tlen) -{ return nm_cmd(MSG_NET_BT_CTL, MSG_NET_BT_RESULT, cmd, arg, text, tlen); } +{ return nm_cmd(MSG_NET_BT_CTL, MSG_NET_BT_RESULT, cmd, arg, text, tlen, NM_TMO_DFL); } int netmgr_ok(void) { @@ -361,7 +373,7 @@ int nm_wifi_saved(meow_net_t *out, int max) int nm_wifi_scan(meow_net_t *out, int max) { char text[MEOW_IPC_MAX - 32]; - int rc = netmgr_wifi("scan", NULL, text, sizeof text); /* netmgrd 内含触发+有界等待+读结果 */ + int rc = netmgr_wifi_tmo("scan", NULL, text, sizeof text, NM_TMO_SCAN); /* netmgrd 内含触发+有界等待+读结果 */ if (rc == NETMGR_UNREACH) return net_wifi_scan(out, max); if (rc != MEOW_OK) return 0; return nm_parse_scan(text, out, max); @@ -374,49 +386,133 @@ void nm_wifi_scan_trigger(void) if (!netmgr_ok()) net_wifi_scan_trigger(); } -/* —— 直连回退:wpa_cli add/set/enable/select 新网络(复刻 netmgrd wifi_connect 逻辑)—— */ -static int nm_direct_connect(const char *ssid, const char *psk, char *err, int elen) +/* —— 直连回退:与 netmgrd wifi_connect/connecth 对齐(bug1/bug2/bug4)。 + * 匹配既有(\xNN 还原同表示)→ 新建或(命中且带新 psk)更新凭据 → hidden 补 scan_ssid=1 → + * enable+select → 有界等待 wpa_state=COMPLETED → 成功 enable_network all + save_config(校验重试); + * 失败时本次新建的网络回收,报文明示原因。仅在 netmgrd 不可达时走此路径。 */ +static const char NM_WPA_PREFIX[] = "PATH=/bin:/sbin:/usr/bin:/usr/sbin wpa_cli -i wlan0"; + +static int nm_direct_wpa(char *out, int cap, const char *args, int tmo) { - const char *wpa = "PATH=/bin:/sbin:/usr/bin:/usr/sbin wpa_cli -i wlan0"; - char cmd[256]; - snprintf(cmd, sizeof cmd, "%s add_network 2>/dev/null", wpa); - char *o = popen_read_tmo(1200, cmd); - int id = o ? atoi(o) : -1; - if (o) free(o); - if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; } - { - char *r; - snprintf(cmd, sizeof cmd, "%s set_network %d ssid '\"%s\"' 2>/dev/null", wpa, id, ssid); - if ((r = popen_read_tmo(1200, cmd))) free(r); - if (psk && psk[0]) { - snprintf(cmd, sizeof cmd, "%s set_network %d psk '\"%s\"' 2>/dev/null", wpa, id, psk); - if ((r = popen_read_tmo(1200, cmd))) free(r); - } else { - snprintf(cmd, sizeof cmd, "%s set_network %d key_mgmt NONE 2>/dev/null", wpa, id); - if ((r = popen_read_tmo(1200, cmd))) free(r); + char cmd[384]; + snprintf(cmd, sizeof cmd, "%s %s 2>/dev/null", NM_WPA_PREFIX, args); + char *o = popen_read_tmo(tmo, cmd); + if (!o) { out[0] = 0; return -1; } + int l = (int)strlen(o); + if (l >= cap) l = cap - 1; + memcpy(out, o, (size_t)l); out[l] = 0; + free(o); + return l; +} + +static int nm_direct_save_config(void) +{ + char out[128]; + if (nm_direct_wpa(out, sizeof out, "save_config", 1500) > 0 && strstr(out, "OK")) return 1; + nm_direct_wpa(out, sizeof out, "set update_config 1", 1500); + if (nm_direct_wpa(out, sizeof out, "save_config", 1500) > 0 && strstr(out, "OK")) return 1; + return 0; +} + +static int nm_direct_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen) +{ + char out[1024]; char args[256]; + int id = -1; + if (nm_direct_wpa(out, sizeof out, "list_networks", 1500) > 0) { + char *ln = strtok(out, "\n"); + while (ln) { + char *tab0 = strchr(ln, '\t'); + if (tab0) { + int this_id = atoi(ln); + char *ss = tab0 + 1; + char *tab1 = strchr(ss, '\t'); + if (tab1) *tab1 = 0; + char clean[128], unesc[160], norm[128]; + size_t cl = strlen(ss); + if (cl >= 2 && ss[0] == '"' && ss[cl - 1] == '"') { + memcpy(clean, ss + 1, cl - 2); clean[cl - 2] = 0; + } else { + snprintf(clean, sizeof clean, "%s", ss); + } + nm_decode_ssid(clean, unesc, sizeof unesc); + char *p = unesc; while (*p == ' ' || *p == '\t') p++; + snprintf(norm, sizeof norm, "%s", p); + size_t nl = strlen(norm); + while (nl > 0 && (norm[nl - 1] == ' ' || norm[nl - 1] == '\t')) norm[--nl] = 0; + if (strcmp(norm, ssid) == 0) { id = this_id; break; } + } + ln = strtok(NULL, "\n"); } - snprintf(cmd, sizeof cmd, "%s enable_network %d 2>/dev/null", wpa, id); - if ((r = popen_read_tmo(1200, cmd))) free(r); - snprintf(cmd, sizeof cmd, "%s select_network %d 2>/dev/null", wpa, id); - if ((r = popen_read_tmo(1200, cmd))) free(r); } - if (err) snprintf(err, elen, "connect: ssid=%s network_id=%d", ssid, id); - logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s id=%d", ssid, id); + int created = 0; + if (id < 0) { + if (nm_direct_wpa(out, sizeof out, "add_network", 1500) <= 0) { + if (err) snprintf(err, elen, "add_network 无 id"); + return MEOW_ERR_INVAL; + } + id = atoi(out); + if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; } + created = 1; + snprintf(args, sizeof args, "set_network %d ssid '\"%s\"'", id, ssid); + nm_direct_wpa(out, sizeof out, args, 1500); + if (psk && psk[0]) { snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk); nm_direct_wpa(out, sizeof out, args, 1500); } + else { snprintf(args, sizeof args, "set_network %d key_mgmt NONE", id); nm_direct_wpa(out, sizeof out, args, 1500); } + } else if (psk && psk[0]) { + snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk); + nm_direct_wpa(out, sizeof out, args, 1500); + } + if (hidden) { snprintf(args, sizeof args, "set_network %d scan_ssid 1", id); nm_direct_wpa(out, sizeof out, args, 1500); } + snprintf(args, sizeof args, "enable_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500); + snprintf(args, sizeof args, "select_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500); + + int wait_ms = hidden ? 15000 : 10000; + int waited = 0, ok = 0; char ip[40] = ""; + for (;;) { + char st[512]; + if (nm_direct_wpa(st, sizeof st, "status", 1500) > 0) { + char *ws = strstr(st, "wpa_state="); + if (ws && !strncmp(ws + 10, "COMPLETED", 9)) { + ok = 1; + char *ib = strstr(st, "ip_address="); + if (ib) { ib += 11; char *ie = ib; while (*ie && *ie != '\n' && *ie != '\r') ie++; + int il = (int)(ie - ib); if (il >= (int)sizeof ip) il = (int)sizeof ip - 1; + memcpy(ip, ib, (size_t)il); ip[il] = 0; } + break; + } + } + if (waited >= wait_ms) break; + usleep(200000); + waited += 200; + } + if (!ok) { + if (created) { snprintf(args, sizeof args, "remove_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500); nm_direct_save_config(); } + if (err) snprintf(err, elen, "连接失败:密码错误或信号不佳(超时未关联)"); + logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s hidden=%d id=%d timeout", ssid, hidden, id); + return MEOW_ERR_NODEV; + } + nm_direct_wpa(out, sizeof out, "enable_network all", 1500); + int persisted = nm_direct_save_config(); + if (err) snprintf(err, elen, "已连接:%s ip=%s%s", ssid, ip[0] ? ip : "-", persisted ? "" : "(记录未持久化)"); + logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s hidden=%d id=%d ip=%s", ssid, hidden, id, ip[0] ? ip : "-"); return MEOW_OK; } -int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen) +int nm_wifi_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen) { if (!ssid || !ssid[0]) { if (err) snprintf(err, elen, "ssid 缺失"); return MEOW_ERR_INVAL; } char arg[132]; - snprintf(arg, sizeof arg, "%s,%s", ssid, psk ? psk : ""); /* connect 带空 psk → netmgrd 复用既有网络 */ + snprintf(arg, sizeof arg, "%s,%s", ssid, psk ? psk : ""); /* 带空 psk → netmgrd 复用既有网络 */ char text[192]; - int rc = netmgr_wifi("connect", arg, text, sizeof text); - if (rc == NETMGR_UNREACH) return nm_direct_connect(ssid, psk, err, elen); + int rc = netmgr_wifi_tmo(hidden ? "connecth" : "connect", arg, text, sizeof text, + hidden ? NM_TMO_CONNECTH : NM_TMO_CONNECT); + if (rc == NETMGR_UNREACH) return nm_direct_connect_ex(ssid, psk, hidden, err, elen); if (err && elen > 0) snprintf(err, elen, "%s", text); return rc; } +int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen) +{ return nm_wifi_connect_ex(ssid, psk, 0, err, elen); } + int nm_wifi_disconnect(char *err, int elen) { char text[128]; diff --git a/patch_v2/meow_launcherd/notif_center.c b/patch_v2/meow_launcherd/notif_center.c index 863293c..08e491f 100644 --- a/patch_v2/meow_launcherd/notif_center.c +++ b/patch_v2/meow_launcherd/notif_center.c @@ -1168,16 +1168,15 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click, struct keypoll kp_power, kp_keys; kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS); - struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; - memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); + /* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */ + struct kd_state kd_play, kd_pwr; memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr); - int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0; + int m_prev_play = 0, m_prev_pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1; } + key_router_rebase(); /* 进入通知中心:音量基线对齐当前 raw(防残留边沿) */ int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK); if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} } unsigned long long touch_cooldown_until = now_ms() + 300; @@ -1304,12 +1303,11 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click, } if (wak) { meow_panel_on(); meow_auto_slept = 0; wake_suppress = 1; last_act = now_ms(); - memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); + /* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); touch_cooldown_until = now_ms() + 300; td = 0; td_ok = 0; drag_slider = 0; tlx = tly = -1; @@ -1331,23 +1329,20 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click, } /* 按键:系统语义(APP §3.4.1 红线——不映射返回/确认) */ + key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */ kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); - int vold = 0, volu = 0, play = 0, pwr = 0; + int play = 0, pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1; } - (void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu); (void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr); - vold = kd_vold.stable; volu = kd_volu.stable; play = kd_play.stable; pwr = kd_pwr.stable; if (wake_suppress) { - if (!pwr && !vold && !volu && !play) wake_suppress = 0; + if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0; } else { - if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; } - if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; } + if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; } + if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; } if (play && !m_prev_play) { logmsg("notif-center: play key caught (media-reserved, no exit)"); last_act = now; @@ -1366,12 +1361,11 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click, show_power_menu(); last_act = now_ms(); pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_suppress_click = 1; - memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); + /* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); after_modal = 1; need_paint = 1; goto ncp_keys_seeded; @@ -1390,14 +1384,14 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click, } } if (vol_ramp_dir != 0) { - int held = (vol_ramp_dir > 0) ? volu : vold; + int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn; int going = vol_ramp_tick(held); g_sys.vol = settings_volume_get(); paint_sb_right(); need_paint = 1; if (!going) logmsg("notif-center vol ramp end -> %d", g_sys.vol); } - m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr; + m_prev_play = play; m_prev_pwr = pwr; ncp_keys_seeded: ; @@ -1562,15 +1556,13 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click, after_modal = 1; /* 电源菜单已弹 → 整帧重绘还原 */ /* 电源菜单返回:菜单期间用户可能按电源确认/取消,键边沿可能错配, * 同电源键长按路径(line 7480-7488)重 poll + 重采 m_prev_*,防误判「单击」→ 误切 panel。 */ + /* 菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); /* 菜单期间用户的触摸经 evdev 广播也会进本 panel 的 tfd → 立即排空, * 防返回后把「菜单里的点击」误判为 panel 内 tap/滑动。 */ diff --git a/patch_v2/meow_launcherd/now_playing.c b/patch_v2/meow_launcherd/now_playing.c index 6add1a8..aea1872 100644 --- a/patch_v2/meow_launcherd/now_playing.c +++ b/patch_v2/meow_launcherd/now_playing.c @@ -198,6 +198,54 @@ static void np_center_text(int y, const char *s, int size, uint32_t color, int m ui_draw((W - ui_width(buf, size)) / 2, y, buf, color, size); } +/* 左对齐 + 超宽截断(UTF-8 安全,近似按字节比例切;歌词页顶部元信息用)。 */ +static void np_left_text(int x, int y, const char *s, int size, uint32_t color, int maxw) +{ + if (!s) s = ""; + if (ui_width(s, size) <= maxw) { + ui_draw(x, y, s, color, size); + return; + } + char buf[160]; + int keep = (int)(strlen(s) * maxw / (ui_width(s, size) + 1)); + if (keep < 0) keep = 0; + if (keep > (int)sizeof buf - 4) keep = (int)sizeof buf - 4; + memcpy(buf, s, (size_t)keep); buf[keep] = 0; + strcat(buf, "..."); + ui_draw(x, y, buf, color, size); +} + +/* 歌词页顶部元信息(两行居左,复刻 ncm 歌词页):第一行歌名(大字白)、第二行歌手(小字灰)。 + * 多歌手已由源侧拼串(ncm art_name 即「A / B」),此处原样显示 + 超宽截断。 + * 左边距与歌词正文一致(LYR_PAD=24);离状态栏留边距(SB_BAR_H + 20);右缘同距截断。 */ +static void np_draw_lyric_title(const meow_media_session_t *s) +{ + if (!s) return; + if (!s->title[0] && !s->artist[0]) return; + int pad = 24; + int y = SB_BAR_H + 20; + int maxw = fb_xres - 2 * pad; + if (s->title[0]) + np_left_text(pad, y, s->title, 28, 0xFFFFFF, maxw); + if (s->artist[0]) + np_left_text(pad, y + meow_font_line_height(28) + 6, s->artist, 20, 0xBBBBBB, maxw); +} + +/* 歌词页强制 ncm 配色:活动行恒纯白 + 上下逐级灰阶(对齐 ncm lyr_gray_tbl + * {FFFFFF,BBBBBB,929292,808080,6A6A6A,555555}——gray[0]=0 按 cur=0xFFFFFF 自动递减即为该表)。 + * 背景恒为 np_draw_lyric_page 铺的黑色半透明叠加层,故关掉 lyric_view 自带 scrim + * (scrim_alpha=0)避免双重压暗;当前行/灰阶不随主题/封面取色(保黑底对比度,夜间可读)。 */ +static void np_set_lyric_ncm_colors(void) +{ + if (!g_np_lyric) return; + meow_lyric_view_colors c; + memset(&c, 0, sizeof c); + c.scrim = 0x000000; + c.cur = 0xFFFFFF; + c.scrim_alpha = 0; /* 不画 scrim:由 launcher 黑底铺(单一叠加层) */ + meow_lyric_view_set_colors(g_np_lyric, &c); +} + /* ================================================================== * M7.0b Now Playing 控件化(launcher 无 meow_ui_run,手动驱动 SDK 控件): * - 进度条 → meow_progress(线性,SDK 主题 track/fill); @@ -346,28 +394,49 @@ static void np_draw_home_bar(void) NP_HOME_BAR_H / 2, th->on_surface_variant); } -/* 歌词页整页绘制:背景统一黑色半透明叠加层(用户 2026-09-04,弃封面取色/封面图半透明底, - * 保证高亮封面下歌词对比度)→ lyric_view(自绘 scrim + 歌词/空态)。 */ +/* 网易云式歌词页封面背景(用户 2026-09-05):有封面 → 等比居中放大铺满内容区(cover-fit, + * 超出部分由画布裁剪,镜像 ncm plr_draw_lyrics 的 meow_ui_img_scaled 算法);无封面 → 纯黑底 + * (现状回退)。本函数只铺底;压暗由调用方随后叠黑色半透明叠加层(alpha176)完成。 */ +static void np_draw_lyric_cover_bg(int W, int H) +{ + int x = 0, y = SB_BAR_H, cw = W, ch = H - SB_BAR_H; /* 只盖内容区(状态栏下),与播放页一致 */ + if (!g_np_cover || meow_img_w(g_np_cover) <= 0 || meow_img_h(g_np_cover) <= 0) { + fill_rect(x, y, cw, ch, 0x000000); /* 无封面 → 纯黑底(回退现状) */ + return; + } + int iw = meow_img_w(g_np_cover), ih = meow_img_h(g_np_cover); + long dw = cw, dh = (long)ih * cw / iw; /* cover-fit:铺满宽度,高按等比 */ + if (dh < ch) { dh = ch; dw = (long)iw * ch / ih; } /* 宽度不足再铺满高度(超出裁剪) */ + meow_ui_img_scaled(x + (cw - (int)dw) / 2, y + (ch - (int)dh) / 2, (int)dw, (int)dh, g_np_cover); +} + +/* 歌词页整页绘制:网易云式封面背景(封面图当底 + 黑色半透明叠加层压暗,保证歌词对比度)→ + * lyric_view(自绘 scrim 关闭,仅歌词/空态)→ 顶部元信息(歌名/歌手)。 */ static void np_draw_lyric_page(int redraw_sb) { const meow_ui_theme_t *th = s_ui_theme(); int W = fb_xres, H = fb_yres; if (!g_np_init) np_widgets_create(); if (redraw_sb) fill_rect(0, 0, W, SB_BAR_H, th->surface); - /* 批5:封面取色背景(主→次渐变)或主题面;并注入歌词页 set_colors。 - * 批6:有封面时以封面作整页背景(衬在 lyric_view 半透明 scrim 之下),取色只做附加; - * 无封面回退渐变/主题面。 + /* 批5:np_cover_theme_update 取色(仅为播放页文字/按钮主题;歌词页背景不走取色,恒 ncm 白/灰)。 + * 批6:有封面时以封面作整页背景(网易云式 cover-fit 铺满,见 np_draw_lyric_cover_bg), + * 无封面回退纯黑底;压暗由下方黑色半透明叠加层(alpha176)统一完成,lyric_view 关内置 scrim。 * M6.33:先 np_cover_load(artwork 空→复原/换源时重载封面)再取色——顺序不能反, * 取色读 g_np_cover,须在封面载入后(否则歌词页在 artwork 复原后仍无封面)。 */ np_cover_load(meow_media_current()); np_cover_theme_update(); - np_apply_lyric_colors(); - /* 用户 2026-09-04:歌词页背景统一黑色半透明叠加层(弃封面取色/封面图半透明底)—— - * 部分高亮度封面对歌词显示不友好,黑底保证对比度。歌词行颜色/布局/滚动不变。 */ + /* 网易云式封面背景(用户 2026-09-05):先铺封面底(cover-fit 原图铺满),无封面回退纯黑底; + * 随后统一叠黑色半透明叠加层(alpha176)压暗,保证歌词对比度。歌词配色仍恒 ncm 白/灰 + * (不随封面取色——取色仅服务播放页文字/按钮)。歌词行布局/滚动逻辑不变,只换背景。 */ + np_draw_lyric_cover_bg(W, H); + np_set_lyric_ncm_colors(); + /* 用户 2026-09-04:黑色半透明叠加层(alpha176)——压暗封面底,保证歌词可读;无封面时叠于 + * 纯黑底之上(仍纯黑,即现状回退)。 */ meow_ui_fill_rect_alpha(0, SB_BAR_H, W, H - SB_BAR_H, 0x000000, 176); np_lyric_refresh(meow_media_current()); if (g_np_lyric) meow_widget_draw(meow_lyric_view_widget(g_np_lyric)); + np_draw_lyric_title(meow_media_current()); /* 顶部元信息(歌名/歌手,居左,避状态栏) */ np_draw_home_bar(); /* 批②:歌词页也示 home 指示(底部上滑可回桌面) */ if (redraw_sb) draw_statusbar(); } @@ -486,6 +555,31 @@ static int64_t np_progress_seek(int tx, int ty) return 0; } +/* 播放页按键动作应用(音量 + 播放切换)。语义与主界面/子页一致: + * - 音量键 = 系统音量(settings_volume_adj 内含插孔消抖 + 线性渐变); + * - 播放键 = 媒体播放/暂停切换(路由最近活跃媒体会话,与主界面同判); + * 边沿由调用方按防抖后稳定值计算(vold_edge = 稳定按下 ∧ 上一稳定为抬起)。 + * 抽成独立小函数供宿主单测(np_apply_key_actions 仅依赖 settings_volume_adj/ + * meow_media_playctl/now_ms/logmsg,可 -ffunction-sections 链接隔离)。 */ +static void np_apply_key_actions(int vold_edge, int volu_edge, int play_edge, + const meow_media_session_t *s, + unsigned long long *last_input) +{ + if (vold_edge) { + settings_volume_adj(-settings_volume_step); + if (last_input) *last_input = now_ms(); + logmsg("np: volume DOWN <- ramp step=%d", settings_volume_step); + } else if (volu_edge) { + settings_volume_adj(+settings_volume_step); + if (last_input) *last_input = now_ms(); + logmsg("np: volume UP <- ramp step=%d", settings_volume_step); + } + if (play_edge) { + if (s) meow_media_playctl((s->state == MEOW_MEDIA_PLAYING) ? 2u : 1u, 0); + if (last_input) *last_input = now_ms(); + } +} + void run_now_playing(struct app *apps, int napps) { int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK); @@ -495,15 +589,17 @@ void run_now_playing(struct app *apps, int napps) kp_open(&kp_keys, DEV_KEYS); struct keypoll kp_power; /* M2.92:NP 页接管电源键(修「页内电源键无反应」) */ kp_open(&kp_power, DEV_POWER); - struct kd_state kd_play; + struct kd_state kd_play; /* 音量键防抖/基线上收 key_router(进程唯一) */ memset(&kd_play, 0, sizeof kd_play); /* 读播放键按住态(用于边沿) */ int m_prev_play = 0; - for (int w = 0; w < KEYBUF_WORDS; w++) - if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1; + for (int w = 0; w < KEYBUF_WORDS; w++) { + if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1; + } int np_prev_power = 0; /* 电源键边沿跟踪 */ for (int w = 0; w < KEYBUF_WORDS; w++) if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) np_prev_power = 1; + key_router_rebase(); /* 进入 Now Playing:音量基线对齐当前 raw(防残留边沿) */ unsigned long long np_last_input = now_ms(); /* 自动息屏计时(M2.92:NP 页也遵守 display.screen_off_timeout) */ int td = 0, td_ok = 0, tdx = -1, tdy = -1, tlx = -1, tly = -1; @@ -521,6 +617,7 @@ void run_now_playing(struct app *apps, int napps) * 控件 owner 一直钉哨兵,命中要走 meow_widget_hit 的 * owner==active 闸门——漏 push 则 active()!=0x51, * 三钮 meow_widget_hit 恒 false(「播放页三键无效」根因)。 */ + key_router_page_enter(NP_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */ g_np_page = NP_PAGE_PLAY; /* 批4:每次进入 Now Playing 从播放页起(上次退出页不残留) */ g_np_lyric_last_valid = -1; /* 批4:重置歌词「是否有词」过渡态,防首帧多清一次 */ g_np_lyric_seq = 0xFFFFFFFFu; /* 批4:强制首次进歌词页重解析(防跨进入残留缓存) */ @@ -631,17 +728,30 @@ void run_now_playing(struct app *apps, int napps) } } - /* 播放键边沿 → 播放/暂停切换 */ + /* 播放键/音量键边沿 → 媒体播放/暂停切换 + 系统音量(与主界面/子页一致)。 + * 修复:run_now_playing 原先只接播放/电源键,未接音量键 → 播放页/歌词页(同一循环, + * 与页无关)音量键无反应。 */ + key_evt_t kev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */ kp_poll(&kp_keys, NULL, NULL); int play = 0; - for (int w = 0; w < KEYBUF_WORDS; w++) + for (int w = 0; w < KEYBUF_WORDS; w++) { if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1; - if (play && !m_prev_play) { - const meow_media_session_t *s = meow_media_current(); - if (s) meow_media_playctl((s->state == MEOW_MEDIA_PLAYING) ? 2u : 1u, 0); - np_last_input = now_ms(); } + (void)kd_tick(&kd_play, play); + play = kd_play.stable; + /* 音量键/播放键边沿 → 动作(与主界面/子页同语义;见 np_apply_key_actions) */ + np_apply_key_actions(kev.vol_dn_edge, kev.vol_up_edge, + play && !m_prev_play, meow_media_current(), &np_last_input); m_prev_play = play; + /* 音量线性渐变(按住长爬;与主界面/子页同语义,状态栏即时刷新)。 + * Now Playing 用 raw held(kev.vol_held_*,同现状 rvold/rvolu)。 */ + if (vol_ramp_dir != 0) { + int held = (vol_ramp_dir > 0) ? kev.vol_held_up : kev.vol_held_dn; + int going = vol_ramp_tick(held); + g_sys.vol = settings_volume_get(); + paint_sb_right(); + if (!going) logmsg("np: volume ramp end -> %d", g_sys.vol); + } /* M2.92:NP 页电源键接管——单击边沿 = 息屏/亮屏(与主界面语义一致; * 修「NP 页电源键无反应」。3s 电源菜单仍由 watchdog→/tmp 标记→主循环消费, @@ -714,6 +824,7 @@ void run_now_playing(struct app *apps, int napps) usleep(10000); } meow_ui_activity_pop(); /* M7.0b:退出 Now Playing,回主 activity(控件 owner 仍钉哨兵,复用) */ + key_router_page_exit(NP_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */ if (tfd >= 0) close(tfd); kp_close(&kp_keys); kp_close(&kp_power); diff --git a/patch_v2/meow_launcherd/settings_page.c b/patch_v2/meow_launcherd/settings_page.c index 401af4f..7ef335a 100644 --- a/patch_v2/meow_launcherd/settings_page.c +++ b/patch_v2/meow_launcherd/settings_page.c @@ -1315,16 +1315,15 @@ void run_settings_page(void) struct keypoll kp_power, kp_keys; kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS); - struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; - memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); + /* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */ + struct kd_state kd_play, kd_pwr; memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr); - int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0; + int m_prev_play = 0, m_prev_pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1; } + key_router_rebase(); /* 进入设置页:音量基线对齐当前 raw(防残留边沿) */ int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK); if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} } unsigned long long touch_cooldown_until = now_ms() + 300; @@ -1358,6 +1357,7 @@ void run_settings_page(void) logmsg("settings entry: net toggle-sync+kick %llums", now_ms() - t_sets); if (!g_set_init) set_widgets_create(); /* M7.2:lazy 建 SDK 控件集(一次,跨次进入复用) */ meow_ui_activity_push(SET_SENTINEL_ACT); /* 控件 owner 闸门打开(hit/按压态生效) */ + key_router_page_enter(SET_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */ set_paint_page(sel); meow_dirty_full(); meow_ui_flush(); logmsg("settings entry: page shown in %llums", now_ms() - t_sets); @@ -1396,13 +1396,12 @@ void run_settings_page(void) meow_auto_slept = 0; wake_suppress = 1; last_act = now_ms(); - /* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸 */ - memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); + /* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸。 + * 音量防抖/基线上收 key_router:唤醒时重播音量基线;播放/电源仍本页自理。 */ + key_router_rebase(); /* 音量:kd_vold/volu 清零 + m_prev_vold/volu=当前 raw */ + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); touch_cooldown_until = now_ms() + 300; td = 0; td_ok = 0; drag = -1; scroll_drag = 0; tlx = tly = -1; @@ -1423,28 +1422,25 @@ void run_settings_page(void) } /* ---- 按键 ---- */ + key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */ kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); - int vold = 0, volu = 0, play = 0, pwr = 0; + int play = 0, pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1; } - (void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu); (void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr); - vold = kd_vold.stable; volu = kd_volu.stable; play = kd_play.stable; pwr = kd_pwr.stable; if (wake_suppress) { /* 刚被唤醒:吸收唤醒键的边沿,等所有按键完全抬起后再接管动作, * 避免「电源唤醒→抬起→被当成电源单击返回」或音量唤醒→误导航。 */ - if (!pwr && !vold && !volu && !play) wake_suppress = 0; + if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0; } else { /* M2.39:设置页音量键 = 调系统音量(写 DAC),不做光标移动(用户约束: * 设置页仅触摸导航;音量键在电菜单/应用列表的系统级导航保持不动)。 * 按下边沿即起线性渐变(vol_ramp_begin),下方 vol_ramp_tick 逐 1% 爬 + 刷状态栏。 */ - if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; } - if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; } + if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; } + if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; } /* M2.x(KEY_CONTROL_DESIGN §2/§3)播放键在设置页同样被系统「抓住」: * 捕获+日志+消费,不再映射为「返回/确认」(原 play 边沿=break 已删,播放键不再退出设置页)。 * 仍视为输入(重置自动息屏计时,与主界面 play 的媒体预留/any_input 一致)。 */ @@ -1480,14 +1476,11 @@ void run_settings_page(void) pwr_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击息屏 */ /* M2.25 同款:阻塞式 show_power_menu() 期间 kd_* 冻结,m_prev_* 与稳定值可能错配, * 重播 kd_* 并以当前 raw 对齐 m_prev_*,杜绝「菜单内触摸点取消->返回后被误判单击息屏」。 */ - memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); - memset(&kd_play, 0, sizeof kd_play); + /* 重播音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); /* 音量:kd_vold/volu 清零 + m_prev_vold/volu=当前 raw */ + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); after_modal = 1; /* 全屏 dim 覆盖过状态栏,需整帧重绘还原 */ need_paint = 1; @@ -1514,14 +1507,14 @@ void run_settings_page(void) } /* M2.39:驱动音量线性渐变(长按续爬/松开即止)+ 刷新状态栏 VOL;「音量」行随 need_paint 更新 */ if (vol_ramp_dir != 0) { - int held = (vol_ramp_dir > 0) ? volu : vold; + int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn; int going = vol_ramp_tick(held); g_sys.vol = settings_volume_get(); paint_sb_right(); need_paint = 1; if (!going) logmsg("settings vol ramp end -> %d", g_sys.vol); } - m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr; + m_prev_play = play; m_prev_pwr = pwr; settings_keys_seeded: ; @@ -1658,14 +1651,10 @@ void run_settings_page(void) scroll_y0 = 0; tdx = tdy = tlx = tly = -1; touch_cooldown_until = now_ms() + 300; - memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); - memset(&kd_play, 0, sizeof kd_play); + key_router_rebase(); /* 音量基线重播 */ + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); need_paint = 1; } else if (r->ctrl == SC_ENUM || r->ctrl == SC_PRESET) { @@ -1703,14 +1692,10 @@ void run_settings_page(void) tdx = tdy = tlx = tly = -1; touch_cooldown_until = now_ms() + 300; /* 重置设置页按键防抖基线,防残边 */ - memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); - memset(&kd_play, 0, sizeof kd_play); + key_router_rebase(); /* 音量基线重播 */ + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); need_paint = 1; } else if (r->ctrl == SC_SLIDER && tlx >= 0) { @@ -1768,6 +1753,7 @@ void run_settings_page(void) done: meow_input_touch_set(0, 0, 0, 0); /* M7.2 防残留按压态 */ meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */ + key_router_page_exit(SET_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */ if (tfd >= 0) close(tfd); kp_close(&kp_power); kp_close(&kp_keys); logmsg("settings page closed"); diff --git a/patch_v2/meow_launcherd/subpage.c b/patch_v2/meow_launcherd/subpage.c index 96bcacd..6719f3d 100644 --- a/patch_v2/meow_launcherd/subpage.c +++ b/patch_v2/meow_launcherd/subpage.c @@ -247,12 +247,11 @@ int meow_subpage_run(sub_page_t *p) meow_auto_slept = 0; wake_suppress = 1; last_act = now; - memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); + /* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); touch_cooldown_until = now_ms() + 300; td = 0; td_ok = 0; scroll_drag = 0; tlx = tly = -1; @@ -302,14 +301,12 @@ int meow_subpage_run(sub_page_t *p) last_act = now_ms(); pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_suppress_click = 1; + /* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); after_modal = 1; need_paint = 1; @@ -456,14 +453,12 @@ int meow_subpage_run(sub_page_t *p) scroll_base = 0; scroll_y0 = 0; tdx = tdy = tlx = tly = -1; touch_cooldown_until = now_ms() + 300; + /* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); after_modal = 1; need_paint = 1; @@ -746,16 +741,15 @@ int meow_subpage_run(sub_page_t *p) { struct keypoll kp_power, kp_keys; kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS); - struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; - memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); + /* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */ + struct kd_state kd_play, kd_pwr; memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr); - int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0; + int m_prev_play = 0, m_prev_pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1; } + key_router_rebase(); /* 进入子页:音量基线对齐当前 raw(防残留边沿) */ int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK); if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} } unsigned long long touch_cooldown_until = now_ms() + 300; @@ -783,6 +777,7 @@ int meow_subpage_run(sub_page_t *p) sp_configure_page(p, top_sw_en, top_on); meow_ui_activity_push(SUB_SENTINEL_ACT); /* 控件 owner 闸门打开(hit 生效) */ + key_router_page_enter(SUB_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */ sp_paint_page(); meow_dirty_full(); meow_ui_flush(); @@ -817,12 +812,11 @@ int meow_subpage_run(sub_page_t *p) meow_auto_slept = 0; wake_suppress = 1; last_act = now; - memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); + /* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); + memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); touch_cooldown_until = now_ms() + 300; td = 0; td_ok = 0; scroll_drag = 0; drag_last_y = 0; tlx = tly = -1; @@ -835,23 +829,20 @@ int meow_subpage_run(sub_page_t *p) } /* ---- 按键(与旧版逐字一致:音量=系统音量/播放=媒体预留/电源=系统级语义)---- */ + key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */ kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); - int vold = 0, volu = 0, play = 0, pwr = 0; + int play = 0, pwr = 0; for (int w = 0; w < KEYBUF_WORDS; w++) { - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1; - if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1; } - (void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu); (void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr); - vold = kd_vold.stable; volu = kd_volu.stable; play = kd_play.stable; pwr = kd_pwr.stable; if (wake_suppress) { - if (!pwr && !vold && !volu && !play) wake_suppress = 0; + if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0; } else { - if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; } - if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; } + if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; } + if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; } if (play && !m_prev_play) { logmsg("subpage: play key caught (media-reserved)"); last_act = now; } if (pwr && !m_prev_pwr) { pwr_hold_at = now; pwr_menu_state = 0; @@ -870,14 +861,12 @@ int meow_subpage_run(sub_page_t *p) last_act = now_ms(); pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_suppress_click = 1; + /* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); after_modal = 1; need_paint = 1; @@ -902,13 +891,13 @@ int meow_subpage_run(sub_page_t *p) } } if (vol_ramp_dir != 0) { - int held = (vol_ramp_dir > 0) ? volu : vold; + int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn; (void)vol_ramp_tick(held); g_sys.vol = settings_volume_get(); paint_sb_right(); need_paint = 1; } - m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr; + m_prev_play = play; m_prev_pwr = pwr; sub_keys_seeded: ; @@ -1024,14 +1013,12 @@ int meow_subpage_run(sub_page_t *p) drag_last_y = 0; tdx = tdy = tlx = tly = -1; touch_cooldown_until = now_ms() + 300; + /* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */ + key_router_rebase(); memset(&kd_pwr, 0, sizeof kd_pwr); - memset(&kd_vold, 0, sizeof kd_vold); - memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); - m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); - m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE); after_modal = 1; need_paint = 1; @@ -1090,6 +1077,7 @@ int meow_subpage_run(sub_page_t *p) subdone: meow_input_touch_set(0, 0, 0, 0); /* 防残留按压态 */ meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */ + key_router_page_exit(SUB_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */ if (tfd >= 0) close(tfd); kp_close(&kp_power); kp_close(&kp_keys); logmsg("subpage '%s' closed", p->title ? p->title : ""); diff --git a/patch_v2/meow_launcherd/test_key_router_vol.c b/patch_v2/meow_launcherd/test_key_router_vol.c new file mode 100644 index 0000000..116ab5e --- /dev/null +++ b/patch_v2/meow_launcherd/test_key_router_vol.c @@ -0,0 +1,229 @@ +/* test_key_router_vol.c — 宿主单测:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1)。 + * + * 测「key_router 自身的音量编排」——它只产「音量边沿 + 原始 held + ramp 状态」, + * 由调用页决定 settings_volume_adj(±step) / 光标导航 / ramp 推进。本测用内存桩: + * - kp_poll/kp_open/kp_is_down → 注入 raw 键位矩阵到 key_router 的静态 kp_keys.cur[]; + * - kd_tick → 用 keys.c 的真实现(40ms 防抖,V4 依赖);keys.c 内 kp_open/kp_poll/ + * kp_is_down/meow_volpage_armed 用宏重命名避开真实 ioctl 与桩冲突; + * - settings_volume_adj/settings_volume_step/vol_ramp_dir/vol_ramp_tick → 内存桩; + * - now_ms → 假时钟(手动推进,40ms 防抖可瞬时测); + * - meow_ui_activity_* → 内存桩(V5 嵌套:真语义=纯静态数组,桩复刻)。 + * + * 断言: + * V1 音量减边沿 → settings_volume_adj(-step) 恰一次 + last_input 复位; + * V2 同会话重复(同 raw 仍按住)→ 不再触发 settings_volume_adj; + * V3 长按 held → vol_ramp_tick 被驱动(vol_ramp_dir!=0,held=正确方向 raw);松开 → dir=0; + * V4 40ms 防抖:raw 在 40ms 内抖动 → 不产边沿;稳定按下 ≥40ms → 才产边沿; + * V5 嵌套返回:page_enter(0x53)→page_exit(0x53)→page_enter(0x55),基线重播后 held 键不产假边沿; + * V6 meow_volpage_armed() → 音量边沿被抑制(不下发,不调 settings_volume_adj)。 + * + * 编译(在 patch_v2/meow_launcherd 下): + * gcc -Wall -Wextra -I../sdk -I. -ffunction-sections -fdata-sections \ + * -Wl,--gc-sections test_key_router_vol.c -o test_key_router_vol + * ./test_key_router_vol + */ +#include +#include +#include +#include + +/* ---- 假时钟 ---- */ +static unsigned long long g_now = 100000; +unsigned long long now_ms(void) { return g_now; } +static void advance_ms(unsigned long long d) { g_now += d; } + +/* ---- 音量机外部符号桩(key_router 依赖)---- */ +int settings_volume_step = 5; +static int g_adj_count = 0, g_adj_delta = 0; +int vol_ramp_dir = 0; /* 桩全局:模拟 audio.c 的 ramp 状态 */ +static int g_tick_count = 0, g_tick_held = -1; + +void settings_volume_adj(int delta) +{ + g_adj_count++; g_adj_delta = delta; + if (delta > 0) vol_ramp_dir = 1; else if (delta < 0) vol_ramp_dir = -1; +} + +int vol_ramp_tick(int key_held) +{ + g_tick_count++; g_tick_held = key_held; + if (!key_held) vol_ramp_dir = 0; + return vol_ramp_dir != 0; +} + +static int g_volpage = 0; /* 桩:meow_volpage_armed */ + +/* ---- keys.c 内 kp_open/kp_poll/kp_is_down/meow_volpage_armed 用宏重命名,避开真实 ioctl ---- + * (宏在 #include keys.c 前生效,仅重命名 keys.c 体内的调用/定义) ---- */ +#define kp_open kp_open_rt_ +#define kp_poll kp_poll_rt_ +#define kp_is_down kp_is_down_rt_ +#define meow_volpage_armed meow_volpage_armed_rt_ +#include "keys.c" /* 真 kd_tick(40ms 防抖素材) */ +#undef kp_open +#undef kp_poll +#undef kp_is_down +#undef meow_volpage_armed + +/* host 桩声明(key_router.c 需要;恢复到正常名,与 keys.c 被更名后的定义不冲突) */ +struct keypoll; +int kp_open(struct keypoll *kp, const char *dev); +int kp_poll(struct keypoll *kp, int (*cb)(int code, int down, void *ctx), void *ctx); +int kp_is_down(const struct keypoll *k, int code); +int meow_volpage_armed(void); + +#include "key_router.c" /* 同 TU #include:拿到静态 kp_keys/kp_power/kd_vold/kd_volu + 全部函数 */ +#include "key_router.h" + +/* ---- meow_ui_activity_* 桩(复刻 sdk/meow_ui_activity.c:纯静态数组,真语义)---- */ +static int g_act[32]; static int g_act_n = 0; +void meow_ui_activity_push(int id) { if (g_act_n < 32) g_act[g_act_n++] = id; } +void meow_ui_activity_pop(void) { if (g_act_n > 0) g_act_n--; } +int meow_ui_activity_active(void) { return g_act_n > 0 ? g_act[g_act_n - 1] : 0; } + +/* ---- 注入 raw 到 key_router 静态 kp_keys ---- */ +static void set_vol(int vold, int volu) +{ + for (int w = 0; w < KEYBUF_WORDS; w++) kp_keys.cur[w] = 0; + if (vold) kp_keys.cur[KEY_VOLUMEDOWN / 32] |= (1UL << (KEY_VOLUMEDOWN % 32)); + if (volu) kp_keys.cur[KEY_VOLUMEUP / 32] |= (1UL << (KEY_VOLUMEUP % 32)); +} + +/* ---- host 桩:kp_*(prev=cur 模拟真实 poll 的键位更新)+ meow_volpage_armed ---- */ +int kp_open(struct keypoll *kp, const char *dev) +{ (void)dev; memset(kp, 0, sizeof *kp); kp->ok = 1; return 0; } +int kp_poll(struct keypoll *kp, int (*cb)(int, int, void *), void *ctx) +{ + (void)cb; (void)ctx; + for (int w = 0; w < KEYBUF_WORDS; w++) kp->prev[w] = kp->cur[w]; + return 0; +} +int kp_is_down(const struct keypoll *k, int code) +{ return (k->cur[code / 32] >> (code % 32)) & 1; } +int meow_volpage_armed(void) { return g_volpage; } + +/* 其它全局桩(keys.c 内被更名函数引用;本测 GC 后不保留,仅满足声明一致性) */ +uint32_t menu_bg = 0; +int screen_off_timeout = 0, screen_off_mode = 0, power_longpress_menu_ms = 0; +int power_forceoff_ms = 0, refresh_rate_cfg = 60, refresh_always_max = 0; + +void logmsg(const char *fmt, ...) { (void)fmt; } + +static int g_fails = 0; +#define CHECK(cond, name) do { \ + if (cond) printf(" PASS %s\n", name); \ + else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fails++; } } while (0) + +static void reset_router(void) +{ + set_vol(0, 0); + key_router_rebase(); + g_adj_count = 0; g_adj_delta = 0; g_tick_count = 0; g_tick_held = -1; + vol_ramp_dir = 0; g_volpage = 0; + g_act_n = 0; +} + +/* 按下并稳定:register(pump) → hold ≥40ms → pump(stable=1, 边沿)。 */ +static key_evt_t press_settle(int vold, int volu) +{ + set_vol(vold, volu); + key_router_pump(); /* 登记 raw 跳变(防抖窗口起点) */ + advance_ms(50); /* ≥KEY_DEBOUNCE_MS(40) */ + return key_router_pump(); /* stable→1,边沿 */ +} + +/* 抬起并稳定:register(pump) → → pump(stable=0)。 */ +static key_evt_t release_settle(void) +{ + set_vol(0, 0); + key_router_pump(); /* 登记 release */ + advance_ms(50); + return key_router_pump(); /* stable→0 */ +} + +int main(void) +{ + unsigned long long last_input = 0; + + /* ---- V1 音量减边沿 → settings_volume_adj(-step) 恰一次 + last_input 复位 ---- */ + reset_router(); + set_vol(0, 0); advance_ms(10); key_router_pump(); /* 全抬起基线 */ + last_input = 0; + key_evt_t ev = press_settle(1, 0); + CHECK(ev.vol_dn_edge == 1, "V1 vol-down edge fires"); + key_router_default_volume(&ev, &last_input); + CHECK(g_adj_count == 1 && g_adj_delta == -settings_volume_step, + "V1 vol-down -> settings_volume_adj(-step) exactly once"); + CHECK(last_input == g_now, "V1 last_input reset on edge"); + + /* ---- V2 同会话重复(同 raw 仍按住)→ 不再触发 ---- */ + reset_router(); + set_vol(0, 0); advance_ms(10); key_router_pump(); + ev = press_settle(1, 0); + CHECK(ev.vol_dn_edge == 1, "V2a press edge fires"); + ev = key_router_pump(); /* 同 raw 仍按住,无新边沿 */ + CHECK(ev.vol_dn_edge == 0, "V2 held: no re-trigger edge"); + key_router_default_volume(&ev, &last_input); + CHECK(g_adj_count == 0, "V2 held: no double settings_volume_adj"); + + /* ---- V3 长按 held → vol_ramp_tick 被驱动(held=正确方向 raw);松开 → dir=0 ---- */ + reset_router(); + set_vol(0, 0); advance_ms(10); key_router_pump(); last_input = 0; + ev = press_settle(1, 0); /* 按下(vol down)并转稳定 */ + key_router_default_volume(&ev, &last_input); + CHECK(g_tick_count == 1 && g_tick_held == ev.vol_held_dn && vol_ramp_dir != 0, + "V3 hold vol-down -> ramp driven with held=raw down"); + advance_ms(50); + ev = key_router_pump(); /* 继续长按(raw=1 仍按住) */ + key_router_default_volume(&ev, &last_input); + CHECK(g_tick_count == 2 && g_tick_held == 1, "V3 long-press continues ramp"); + ev = release_settle(); /* 松开:raw held 立即为 0 */ + key_router_default_volume(&ev, &last_input); + CHECK(vol_ramp_dir == 0, "V3 release -> ramp dir 0 (stops)"); + + /* ---- V4 40ms 防抖:raw 在 40ms 内抖动不产边沿;稳定 ≥40ms 才产 ---- */ + reset_router(); + set_vol(0, 0); advance_ms(10); key_router_pump(); + set_vol(1, 0); advance_ms(20); key_router_pump(); /* 按下未满 40ms */ + set_vol(0, 0); advance_ms(20); key_router_pump(); /* 40ms 内又抬起 */ + ev = key_router_pump(); + CHECK(ev.vol_dn_edge == 0, "V4 <40ms jitter -> no edge"); + ev = press_settle(1, 0); /* 稳定按下 ≥40ms */ + CHECK(ev.vol_dn_edge == 1, "V4 stable >=40ms press -> edge"); + ev = release_settle(); /* 抬起稳定 */ + CHECK(ev.vol_dn_edge == 0 && ev.vol_up_edge == 0, "V4 released -> no residual edge"); + ev = press_settle(0, 1); /* 稳定按音量加 */ + CHECK(ev.vol_up_edge == 1, "V4 vol-up stable press -> up edge"); + + /* ---- V5 嵌套返回:page_enter/exit 重播基线,held 键不产假边沿 ---- */ + reset_router(); + meow_ui_activity_push(0x55); key_router_page_enter(0x55); /* 主菜单 */ + set_vol(0, 0); advance_ms(10); key_router_pump(); + ev = press_settle(1, 0); + CHECK(ev.vol_dn_edge == 1, "V5a press edge in menu"); + /* 嵌套进设置页:压栈 + 登记 */ + meow_ui_activity_push(0x53); key_router_page_enter(0x53); + CHECK(meow_ui_activity_active() == 0x53, "V5b settings active sentinel"); + ev = release_settle(); /* 设置页内把键抬起 */ + CHECK(ev.vol_dn_edge == 0, "V5c settings-up: no fresh edge (consumer)"); + /* 设置页返回(模拟 run_settings_page 结束):弹栈 + 重播基线 → m_prev=当前 raw(0) */ + key_router_page_exit(0x53); meow_ui_activity_pop(); + key_router_page_enter(0x55); + CHECK(meow_ui_activity_active() == 0x55, "V5d menu active again"); + /* 主菜单基线已重播;再次按下应为新边沿,而非残留/双发 */ + ev = press_settle(1, 0); + CHECK(ev.vol_dn_edge == 1, "V5e fresh press after nest return -> single edge"); + + /* ---- V6 meow_volpage_armed() → 音量边沿被抑制(不下发)---- */ + reset_router(); + set_vol(0, 0); advance_ms(10); key_router_pump(); + g_volpage = 1; + ev = press_settle(1, 0); + CHECK(ev.vol_dn_edge == 0, "V6 volpage armed -> edge suppressed"); + key_router_default_volume(&ev, &last_input); + CHECK(g_adj_count == 0, "V6 volpage armed -> settings_volume_adj NOT called"); + g_volpage = 0; + + printf("\nRESULT: %s (%d fails)\n", g_fails ? "FAIL" : "ALL PASS", g_fails); + return g_fails ? 1 : 0; +} diff --git a/patch_v2/meow_launcherd/test_np_keys.c b/patch_v2/meow_launcherd/test_np_keys.c new file mode 100644 index 0000000..90b3095 --- /dev/null +++ b/patch_v2/meow_launcherd/test_np_keys.c @@ -0,0 +1,114 @@ +/* test_np_keys.c — 宿主单测:Now Playing 播放页/歌词页物理键分发(Task C)。 + * + * 背景(真机现象):桌面播放页与歌词页上,音量键 + 播放键「完全无反应」。 + * 根因(now_playing.c):run_now_playing() 原先只接播放键(164)与电源键,未接音量键 + * (114/115),导致播放页/歌词页(同一循环,与页无关)调不了音量。 + * + * 本测复现 run_now_playing() 的按键动作应用(np_apply_key_actions):音量键走 + * settings_volume_adj(±step)、播放键走 meow_media_playctl(按会话当前态 1=播放 2=暂停)。 + * 断言: + * K1 音量减边沿 → settings_volume_adj(-step); + * K2 音量加边沿 → settings_volume_adj(+step); + * K3 播放键边沿 + 会话正在播放 → meow_media_playctl(2,0)(暂停); + * K4 播放键边沿 + 会话已暂停 → meow_media_playctl(1,0)(播放); + * K5 播放键边沿 + 无会话 → 不下发(空态不误报); + * K6 无任何边沿 → 两个回调都不触发; + * K7 音量/播放边沿都会更新 last_input(自动息屏计时复位)。 + * + * 编译(在 patch_v2/meow_launcherd 下): + * gcc -Wall -Wextra -I../sdk -I. -ffunction-sections -fdata-sections \ + * -Wl,--gc-sections -DMEOW_NP_KEYS_TEST \ + * test_np_keys.c -o test_np_keys + * ./test_np_keys + * 说明:now_playing.c 用 #include 同 TU 平移(惯例同 test_vol_ramp_jack);-ffunction-sections + * + --gc-sections 只保留 np_apply_key_actions 及其依赖,其余(渲染/控件/绘制)被回收, + * 故仅需桩 settings_volume_adj/settings_volume_step/meow_media_playctl/now_ms/logmsg。 + */ +#include "now_playing.c" /* 同 TU #include:拿到 static np_apply_key_actions */ +#include +#include + +static int g_fails = 0; +#define CHECK(cond, name) do { \ + if (cond) printf(" PASS %s\n", name); \ + else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fails++; } } while (0) + +/* ---- 桩:np_apply_key_actions 依赖的 launcher 外部符号 ---- */ +int settings_volume_step = 5; +static int g_adj_count = 0, g_adj_delta = 0; +void settings_volume_adj(int delta) { g_adj_count++; g_adj_delta = delta; } + +static int g_playctl_count = 0; +static uint32_t g_playctl_cmd = 0; +static int64_t g_playctl_seek = 0; +int meow_media_playctl(uint32_t cmd, int64_t seek_ms) +{ g_playctl_count++; g_playctl_cmd = cmd; g_playctl_seek = seek_ms; return 0; } + +static unsigned long long g_now = 100000; +unsigned long long now_ms(void) { return g_now; } + +void logmsg(const char *fmt, ...) { (void)fmt; } + +/* 会话(meow_media_session_t 来自 meow_media.h,含 state 字段) */ +static meow_media_session_t g_s; + +static void reset_callbacks(void) +{ + g_adj_count = 0; g_adj_delta = 0; + g_playctl_count = 0; g_playctl_cmd = 0; g_playctl_seek = 0; +} + +int main(void) +{ + memset(&g_s, 0, sizeof g_s); + unsigned long long last_input = 0; + + /* K1 音量减边沿 → settings_volume_adj(-step) */ + reset_callbacks(); last_input = 0; g_now = 100000; + np_apply_key_actions(1, 0, 0, &g_s, &last_input); + CHECK(g_adj_count == 1 && g_adj_delta == -settings_volume_step, + "K1 vol-down edge -> settings_volume_adj(-step)"); + CHECK(last_input == g_now, "K7a vol-down updates last_input"); + + /* K2 音量加边沿 → settings_volume_adj(+step) */ + reset_callbacks(); last_input = 0; g_now = 100050; + np_apply_key_actions(0, 1, 0, &g_s, &last_input); + CHECK(g_adj_count == 1 && g_adj_delta == +settings_volume_step, + "K2 vol-up edge -> settings_volume_adj(+step)"); + CHECK(last_input == g_now, "K7b vol-up updates last_input"); + + /* K3 播放键边沿 + 正在播放 → MSG_PLAYCTL(2,0) 暂停 */ + g_s.state = MEOW_MEDIA_PLAYING; + reset_callbacks(); + np_apply_key_actions(0, 0, 1, &g_s, &last_input); + CHECK(g_playctl_count == 1 && g_playctl_cmd == 2 && g_playctl_seek == 0, + "K3 play edge (playing) -> playctl pause(2)"); + + /* K4 播放键边沿 + 已暂停 → MSG_PLAYCTL(1,0) 播放 */ + g_s.state = MEOW_MEDIA_PAUSED; + reset_callbacks(); + np_apply_key_actions(0, 0, 1, &g_s, &last_input); + CHECK(g_playctl_count == 1 && g_playctl_cmd == 1 && g_playctl_seek == 0, + "K4 play edge (paused) -> playctl play(1)"); + + /* K5 播放键边沿 + 无会话 → 不下发(并仍复位 last_input) */ + reset_callbacks(); last_input = 0; g_now = 100100; + np_apply_key_actions(0, 0, 1, NULL, &last_input); + CHECK(g_playctl_count == 0, "K5 play edge (no session) -> no playctl"); + CHECK(last_input == g_now, "K7c play edge updates last_input (no session)"); + + /* K6 无任何边沿 → 两个回调都不触发 */ + reset_callbacks(); last_input = 0; + np_apply_key_actions(0, 0, 0, &g_s, &last_input); + CHECK(g_adj_count == 0 && g_playctl_count == 0, "K6 no edge -> no callbacks"); + + /* K7 音量递减时播放键不同时触发(音量边沿优先,播放单独判) */ + g_s.state = MEOW_MEDIA_PLAYING; + reset_callbacks(); + np_apply_key_actions(1, 0, 1, &g_s, &last_input); + CHECK(g_adj_count == 1 && g_playctl_count == 1, + "K8 vol-down + play_edge both fire (independent)"); + + printf("\nRESULT: %s (%d fails)\n", g_fails ? "FAIL" : "ALL PASS", g_fails); + return g_fails ? 1 : 0; +} diff --git a/patch_v2/meow_launcherd/test_settings_widgets.c b/patch_v2/meow_launcherd/test_settings_widgets.c index 7c3b8ca..a00c1be 100644 --- a/patch_v2/meow_launcherd/test_settings_widgets.c +++ b/patch_v2/meow_launcherd/test_settings_widgets.c @@ -186,23 +186,29 @@ static void lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx) uint32_t textcol = sel ? mt->onPrimaryContainer : mt->onSurface; uint32_t subcol = sel ? mt->onPrimaryContainer : mt->onSurfaceVariant; + /* MD3 容器化(P0):行=surface 上 surfaceContainer 圆角卡;段头=surface 上的 primary 段标签(无卡 + * 无分隔线);与 settings_page.c set_lv_draw 同构造。 */ + int cx = x + SET_CARD_X; + int cw = w - 2 * SET_CARD_X; + int cy = y + 8 / 2; + int ch = SET_ITEM_H - 8; if (r->ctrl == SC_HEADER) { - meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->surfaceContainerHigh); int ty = y + (SET_ITEM_H - thh) / 2; - if (ty >= 0) meow_ui_text(x + 16, ty, r->label, mt->font_body, mt->primary); - meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant); + if (ty >= 0) meow_ui_text(cx + 16, ty, r->label, mt->font_body, mt->primary); return; } - if (sel) meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->primaryContainer); - else if (press) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->primary, 26); + meow_gfx_fill_round(cx, cy, cw, ch, 12, mt->surfaceContainer); + if (sel) meow_gfx_fill_round(cx, cy, cw, ch, 12, mt->primaryContainer); + else if (press) meow_gfx_fill_round_alpha(cx, cy, cw, ch, 12, mt->primary, 26); int ly = y + (SET_ITEM_H - thh) / 2; + int rgt = cx + cw - 16; switch (r->ctrl) { case SC_SWITCH: { - meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol); + meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol); meow_widget *sw = set_sw_take(); if (sw) { int on = 0; meow_settings_get_switch(r->key, &on); - meow_widget_set_rect(sw, x + w - 16 - 56, y + (SET_ITEM_H - 32) / 2, 56, 32); + meow_widget_set_rect(sw, rgt - 56, y + (SET_ITEM_H - 32) / 2, 56, 32); meow_switch_set(sw, on); meow_widget_draw(sw); } @@ -211,26 +217,25 @@ static void lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx) case SC_SLIDER: { /* 宿主测试不引入 launcher s_draw_slider/s_ui_theme:只验证行文字渲染 + 命中,track 视觉由 * 真机 s_draw_slider 呈现(settings_page.c set_lv_draw 已复用)。 */ - meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol); + meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol); char vb[32]; s_fmt_value(r, vb, sizeof vb); - meow_ui_text(x + w - 16 - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol); + meow_ui_text(rgt - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol); break; } case SC_LIST: { - meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol); - meow_ui_text(x + w - 16 - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol); + meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol); + meow_ui_text(rgt - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol); break; } default: { /* SC_ENUM / SC_PRESET */ - meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol); + meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol); char vb[40]; s_display_value(r, vb, sizeof vb); - int vx = x + w - 16 - meow_ui_text_width(vb, mt->font_body); + int vx = rgt - meow_ui_text_width(vb, mt->font_body); meow_ui_text(vx, ly, vb, mt->font_body, subcol); break; } } - meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant); - if (state & MEOW_LISTVIEW_ST_DISABLED) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->onSurface, 90); + if (state & MEOW_LISTVIEW_ST_DISABLED) meow_gfx_fill_round_alpha(cx, cy, cw, ch, 12, mt->onSurface, 90); } static void mk_widgets(void) @@ -247,7 +252,7 @@ static void draw_all(void) { const meow_theme *th = meow_theme_get(); meow_ui_fill_rect(0, 0, XRES, YRES, th->surface); - meow_ui_fill_rect(0, SET_LIST_Y, XRES, LV_H, th->surfaceVariant); + meow_ui_fill_rect(0, SET_LIST_Y, XRES, LV_H, th->surface); /* P0:内容底 surface(卡在其上抬升) */ if (lv) { g_set_sw_use = 0; meow_widget_draw(lv); } } @@ -305,16 +310,17 @@ int main(void) meow_ui_activity_push(SET_SENTINEL); const meow_theme *th = meow_theme_get(); draw_all(); - /* 段头行整体填 surfaceContainerHigh(覆盖列表底 surfaceVariant)→ 对列表底背景即非背景; - * 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「段头底 vs 列表底」判渲染而非文字像素。 */ - int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant); - int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant); - CHECK(hdrcnt > 0, "section header band renders (hdr=%d)", hdrcnt); - CHECK(rowcnt > 40, "row band renders (title/value/switch) cnt=%d", rowcnt); - /* P1b:开关行(ROWS[2]=最高刷新率 right switch)中心右缘区非背景 */ + /* MD3 容器化(P0):段头=surface 上的 primary 段标签(无满幅 band/卡,宿主文本为 no-op → + * 段头区应回 surface);普通行=surfaceContainer 圆角卡(region_nonbg 应见大量非 surface 像素)。 + * 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「卡/段头 vs 列表底」判渲染而非文字像素。 */ + int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surface); + int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surface); + CHECK(hdrcnt == 0, "section header no-band(label-on-surface) nb=%d", hdrcnt); + CHECK(rowcnt > 40, "row card renders (title/value/switch) cnt=%d", rowcnt); + /* P1b:开关行(ROWS[2]=最高刷新率 right switch)中心右缘区非背景(卡内 switch) */ { int swy = SET_LIST_Y + 2 * SET_ITEM_H + SET_ITEM_H / 2; - int swc = region_nonbg(XRES - 16 - 56, swy - 4, 56, 8, th->surfaceVariant); + int swc = region_nonbg(XRES - 16 - 56, swy - 4, 56, 8, th->surface); CHECK(swc > 0, "switch row right tail (meow_switch) renders swc=%d", swc); } diff --git a/patch_v2/meow_launcherd/test_subpage_widgets.c b/patch_v2/meow_launcherd/test_subpage_widgets.c index 133aeba..f832733 100644 --- a/patch_v2/meow_launcherd/test_subpage_widgets.c +++ b/patch_v2/meow_launcherd/test_subpage_widgets.c @@ -125,6 +125,7 @@ static void mk_widgets(void) meow_listview_set_vsource(lv, sp_lv_count, sp_lv_bind, NULL); meow_listview_set_item_renderer(lv, 2, 0, 1); meow_listview_set_item_h(lv, SUB_ITEM_H); + meow_listview_set_container(lv, 1); /* MD3 容器模式(M7.1a P0):圆角卡+空隙+去分隔线 */ sw = meow_switch_create(XRES - 16 - 56, SUB_TB_Y + (SUB_TB_H - 32) / 2, 1); meow_ui_activity_pop(); } @@ -153,12 +154,14 @@ int main(void) meow_ui_activity_push(SUB_SENTINEL); const meow_theme *th = meow_theme_get(); draw_all(); + /* MD3 容器模式:段头=surface 上的 primary 段标签(无满幅 band/卡,宿主文本为 no-op → + * 段头区应回 surface);普通行=surfaceContainer 圆角卡(region_nonbg 应见大量非 surface 像素)。 */ int hdrcnt = region_nonbg(2, SUB_CONT_TOP + 4, XRES - 4, SUB_ITEM_H - 8, th->surface); int rowcnt = region_nonbg(2, SUB_CONT_TOP + SUB_ITEM_H + 4, XRES - 4, SUB_ITEM_H - 8, th->surface); char nm[80]; - snprintf(nm, sizeof nm, "section header renders (band=%d)", hdrcnt); - CHECK(hdrcnt > 0, "%s", nm); - snprintf(nm, sizeof nm, "row renders (title hdr/switch/arrow) cnt=%d", rowcnt); + snprintf(nm, sizeof nm, "section header no-band(label-on-surface) nb=%d", hdrcnt); + CHECK(hdrcnt == 0, "%s", nm); + snprintf(nm, sizeof nm, "row renders (card) cnt=%d", rowcnt); CHECK(rowcnt > 40, "%s", nm); /* ============ P2 listview 命中(行号 + right 操作区) ============ */ @@ -190,6 +193,7 @@ int main(void) meow_listview_set_vsource(scroll_lv, big_count, big_bind, NULL); meow_listview_set_item_renderer(scroll_lv, 2, 0, 1); meow_listview_set_item_h(scroll_lv, SUB_ITEM_H); + meow_listview_set_container(scroll_lv, 1); /* 与子页一致:MD3 容器模式 */ meow_scroll *s = meow_scroll_wrap(scroll_lv, 1); /* 1=W_LISTVIEW */ CHECK(s != NULL, "meow_scroll_wrap(listview)"); int maxs = meow_listview_max_scroll(scroll_lv); diff --git a/patch_v2/meow_launcherd/wifi_page.c b/patch_v2/meow_launcherd/wifi_page.c index 47573dd..ec5f7a9 100644 --- a/patch_v2/meow_launcherd/wifi_page.c +++ b/patch_v2/meow_launcherd/wifi_page.c @@ -19,6 +19,7 @@ #define WF_ACT_CURR (-1) /* 当前连接(SUB_ROW,detail) */ #define WF_ACT_SAVED 0 /* 已保存网络 idx(SUB_ACT,connect) */ #define WF_ACT_SCAN_BASE 1000 /* 附近网络 1000+idx(SUB_ROW,连接) */ +#define WF_ACT_MANUAL 2000 /* 「手动添加隐藏网络」动作行(SUB_ACT,connecth) */ typedef struct { meow_net_t saved[S_NET_MAX]; @@ -122,13 +123,22 @@ static int wifi_rows(void *ctx, sub_row_t *rbuf, int max) rbuf[n].on = 0; rbuf[n].act = WF_ACT_SCAN_BASE + i; n++; } + /* bug2:附近网络末尾的「手动添加隐藏网络」入口(SUB_ACT → wifi_act)。 */ + if (n < max) { + rbuf[n].kind = SUB_ACT; + rbuf[n].label = "手动添加隐藏网络"; + rbuf[n].sub = ""; + rbuf[n].on = 0; + rbuf[n].act = WF_ACT_MANUAL; + n++; + } return n; } -/* SDK IME 输密码(PW 模式=ASCII 软键盘输入,现有输入法复用;回车提交/ESC或点背景取消)。 +/* SDK IME 通用文本输入(MEOW_IME_ASCII:QWERTY,顶条「中」可切拼音输中文 SSID)。 * 返回 1=已输入;0=取消。 */ -static int wifi_ask_psk(char *psk, int maxlen) +static int wifi_ask_text(char *out, int maxlen) { meow_ime_ctx ic; memset(&ic, 0, sizeof ic); @@ -136,36 +146,72 @@ static int wifi_ask_psk(char *psk, int maxlen) ic.maxlen = maxlen; ic.buf[0] = 0; int res = meow_ui_text_input(&ic); /* 阻塞:提交后 ctx.buf 回填最终文本 */ - if (res && ic.buf[0]) { snprintf(psk, maxlen, "%s", ic.buf); return 1; } + if (res && ic.buf[0]) { snprintf(out, maxlen, "%s", ic.buf); return 1; } return 0; } +/* bug1:connect 阻塞前画非模态「正在连接…」横幅。子页单线程,阻塞期间画面停在此横幅。 */ +static void wifi_connecting_banner(const char *ssid) +{ + const meow_ui_theme_t *th = s_ui_theme(); + int W = meow_ui_width(); + int y = 108, h = 44; + char line[160]; + snprintf(line, sizeof line, "正在连接 %s ...", ssid && ssid[0] ? ssid : "网络"); + meow_ui_fill_rect(0, y, W, h, th->surface_container); + meow_ui_text_center(W / 2, y + h / 2 - meow_ui_text_height(20) / 2, line, 20, th->on_surface); + meow_dirty_full(); + meow_ui_flush(); +} + + +/* bug2:手动添加隐藏网络(附近网络占位行点击 / 「手动添加隐藏网络」动作行共用)。 */ +static void wifi_manual_add(wifi_ctx_t *c) +{ + char ssid[96] = ""; + if (!wifi_ask_text(ssid, sizeof ssid)) return; + const char *items[2]; + items[0] = "加密网络(WPA/WPA2/WPA3)"; + items[1] = "开放网络"; + int pick = s_dd_modal("网络加密方式", items, 2, -1); + if (pick < 0) return; + int open = (pick == 1); + char psk[96] = ""; + if (!open && !wifi_ask_text(psk, sizeof psk)) return; + char err[160]; err[0] = 0; + wifi_connecting_banner(ssid); + int rc = nm_wifi_connect_ex(ssid, open ? NULL : psk, 1, err, sizeof err); + char line[192]; const char *txt[1]; txt[0] = line; + snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败")); + logmsg("wifi_page: manual connect ssid=%s hidden=1 open=%d rc=%d err=%s", ssid, open, rc, err); + s_dd_modal(ssid, txt, 1, -1); + nm_wifi_scan_trigger(); + c->last_scan_ms = now_ms(); +} + + /* 附近网络点选 →【WiFi 新网络录入】连接流程。返回 1=重绘。 */ static int wifi_connect_scan(wifi_ctx_t *c, int scan_idx) { if (scan_idx < 0 || scan_idx >= c->scan_n) return 0; meow_net_t *s = &c->scan[scan_idx]; if (!s->name[0] || !strcmp(s->name, "(隐藏网络)")) { - const char *txt[1]; txt[0] = "隐藏网络无法直接连接"; - s_dd_modal("附近网络", txt, 1, -1); + wifi_manual_add(c); /* bug2:占位隐藏网络 → 手动录入(SSID 待输) */ return 1; } int open = (s->sec[0] == 0 || !strcmp(s->sec, "Open")); char psk[96] = ""; if (!open) { - if (!wifi_ask_psk(psk, sizeof psk)) return 1; /* 用户取消输密码 */ + if (!wifi_ask_text(psk, sizeof psk)) return 1; /* 用户取消输密码 */ } char err[160]; err[0] = 0; + wifi_connecting_banner(s->name); /* bug1:阻塞前横幅,避免「点完无反馈」 */ int rc = nm_wifi_connect(s->name, open ? NULL : psk, err, sizeof err); char line[192]; const char *txt[1]; txt[0] = line; - if (rc == MEOW_OK) { - snprintf(line, sizeof line, "连接成功:%s%s", s->name, - err[0] ? "" : "(已启用)"); - } else { - snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误"); - } + /* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */ + snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败")); logmsg("wifi_page: scan connect ssid=%s open=%d rc=%d err=%s", s->name, open, rc, err); /* 连接结果反馈(成功后进已保存网络列表由 rows() 重扫刷新) */ s_dd_modal(s->name, txt, 1, -1); @@ -208,20 +254,23 @@ static int wifi_detail(void *ctx, int idx, int act) } -/* 动作行(SUB_ACT)点击:已保存网络 → netmgr connect(ssid;复用既有网络) */ +/* 动作行(SUB_ACT)点击:已保存网络 → netmgr connect(ssid;复用既有网络)。 + * 另承接「手动添加隐藏网络」入口(bug2)。 */ static int wifi_act(void *ctx, int idx, int act) { (void)idx; wifi_ctx_t *c = ctx; + if (act == WF_ACT_MANUAL) { wifi_manual_add(c); return 1; } if (act >= WF_ACT_SCAN_BASE) return 0; /* 附近是 SUB_ROW(detail),不走这里 */ int sidx = act - WF_ACT_SAVED; if (sidx < 0 || sidx >= c->saved_n) return 0; if (c->saved[sidx].id < 0) return 0; char err[160]; err[0] = 0; + wifi_connecting_banner(c->saved[sidx].name); /* bug1:阻塞前横幅 */ int rc = nm_wifi_connect(c->saved[sidx].name, NULL, err, sizeof err); char line[192]; const char *txt[1]; txt[0] = line; - if (rc == MEOW_OK) snprintf(line, sizeof line, "已连接:%s", c->saved[sidx].name); - else snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误"); + /* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */ + snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败")); logmsg("wifi_page: saved connect ssid=%s rc=%d err=%s", c->saved[sidx].name, rc, err); s_dd_modal(c->saved[sidx].name, txt, 1, -1); nm_wifi_scan_trigger(); diff --git a/patch_v2/meow_netmgrd/meow_netmgrd.c b/patch_v2/meow_netmgrd/meow_netmgrd.c index bc20185..7f0d2d6 100644 --- a/patch_v2/meow_netmgrd/meow_netmgrd.c +++ b/patch_v2/meow_netmgrd/meow_netmgrd.c @@ -85,6 +85,10 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET #define NET_POLL_SEC_DEF 2 /* 接口轮询/状态节拍(秒;MEOW_NET_POLL_SEC 覆盖,测试用) */ #define RUNCMD_TMO_MS 1500 /* 后端命令(wpa_cli/hciconfig/etc)有界收割限时(ms) */ +/* WiFi 设置页 bug1(2026-09-15):connect 有界等待 wpa_state=COMPLETED 的上限(ms)。 + * 普通 ~10s;隐藏网络(connecth,主动探测+关联更慢)~15s。env 可调(宿主桩测试缩短)。 */ +#define WIFI_CONNECT_WAIT_MS 10000 /* MEOW_WIFI_CONNECT_WAIT_MS */ +#define WIFI_CONNECTH_WAIT_MS 15000 /* MEOW_WIFI_CONNECTH_WAIT_MS */ #define IFACE_MAX 16 #define RFKILL_MAX 8 #define DNS_MAX 8 @@ -112,6 +116,8 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET #define DNS_SELF_IP "127.0.0.1" static int g_poll_sec = NET_POLL_SEC_DEF; +static int g_connect_wait_ms = WIFI_CONNECT_WAIT_MS; /* MEOW_WIFI_CONNECT_WAIT_MS */ +static int g_connecth_wait_ms = WIFI_CONNECTH_WAIT_MS; /* MEOW_WIFI_CONNECTH_WAIT_MS */ static int g_dns_port = DNS_PORT_DEF; /* MEOW_DNS_PORT */ static int g_dns_disabled = 0; /* MEOW_DNS_DISABLE=1:禁用 DNS 代理(宿主测试无 root 绑 53) */ @@ -2650,13 +2656,82 @@ static const char *str_capsafe(const char *s, char *tmp, size_t cap) return tmp; } +/* wpa_cli 单命令封装:拼 PATH 前缀 + 有界收割(RUNCMD_TMO_MS)。返回 run_cmd 读到的字节数。 */ +static int wpa_cmd(char *out, int cap, const char *args) +{ + char cmd[512]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s %s 2>/dev/null", g_wpa_cli, args); + return run_cmd(out, cap, RUNCMD_TMO_MS, cmd); +} + +/* WiFi 设置页 bug4-②(2026-09-15):list_networks/scan_results 对非 ASCII 字节输出 \xNN 转义 + * (中文 SSID)——还原为原始字节串再与输入 SSID 做同表示比较;不还原则中文 SSID 永不命中 + * 既有网络 → 新建同名 open 记录顶影原记录。net.c wpa_decode_ssid / netmgr_ipc.c nm_decode_ssid + * 已有两份同功能实现(后续可收拢,本次先在 netmgrd 补齐)。 */ +static int wpa_hexval(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +static void wpa_unescape_ssid(const char *src, char *dst, size_t dcap) +{ + size_t o = 0; + while (*src && o + 1 < dcap) { + if (src[0] == '\\' && src[1] == 'x' && wpa_hexval(src[2]) >= 0 && wpa_hexval(src[3]) >= 0) { + dst[o++] = (char)((wpa_hexval(src[2]) << 4) | wpa_hexval(src[3])); + src += 4; + } else { + dst[o++] = *src++; + } + } + dst[o] = 0; +} + +/* WiFi 设置页 bug4-③(2026-09-15):save_config 结果校验。wpa_supplicant 仅在 conf 含 + * update_config=1 时允许写盘;固件模板 /etc/wpa_supplicant.conf 无此项 → 首次 FAIL 时先 + * `set update_config 1`(运行期开关,wpa_supplicant 标准用法;成功落盘后文件自带 + * update_config=1)再重试一次。返回 1=已持久化;0=仍失败(调用方须在报文/日志明示 + * 「记录未持久化」)。仅 MeowOS 模式调用(写盘红线不变,NETMGR_DESIGN §14)。 */ +static int wifi_save_config(void) +{ + char out[160]; + if (wpa_cmd(out, sizeof out, "save_config") > 0 && strstr(out, "OK")) return 1; + wpa_cmd(out, sizeof out, "set update_config 1"); + if (wpa_cmd(out, sizeof out, "save_config") > 0 && strstr(out, "OK")) { + net_log("info", "write", "save_config 初次 FAIL → set update_config 1 重试成功(此后 conf 自带该位)"); + return 1; + } + net_log("warn", "write", "save_config 失败(含 set update_config 1 重试):记录未持久化"); + return 0; +} + +/* WiFi 设置页 bug1(2026-09-15):连接状态机——有界等待 wpa_state=COMPLETED(每 200ms 查 + * status,上限 timeout_ms,防卡死)。返回 1=已关联(out 带回 ssid/ip 供诚实报文);0=超时。 */ +static int wifi_wait_completed(int timeout_ms, wifi_t *out) +{ + int waited = 0; + for (;;) { + wifi_t w; + wifi_status(&w); + if (w.connected) { if (out) *out = w; return 1; } + if (waited >= timeout_ms) return 0; + struct timespec ts = { 0, 200 * 1000000L }; + nanosleep(&ts, NULL); + waited += 200; + } +} + static void wifi_scan(ctl_result_t *r) { char cmd[160]; snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s scan 2>/dev/null", g_wpa_cli); char buf[128]; run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd); - struct timespec ts = { 0, 300 * 1000000L }; /* 触发后稍候再读结果(有界)。 */ + /* P3(WiFi 设置页附带,2026-09-15):触发后 300ms→1500ms 再读结果——真机扫描常 1-2s, + * 300ms 读到的多是上一轮陈旧结果。IPC 客户端 per-cmd 超时相应放宽(netmgr_ipc scan=3.5s)。 */ + struct timespec ts = { 1, 500 * 1000000L }; nanosleep(&ts, NULL); char bufout[MEOW_IPC_MAX - 16]; snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s scan_results 2>/dev/null", g_wpa_cli); @@ -2675,7 +2750,13 @@ static void wifi_networks(ctl_result_t *r) ctl_sets(r, MEOW_OK, buf); } -static void wifi_connect(const char *arg, ctl_result_t *r) +/* WiFi 连接(bug1/bug2/bug4 修复,2026-09-15)。hidden=1 走隐藏网络流程(connecth): + * 对所用 id(新建/命中皆然)追加 scan_ssid=1,并把 COMPLETED 等待上限放宽(主动探测+关联更慢)。 + * 流程:匹配既有(\xNN 还原同表示比较)→ 新建或(命中且带新凭据时)更新 psk → + * enable+select → 有界等待 wpa_state=COMPLETED(不再无条件报成功)→ + * 成功:enable_network all(消除 select 的 disabled 副作用)+ save_config(校验+重试); + * 失败:本次新建的网络 remove 回收(防坏记录堆积),报文明示失败原因。 */ +static void wifi_connect_ex(const char *arg, ctl_result_t *r, int hidden) { char ssid[96] = "", psk[96] = ""; if (arg && arg[0]) { @@ -2694,11 +2775,10 @@ static void wifi_connect(const char *arg, ctl_result_t *r) str_capsafe(psk, tmp_psk, sizeof tmp_psk); if (!tmp_ssid[0]) { ctl_sets(r, MEOW_ERR_INVAL, "connect: ssid 缺失"); return; } - /* 找既有网络:list_networks → 匹配 ssid(剥引号比较);命中则复用其 id。 */ - char cmd[200]; + /* 找既有网络:list_networks → 匹配 ssid(剥引号 + \xNN 还原后同表示比较);命中则复用其 id。 */ + char cmd[256]; char list[1024]; - snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s list_networks 2>/dev/null", g_wpa_cli); - int n = run_cmd(list, sizeof list, RUNCMD_TMO_MS, cmd); + int n = wpa_cmd(list, sizeof list, "list_networks"); int id = -1; if (n > 0) { char *ln = strtok(list, "\n"); @@ -2709,61 +2789,81 @@ static void wifi_connect(const char *arg, ctl_result_t *r) char *ss = tab0 + 1; char *tab1 = strchr(ss, '\t'); if (tab1) *tab1 = 0; - char clean[128], norm_clean[128]; + char clean[128], unesc[160], norm_clean[128]; size_t cl = strlen(ss); if (cl >= 2 && ss[0] == '"' && ss[cl-1] == '"') { memcpy(clean, ss + 1, cl - 2); clean[cl - 2] = 0; } else { snprintf(clean, sizeof clean, "%s", ss); } - str_capsafe(clean, norm_clean, sizeof norm_clean); + wpa_unescape_ssid(clean, unesc, sizeof unesc); /* bug4-②:中文 SSID 转义还原 */ + str_capsafe(unesc, norm_clean, sizeof norm_clean); if (strcmp(norm_clean, tmp_ssid) == 0) id = this_id; } ln = strtok(NULL, "\n"); } } char out[128]; + int created = 0; if (id < 0) { - snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s add_network 2>/dev/null", g_wpa_cli); - if (run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd) <= 0) { + if (wpa_cmd(out, sizeof out, "add_network") <= 0) { ctl_sets(r, MEOW_ERR_NODEV, "connect: add_network 失败 (wpa_cli 不可用?)"); return; } id = atoi(out); if (id < 0) { ctl_sets(r, MEOW_ERR_INVAL, "connect: add_network 无 id"); return; } - snprintf(cmd, sizeof cmd, - "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d ssid '\"%s\"' 2>/dev/null", - g_wpa_cli, id, tmp_ssid); - run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd); - if (tmp_psk[0]) { - snprintf(cmd, sizeof cmd, - "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d psk '\"%s\"' 2>/dev/null", - g_wpa_cli, id, tmp_psk); - } else { - snprintf(cmd, sizeof cmd, - "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d key_mgmt NONE 2>/dev/null", - g_wpa_cli, id); - } - run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd); + created = 1; + snprintf(cmd, sizeof cmd, "set_network %d ssid '\"%s\"'", id, tmp_ssid); + wpa_cmd(out, sizeof out, cmd); + if (tmp_psk[0]) snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk); + else snprintf(cmd, sizeof cmd, "set_network %d key_mgmt NONE", id); + wpa_cmd(out, sizeof out, cmd); + } else if (tmp_psk[0]) { + /* bug4-①/bug1-④:命中既有网络且调用方带了新 psk → 先更新凭据再连 + * (旧记录密码错误可经扫描路径重输修复;psk 为空则不动既有凭据)。 */ + snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk); + wpa_cmd(out, sizeof out, cmd); + } + if (hidden) { + /* bug2:隐藏 AP 必须 scan_ssid=1,supplicant 才会主动发 Probe Request(命中既有也要补)。 */ + snprintf(cmd, sizeof cmd, "set_network %d scan_ssid 1", id); + wpa_cmd(out, sizeof out, cmd); } { /* enable + select 该网络(复用/新建皆用)。 */ - char en[256], sel[256]; - snprintf(en, sizeof en, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s enable_network %d 2>/dev/null", - g_wpa_cli, id); - run_cmd(out, sizeof out, RUNCMD_TMO_MS, en); - snprintf(sel, sizeof sel, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s select_network %d 2>/dev/null", - g_wpa_cli, id); - run_cmd(out, sizeof out, RUNCMD_TMO_MS, sel); - /* 期3:MeowOS 模式 connect 后持久化 wpa_supplicant.conf(HibyOS 只读由服务端门控,这里仅 MeowOS 生效)。 */ - if (g_mode_meow) { - char sc[160]; - snprintf(sc, sizeof sc, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s save_config 2>/dev/null", g_wpa_cli); - run_cmd(out, sizeof out, RUNCMD_TMO_MS, sc); - } + snprintf(cmd, sizeof cmd, "enable_network %d", id); + wpa_cmd(out, sizeof out, cmd); + snprintf(cmd, sizeof cmd, "select_network %d", id); + wpa_cmd(out, sizeof out, cmd); } - char msg[140]; - snprintf(msg, sizeof msg, "connect: ssid=%s network_id=%d (已 enable+select%s)", tmp_ssid, id, - g_mode_meow ? "+save_config" : ""); + /* bug1:有界等待 wpa_state=COMPLETED——密码错误/AP 不在范围/握手失败不再误报成功。 */ + wifi_t w; + int wait_ms = hidden ? g_connecth_wait_ms : g_connect_wait_ms; + if (!wifi_wait_completed(wait_ms, &w)) { + if (created) { /* 本次新建且失败 → 回收防坏记录堆积(bug4 联动) */ + snprintf(cmd, sizeof cmd, "remove_network %d", id); + wpa_cmd(out, sizeof out, cmd); + if (g_mode_meow) wifi_save_config(); + } + net_log("info", "write", "wifi connect ssid=%s id=%d 超时未关联(%dms)", tmp_ssid, id, wait_ms); + char msg[128]; + snprintf(msg, sizeof msg, "连接失败:密码错误或信号不佳(超时未关联)"); + ctl_sets(r, MEOW_ERR_NODEV, msg); + return; + } + /* bug4-④:验证成功后 enable_network all 复位其它网络 disabled 态(select_network 副作用), + * 再 save_config 落盘(bug4-③:结果校验,FAIL 时 set update_config 1 重试)。 + * 写盘仍受 MeowOS 模式门控(红线不变,NETMGR_DESIGN §14)。 */ + int persisted = 0; + if (g_mode_meow) { + wpa_cmd(out, sizeof out, "enable_network all"); + persisted = wifi_save_config(); + } + net_log("info", "write", "wifi connect ssid=%s id=%d 已连接 ip=%s persisted=%d", + tmp_ssid, id, w.ip[0] ? w.ip : "-", persisted); + char msg[160]; + snprintf(msg, sizeof msg, "已连接:%s ip=%s%s", + w.ssid[0] ? w.ssid : tmp_ssid, w.ip[0] ? w.ip : "-", + (g_mode_meow && !persisted) ? "(记录未持久化)" : ""); ctl_sets(r, MEOW_OK, msg); } @@ -2840,7 +2940,9 @@ static void wifi_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r) } else if (!strcmp(cmd, "networks")) { wifi_networks(r); } else if (!strcmp(cmd, "connect")) { - wifi_connect(arg, r); + wifi_connect_ex(arg, r, 0); + } else if (!strcmp(cmd, "connecth")) { + wifi_connect_ex(arg, r, 1); /* bug2:隐藏网络(scan_ssid=1 + 更长 COMPLETED 等待) */ } else if (!strcmp(cmd, "disconnect")) { char cmd2[160]; snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s disconnect 2>/dev/null", g_wpa_cli); @@ -2857,13 +2959,13 @@ static void wifi_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r) g_wpa_cli, nid); char buf[128]; int n = run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2); - /* 期4:移除后持久化(MeowOS 模式;HibyOS 只读由服务端门控)。 */ - if (g_mode_meow) { - snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s save_config 2>/dev/null", g_wpa_cli); - run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2); - } - char msg[64]; - snprintf(msg, sizeof msg, "wifi forget: network_id=%d 已移除%s", nid, g_mode_meow ? "+save_config" : ""); + /* 期4:移除后持久化(MeowOS 模式;HibyOS 只读由服务端门控)。 + * bug4-③:save_config 结果校验(FAIL 时 set update_config 1 重试),报文不再谎称已落盘。 */ + int persisted = 0; + if (g_mode_meow) persisted = wifi_save_config(); + char msg[96]; + snprintf(msg, sizeof msg, "wifi forget: network_id=%d 已移除%s", nid, + g_mode_meow ? (persisted ? "+save_config" : "(记录未持久化)") : ""); ctl_sets(r, n >= 0 ? MEOW_OK : MEOW_ERR_NODEV, msg); } else if (!strcmp(cmd, "rfkill")) { rfkill_ctl(arg, r); @@ -3704,7 +3806,7 @@ static int cli_policy(int op, const char *entity, const char *arg) static const char *usage(void) { - return "status | iface [name] | wifi scan|networks|connect [,psk]|disconnect|on|off" + return "status | iface [name] | wifi scan|networks|connect [,psk]|connecth [,psk]|disconnect|on|off" " | bt status|on|off|paired|connected|a2dp|devices|scan on|off|connect|disconnect|pair|unpair " " | rfkill [ ] | udhcpc renew|release [if]" " | apps | conns [entity] | top | dns status|stats|flush" @@ -3757,9 +3859,9 @@ static int cli_run(int n, char **v) if (!strcmp(cmd, "wifi")) { if (!arg) { fprintf(stderr, "wifi 子命令缺失(%s)\n", usage()); return 1; } if (!strcmp(arg, "on") || !strcmp(arg, "off")) return cli_cmd_result(MSG_NET_WIFI_CTL, arg, NULL); - if (!strcmp(arg, "connect")) { - if (!arg2) { fprintf(stderr, "connect 需 [,psk]\n"); return 1; } - return cli_cmd_result(MSG_NET_WIFI_CTL, "connect", arg2); + if (!strcmp(arg, "connect") || !strcmp(arg, "connecth")) { + if (!arg2) { fprintf(stderr, "%s 需 [,psk]\n", arg); return 1; } + return cli_cmd_result(MSG_NET_WIFI_CTL, arg, arg2); } if (!strcmp(arg, "forget")) { if (!arg2) { fprintf(stderr, "forget 需 \n"); return 1; } @@ -3821,6 +3923,8 @@ int main(int argc, char **argv) if ((e = getenv("MEOW_WIFI_IF")) && e[0]) g_wifi_if = e; if ((e = getenv("MEOW_NET_SOCK")) && e[0]) g_sock = e; if ((e = getenv("MEOW_NET_POLL_SEC")) && atoi(e) > 0) g_poll_sec = atoi(e); + if ((e = getenv("MEOW_WIFI_CONNECT_WAIT_MS")) && atoi(e) >= 200) g_connect_wait_ms = atoi(e); + if ((e = getenv("MEOW_WIFI_CONNECTH_WAIT_MS")) && atoi(e) >= 200) g_connecth_wait_ms = atoi(e); if ((e = getenv("MEOW_TCP")) && e[0]) snprintf(g_proc_tcp, sizeof g_proc_tcp, "%s", e); if ((e = getenv("MEOW_UDP")) && e[0]) snprintf(g_proc_udp, sizeof g_proc_udp, "%s", e); if ((e = getenv("MEOW_AMS_SOCK")) && e[0]) snprintf(g_ams_sock, sizeof g_ams_sock, "%s", e); diff --git a/patch_v2/meow_netmgrd/test_netmgrd.sh b/patch_v2/meow_netmgrd/test_netmgrd.sh index f1bc6ea..afec0bd 100755 --- a/patch_v2/meow_netmgrd/test_netmgrd.sh +++ b/patch_v2/meow_netmgrd/test_netmgrd.sh @@ -423,6 +423,25 @@ kill $D2PID 2>/dev/null; wait $D2PID 2>/dev/null # 恢复守护 A 的 MEOW_RESOLV(其未被 DNS 触碰,此处确认) grep -q "nameserver 223.5.5.5" "$T/resolv" && { PASS=$((PASS+1)); echo "PASS T14f daemonA resolv untouched"; } || { FAIL=$((FAIL+1)); echo "FAIL T14f daemonA resolv touched"; } +# ---- T19: WiFi connect 状态机/持久化/隐藏网络/中文 SSID(宿主桩直驱 wifi_connect_ex)---- +# daemon IPC 写级需系统身份(SO_PEERCRED /proc/exe),宿主 CLI=CALL_OTHER 必拒;故用 #include +# meow_netmgrd.c 的独立宿主二进制直驱 static wifi_connect_ex,桩 wpa_cli 由二进制自建。 +if [ -z "$Q" ]; then + WCON="$T/test_wifi_connect" + if cc -Os -Wall -Wextra -Wno-format-truncation -I../meow_launcherd \ + test_wifi_connect.c -o "$WCON" >/dev/null 2>&1; then + if "$WCON" >"$T/wcon.out" 2>/dev/null && grep -q "FAIL=0" "$T/wcon.out"; then + PASS=$((PASS+1)); echo "PASS T19 wifi-connect host (12 asserts, FAIL=0)" + else + FAIL=$((FAIL+1)); echo "FAIL T19 wifi-connect host"; cat "$T/wcon.out" + fi + else + FAIL=$((FAIL+1)); echo "FAIL T19 wifi-connect host build" + fi +else + echo "SKIP T19 wifi-connect(qemu 下 fork/exec 宿主 /bin/sh 不便,宿主已覆盖)" +fi + echo "==============================" echo "PASS=$PASS FAIL=$FAIL" [ $FAIL -eq 0 ] diff --git a/patch_v2/meow_netmgrd/test_wifi_connect.c b/patch_v2/meow_netmgrd/test_wifi_connect.c new file mode 100644 index 0000000..effc32e --- /dev/null +++ b/patch_v2/meow_netmgrd/test_wifi_connect.c @@ -0,0 +1,168 @@ +/* test_wifi_connect.c — WiFi 设置页 bug1/bug2/bug4 宿主桩测试(2026-09-15)。 + * + * 通过 #include meow_netmgrd.c 直接驱动 static 的 wifi_connect_ex(),绕过 daemon 的 + * SO_PEERCRED 系统身份鉴权(宿主 CLI=CALL_OTHER 写级必拒,无法经 IPC 测连接路径)。 + * 桩 wpa_cli 由本程序写入临时目录;控制文件(status_mode/list/save_fail_once)决定 + * status 序列 / list_networks 原文 / save_config 是否首败,调用日志写入 /log 供断言。 + * + * 编译:cc -Wall -Wextra -I../meow_launcherd test_wifi_connect.c -o test_wifi_connect + */ +#define _GNU_SOURCE +#define main meow_netmgrd_main +#include "meow_netmgrd.c" +#undef main + +#include + +static char g_dir[128]; +static int g_pass = 0, g_fail = 0; + +static void ck(const char *name, int cond, const char *detail) +{ + if (cond) { g_pass++; printf("PASS %s\n", name); } + else { g_fail++; printf("FAIL %s: %s\n", name, detail ? detail : ""); } + fflush(stdout); +} + +static void putf(const char *name, const char *fmt, ...) +{ + char path[192], body[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(body, sizeof body, fmt, ap); + va_end(ap); + snprintf(path, sizeof path, "%s/%s", g_dir, name); + FILE *f = fopen(path, "w"); + if (f) { fputs(body, f); fclose(f); } +} + +static int log_has(const char *needle) +{ + char path[192], buf[8192]; + snprintf(path, sizeof path, "%s/log", g_dir); + FILE *f = fopen(path, "r"); + if (!f) return 0; + size_t n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + buf[n] = 0; + return strstr(buf, needle) != NULL; +} + +static void reset_log(void) +{ + char path[192]; + snprintf(path, sizeof path, "%s/log", g_dir); + FILE *f = fopen(path, "w"); + if (f) fclose(f); +} + +static void write_stub(void) +{ + char path[192]; + snprintf(path, sizeof path, "%s/wpa_cli", g_dir); + FILE *f = fopen(path, "w"); + if (!f) { fprintf(stderr, "stub open fail\n"); exit(2); } + fputs( + "#!/bin/sh\n" + "D=\"${MN_WIFI_DIR:-/tmp}\"\n" + "echo \"$@\" >> \"$D/log\"\n" + "case \"$1\" in\n" + " status)\n" + " m=$(cat \"$D/status_mode\" 2>/dev/null || echo completed)\n" + " if [ \"$m\" = \"associating\" ]; then\n" + " echo \"wpa_state=ASSOCIATING\"; echo \"ssid=MyWiFi\"\n" + " else\n" + " echo \"wpa_state=COMPLETED\"; echo \"ssid=MyWiFi\"\n" + " echo \"ip_address=10.78.193.50\"; echo \"signal=-45\"\n" + " fi\n" + " ;;\n" + " list_networks)\n" + " if [ -f \"$D/list\" ]; then cat \"$D/list\"\n" + " else printf 'network id / ssid / bssid / flags\\n0\\tMyWiFi\\tany\\t[CURRENT]\\n'\n" + " fi\n" + " ;;\n" + " add_network) echo 1 ;;\n" + " set_network) echo OK ;;\n" + " enable_network) echo OK ;;\n" + " select_network) echo OK ;;\n" + " disconnect) echo OK ;;\n" + " save_config)\n" + " if [ -f \"$D/save_fail_once\" ]; then rm -f \"$D/save_fail_once\"; echo FAIL; else echo OK; fi\n" + " ;;\n" + " set) echo OK ;;\n" + " remove_network) echo OK ;;\n" + " *) echo OK ;;\n" + "esac\n" + "exit 0\n", + f); + fclose(f); + chmod(path, 0755); +} + +int main(void) +{ + snprintf(g_dir, sizeof g_dir, "/tmp/mnwifi.XXXXXX"); + if (!mkdtemp(g_dir)) { fprintf(stderr, "mkdtemp fail\n"); return 2; } + write_stub(); + setenv("MN_WIFI_DIR", g_dir, 1); + + g_mode_meow = 1; + g_connect_wait_ms = 400; + g_connecth_wait_ms = 400; + { + char p[192]; + snprintf(p, sizeof p, "%s/wpa_cli", g_dir); + g_wpa_cli = p; + } + + ctl_result_t r; + + /* ① 成功:命中既有 id=0 + 带新 psk → 更新凭据,等待 COMPLETED,成功报 ip。 */ + putf("status_mode", "completed"); + ctl_reset(&r, MEOW_OK); + reset_log(); + wifi_connect_ex("MyWiFi,secret", &r, 0); + ck("connect-success code", r.code == MEOW_OK, r.text); + ck("connect-success text ip", strstr(r.text, "已连接") && strstr(r.text, "ip=10.78.193.50"), r.text); + ck("connect-success psk update", log_has("set_network 0 psk"), ""); + ck("connect-success enable all", log_has("enable_network all"), ""); + + /* ② 错误密码/不在范围:status 恒 ASSOCIATING → 超时失败,新建 id 被回收。 */ + putf("status_mode", "associating"); + reset_log(); + ctl_reset(&r, MEOW_OK); + wifi_connect_ex("OtherWiFi", &r, 0); + ck("connect-timeout code", r.code == MEOW_ERR_NODEV, r.text); + ck("connect-timeout text", strstr(r.text, "连接失败") != NULL, r.text); + ck("connect-timeout remove new", log_has("remove_network 1"), ""); + + /* ③ 中文 SSID \xNN 还原匹配:list_networks 原文转义,输入 UTF-8 名 → 命中 id=0 不新建。 */ + putf("status_mode", "completed"); + putf("list", "network id / ssid / bssid / flags\n0\t\\xe5\\xa4\\xa9\\xe5\\xa4\\xa9\tany\t[CURRENT]\n"); + reset_log(); + ctl_reset(&r, MEOW_OK); + wifi_connect_ex("天天", &r, 0); + ck("connect-escape match", r.code == MEOW_OK, r.text); + ck("connect-escape no add", !log_has("add_network"), ""); + + /* ④ connecth:隐藏网络命中既有 id → 追加 scan_ssid 1。 */ + putf("status_mode", "completed"); + putf("list", "network id / ssid / bssid / flags\n0\tMyWiFi\tany\t[CURRENT]\n"); + reset_log(); + ctl_reset(&r, MEOW_OK); + wifi_connect_ex("MyWiFi", &r, 1); + ck("connecth scan_ssid", r.code == MEOW_OK && log_has("set_network 0 scan_ssid 1"), r.text); + + /* ⑤ save_config 首败 → set update_config 1 重试 → 持久化成功。 */ + putf("status_mode", "completed"); + putf("list", "network id / ssid / bssid / flags\n0\tMyWiFi\tany\t[CURRENT]\n"); + putf("save_fail_once", "1"); + reset_log(); + ctl_reset(&r, MEOW_OK); + wifi_connect_ex("MyWiFi", &r, 0); + ck("save-config retry", r.code == MEOW_OK && !strstr(r.text, "记录未持久化"), r.text); + ck("save-config set update_config", log_has("set update_config 1"), ""); + + printf("==============================\nPASS=%d FAIL=%d\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/patch_v2/meow_watchdog/build_watchdog.sh b/patch_v2/meow_watchdog/build_watchdog.sh index 8dd2e37..1b934dc 100755 --- a/patch_v2/meow_watchdog/build_watchdog.sh +++ b/patch_v2/meow_watchdog/build_watchdog.sh @@ -8,16 +8,17 @@ set -e cd "$(dirname "$0")" ROOT=../.. CC="$ROOT/.tools/zig-mipsel-cc" +INC="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(音量收编 v2:MSG_AUDIO_VOLUME/MSG_ACK) if [ "$1" = "host" ]; then - gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \ - test_wd_fw.c -o test_wd_fw -lm + gcc -Wall -Wextra -Os -I"$INC" -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \ + -Wno-unused-variable test_wd_fw.c -o test_wd_fw -lm ./test_wd_fw exit $? fi echo "== meow_watchdog (mipsel static) ==" -"$CC" -Os -Wall -Wextra -static meow_watchdog.c -o meow_watchdog +"$CC" -Os -Wall -Wextra -static -I"$INC" meow_watchdog.c -o meow_watchdog ls -la meow_watchdog md5sum meow_watchdog file meow_watchdog diff --git a/patch_v2/meow_watchdog/meow_watchdog.c b/patch_v2/meow_watchdog/meow_watchdog.c index aca36eb..78ff440 100644 --- a/patch_v2/meow_watchdog/meow_watchdog.c +++ b/patch_v2/meow_watchdog/meow_watchdog.c @@ -19,7 +19,10 @@ * (launcher 在 waitpid 轮询中消费;菜单期该进程靠 /tmp/meow_power_menu_active * 让位,绝不与 launcher 抢屏/抢键);launcher 画菜单,本进程不自绘。 * 电源长按 ≥8s -> 强制关机兜底(仅当菜单未生效/launcher 未接管)。 - * 音量±(115/114) -> 写真实 DAC(CS43198 amixer numid=1/2)+ 持久化 settings.conf(同 launcher 同源)。 + * 音量±(115/114) -> 音量收编 v2(FUTURE_AUDIO 事项三):首选发 audiod 权威 + * MSG_AUDIO_VOLUME(audiod 写 DAC numid=1/2 + DSD_VOLUME + 持久化 + * settings.conf,同源同键单写者);audiod 未起(HibyOS/开机早期) + * 才回退本地 amixer 直写兜底 + 本地持久化。 * 插孔消抖:/sys/class/switch/{headset,balance} 变化后 800ms 内忽略音量键边沿 * (插拔电气瞬态会在 ADC 键上产生幽灵按下,真机实证 2026-09-01)。 * 播放/上下曲(164/165/163/19) -> 捕获 + 日志 + 消费,不路由(媒体会话落地后转活跃会话)。 @@ -50,8 +53,12 @@ #include #include #include +#include +#include +#include #include #include +#include "meow_ipc.h" /* MSG_AUDIO_VOLUME/MSG_ACK/帧格式(音量收编 v2:首选 audiod 权威) */ /* device-verified: VSCREENINFO=0x4600, FSCREENINFO=0x4602 (do not swap) */ #define FBIOGET_VSCREENINFO 0x4600 @@ -214,6 +221,14 @@ static void write_brightness(int val) close(fd); } +/* settings.conf 路径(缺省 /usr/data/meow/settings.conf;env MEOW_SETTINGS_FILE 覆盖, + * 供宿主测试隔离——与 audiod MEOW_AUDIO_SETTINGS 同款测试钩子,真机无 env 走默认)。 */ +static const char *wd_settings_path(void) +{ + const char *e = getenv("MEOW_SETTINGS_FILE"); + return (e && e[0]) ? e : MEOW_SETTINGS_FILE; +} + /* M6.23:亮屏恢复亮度以**用户设置**为准(/usr/data/meow/settings.conf 的 display.brightness), * 而非 wd_saved_bri 快照——该快照可能被污染:panel OFF 前 read_brightness() 可能读到 launcher * 先写好的 0(launcher meow_panel_off 已 write_brightness(0)),`wd_saved_bri=(br>0)?br:60` 便落到 @@ -225,7 +240,7 @@ static void write_brightness(int val) * → 返回 -1,调用方回退 wd_saved_bri(保持旧行为,防开机竞态)。 */ static int wd_settings_brightness(void) { - FILE *f = fopen(MEOW_SETTINGS_FILE, "r"); + FILE *f = fopen(wd_settings_path(), "r"); if (!f) return -1; int b = 101; /* display.brightness 默认(与 launcher 设置表同值,键缺失时 clamp→100) */ int mn = 1; /* display.brightness_min 默认(极暗但可见;键缺失时保底) */ @@ -372,7 +387,7 @@ static int wd_vol_step = 1; /* 跳步 */ static void wd_vol_load(void) { wd_vol = 60; wd_vol_max = 100; wd_vol_step = 1; - FILE *f = fopen(MEOW_SETTINGS_FILE, "r"); + FILE *f = fopen(wd_settings_path(), "r"); if (!f) { wd_log("vol: no settings, default %d", wd_vol); return; } char buf[128]; while (fgets(buf, sizeof buf, f)) { @@ -391,13 +406,17 @@ static void wd_vol_load(void) wd_log("vol: loaded volume=%d max=%d step=%d", wd_vol, wd_vol_max, wd_vol_step); } -/* 写真实 DAC(CS43198, numid 1/2, 0-255),与 launcher meow_dac_vol_write 同路径。 */ +/* 写真实 DAC(CS43198, numid 1/2, 0-255),与 launcher meow_dac_vol_write 同路径。 + * ⚠️ 音量收编 v2 兜底函数:仅 audiod 未起(HibyOS/开机早期)时由 wd_vol_adj 回退调用; + * MeowOS 常态一律走 wd_audiod_set_volume(audiod 唯一权威,防多写者漂移)。 + * 锥度为定案平方锥度(与 audiod dac_vol_set 同款,勿改语义:CS43198=衰减量,0 最响/255 静音)。 */ static void wd_vol_write(int pct) { int t = 100 - pct, dac; if (pct <= 0) dac = 255; /* 真静音(DS1039:255=-127.5dB) */ else dac = t * t * 192 / 10000; /* 感知平方锥度:dB 衰减=(1-pct)^2×96dB */ - if (dac > 255) dac = 255; if (dac < 0) dac = 0; /* CS43198=衰减量(0最响255静音)+平方锥度(听感线性) */ + if (dac > 255) dac = 255; + if (dac < 0) dac = 0; /* CS43198=衰减量(0最响255静音)+平方锥度(听感线性) */ char cmd[160]; snprintf(cmd, sizeof cmd, "amixer -c 0 cset numid=1 %d >/dev/null 2>&1; amixer -c 0 cset numid=2 %d >/dev/null 2>&1", @@ -406,14 +425,97 @@ static void wd_vol_write(int pct) wd_log("DAC vol -> L/R=%d (pct %d)", dac, pct); } +/* ---- 音量收编 v2(FUTURE_AUDIO 事项三):首选 audiod 权威 MSG_AUDIO_VOLUME ---- + * watchdog 为 per-App 小进程,audiod sock 0666 无鉴权可直接连。一次性短连接(按键 rare, + * 无常驻连接/陈旧态负担):非阻塞 connect + poll 300ms,SO_SNDTIMEO/RCVTIMEO 1s,发 i32 pct + * 等 MSG_ACK。audiod 侧已异步化(末值去抖,ACK=已应用+已持久化),正常 <<1s 返回。 + * 返回 0=audiod 已受理(DAC 写+持久化均由 audiod 完成,本进程不再写 settings 防双写); + * -1=audiod 缺席/失败(调用方回退 wd_vol_write 本地直写 + wd_vol_save 本地持久化)。 */ +#define WD_AUD_SOCK_DEFAULT "/usr/data/meow_audio.sock" /* 同 audiod AUD_SOCK_DEFAULT */ +#define WD_AUD_CONN_TMO_MS 300 +#define WD_AUD_IO_TMO_MS 1000 + +static const char *wd_aud_sock(void) +{ + const char *e = getenv("MEOW_AUDIO_SOCK"); /* 与 SDK sock_path() 同款 env(宿主测覆盖) */ + return (e && e[0]) ? e : WD_AUD_SOCK_DEFAULT; +} + +static int wd_wr_all(int fd, const void *p, size_t n) +{ + const uint8_t *b = p; size_t o = 0; + while (o < n) { ssize_t r = write(fd, b + o, n - o); if (r < 0) { if (errno == EINTR) continue; return -1; } o += (size_t)r; } + return 0; +} +static int wd_rd_all(int fd, void *p, size_t n) +{ + uint8_t *b = p; size_t o = 0; + while (o < n) { ssize_t r = read(fd, b + o, n - o); if (r < 0) { if (errno == EINTR) continue; return -1; } if (r == 0) return -1; o += (size_t)r; } + return 0; +} + +static int wd_audiod_set_volume(int pct) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + int fl = fcntl(fd, F_GETFL, 0); + if (fl >= 0) fcntl(fd, F_SETFL, fl | O_NONBLOCK); + struct sockaddr_un a; + memset(&a, 0, sizeof a); + a.sun_family = AF_UNIX; + snprintf(a.sun_path, sizeof a.sun_path, "%s", wd_aud_sock()); + int r = connect(fd, (struct sockaddr *)&a, sizeof a); + if (r < 0 && errno == EINPROGRESS) { + struct pollfd p; + p.fd = fd; p.events = POLLOUT; p.revents = 0; + if (poll(&p, 1, WD_AUD_CONN_TMO_MS) > 0) { + int err = 0; + socklen_t el = sizeof err; + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &el) == 0 && err != 0) { errno = err; r = -1; } + } else { + r = -1; /* 超时/错误 */ + } + } + if (r < 0) { close(fd); return -1; } + if (fl >= 0) fcntl(fd, F_SETFL, fl); /* 回阻塞模式;读写超时由 SO_*TIMEO 兜底 */ + struct timeval tv; + tv.tv_sec = WD_AUD_IO_TMO_MS / 1000; tv.tv_usec = (WD_AUD_IO_TMO_MS % 1000) * 1000; + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + /* 发 MSG_AUDIO_VOLUME(i32 pct);req_id 任意(一次性连接单请求) */ + uint8_t frame[MEOW_IPC_HDR_LEN + sizeof(int32_t)]; + uint32_t magic = MEOW_IPC_MAGIC, flen = sizeof(int32_t), freq = 1; + uint16_t ver = MEOW_IPC_VERSION, type = MSG_AUDIO_VOLUME; + memcpy(frame + 0, &magic, 4); + memcpy(frame + 4, &ver, 2); + memcpy(frame + 6, &type, 2); + memcpy(frame + 8, &freq, 4); + memcpy(frame + 12, &flen, 4); + int32_t p32 = pct; + memcpy(frame + MEOW_IPC_HDR_LEN, &p32, sizeof p32); + if (wd_wr_all(fd, frame, sizeof frame) != 0) { close(fd); return -1; } + /* 等 MSG_ACK(有界:RCVTIMEO;audiod 异步执行点应用后回 ACK) */ + uint8_t hdr[MEOW_IPC_HDR_LEN]; + if (wd_rd_all(fd, hdr, sizeof hdr) != 0) { close(fd); return -1; } + uint32_t rmagic, rlen; + uint16_t rver, rtype; + memcpy(&rmagic, hdr, 4); memcpy(&rver, hdr + 4, 2); memcpy(&rtype, hdr + 6, 2); memcpy(&rlen, hdr + 12, 4); + if (rmagic != MEOW_IPC_MAGIC || rver != MEOW_IPC_VERSION || rtype != MSG_ACK || rlen > 64) { close(fd); return -1; } + uint8_t pl[64]; + if (rlen && wd_rd_all(fd, pl, rlen) != 0) { close(fd); return -1; } + close(fd); + return 0; +} + /* M2.92+ 修复(真机实证 settings.conf 被毁):旧版 O_TRUNC 整文件重写只剩 4 行 —— * 抹掉全部其它设置;与其它写者交错还叠出重复 audio.volume 行、丢 volume_max/step。 * 改为读改写单键(流式拷贝、命中行全部改写去重、缺失末尾追加、rename 原子替换), * 与 SDK meow_ui_vol_set 同款语义。 */ static void wd_vol_save(void) { - system("mkdir -p /usr/data/meow 2>/dev/null"); - const char *path = MEOW_SETTINGS_FILE; + const char *path = wd_settings_path(); + if (strcmp(path, MEOW_SETTINGS_FILE) == 0) /* 仅默认路径补目录(env 覆盖=宿主测,目录自备) */ + system("mkdir -p /usr/data/meow 2>/dev/null"); char tmp[300]; snprintf(tmp, sizeof tmp, "%s.tmp", path); FILE *in = fopen(path, "r"); @@ -490,7 +592,9 @@ static int wd_jack_debounce(void) (now_ms() - wd_jack_changed_at < (unsigned long long)WD_JACK_DEBOUNCE_MS); } -/* 音量步进:+1/-1(每按下边沿一次;clamp 到极大/最小)。 */ +/* 音量步进:+1/-1(每按下边沿一次;clamp 到极大/最小)。 + * 音量收编 v2:首选 audiod 权威(IPC;DAC 写+持久化 audiod 完成);audiod 缺席才本地 + * amixer 直写 + 本地持久化兜底(wd_vol 本地缓存仅作步进基数,两路都维护)。 */ static void wd_vol_adj(int dir) { int target = wd_vol + dir * wd_vol_step; @@ -498,9 +602,13 @@ static void wd_vol_adj(int dir) if (target > wd_vol_max) target = wd_vol_max; if (target == wd_vol) { wd_log("volume %s at limit (%d)", dir > 0 ? "UP" : "DOWN", wd_vol); return; } wd_vol = target; - wd_vol_write(wd_vol); + if (wd_audiod_set_volume(wd_vol) == 0) { + wd_log("volume %s -> %d (via audiod)", dir > 0 ? "UP" : "DOWN", wd_vol); + return; + } + wd_vol_write(wd_vol); /* 兜底:仅 audiod 未起(HibyOS/开机早期) */ wd_vol_save(); - wd_log("volume %s -> %d", dir > 0 ? "UP" : "DOWN", wd_vol); + wd_log("volume %s -> %d (local amixer fallback, audiod absent)", dir > 0 ? "UP" : "DOWN", wd_vol); } static void handle_touch(int immersive, int app_pid, diff --git a/patch_v2/meowpanel/src/mp.h b/patch_v2/meowpanel/src/mp.h index 086df48..041ce06 100644 --- a/patch_v2/meowpanel/src/mp.h +++ b/patch_v2/meowpanel/src/mp.h @@ -289,6 +289,8 @@ void net_policy_parse(const char *txt, sbuf_t *s); int api_net_policy_get(int sock); int api_net_policy_set(int sock, const char *body, size_t body_len); int net_locked_entity(const char *entity); /* 防自锁:系统关键网络/服务组件(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init) */ +int net_bt_aggregate(const char *st, const char *paired, const char *connected, const char *a2dp, const char *devices, sbuf_t *s); /* F1-③ 0xE6 五读文本聚合(selftest 可测) */ +int api_net_bt(int sock); /* F1-③ GET /api/net/bt 蓝牙只读详情 */ int panel_policy_write_enabled(void); /* policy 默认关 gate:settings.conf `panel.policy_enabled`,缺省 0=关(只读透传) */ void panel_strip_ansi(char *s); int txt_tokens(char *buf, char **toks, int maxtoks); @@ -415,6 +417,8 @@ int api_notification_dismiss_all(int sock); int api_files_list(int sock, const req_t *r); int api_files_read(int sock, const req_t *r); int api_files_download(int sock, const req_t *r); +int files_search_json(const char *abs_start, const char *name, const char *typef, int max, sbuf_t *out); /* F1-④ 搜索核心(selftest 可测) */ +int api_files_search(int sock, const req_t *r); /* F1-④ GET /api/files/search(只读) */ int api_files_upload(int sock, const req_t *r); int rm_recursive(const char *path); int api_files_mkdir(int sock, const req_t *r); diff --git a/patch_v2/meowpanel/src/mp_files.c b/patch_v2/meowpanel/src/mp_files.c index 26cce49..26d4873 100644 --- a/patch_v2/meowpanel/src/mp_files.c +++ b/patch_v2/meowpanel/src/mp_files.c @@ -571,6 +571,142 @@ int api_files_delete(int sock, const req_t *r) return send_json(sock, 200, "{\"removed\":true}"); } +/* ---- F1-④ 文件搜索(FUTURE_MEOWPANEL §2.3,只读):白名单内 readdir 递归(限深 4 / 限量 200 / + * 限遍历 8000 条),不 run_cmd("find")(防注入、可控)。name=名子串(ASCII 大小写不敏感); + * type=file|dir 可选过滤。不跟符号链接(防循环/逃逸出白名单根)。 ---- */ +#define SEARCH_MAX_DEPTH 4 +#define SEARCH_MAX_ITEMS 200 +#define SEARCH_MAX_VISIT 8000 + +typedef struct { + const char *needle; /* 已小写化的匹配子串 */ + int want_type; /* 0=不限 1=仅文件 2=仅目录 */ + int max; + int count; + long visited; + sbuf_t *s; +} search_ctx_t; + +static void name_lower(const char *in, char *out, size_t cap) +{ + size_t i = 0; + for (; in[i] && i + 1 < cap; i++) { + unsigned char c = (unsigned char)in[i]; + out[i] = (c >= 'A' && c <= 'Z') ? (char)(c + 32) : (char)c; + } + out[i] = 0; +} + +/* 返回 1=到达上限应全程停止(truncated)。 */ +static int search_walk(search_ctx_t *c, const char *dir, int depth) +{ + if (c->count >= c->max || c->visited >= SEARCH_MAX_VISIT) return 1; + DIR *d = opendir(dir); + if (!d) return 0; + int stop = 0; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + if (++c->visited > SEARCH_MAX_VISIT) { stop = 1; break; } + char full[1400]; + snprintf(full, sizeof full, "%s/%s", dir, e->d_name); + struct stat st; + if (lstat(full, &st) < 0) continue; + if (S_ISLNK(st.st_mode)) continue; /* 不跟符号链接 */ + int isdir = S_ISDIR(st.st_mode); + int type_ok = !c->want_type || (c->want_type == 1 && !isdir) || (c->want_type == 2 && isdir); + if (type_ok) { + char low[300]; + name_lower(e->d_name, low, sizeof low); + if (strstr(low, c->needle)) { + if (c->count >= c->max) { stop = 1; break; } + char rel[1200]; + make_rel(full, rel, sizeof rel); + if (c->count) sA(c->s, ","); + sA(c->s, "{\"name\":"); + sA_str(c->s, e->d_name); + sA(c->s, ",\"type\":\"%s\",\"size\":%lld,\"mtime\":%lld,\"rel\":", + isdir ? "dir" : "file", isdir ? 0LL : (long long)st.st_size, (long long)st.st_mtime); + sA_str(c->s, rel); + sA(c->s, "}"); + c->count++; + } + } + if (isdir && depth < SEARCH_MAX_DEPTH && search_walk(c, full, depth + 1)) { stop = 1; break; } + } + closedir(d); + return stop; +} + +/* 0=OK(JSON 入 out);-1 白名单外;-2 路径不存在/非目录;-3 name 空。 */ +int files_search_json(const char *abs_start, const char *name, const char *typef, int max, sbuf_t *out) +{ + if (!path_in_whitelist(abs_start)) return -1; + struct stat st; + if (stat(abs_start, &st) < 0 || !S_ISDIR(st.st_mode)) return -2; + char needle[300]; + name_lower(name ? name : "", needle, sizeof needle); + if (!needle[0]) return -3; + int want_type = 0; + if (typef && !strcmp(typef, "file")) want_type = 1; + else if (typef && !strcmp(typef, "dir")) want_type = 2; + else if (typef && typef[0]) return -4; /* 非法 type 值(api 层映射 422) */ + if (max <= 0 || max > SEARCH_MAX_ITEMS) max = SEARCH_MAX_ITEMS; + search_ctx_t c; + memset(&c, 0, sizeof c); + c.needle = needle; c.want_type = want_type; c.max = max; c.s = out; + char rel[1200]; + make_rel(abs_start, rel, sizeof rel); + sA(out, "{\"path\":"); + sA_str(out, abs_start); + sA(out, ",\"rel\":"); + sA_str(out, rel); + sA(out, ",\"name\":"); + sA_str(out, name); + sA(out, ",\"items\":["); + int stopped = search_walk(&c, abs_start, 1); + sA(out, "],\"count\":%d,\"truncated\":%s}", c.count, + (stopped || c.visited >= SEARCH_MAX_VISIT) ? "true" : "false"); + return 0; +} + +int api_files_search(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024] = "/"; + if (r->query[0]) qparam(r->query, "path", path, sizeof path); + char name[512] = ""; + if (!qparam(r->query, "name", name, sizeof name) || !name[0]) + return send_err(sock, 400, "BAD_REQUEST", "缺 name(按名子串搜索)"); + char typef[16] = ""; + qparam(r->query, "type", typef, sizeof typef); + char maxs[16] = ""; + int max = SEARCH_MAX_ITEMS; + if (qparam(r->query, "max", maxs, sizeof maxs)) { int m = atoi(maxs); if (m > 0) max = m; } + char abs[1024]; + if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) + return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + sbuf_t s = {0}; + int rc = files_search_json(abs, name, typef, max, &s); + if (rc == -1) { sbuf_free(&s); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); } + if (rc == -2) { sbuf_free(&s); return send_err(sock, 404, "NOT_FOUND", "路径不存在或非目录"); } + if (rc == -4) { sbuf_free(&s); return send_err(sock, 422, "UNSUPPORTED", "type 须为 file|dir"); } + if (rc != 0) { sbuf_free(&s); return send_err(sock, 400, "BAD_REQUEST", "name 为空"); } + /* 放行前自校验(与 overview 同口径:括号平衡/内嵌 NUL/坏字节,浏览器 JSON.parse 不容忍) */ + jv_t *chk = jv_parse(s.b ? s.b : "{}", (int)(s.b ? s.n : 2)); + int bad = !s.b || !chk || (strlen(s.b) != s.n) || + !utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n); + if (chk) jv_free(chk); + if (bad) { + panel_log("files/search: SELF-CHECK fail (n=%zu)", s.n); + sbuf_free(&s); + return send_err(sock, 500, "INTERNAL", "搜索结果输出未通过 JSON 自校验"); + } + int out = send_json(sock, 200, s.b); + sbuf_free(&s); + return out; +} + int multipart_extract_file(const req_t *r, const char *outpath) { const char *b = strstr(r->content_type, "boundary="); diff --git a/patch_v2/meowpanel/src/mp_net.c b/patch_v2/meowpanel/src/mp_net.c index 51783f4..41c27ee 100644 --- a/patch_v2/meowpanel/src/mp_net.c +++ b/patch_v2/meowpanel/src/mp_net.c @@ -175,6 +175,160 @@ int api_net_dns_flush(int sock) return rc; } +/* ---- F1-③ 蓝牙只读详情(FUTURE_MEOWPANEL §2.6):netmgrd MSG_NET_BT_CTL(0xE6) 读级五命令 + * (status/paired/connected/a2dp/devices)聚合为一份 JSON。读级任意调用者(bt_cmd_is_write=读), + * 无阻塞(net_ipc 2s 超时兜底);子命令失败/坏字节 → 该子块空数组降级,绝不拖垮整卡、绝不发非法 JSON。 ---- */ + +/* 单发 0xE6 读命令;成功返回文本长度(>0),IPC/服务端失败 -1,坏 UTF-8 -2(该子块降级)。 */ +static int bt_ctl_read(const char *cmd, char *out, size_t cap) +{ + m_net_cmd_t q; memset(&q, 0, sizeof q); + snprintf(q.cmd, sizeof q.cmd, "%s", cmd); + uint16_t t; uint32_t code; + int n = net_ipc(MSG_NET_BT_CTL, &q, sizeof q, &t, &code, NULL, out, cap); + if (n <= 0 || t != MSG_NET_BT_RESULT || code != MEOW_OK) return -1; + if (!utf8_valid_n(out, (size_t)n)) return -2; + return n; +} + +static int bt_mac_ok(const char *m) +{ + for (int i = 0; i < 17; i++) { + if (i % 3 == 2) { if (m[i] != ':') return 0; } + else if (!is_hex(m[i])) return 0; + } + return 1; +} + +/* "Device " 行集 → JSON 数组项(不合法行跳过;名经 sA_str_n 消毒)。返回条数。 */ +static int bt_devlines_json(sbuf_t *s, const char *txt, int max) +{ + int n = 0; + const char *ln = txt; + while (ln && *ln && n < max) { + const char *nl = strchr(ln, '\n'); + size_t ll = nl ? (size_t)(nl - ln) : strlen(ln); + const char *p = ln; + while ((size_t)(p - ln) < ll && (*p == ' ' || *p == '\t')) p++; + if ((size_t)(ln + ll - p) >= 7 + 17 && !strncmp(p, "Device ", 7) && bt_mac_ok(p + 7)) { + const char *mac = p + 7; + const char *nm = mac + 17; + while ((size_t)(nm - ln) < ll && (*nm == ' ' || *nm == '\t')) nm++; + size_t nlen = ll - (size_t)(nm - ln); + while (nlen && (nm[nlen - 1] == ' ' || nm[nlen - 1] == '\t' || nm[nlen - 1] == '\r')) nlen--; + if (n) sA(s, ","); + sA(s, "{\"mac\":"); + sA_str_n(s, mac, 17); + sA(s, ",\"name\":"); + sA_str_n(s, nm, nlen); + sA(s, "}"); + n++; + } + ln = nl ? nl + 1 : NULL; + } + return n; +} + +/* 行内 " key=value"(空白/行尾收尾)取值。 */ +static void bt_line_kv(const char *line, const char *key, char *out, size_t cap) +{ + out[0] = 0; + const char *v = strstr(line, key); + if (!v) return; + v += strlen(key); + size_t i = 0; + while (v[i] && v[i] != ' ' && v[i] != '\t' && v[i] != '\n' && v[i] != '\r' && i + 1 < cap) { out[i] = v[i]; i++; } + out[i] = 0; +} + +/* "A2DP codec=.. rate=.. channels=.. bits=.. acquired=<0|1> role=<..>" 行集 → JSON 数组项。 */ +static int bt_a2dp_json(sbuf_t *s, const char *txt, int max) +{ + int n = 0; + const char *ln = txt; + while (ln && *ln && n < max) { + const char *nl = strchr(ln, '\n'); + size_t ll = nl ? (size_t)(nl - ln) : strlen(ln); + char line[320]; + if (ll >= sizeof line) ll = sizeof line - 1; + memcpy(line, ln, ll); line[ll] = 0; + if (!strncmp(line, "A2DP ", 5) && strncmp(line, "A2DP none", 9) && bt_mac_ok(line + 5)) { + char codec[24], rate[16], chn[8], bits[8], acq[8], role[8]; + bt_line_kv(line, " codec=", codec, sizeof codec); + bt_line_kv(line, " rate=", rate, sizeof rate); + bt_line_kv(line, " channels=", chn, sizeof chn); + bt_line_kv(line, " bits=", bits, sizeof bits); + bt_line_kv(line, " acquired=", acq, sizeof acq); + bt_line_kv(line, " role=", role, sizeof role); + if (n) sA(s, ","); + sA(s, "{\"mac\":"); + sA_str_n(s, line + 5, 17); + sA(s, ",\"codec\":"); sA_str(s, codec); + sA(s, ",\"rate\":"); sA_str(s, rate); + sA(s, ",\"channels\":"); sA_str(s, chn); + sA(s, ",\"bits\":"); sA_str(s, bits); + sA(s, ",\"acquired\":%s", !strcmp(acq, "1") ? "true" : "false"); + sA(s, ",\"role\":"); sA_str(s, role); + sA(s, "}"); + n++; + } + ln = nl ? nl + 1 : NULL; + } + return n; +} + +/* 五读文本聚合(任一 NULL=该子块空数组;st=NULL → {"available":false})。 */ +int net_bt_aggregate(const char *st, const char *paired, const char *connected, + const char *a2dp, const char *devices, sbuf_t *s) +{ + if (!st) { sA(s, "{\"available\":false}"); return -1; } + int avail = 0, powered = 0; long long paired_n = -1, connected_n = -1; + char addr[24] = ""; + (void)sscanf(st, "bt available=%d powered=%d address=%23s paired=%lld connected=%lld", + &avail, &powered, addr, &paired_n, &connected_n); + if (!strcmp(addr, "-")) addr[0] = 0; + sA(s, "{\"available\":%s", avail ? "true" : "false"); + sA(s, ",\"powered\":%s", powered ? "true" : "false"); + sA(s, ",\"address\":"); sA_str(s, addr); + if (paired_n >= 0) sA(s, ",\"paired_n\":%lld", paired_n); else sA(s, ",\"paired_n\":null"); + if (connected_n >= 0) sA(s, ",\"connected_n\":%lld", connected_n); else sA(s, ",\"connected_n\":null"); + sA(s, ",\"paired\":["); if (paired) bt_devlines_json(s, paired, 32); sA(s, "]"); + sA(s, ",\"connected\":["); if (connected) bt_devlines_json(s, connected, 16); sA(s, "]"); + sA(s, ",\"a2dp\":["); if (a2dp) bt_a2dp_json(s, a2dp, 8); sA(s, "]"); + sA(s, ",\"nearby\":["); if (devices) bt_devlines_json(s, devices, 16); sA(s, "]"); + sA(s, "}"); + return 0; +} + +int api_net_bt(int sock) +{ + char st[MEOW_IPC_MAX], pa[MEOW_IPC_MAX], co[MEOW_IPC_MAX], a2[MEOW_IPC_MAX], dv[MEOW_IPC_MAX]; + int ns = bt_ctl_read("status", st, sizeof st); + if (ns <= 0) /* netmgrd 不可达/status 坏字节 → 整体降级(net 域惯例:200 available:false) */ + return send_json(sock, 200, "{\"available\":false}"); + int np = bt_ctl_read("paired", pa, sizeof pa); + int nc = bt_ctl_read("connected", co, sizeof co); + int na = bt_ctl_read("a2dp", a2, sizeof a2); + int nd = bt_ctl_read("devices", dv, sizeof dv); + sbuf_t s = {0}; + net_bt_aggregate(st, np > 0 ? pa : NULL, nc > 0 ? co : NULL, na > 0 ? a2 : NULL, nd > 0 ? dv : NULL, &s); + /* 放行前自校验(与 overview 同口径:浏览器 JSON.parse 不容忍尾随垃圾/括号错位/内嵌 NUL/坏字节) */ + jv_t *chk = jv_parse(s.b ? s.b : "{}", (int)(s.b ? s.n : 2)); + int bad = !s.b || !chk || (strlen(s.b) != s.n) || + !utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n); + if (chk) jv_free(chk); + if (bad) { + FILE *df = fopen("/tmp/net_bt_bad.json", "w"); + if (df && s.b) { fwrite(s.b, 1, s.n, df); fclose(df); } + panel_log("net/bt: SELF-CHECK fail, raw dumped /tmp/net_bt_bad.json"); + sbuf_free(&s); + return send_err(sock, 500, "INTERNAL", "蓝牙聚合输出未通过 JSON 自校验(原始数据已留 /tmp/net_bt_bad.json)"); + } + int rc = send_json(sock, 200, s.b); + sbuf_free(&s); + return rc; +} + int api_net_log(int sock) { if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); diff --git a/patch_v2/meowpanel/src/mp_server.c b/patch_v2/meowpanel/src/mp_server.c index 62fa0b1..9f7a620 100644 --- a/patch_v2/meowpanel/src/mp_server.c +++ b/patch_v2/meowpanel/src/mp_server.c @@ -301,6 +301,7 @@ int handle(int sock, req_t *r) /* ---- 网络管理(meow_netmgrd 期2/期3 透传:读级任意调用者;flush/policy 写级走 require_auth) ---- */ if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/status")) return api_net_status(sock); if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/ifaces")) return api_net_ifaces(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/bt")) return api_net_bt(sock); /* F1-③ 蓝牙只读详情(0xE6 读级聚合) */ if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/policy")) return api_net_policy_get(sock); if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/conns")) return api_net_conns(sock, r->query); if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/dns")) return api_net_dns(sock); @@ -381,6 +382,7 @@ int handle(int sock, req_t *r) if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files")) return api_files_list(sock, r); if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/read")) return api_files_read(sock, r); if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/download")) return api_files_download(sock, r); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/search")) return api_files_search(sock, r); /* F1-④ 搜索(只读) */ if (!strcmp(r->method, "DELETE") && !strcmp(p, "/api/files")) return api_files_delete(sock, r); /* ---- 应用 ---- */ @@ -634,7 +636,116 @@ int panel_selftest(void) if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } } - printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 新增端点池 → 合法 JSON + 无非法字节;防自锁实体 + policy 门禁 默认关/开启 校验通过\n"); + /* 10) net_bt_aggregate(F1-③):0xE6 五读文本聚合 → 合法 JSON + 字段断言; + * 设备名含截断坏字节 → sA_str_n 消毒为 '?' 且产物仍合法;子文本 NULL → 空数组;status NULL → available:false。 */ + { + sbuf_t b = {0}; + const char *st = "bt available=1 powered=1 address=4C:BC:98:B0:98:DB paired=2 connected=1"; + const char *pa = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice 17:0A:15:AA:BB:CC 流媒体客户\xE7\n"; + const char *co = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\n"; + const char *a2 = "A2DP 33:DE:2E:21:AD:30 codec=sbc rate=44100 channels=2 bits=16 acquired=1 role=src\n"; + const char *dv = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice 17:0A:15:AA:BB:CC 17OP15\n"; + if (net_bt_aggregate(st, pa, co, a2, dv, &b) != 0) { printf("SELFTEST FAIL: net_bt_aggregate 正常文本返回非 0\n"); sbuf_free(&b); return 1; } + if (!b.b || !utf8_valid_n(b.b, b.n) || !json_strict_balanced(b.b, b.n)) { printf("SELFTEST FAIL: net_bt_aggregate 产物非法字节/括号错位\n"); sbuf_free(&b); return 1; } + jv_t *br = jv_parse(b.b, (int)b.n); + if (!br) { printf("SELFTEST FAIL: net_bt_aggregate 产物 JSON 无法解析\n"); sbuf_free(&b); return 1; } + jv_t *pw = jv_obj_get(br, "powered"); + if (!pw || pw->type != J_BOOL || !pw->bval) { printf("SELFTEST FAIL: net_bt_aggregate powered 非 true\n"); jv_free(br); sbuf_free(&b); return 1; } + jv_t *pr = jv_obj_get(br, "paired"); + if (!pr || pr->type != J_ARR || !pr->child || !pr->child->next || pr->child->next->next) { printf("SELFTEST FAIL: net_bt_aggregate paired 应 2 条\n"); jv_free(br); sbuf_free(&b); return 1; } + jv_t *cn = jv_obj_get(br, "connected"); + if (!cn || cn->type != J_ARR || !cn->child || cn->child->next) { printf("SELFTEST FAIL: net_bt_aggregate connected 应 1 条\n"); jv_free(br); sbuf_free(&b); return 1; } + jv_t *a2p = jv_obj_get(br, "a2dp"); + if (!a2p || a2p->type != J_ARR || !a2p->child) { printf("SELFTEST FAIL: net_bt_aggregate 缺 a2dp 条目\n"); jv_free(br); sbuf_free(&b); return 1; } + jv_t *cd = jv_obj_get(a2p->child, "codec"); + if (!cd || cd->type != J_STR || !cd->str || strcmp(cd->str, "sbc") != 0) { printf("SELFTEST FAIL: net_bt_aggregate a2dp codec 非 sbc\n"); jv_free(br); sbuf_free(&b); return 1; } + jv_t *aq = jv_obj_get(a2p->child, "acquired"); + if (!aq || aq->type != J_BOOL || !aq->bval) { printf("SELFTEST FAIL: net_bt_aggregate a2dp acquired 非 true\n"); jv_free(br); sbuf_free(&b); return 1; } + jv_free(br); + sbuf_free(&b); + /* status NULL → available:false;子文本 NULL → 空数组 */ + sbuf_t b2 = {0}; + net_bt_aggregate(NULL, NULL, NULL, NULL, NULL, &b2); + if (!b2.b || strstr(b2.b, "\"available\":false") == NULL) { printf("SELFTEST FAIL: net_bt_aggregate status NULL 未降级 available:false\n"); sbuf_free(&b2); return 1; } + sbuf_free(&b2); + sbuf_t b3 = {0}; + net_bt_aggregate(st, NULL, NULL, NULL, NULL, &b3); + jv_t *b3r = b3.b ? jv_parse(b3.b, (int)b3.n) : NULL; + jv_t *b3p = b3r ? jv_obj_get(b3r, "paired") : NULL; + if (!b3p || b3p->type != J_ARR || b3p->child) { printf("SELFTEST FAIL: net_bt_aggregate 子文本 NULL 应为空数组\n"); if (b3r) jv_free(b3r); sbuf_free(&b3); return 1; } + jv_free(b3r); + sbuf_free(&b3); + } + + /* 11) files_search_json(F1-④):沙盒卡根递归搜索(限深/限量/类型过滤/rel 回显)→ 合法 JSON + 计数断言; + * 白名单外起点 → -1;name 空 → -3;非法 type → -4。 */ + { + char tmpl[] = "/tmp/mp_search_XXXXXX"; + char *root = mkdtemp(tmpl); + if (!root) { printf("SELFTEST FAIL: mkdtemp 失败\n"); return 1; } + char saved_root[1024]; + snprintf(saved_root, sizeof saved_root, "%s", g_tf_root_buf); + snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", root); + char d1[1200], d2[1200], f1[1200], f2[1200], f3[1200]; + snprintf(d1, sizeof d1, "%s/MeowOS", root); + snprintf(d2, sizeof d2, "%s/MeowOS/sub", root); + snprintf(f1, sizeof f1, "%s/MeowOS/Test Song.mp3", root); + snprintf(f2, sizeof f2, "%s/MeowOS/another.txt", root); + snprintf(f3, sizeof f3, "%s/MeowOS/sub/x Song2.log", root); + int setup_ok = (mkdir(d1, 0755) == 0 && mkdir(d2, 0755) == 0); + const char *mk[3] = { f1, f2, f3 }; + for (int i = 0; i < 3 && setup_ok; i++) { + FILE *f = fopen(mk[i], "w"); + if (!f) { setup_ok = 0; break; } + fputs("x", f); fclose(f); + } + if (!setup_ok) { printf("SELFTEST FAIL: 搜索沙盒构建失败\n"); snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", saved_root); rm_recursive(root); rmdir(root); return 1; } + int fails = 0; + sbuf_t s = {0}; + if (files_search_json(d1, "song", NULL, 0, &s) != 0) { printf("SELFTEST FAIL: files_search_json 正常搜索返回非 0\n"); fails = 1; } + jv_t *sr = (!fails && s.b) ? jv_parse(s.b, (int)s.n) : NULL; + if (!fails && (!sr || !utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n))) { printf("SELFTEST FAIL: files_search_json 产物非法\n"); fails = 1; } + jv_t *cnt = (!fails && sr) ? jv_obj_get(sr, "count") : NULL; + if (!fails && (!cnt || cnt->type != J_NUM || cnt->inum != 2)) { printf("SELFTEST FAIL: files_search_json song 应中 2 条(递归命中子目录)\n"); fails = 1; } + jv_t *its = (!fails && sr) ? jv_obj_get(sr, "items") : NULL; + jv_t *rel0 = (its && its->child) ? jv_obj_get(its->child, "rel") : NULL; + if (!fails && (!rel0 || rel0->type != J_STR || !rel0->str || strncmp(rel0->str, "/MeowOS/", 8) != 0)) { printf("SELFTEST FAIL: files_search_json rel 未按卡根相对回显\n"); fails = 1; } + if (sr) jv_free(sr); + sbuf_free(&s); + /* type=dir 过滤:建 Songs 目录应仅中目录 */ + char d3[1200]; + snprintf(d3, sizeof d3, "%s/MeowOS/Songs", root); + if (!fails && mkdir(d3, 0755) != 0) { printf("SELFTEST FAIL: 沙盒 Songs 目录创建失败\n"); fails = 1; } + sbuf_t s2 = {0}; + if (!fails && files_search_json(d1, "song", "dir", 0, &s2) == 0) { + jv_t *sr2 = jv_parse(s2.b, (int)s2.n); + jv_t *c2 = sr2 ? jv_obj_get(sr2, "count") : NULL; + jv_t *i2 = sr2 ? jv_obj_get(sr2, "items") : NULL; + jv_t *t0 = (i2 && i2->child) ? jv_obj_get(i2->child, "type") : NULL; + if (!c2 || c2->type != J_NUM || c2->inum != 1 || !t0 || t0->type != J_STR || strcmp(t0->str, "dir") != 0) { + printf("SELFTEST FAIL: files_search_json type=dir 应仅中 1 个目录\n"); fails = 1; + } + if (sr2) jv_free(sr2); + } else if (!fails) { printf("SELFTEST FAIL: files_search_json type=dir 搜索失败\n"); fails = 1; } + sbuf_free(&s2); + /* 无匹配 → count 0;白名单外 → -1;空 name → -3;非法 type → -4 */ + sbuf_t s3 = {0}; + if (!fails && files_search_json(d1, "zzzz", NULL, 0, &s3) == 0) { + if (!strstr(s3.b, "\"count\":0")) { printf("SELFTEST FAIL: files_search_json 无匹配应 count 0\n"); fails = 1; } + } else if (!fails) { printf("SELFTEST FAIL: files_search_json 无匹配搜索失败\n"); fails = 1; } + sbuf_free(&s3); + sbuf_t s4 = {0}; + if (!fails && files_search_json("/etc", "x", NULL, 0, &s4) != -1) { printf("SELFTEST FAIL: files_search_json 白名单外未拒\n"); fails = 1; } + if (!fails && files_search_json(d1, "", NULL, 0, &s4) != -3) { printf("SELFTEST FAIL: files_search_json 空 name 未拒\n"); fails = 1; } + if (!fails && files_search_json(d1, "x", "bogus", 0, &s4) != -4) { printf("SELFTEST FAIL: files_search_json 非法 type 未拒\n"); fails = 1; } + sbuf_free(&s4); + snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", saved_root); + rm_recursive(root); + rmdir(root); + if (fails) return 1; + } + + printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 端点池 + 蓝牙 0xE6 聚合 + 文件搜索 → 合法 JSON + 无非法字节;防自锁实体 + policy 门禁 默认关/开启 校验通过\n"); return 0; } diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index 0d9cf0d..43e5bb2 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -537,7 +537,10 @@ int audio_out_pause(int paused); /* 1=暂停(停 PCM 时钟不丢流) 0=恢 void audio_out_close(void); int audio_out_sync_volume(void); /* 任务1:读 settings.conf 当前系统音量并下发到 audiod (MSG_AUDIO_VOLUME→写 DAC+持久化)。0=下发成功/-1=失败。 - audio_out_open SINK_AUDIOD 成功后已自动调;独立导出供宿主验证与复调。 */ + 每音频会话(新 audiod 流 sid)首同步一次 + 值变才重发——切歌/新建 + 会话时 DAC 音量不残留默认响度(修「切歌音量骤增」);同会话内不 + 重复建连(不做 fd 风暴)。audio_out_open SINK_AUDIOD 成功后已自动调; + 独立导出供宿主验证与复调。 */ void audio_out_set_target(int ms); /* 预留:缓冲目标 */ int audio_out_buffered_ms(void); diff --git a/patch_v2/ncm_app/app/play/audio_out.c b/patch_v2/ncm_app/app/play/audio_out.c index 6ec6184..ab2426a 100644 --- a/patch_v2/ncm_app/app/play/audio_out.c +++ b/patch_v2/ncm_app/app/play/audio_out.c @@ -233,20 +233,27 @@ static void audiod_sync_volume(int pct) close(fd); } -/* 每进程同步一次 + 值变才重发:杜绝每次 audio_out_open 新建短连接(fd=N accept 风暴根因)。 - * g_vol_synced=1 即已同步过;g_vol_last=上次下发的 pct,二者相同则零连接直接返回。 */ -static int g_vol_synced = 0; -static int g_vol_last = -1; +/* 会话身份 = audiod 流 sid(g_sid):切歌/新建音频会话 = 每次 audio_out_open 都向 audiod 开一条 + * 新流、audiod 分配新 sid(单调不重用)。此前用进程级 g_vol_synced「首次同步一次」——切歌(新会话) + * 时音量不再被重发到 audiod,DAC 落在会话建立/重配后的默认/上次残留值(衰减量 0=最响),造成 + * 「切歌音量骤增,按一下音量键才回落到设定值」(用户真机 2026-09-05)。audiod 只在 reconfigure + * 且参数变化时才 re-apply 音量(_audio_mark_reconfig_needed 同参数跳过),本会话重发是权威兜底。 + * 修法:**每会话首同步一次**——新 sid(新会话)必重发一次当前音量;同会话内仅值变才重发。 + * 每会话恰 1 条短连接,不回到「每次 open 都建连」的 fd 风暴(§2026-09-02 根因,vol_sync_sim 60 次 + * 同值→1 连接)。音量值取自 settings.conf(audiod 权威同源同键),反转/真静音(255) 由 audiod + * dac_vol_set 负责,本层只传 pct(0-100)。 */ +static int g_vol_synced_sid = -1; /* 上次音量同步针对的 audiod 流 sid;-1=从未(必发) */ +static int g_vol_last = -1; int audio_out_sync_volume(void) { int pct = settings_read_volume(); - if (g_vol_synced && pct == g_vol_last) return 0; /* 已同步且未变:零新建连接 */ - g_vol_synced = 1; - g_vol_last = pct; + if (g_vol_synced_sid == g_sid && pct == g_vol_last) return 0; /* 本会话已同步且值未变:零新建连接 */ + g_vol_synced_sid = g_sid; + g_vol_last = pct; audiod_sync_volume(pct); - meow_log(MEOW_LOG_INFO, "audio: sync volume -> audiod pct=%d (settings %s)", - pct, settings_conf_path()); + meow_log(MEOW_LOG_INFO, "audio: sync volume -> audiod pct=%d (settings %s, sid=%d)", + pct, settings_conf_path(), g_sid); return 0; } @@ -379,6 +386,7 @@ int audio_out_pause(int paused) } g_sid = sid; meow_log(MEOW_LOG_INFO, "audio: audiod reopen ok sid=%d", sid); + audio_out_sync_volume(); /* 新会话(新 sid):音量权威兜底重发一次(DAC 复位后不残留默认响度) */ return 0; } diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index 33c4d12..671928e 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -1032,10 +1032,18 @@ static int g_lyr_thr_on; static volatile int g_lyr_state; /* 0=空闲/未拉 1=加载中 2=已拉完 */ static char g_lyr_song_id[24]; /* 歌词归属歌曲 id */ +/* 歌词拉取失败后的重试退避(ms,指数翻倍封顶 OS):掉线瞬间失败的请求**不**写死「无歌词」, + * 交回退避重试;网络恢复后下一轮到点即重拉成功(真机症状:掉线一会儿再重连后恒「暂无歌词」)。 */ +#define LYRIC_RETRY_BASE_MS 1000 +#define LYRIC_RETRY_MAX_MS (30 * 1000) + static void *lyric_worker(void *arg) { (void)arg; char last_id[24] = ""; + int last_ok = 1; /* last_id 对应结果是否成功(成功不重拉;失败记退避待重试) */ + int retry_delay = 0; /* 失败后逐次翻倍的退避基值(ms) */ + int64_t retry_at = 0; /* 单调时刻:下次重试;0=无待重试 */ while (!g_app_quit) { usleep(300 * 1000); char id[24]; @@ -1051,14 +1059,28 @@ static void *lyric_worker(void *arg) g_lyr_state = 0; pthread_mutex_unlock(&g_lyrmu); last_id[0] = 0; + last_ok = 1; + retry_delay = 0; + retry_at = 0; continue; } if (!id[0]) continue; - if (strcmp(id, last_id) == 0) continue; /* 同一曲已拉/在拉 */ - if (meow_cachegov_prefetch_paused()) continue; /* page-cache 治理(§2.3/§3):暂停歌词预取 - * (A 回退硬暂停 OR svc_perf C Cached 降载), - * 不置 last_id → 降载恢复后下轮再拉。 */ - ncm_strlcpy(last_id, id, sizeof last_id); + + int is_new = (strcmp(id, last_id) != 0); + if (is_new) { + if (meow_cachegov_prefetch_paused()) continue; /* page-cache 治理(§2.3/§3):暂停歌词预取 + * (A 回退硬暂停 OR svc_perf C Cached 降载), + * 不置 last_id → 降载恢复后下轮再拉。 */ + ncm_strlcpy(last_id, id, sizeof last_id); + last_ok = 0; + retry_delay = 0; + retry_at = 0; + } else { + if (last_ok) continue; /* 同一曲已成功拉完(含 NCM_OK 空词=真无词),不重拉 */ + if (meow_cachegov_prefetch_paused()) continue; + if (retry_at > ncm_monotonic_ms()) continue; /* 失败退避未到 */ + /* 到点重试:保留 retry_delay 以便失败时继续翻倍 */ + } lyric_clear(); pthread_mutex_lock(&g_lyrmu); g_lyr_state = 1; /* 加载中 */ @@ -1068,22 +1090,32 @@ static void *lyric_worker(void *arg) char *tl = (char *)malloc(32 * 1024); if (!lrc || !tl) { free(lrc); free(tl); + retry_delay = retry_delay ? (retry_delay * 2) : LYRIC_RETRY_BASE_MS; + if (retry_delay > LYRIC_RETRY_MAX_MS) retry_delay = LYRIC_RETRY_MAX_MS; + retry_at = ncm_monotonic_ms() + retry_delay; pthread_mutex_lock(&g_lyrmu); g_lyr_state = 0; pthread_mutex_unlock(&g_lyrmu); continue; } lrc[0] = tl[0] = 0; /* 默认空串(fetch 失败/空词时上报清词,防会话留旧曲歌词) */ int rc = ncm_api_lyric(g_st.ctx.device_id, g_st.ctx.cookie, id, lrc, 64 * 1024, tl, 32 * 1024); if (rc == NCM_OK) { + last_ok = 1; /* 真正结果:空词(NCM_OK 无词也算「已拉完」) */ + retry_delay = 0; + retry_at = 0; lyric_parse(lrc, tl); ipc_media_lyric_report(lrc, tl); /* 批3:上报 live 歌词到媒体会话 */ } else { + /* 拉取失败(网络/HTTP/会话):不写死「无词」,交回退避重试;UI 呈「加载中」而非「暂无歌词」 */ + retry_delay = retry_delay ? (retry_delay * 2) : LYRIC_RETRY_BASE_MS; + if (retry_delay > LYRIC_RETRY_MAX_MS) retry_delay = LYRIC_RETRY_MAX_MS; + retry_at = ncm_monotonic_ms() + retry_delay; ipc_media_lyric_report("", ""); /* 无词/拉取失败:清 media session 该曲歌词 */ } free(lrc); free(tl); pthread_mutex_lock(&g_lyrmu); ncm_strlcpy(g_lyr_song_id, id, sizeof g_lyr_song_id); - g_lyr_state = 2; /* 拉完(可能 0 行=无词) */ + g_lyr_state = last_ok ? 2 : 0; /* 2=已拉完(可 0 行=无词);0=失败待重试(UI 呈加载中) */ pthread_mutex_unlock(&g_lyrmu); } return NULL; diff --git a/patch_v2/ncm_app/app/ui/playlist.c b/patch_v2/ncm_app/app/ui/playlist.c index f786194..ce521e8 100644 --- a/patch_v2/ncm_app/app/ui/playlist.c +++ b/patch_v2/ncm_app/app/ui/playlist.c @@ -26,8 +26,12 @@ static volatile int g_load_err; static int g_press_row, g_press_delta, g_dragging, g_drag_last_y; /* 歌单详情加载后台 worker(大歌单可达数十秒,不能占 UI 线程)。 - * §17 滑动窗口:worker 两种任务——①首屏(g_ens_first=1):重建 trackIds 索引 + 物化首段; - * ②滑窗补载:物化视口区间 [g_ens_lo,g_ens_hi)(ensure_range,盘缓存优先)。 */ + * §17 滑动窗口:worker 三种任务——①首屏(g_ens_first=1):重建 trackIds 索引 + 物化第 0 页 + * (≤NCM_SONG_BATCH 首,一次一页——不再首屏连拉多批补满窗口,大歌单阻塞久且触发风控); + * ②顺序下一页(g_ens_page=1):仅当用户滑到已分页底部(余量 ≤NCM_PL_PAGE_EDGE 首)时拉一页, + * 成功推进 g_paged_hi,拉完该歌单全部曲目即止;③回滚补物化:已分页区间内被窗口挤出的 + * 失效槽按视口 ±24 补(盘缓存命中为主,不占网络分页额度)。 */ +#define NCM_PL_PAGE_EDGE 20 /* 懒加载触发余量(首):视口底距已分页底部 ≤20 首才拉下一页 */ static pthread_mutex_t g_plmu = PTHREAD_MUTEX_INITIALIZER; static pthread_t g_plthr; static int g_plthr_on; @@ -37,9 +41,11 @@ static volatile int g_plloading; static volatile int g_plloaded_idx; static volatile int g_plloaded_gen; /* worker 启动时代次(§26:过期结果丢弃判据) */ static volatile int g_pl_stale; /* 1=本次结果被判过期(未写全局,需重拉当前目标) */ -static volatile int g_ens_lo, g_ens_hi; /* 滑窗补载目标区间(全局下标) */ +static volatile int g_ens_lo, g_ens_hi; /* 补载/下一页目标区间(全局下标) */ static volatile int g_ens_first; /* 1=首屏重头(offset=0) */ +static volatile int g_ens_page; /* 1=顺序下一页任务(成功推进 g_paged_hi) */ static volatile int g_ens_err; /* 最近一次滑窗补载错误(0=ok;首屏错误走 g_load_err) */ +static volatile int g_paged_hi; /* 已分页高水位:从顶部连续物化到的全局下标(下一页起点) */ /* 打开快照(§26 开A→未载完→开B 防串):refresh 在启动 worker 前把「目标歌单」拷进 g_tgt, * worker 启动时再拷到本地——detail 加载不再依赖 g_st.playlists[](会被回家时 home_worker 的 @@ -239,12 +245,15 @@ static void pl_draw_footer(void *ctx) ncm_draw_footer(r_cont, foot); } -/* apply:reset_scroll=1 时列表回顶并清选中(首屏/刷新);0=保持当前滚动(滑窗补载,防跳顶)。 */ +/* apply:reset_scroll=1 时列表回顶并清选中(首屏/刷新);0=保持当前滚动(滑窗补载,防跳顶)。 + * 拉到数据后必须让列表项重新绑定/重绘:src2 每帧 live 取行(占位行 ↔ 已物化歌名/歌手), + * 失效列表带 + 整帧重画把刚物化的歌名/歌手填进各项(修「拉完曲目行仍 ... 加载中」)。 */ static void pl_apply(int reset_scroll) { if (g_list) { meow_listview_set_count(g_list, g_st.track_ids_count); /* 全量行数(占位行兜底未物化区) */ if (reset_scroll) { meow_listview_set_scroll(g_list, 0); meow_listview_set_sel(g_list, -1); } + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); } const ncm_playlist_t *pcur = pl_cur(); if (g_tb && pcur) { @@ -261,30 +270,24 @@ static void *playlist_worker(void *arg) (void)arg; /* 拷贝打开快照 + 任务参数:单飞(g_plthr_on)保证 worker 运行期间 g_tgt 不被改写。 */ ncm_playlist_t tpl; - int tsearch, tidx, tgen, first, lo, hi; + int tsearch, tidx, tgen, first, lo, hi, page; pthread_mutex_lock(&g_plmu); tpl = g_tgt.pl; tsearch = g_tgt.is_search; tidx = g_tgt.idx; tgen = g_tgt.gen; - first = g_ens_first; lo = g_ens_lo; hi = g_ens_hi; + first = g_ens_first; lo = g_ens_lo; hi = g_ens_hi; page = g_ens_page; pthread_mutex_unlock(&g_plmu); int cur_active = g_st.active_playlist; /* ensure 路径结束校验用(当前目标) */ int rc; if (g_host_mock) rc = 0; else if (first) { - /* 首屏:快照式加载(detail 全量 trackIds + 物化首段)。ctx 校验代次/目标,过期自动丢弃。 */ + /* 首屏:快照式加载(detail 全量 trackIds + 物化第 0 页)。ctx 校验代次/目标,过期自动丢弃。 + * §懒加载:首屏只拉一页(≤NCM_SONG_BATCH 首,一次仅一批)——不再 ensure_range 补满整个 + * 滑动窗口(旧版首屏连拉 ≤3 批:5k+ 大歌单「我喜欢的音乐」长时间卡住不出列表,连续 + * 请求还触发风控);后续页由 pl_maybe_ensure 在用户滑到已分页底部时逐页懒加载。 */ if (tidx < 0) rc = NCM_ERR_ARG; else rc = appstate_load_playlist_songs_ctx(&tpl, tsearch, tgen, tidx, 0, NCM_SONG_BATCH); - /* §22 整张歌单逐渐物化:首屏成功后把「能装进滑动窗口」的歌单一次性补填入窗 - * (≤NCM_SONG_WINDOW 的歌单整张物化,末叶行不再恒「.../加载中」——真机反馈 - * 「曲目稍多时行一直加载中,点开播放这首才回填」);超窗口的歌单仍按视口滑动补载 - * (内存纪律:窗口 ≤256 首)。窗口基址=0 覆盖 [0,min(track,WINDOW)),不滑窗不挤占视口。 */ - if (rc == NCM_OK) { - int full = (g_st.track_ids_count < NCM_SONG_WINDOW) ? g_st.track_ids_count : NCM_SONG_WINDOW; - if (full > NCM_SONG_BATCH) { - int r2 = appstate_song_ensure_range(0, full); - if (r2 != NCM_OK && rc == NCM_OK) rc = r2; /* 补填失败非致命:已物化槽保留 */ - } - } + if (rc == NCM_OK) + g_paged_hi = (g_st.track_ids_count < NCM_SONG_BATCH) ? g_st.track_ids_count : NCM_SONG_BATCH; /* REPORT §30(真机四连症状①):首屏结果打日志——tidx/track_ids_count/物化数/rc, * 供「162 首歌单所有条目占位 加载中」定位(首屏 song_detail 失败/部分失败=非致命 * g_ens_err=rc,窗口空 → 行占位;g_st.song_count 为诊断竞态读,仅日志用途)。 */ @@ -292,8 +295,10 @@ static void *playlist_worker(void *arg) "playlist: first-screen idx=%d plid=%s trackIds=%d materialized=%d rc=%d", tidx, tpl.id, g_st.track_ids_count, g_st.song_count, rc); } else { - /* 滑窗补载:物化视口区间(盘缓存优先,批网格补载)。 */ + /* 补载:物化目标区间(顺序下一页 / 回滚补物化;盘缓存优先,批网格补载)。 + * 顺序下一页成功 → 已分页高水位推进到 hi;部分失败不推进(占位行保留,滑到底部重试本页)。 */ rc = (lo < hi) ? appstate_song_ensure_range(lo, hi) : NCM_OK; + if (rc == NCM_OK && page && hi > g_paged_hi) g_paged_hi = hi; } pthread_mutex_lock(&g_plmu); if (first) { @@ -324,7 +329,25 @@ static void *playlist_worker(void *arg) return NULL; } -/* 视口驱动滑窗补载:视口 ±24 行有未物化槽位 → 发 ensure worker(已在载/失败态/宿主 mock 不发)。 */ +/* 发补载 worker(单飞;调用方先判 g_plthr_on)。page=1 为顺序下一页任务。 */ +static void pl_ensure_post(int lo, int hi, int page) +{ + g_ens_lo = lo; g_ens_hi = hi; + g_ens_first = 0; + g_ens_page = page; + g_plloading = 1; + g_plthr_on = 1; + if (ncm_thread_create(&g_plthr, playlist_worker, NULL) != 0) { + g_plthr_on = 0; g_plloading = 0; ncm_snack("加载失败"); + } +} + +/* 懒加载分页触发(Bug 修①:旧版视口 ±24 触发 + 首屏连拉多批 → 大歌单连续拉多页、阻塞 + 风控): + * ① 顺序下一页:仅在视口底滑到「已分页底部」(余量 ≤NCM_PL_PAGE_EDGE 首)时拉下一页, + * 一次只拉一页(≤NCM_SONG_BATCH);g_paged_hi 达 track_ids_count = 全部拉完,停止。 + * 远离已分页底部(快速猛滑到深处占位区)不拉——防连续拉多页触发风控。 + * ② 回滚补物化:视口 ±24 落在「已分页区间内」的失效槽(窗口挤出)按旧逻辑补—— + * 盘缓存(songmeta)命中为主,不消耗网络分页额度;区间外(未分页)一律不预拉。 */ static void pl_maybe_ensure(void) { if (g_plthr_on || g_host_mock) return; @@ -334,23 +357,32 @@ static void pl_maybe_ensure(void) int scroll = meow_listview_scroll(g_list); int first = scroll / APP_ROW_H; int last = (scroll + r_list.h) / APP_ROW_H + 1; + if (last > total) last = total; + int paged = g_paged_hi; + if (paged > total) paged = total; /* 防御:索引重建后高水位越界 */ + + /* ① 顺序下一页(懒加载) */ + if (paged < total) { + int margin = paged - last; /* 视口底距已分页底部余量(首;负=已越过) */ + if (margin <= NCM_PL_PAGE_EDGE && margin >= -NCM_PL_PAGE_EDGE) { + int hi = paged + NCM_SONG_BATCH; + if (hi > total) hi = total; + pl_ensure_post(paged, hi, 1); + return; + } + } + /* ② 回滚补物化(仅已分页区间内) */ int lo = first - 24, hi = last + 24; if (lo < 0) lo = 0; - if (hi > total) hi = total; + if (hi > paged) hi = paged; if (lo >= hi) return; if (appstate_range_loaded(lo, hi)) return; /* 已物化:无操作(窗口原地不动) */ - g_ens_lo = lo; g_ens_hi = hi; - g_ens_first = 0; - g_plloading = 1; - g_plthr_on = 1; - if (ncm_thread_create(&g_plthr, playlist_worker, NULL) != 0) { - g_plthr_on = 0; g_plloading = 0; ncm_snack("加载失败"); - } + pl_ensure_post(lo, hi, 0); } void playlist_refresh(void) { - if (g_host_mock) { g_load_err = 0; g_ens_err = 0; pl_apply(1); return; } + if (g_host_mock) { g_load_err = 0; g_ens_err = 0; g_paged_hi = g_st.track_ids_count; pl_apply(1); return; } if (g_plthr_on) return; g_load_err = 0; g_ens_err = 0; @@ -373,7 +405,9 @@ void playlist_refresh(void) if (!appstate_playlist_window_is(g_tgt.pl.id)) appstate_playlist_reset(); - g_ens_first = 1; /* 首屏:重建索引 + 物化首段 */ + g_ens_first = 1; /* 首屏:重建索引 + 物化第 0 页 */ + g_ens_page = 0; + g_paged_hi = 0; /* 分页高水位归零:首屏成功后由 worker 重标 */ g_pl_stale = 0; g_plloading = 1; g_plthr_on = 1; diff --git a/patch_v2/ncm_app/bench/lyric_sim.c b/patch_v2/ncm_app/bench/lyric_sim.c new file mode 100644 index 0000000..726292c --- /dev/null +++ b/patch_v2/ncm_app/bench/lyric_sim.c @@ -0,0 +1,145 @@ +/* bench/lyric_sim.c — 歌词「掉线失败 → 重连 → 重试成功」宿主离线回归(0 真实请求)。 + * + * 驱动真实 app/play/player_manager.c 的 lyric_worker(后台歌词线程),网络换成可注入的 + * ncm_api_lyric 桩:第 1 次调用返回 NCM_ERR_NET(模拟掉线瞬间歌词请求失败),第 2 次返回 + * NCM_OK + 一段 LRC(模拟网络恢复后重试成功)。 + * + * 复现的真机症状(REPORT/任务):播放中歌词正常;网络掉线一会儿再重连后某曲恒「暂无歌词」。 + * 根因:旧 lyric_worker 在**发起网络请求前**就把 last_id 记为已拉,且失败也置 lyric_state=2 + * (=已拉完),故网络失败被写死成「无歌词」,重连后永不重试。本机回归: + * ① 第 1 次失败后 lyric_state() 必须**不是** 2(可重试),且无词(lyric_count()==0); + * ② 推进单调时钟越过退避点(模拟重连)后必须**自动重试**并成功(第 2 次调用)+ 词展示 + * (lyric_count()>0、lyric_state()==2)。 + * 旧代码在 ② 上 FAIL(g_lyr_state 恒 2、第 2 次调用永不发生)。 + * + * 编译(宿主,ncm_app 目录下;gc-sections 只保留歌词路径,避免链整个播放链的符号): + * CC=gcc F="-ffunction-sections -Wall -Wextra -DJSMN_HEADER -I. -Icore -I../sdk -I../meow_launcherd -Ivendor/jsmn" + * $CC $F -c bench/lyric_sim.c -o /tmp/ly.o + * $CC $F -c app/play/player_manager.c -o /tmp/ly_pm.o + * $CC $F -c app/logic/lyric.c -o /tmp/ly_lrc.o + * $CC -Wl,--gc-sections -o lyric_sim /tmp/ly.o /tmp/ly_pm.o /tmp/ly_lrc.o -lpthread -lm + * 运行:./lyric_sim (PASS/FAIL 退出码) + */ +#include "../app/ncm_app_internal.h" +#include +#include +#include +#include /* usleep */ + +/* ---- 全局状态(app.c 定义;本 sim 提供桩) ---- */ +ncm_app_state_t g_st; +ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 }; +volatile int g_app_quit = 0; +volatile int g_net_ready = 0; +int g_host_mock = 1; +int g_dark = 0; + +/* ---- 可控单调时钟(lyric_worker 用 ncm_monotonic_ms 做失败退避) ---- */ +static int64_t g_now_ms = 1000; +int64_t ncm_monotonic_ms(void) { return g_now_ms; } + +size_t ncm_strlcpy(char *dst, const char *src, size_t sz) +{ + size_t n = 0; + if (sz) { while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; } dst[n] = 0; } + while (src[n]) n++; + return n; +} +void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; } + +/* appstate 桩:单曲队列(idx 0 恒有效,song id=1000) */ +int appstate_queue_count(void) { return 1; } + +/* page-cache 治理桩:恒非暂停(不挡歌词预取) */ +int meow_cachegov_prefetch_paused(void) { return 0; } + +/* 媒体会话歌词上报桩:失败上报空、成功上报 LRC;计数供断言 */ +static int g_report_empty; +static int g_report_lyric; +static char g_report_last_lrc[256]; +void ipc_media_lyric_report(const char *lrc, const char *tl) +{ + (void)tl; + if (lrc && lrc[0]) { g_report_lyric++; ncm_strlcpy(g_report_last_lrc, lrc, sizeof g_report_last_lrc); } + else { g_report_empty++; } +} + +/* 歌词 API 桩:第 1 次=网络失败,其后=成功返回 LRC。 */ +static int g_fetch_calls; +int ncm_api_lyric(const char *device_id, const char *cookie, const char *id, + char *lrc, size_t lrc_sz, char *tlrc, size_t tlrc_sz) +{ + (void)device_id; (void)cookie; (void)id; (void)tlrc; (void)tlrc_sz; + g_fetch_calls++; + if (g_fetch_calls == 1) { + if (lrc && lrc_sz) lrc[0] = 0; /* 掉线失败:不写词 */ + return NCM_ERR_NET; /* 网络错误(掉线) */ + } + static const char body[] = + "[00:00.00]Line one\n[00:05.00]Line two\n[00:10.00]Line three\n"; + if (lrc && lrc_sz) snprintf(lrc, lrc_sz, "%s", body); + return NCM_OK; +} + +static void wait_ms(int ms) +{ + for (int i = 0; i < ms; i += 20) { + usleep(20000); + if (g_app_quit) break; + } +} + +static int wait_until(const volatile int *val, int target, int timeout_ms) +{ + for (int i = 0; i < timeout_ms; i += 10) { + if (*val >= target) return 1; + usleep(10000); + } + return (*val >= target); +} + +int main(void) +{ + memset(&g_st, 0, sizeof g_st); + pthread_mutex_init(&g_play.mu, NULL); + g_play.state = NCM_PC_PLAYING; + g_play.cur_song = 0; + g_play.play_song_valid = 1; + ncm_strlcpy(g_play.play_song.id, "1000", sizeof g_play.play_song.id); + + if (player_manager_load_lyric() != NCM_OK) { printf("FAIL start worker\n"); return 1; } + + int ok = 1; + + /* ① 第 1 次拉取失败(网络错误):须可重试(state!=2)、无词 */ + if (!wait_until(&g_fetch_calls, 1, 3000)) { printf("FAIL first fetch never happened\n"); return 1; } + wait_ms(400); /* 让 worker 完成失败收尾(state=0, retry_at 已算) */ + int st1 = lyric_state(); + int n1 = lyric_count(); + int ok1 = (g_fetch_calls == 1 && st1 != 2 && n1 == 0); + printf("step1 failure: calls=%d state=%d (expect !=2) count=%d rep_empty=%d\n", + g_fetch_calls, st1, n1, g_report_empty); + printf(" -> %s\n", ok1 ? "PASS" : "FAIL"); + ok &= ok1; + + /* 模拟网络重连:推进单调时钟越过失败退避点 */ + g_now_ms = 10000; + + /* ② 重试自动发生并成功:第 2 次调用、state=2、词已展示 */ + if (!wait_until(&g_fetch_calls, 2, 4000)) { + printf("FAIL retry never fired (calls=%d) — old code keeps state=2, no retry\n", g_fetch_calls); + return 1; + } + int st2 = lyric_state(); + int n2 = lyric_count(); + int ok2 = (g_fetch_calls == 2 && st2 == 2 && n2 > 0 && g_report_lyric >= 1); + printf("step2 retry: calls=%d state=%d count=%d rep_lyric=%d lrc=%s\n", + g_fetch_calls, st2, n2, g_report_lyric, g_report_last_lrc); + printf(" -> %s\n", ok2 ? "PASS" : "FAIL"); + ok &= ok2; + + g_app_quit = 1; + printf("lyric_sim overall -> %s\n", ok ? "PASS" : "FAIL"); + fflush(stdout); + return ok ? 0 : 1; +} diff --git a/patch_v2/ncm_app/bench/plpage_sim.c b/patch_v2/ncm_app/bench/plpage_sim.c new file mode 100644 index 0000000..5c4788e --- /dev/null +++ b/patch_v2/ncm_app/bench/plpage_sim.c @@ -0,0 +1,277 @@ +/* bench/plpage_sim.c — 歌单页「懒加载分页 + 加载后列表刷新」离线回归(0 真实 NCM 请求)。 + * + * 驱动真实 app/ui/playlist.c(页面打开 / 滚动触发 / worker / tick 消费全链路)+ 真实 + * appstate.c(滑动窗口)+ 真实 songmeta.c(/tmp 盘缓存)+ 宿主 SDK(meow_listview/draw 全真, + * 与 ncm_host 同一批 out/host/sdk_*.o)。网络端点(ncm_api_playlist_detail / + * ncm_api_song_detail)为本地合成 stub(计调用次数、可注入单批失败)。 + * + * Bug1(大歌单懒加载分页)验证点: + * ① 打开 400 首歌单:首屏 = 1 detail + 恰 1 批 song_detail(物化 [0,100),第 100 首未物化) + * ——不再首屏连拉多批补满窗口(旧行为 ≤3 批连发,5k+ 歌单阻塞 + 风控); + * ② 顶部小幅滚动(视口底距已分页底部 >20 首):不发任何新请求; + * ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败时 g_paged_hi 不推进(占位保留), + * 再滑到底部重试同一页成功(一次一页,不连拉); + * ④ 逐页到底(400 = 4 页);全部拉完后滑到尾部:不再发请求(停止); + * ⑤ 回滚到顶部:失效槽(窗口挤出)由盘缓存补物化,song_detail 调用数 0 增长(不占网络额度); + * Bug2(加载完列表刷新)验证点: + * ⑥ 首屏/每页 worker 完成经 playlist_tick 消费后,整帧重画计数(--wrap=meow_ui_paint_full) + * 递增,且新物化行经 appstate_song_copy 取到真实歌名/歌手(= pl_src2 将绑定真实数据, + * 不再是「... 加载中」占位)。 + * 风控纪律:0 次真实 NCM 请求(离线 stub + /tmp 盘缓存)。 + * + * 编译/运行见 build_ncm_sims.sh「plpage_sim」段(宿主,ncm_app 目录下)。 + */ +#include "../app/ncm_app_internal.h" +#include +#include +#include +#include +#include + +ncm_app_state_t g_st; +ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 }; +volatile int g_app_quit = 0; +volatile int g_net_ready = 0; +int g_host_mock = 0; /* 真实分页链路(不走 mock 跳网分支) */ + +/* ---- core 符号最小实现(同 playlist_race_sim 口径) ---- */ +size_t ncm_strlcpy(char *dst, const char *src, size_t sz) +{ + size_t n = 0; + if (sz) { + while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; } + dst[n] = 0; + } + while (src[n]) n++; + return n; +} +int64_t ncm_now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +/* ---- cover / 播放 stub(本 sim 不测封面与播放;playlist.c 引用,链接需要) ---- */ +void cover_request(const char *url, const char *song_id) { (void)url; (void)song_id; } +meow_img *cover_get(void) { return NULL; } +void cover_put(void) {} +int cover_changed(void) { return 0; } +void cover_ack(void) {} +void cover_cache_reset(void) {} +void player_play_song(int idx) { (void)idx; } + +/* ---- core 符号(appstate.c 引用;运行时不到达,仅链接需要) ---- */ +int ncm_login_status_check(const char *device_id, const char *cookie, char *uid, size_t uid_sz, + char *nick, size_t nick_sz) +{ (void)device_id;(void)cookie;(void)uid;(void)uid_sz;(void)nick;(void)nick_sz; return NCM_ERR_AUTH; } +int ncm_ctx_clear_cookie(ncm_ctx_t *ctx) { (void)ctx; return NCM_OK; } +int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid, + int limit, int offset, ncm_playlist_t *out, int max, int *out_n) +{ (void)device_id;(void)cookie;(void)uid;(void)limit;(void)offset;(void)out;(void)max;(void)out_n; return NCM_ERR_NET; } + +/* ---- 网络端点 stub(离线,合成数据;计调用次数 + 可注入单批失败) ---- */ +#define SIM_TOTAL 400 /* 4 页:100 × 4(> NCM_SONG_WINDOW 256,回滚必滑窗) */ +static int g_detail_calls; +static int g_song_calls; /* song_detail 成功批次数 */ +static int g_song_attempts; /* song_detail 发起次数(含失败批) */ +static int g_song_fail_n; /* 接下来 N 批 song_detail 网络失败模拟 */ + +int ncm_api_playlist_detail(const char *device_id, const char *cookie, const char *playlist_id, + char *name, size_t name_sz, char *cover, size_t cover_sz, + int64_t *ids, int id_cap, int *out_id_n) +{ + (void)device_id; (void)cookie; (void)playlist_id; (void)cover_sz; + g_detail_calls++; + if (name) snprintf(name, name_sz, "Sim Playlist"); + if (cover) cover[0] = 0; + int n = (SIM_TOTAL < id_cap) ? SIM_TOTAL : id_cap; + for (int i = 0; i < n; i++) ids[i] = 1000000LL + i; + *out_id_n = n; + return NCM_OK; +} + +int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t *ids, int n, + ncm_song_t *out, int max, int *out_n) +{ + (void)device_id; (void)cookie; + g_song_attempts++; + if (g_song_fail_n > 0) { /* 单批网络失败模拟(失败不推进分页高水位 → 同页重试) */ + g_song_fail_n--; + if (out_n) *out_n = 0; + return NCM_ERR_NET; + } + g_song_calls++; + int m = (n < max) ? n : max; + for (int i = 0; i < m; i++) { + snprintf(out[i].id, sizeof out[i].id, "%lld", (long long)ids[i]); + snprintf(out[i].name, sizeof out[i].name, "Song%lld", (long long)ids[i]); + snprintf(out[i].art_name, sizeof out[i].art_name, "Artist%lld", (long long)ids[i]); + snprintf(out[i].album_name, sizeof out[i].album_name, "Album%lld", (long long)ids[i]); + out[i].duration_ms = 200000; + out[i].fee = 0; + } + if (out_n) *out_n = m; + return NCM_OK; +} + +/* ---- Bug2 证据:整帧重画计数(链接时 --wrap=meow_ui_paint_full 重定向到此) ---- */ +extern void __real_meow_ui_paint_full(void); +static int g_paint_full_calls; +void __wrap_meow_ui_paint_full(void) +{ + g_paint_full_calls++; + __real_meow_ui_paint_full(); +} + +/* ---- 驱动辅助:模拟 app_on_tick 周期性消费 worker 完成 ---- */ +static int pump_until(int (*cond)(void), int timeout_ms) +{ + for (int t = 0; t < timeout_ms; t += 5) { + playlist_tick(0); + if (cond()) { + /* 再喂若干 tick:确保完成态被消费(g_plthr_on 清零),后续滚动可再触发 */ + for (int k = 0; k < 30; k++) { playlist_tick(0); usleep(2000); } + return 1; + } + usleep(5000); + } + return 0; +} + +static int g_fail = 0; +#define CHECK(cond, fmt, ...) do { \ + if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \ +} while (0) + +/* 条件(C 无闭包,用全局目标值) */ +static int g_want_calls; +static int cond_calls(void) { return g_song_calls >= g_want_calls; } +static int g_want_attempts; +static int cond_attempts(void) { return g_song_attempts >= g_want_attempts; } +static int cond_first_screen(void) +{ + return g_st.track_ids_count == SIM_TOTAL && g_song_calls >= 1; +} + +/* 滚动到指定首行(行高 APP_ROW_H px;swipe 距离按当前行差换算) */ +static void scroll_to_row(int row) +{ + static int cur; + int d = row - cur; + cur = row; + playlist_swipe(d >= 0 ? MEOW_SWIPE_UP : MEOW_SWIPE_DOWN, + (d >= 0 ? d : -d) * APP_ROW_H); +} + +int main(void) +{ + printf("== plpage_sim: 懒加载分页 + 加载后列表刷新(%d 首,窗口 %d,批 %d,触底余量 20)==\n", + SIM_TOTAL, NCM_SONG_WINDOW, NCM_SONG_BATCH); + + /* 宿主 SDK 离屏初始化(与 ncm_host 同口径) */ + setenv("MEOW_UI_DUMP", "/tmp/plpage_sim.ppm", 0); + meow_ui_opts o = { APP_W, APP_H }; + if (meow_ui_init(&o) != 0) { fprintf(stderr, "init failed\n"); return 1; } + meow_theme_set_mode(MEOW_THEME_DARK); + ncm_on_theme(MEOW_THEME_DARK); + + /* 歌单 mock:1 张 SIM_TOTAL 首歌单;songmeta 盘缓存用临时目录 */ + char cdt[] = "/tmp/plpage_cache_XXXXXX"; + if (!mkdtemp(cdt)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } + snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "%s", cdt); + g_st.playlist_count = 1; + snprintf(g_st.playlists[0].id, sizeof g_st.playlists[0].id, "pl0"); + snprintf(g_st.playlists[0].name, sizeof g_st.playlists[0].name, "SimPlaylist"); + g_st.playlists[0].track_count = SIM_TOTAL; + + ncm_song_t sq; + int paint0; + + /* ---- ① Bug1:打开歌单 → 首屏恰 1 detail + 1 批 song_detail(物化 [0,100)) ---- */ + printf("== case 1: 首屏只拉一页 ==\n"); + paint0 = g_paint_full_calls; + playlist_view_create(); + playlist_open(0); + CHECK(pump_until(cond_first_screen, 8000), "first-screen loaded (track_ids=%d calls=%d)", + g_st.track_ids_count, g_song_calls); + CHECK(g_detail_calls == 1, "detail calls=%d (expect 1)", g_detail_calls); + CHECK(g_song_calls == 1, "song_detail calls=%d (expect 1, 不再首屏连拉多批)", g_song_calls); + CHECK(g_st.track_ids_count == SIM_TOTAL, "track_ids_count=%d (expect %d)", + g_st.track_ids_count, SIM_TOTAL); + CHECK(appstate_song_copy(99, &sq) == 0 && strcmp(sq.name, "Song1000099") == 0, + "row 99 materialized name='%s'", sq.name); + CHECK(appstate_song_copy(100, &sq) != 0, "row 100 NOT materialized yet (placeholder)"); + /* Bug2:首屏完成 → 重画计数递增 + 行绑定真实歌名/歌手(非占位) */ + CHECK(g_paint_full_calls > paint0, "paint_full after first-screen %d -> %d (list repainted)", + paint0, g_paint_full_calls); + CHECK(appstate_song_copy(5, &sq) == 0 && strcmp(sq.art_name, "Artist1000005") == 0, + "row 5 binds real artist='%s' (src2 填真实数据,非「... 加载中」)", sq.art_name); + + /* ---- ② 顶部小幅滚动:不触发任何新请求(距已分页底部 >20 首) ---- */ + printf("== case 2: 顶部滚动不拉页 ==\n"); + scroll_to_row(10); + int a0 = g_song_attempts; + for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); } + CHECK(g_song_attempts == a0, "song_detail attempts=%d (unchanged, 顶部滚动不拉页)", g_song_attempts); + + /* ---- ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败不推进,再滑重试同页成功 ---- */ + printf("== case 3: 触底拉下一页(一次一页;失败重试同页) ==\n"); + g_song_fail_n = 1; /* 注入:下一次 song_detail 网络失败 */ + int att_before = g_song_attempts; + scroll_to_row(90); /* 视口底 ≈97 行,余量 ≤20 → 触发第 2 页(失败) */ + g_want_attempts = att_before + 1; + CHECK(pump_until(cond_attempts, 8000), "page-2 first attempt fired (attempts=%d)", g_song_attempts); + CHECK(g_song_calls == 1, "page-2 failed -> calls=%d (g_paged_hi 不推进,占位保留)", g_song_calls); + CHECK(appstate_song_copy(199, &sq) != 0, "row 199 still placeholder after failed page"); + scroll_to_row(91); /* 再滑(滚动事件)→ 重试同一页 */ + g_want_calls = 2; + CHECK(pump_until(cond_calls, 8000), "page 2 retried & loaded (calls=%d attempts=%d)", + g_song_calls, g_song_attempts); + CHECK(g_song_attempts == att_before + 2, + "attempts +%d (1 失败 + 1 重试同页 = 一次一页,未连拉)", g_song_attempts - att_before); + CHECK(appstate_song_copy(199, &sq) == 0 && strcmp(sq.name, "Song1000199") == 0, + "row 199 materialized name='%s'", sq.name); + CHECK(g_paint_full_calls > paint0, "paint_full after page-2 consumed (=%d)", g_paint_full_calls); + + /* ---- ④ 逐页到底;全部拉完后滑到尾部:不再发请求 ---- */ + printf("== case 4: 逐页到底 + 拉完即止 ==\n"); + att_before = g_song_attempts; + scroll_to_row(190); /* 余量 ≤20 → 触发第 3 页 */ + g_want_calls = 3; + CHECK(pump_until(cond_calls, 8000), "page 3 loaded (calls=%d)", g_song_calls); + scroll_to_row(290); /* 余量 ≤20 → 触发第 4 页(末页) */ + g_want_calls = 4; + CHECK(pump_until(cond_calls, 8000), "page 4 (last) loaded (calls=%d)", g_song_calls); + CHECK(g_song_attempts == att_before + 2, "attempts +%d (第 3、4 页恰各 1 批)", + g_song_attempts - att_before); + CHECK(appstate_song_copy(399, &sq) == 0 && strcmp(sq.name, "Song1000399") == 0, + "tail row 399 materialized name='%s'", sq.name); + att_before = g_song_attempts; + scroll_to_row(390); /* 已在尾部:全部拉完 → 不再发请求 */ + for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); } + CHECK(g_song_attempts == att_before, "all loaded -> scroll at tail: attempts=%d (unchanged)", + g_song_attempts); + + /* ---- ⑤ 回滚到顶部:失效槽(窗口挤出)盘缓存补物化,song_detail 0 增长 ---- */ + printf("== case 5: 回滚补物化走盘缓存(0 网络) ==\n"); + att_before = g_song_attempts; + scroll_to_row(0); /* 回顶部:窗口已滑走,行 0-35 失效 → 盘缓存补 */ + for (int k = 0; k < 400 && !appstate_range_loaded(0, 30); k++) { playlist_tick(0); usleep(5000); } + CHECK(appstate_range_loaded(0, 30), "rows 0-29 re-materialized after scroll-back"); + CHECK(g_song_attempts == att_before, "scroll-back refill: song_detail attempts=%d " + "(unchanged, 盘缓存命中不占网络)", g_song_attempts); + CHECK(appstate_song_copy(0, &sq) == 0 && strcmp(sq.name, "Song1000000") == 0, + "row 0 re-materialized name='%s'", sq.name); + + printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS"); + /* 清理临时盘缓存(≤400 个小文件) */ + { + char cmd[600]; + snprintf(cmd, sizeof cmd, "rm -rf %s", cdt); + if (system(cmd) != 0) fprintf(stderr, "cleanup failed: %s\n", cdt); + } + meow_ui_shutdown(); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/bench/vol_sync_sim.c b/patch_v2/ncm_app/bench/vol_sync_sim.c index b482e8a..0ab0ec2 100644 --- a/patch_v2/ncm_app/bench/vol_sync_sim.c +++ b/patch_v2/ncm_app/bench/vol_sync_sim.c @@ -40,8 +40,11 @@ size_t ncm_strlcpy(char *dst, const char *src, size_t sz) } void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; } -/* audio_out.c 引用但本 sim 不经 SDK 调用的符号:只链不跑就满足链接 */ -int meow_audio_open(int rate, int ch, int fmt) { (void)rate;(void)ch;(void)fmt; return -1; } +/* audio_out.c 引用但本 sim 不经 SDK 调用的符号:只链不跑就满足链接。 + * 会话建立→音量同步用例(§切歌音量骤增回归)需要 audio_out_open 真正「开新流/分配新 sid」, + * 故 meow_audio_open 桩改为返回递增 sid(模拟 audiod 单调分配),供 §切歌 会话切换复现。 */ +static int g_sim_sid = 0; +int meow_audio_open(int rate, int ch, int fmt) { (void)rate;(void)ch;(void)fmt; return ++g_sim_sid; } int meow_audio_write(const void *pcm, int frames) { (void)pcm; return frames; } int meow_audio_pause(int sid, int paused) { (void)sid;(void)paused; return 0; } int meow_audio_close(int sid) { (void)sid; return 0; } @@ -161,6 +164,46 @@ int main(void) printf("PASS changed-volume re-sync: settings=50 -> 2nd connection pct=50\n"); } + /* §切歌音量骤增回归:会话建立→音量同步。切歌 = 新 audio_out_open = 新 audiod 流(新 sid), + * 必须**每会话重发一次**当前音量——此前 g_vol_synced 进程级「首同步一次」,新会话被跳过, + * DAC 停在会话建立后的默认响度(切歌骤增,按音量键才回落)。用真实 audio_out_open/close + * (meow_audio_open 桩返回递增 sid)断言:新会话必 +1 连接(重发音量),同会话内重复 sync 不新增。 */ + { + int base = g_nconn; /* 上面值变重发测试后基线 = 2 条连接 */ + /* 会话 1(首开):应重发 1 条 VOLUME,pct==当前 settings(50) */ + if (audio_out_open(44100, 2, 16) != 0) { printf("FAIL session1 audio_out_open\n"); fail = 1; } + usleep(50000); + if (g_nconn != base + 1) { + printf("FAIL session1 establish: conns=%d (expect %d) — 新会话未重发音量\n", (int)g_nconn, base + 1); + fail = 1; + } else if (g_got_pct != 50) { + printf("FAIL session1 establish: pct=%d (expect 50)\n", (int)g_got_pct); fail = 1; + } else { + printf("PASS session1 establish -> 1 volume sync (pct=50)\n"); + } + /* 同会话内反复 sync(同值):不得新建连接(防 fd 风暴) */ + for (int i = 0; i < 20; i++) audio_out_sync_volume(); + usleep(50000); + if (g_nconn != base + 1) { + printf("FAIL same-session repeat sync: conns=%d (expect %d)\n", (int)g_nconn, base + 1); + fail = 1; + } else { + printf("PASS same-session repeat sync -> no new connection (storm-free)\n"); + } + audio_out_close(); + /* 会话 2(切歌):再开 → 必须再重发 1 条(新 sid),pct==50 */ + if (audio_out_open(44100, 2, 16) != 0) { printf("FAIL session2 audio_out_open\n"); fail = 1; } + usleep(50000); + if (g_nconn != base + 2) { + printf("FAIL session2 (song switch): conns=%d (expect %d) — 切歌未重发音量\n", (int)g_nconn, base + 2); + fail = 1; + } else if (g_got_pct != 50) { + printf("FAIL session2 switch: pct=%d (expect 50)\n", (int)g_got_pct); fail = 1; + } else { + printf("PASS session2 (song switch) -> new volume sync (pct=50)\n"); + } + } + g_stop = 1; pthread_join(th, NULL); unlink(g_sock); diff --git a/patch_v2/ncm_app/build_ncm_sims.sh b/patch_v2/ncm_app/build_ncm_sims.sh index d0c3a22..1a9029a 100755 --- a/patch_v2/ncm_app/build_ncm_sims.sh +++ b/patch_v2/ncm_app/build_ncm_sims.sh @@ -75,6 +75,18 @@ gcc -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o \ out/core-host/ncm_apicache.o -lpthread # appstate.c 调 ncm_apicache_init/clear(自包含模块) run_sim window_sim +echo "==> build plpage_sim(歌单页懒加载分页 + 加载后列表刷新;真实 playlist.c 全链路 + 宿主 SDK,0 网络。" +echo " 依赖 out/host/sdk_*.o:若缺请先跑 build_ncm_host.sh)" +PPINC="-DMEOW_UI_HOST_TEST -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icore -Ivendor/jsmn -Ivendor/mbedtls/include -Ivendor/minimp3 -Ivendor/dr_flac" +for f in bench/plpage_sim.c app/ui/playlist.c app/logic/appstate.c app/logic/songmeta.c app/ui/chrome.c app/ui/ncm_colors.c; do + b=$(basename "$f" .c) + gcc -O2 -Wall -Wextra -ffunction-sections -fdata-sections $PPINC -c "$f" -o /tmp/pp_$b.o +done +gcc -O2 -Wl,--gc-sections -Wl,--wrap=meow_ui_paint_full -o plpage_sim \ + /tmp/pp_plpage_sim.o /tmp/pp_playlist.o /tmp/pp_appstate.o /tmp/pp_songmeta.o /tmp/pp_chrome.o /tmp/pp_ncm_colors.o \ + out/host/sdk_*.o -lz -lpthread -lm +run_sim plpage_sim + echo "==> build search_sim" gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icore -Ivendor/jsmn \ -c bench/search_sim.c -o /tmp/sh.o diff --git a/patch_v2/ncm_app/core/ncm_api.c b/patch_v2/ncm_app/core/ncm_api.c index 460ad00..e9d064f 100644 --- a/patch_v2/ncm_app/core/ncm_api.c +++ b/patch_v2/ncm_app/core/ncm_api.c @@ -12,9 +12,10 @@ /* 响应缓冲上界(内存瘦身,OOM 修复):多数接口响应远小于 1MB,按需给尺寸避免每次调用 * 都 malloc 1MB 堆峰值(播放中 song_url/lyric/song_detail 并发,几 × 1MB 是可砍的瞬态)。 - * 仅 playlist detail(全量 trackIds,最多 NCM_TRACKID_MAX 条)保留传统 2MB。 */ + * playlist detail(全量 trackIds,最多 NCM_TRACKID_MAX 条)保留 2MB; + * user_playlist(120 首歌单列表)用 NCM_RESP_MAX(1MB),见 ncm_api_user_playlist。 */ #define RESP_SMALL (64 * 1024) /* song_url:url+字段,实际 ~1KB */ -#define RESP_MED (256 * 1024) /* lyric/song_detail/playlist/cloudsearch:实际 ≤100KB */ +#define RESP_MED (256 * 1024) /* lyric/song_detail/cloudsearch:实际 ≤100KB */ const char *ncm_api_level_str(int level) { switch (level) { @@ -279,8 +280,11 @@ int ncm_api_user_playlist(const char *device_id, const char *cookie, const char uid, limit > 0 ? limit : 30, offset > 0 ? offset : 0); api_json_t c; memset(&c, 0, sizeof c); + /* 我的歌单列表:真机 120 首歌单响应可超 RESP_MED(256KB) → ncm_http 回 NCM_ERR_FULL(-11) + * (日志 home: playlists rc=-11)。该响应不含 tracks(每首 tracks:null),1MB 足够, + * 用 NCM_RESP_MAX(与 vip/info 同口径的堆瞬态,解析后即释,非 bss)。 */ int rc = api_post_cached(device_id, cookie, "/api/user/playlist", json, NCM_CRYPT_WEAPI, - RESP_MED, &c); + NCM_RESP_MAX, &c); if (rc != NCM_OK) return rc; int arr = ncm_json_path(&c.j, "playlist"); diff --git a/patch_v2/ncm_app/core/ncm_req.c b/patch_v2/ncm_app/core/ncm_req.c index 949c7b3..d6dbe8a 100644 --- a/patch_v2/ncm_app/core/ncm_req.c +++ b/patch_v2/ncm_app/core/ncm_req.c @@ -302,10 +302,12 @@ int ncm_req_build(const char *device_id, const char *cookie, const char *path, } } - /* params 是 full2 的 AES-ECB 大写 hex(≈ plaintext×2)。 - * 批量 song_detail 的 c 数组转义后 full2 ~1.6KB → hex ~3.2KB,旧 2048 溢出 - * → ncm_crypto_eapi 返回 NCM_ERR_CRYPTO(实测 batch=64 时 rc=-8)。 */ - char params[4096]; + /* params 是 full2 的 AES-ECB 大写 hex(≈ plaintext×2 + 1)。 + * NCM_SONG_BATCH=100 时:c 数组转义后 full2 ~2.5KB(10 位 id) / ~3.4KB(19 位 id) + * → hex ~5.1KB / ~6.9KB。旧 params[4096] 对 100 首批量恒溢出 → aes_ecb_hex + * 返回 -1 → ncm_crypto_eapi 返回 NCM_ERR_CRYPTO(-8)(真机 first-screen rc=-8)。 + * 用 NCM_REQ_BODY_MAX(8192) 兜住 100 首最坏情况(body="params="+hex ≤ 8192)。 */ + char params[NCM_REQ_BODY_MAX]; if (ncm_crypto_eapi(path, full2, params, sizeof params) != 0) return NCM_ERR_CRYPTO; char cookie_hdr[3072]; diff --git a/patch_v2/ncm_app/test/test_ncm_core.c b/patch_v2/ncm_app/test/test_ncm_core.c index d5b37a9..18477b5 100644 --- a/patch_v2/ncm_app/test/test_ncm_core.c +++ b/patch_v2/ncm_app/test/test_ncm_core.c @@ -234,6 +234,35 @@ static void test_eapi_musicu_hdr(void) { CHECK(strstr(h2, "MUSIC_U=") == NULL, "guest eapi has no MUSIC_U"); } +static void test_eapi_song_detail_batch(void) { + /* §NEW:NCM_SONG_BATCH=100 的 song_detail eapi 请求构建(不打网络)。 + * 旧 params[4096] 对 100 首的 AES-ECB hex(~5.1KB) 溢出 → NCM_ERR_CRYPTO(-8), + * 真机 first-screen 因此 rc=-8。这里只构建请求验证 params/body 缓冲够。 */ + printf("== eapi 100-song detail batch ==\n"); + const char *device = "AA11BB22CC33DD44EE55FF6600112233445566778899AABB00"; + char clist[4096] = "[", tmp[64]; + for (int i = 0; i < 100 && strlen(clist) < sizeof clist - 80; i++) { + snprintf(tmp, sizeof tmp, "%s{\\\"id\\\":%lld}", i ? "," : "", + (long long)(2048962657LL + (long long)i)); + ncm_strlcat(clist, tmp, sizeof clist); + } + ncm_strlcat(clist, "]", sizeof clist); + char json[4500]; + snprintf(json, sizeof json, "{\"c\":\"%s\"}", clist); + + char params[8192]; + CHECKRC(ncm_crypto_eapi("/api/v3/song/detail", json, params, sizeof params), + "eapi crypto 100-song"); + CHECK(strlen(params) > 4096, "params exceeds old 4096 cap (got %zu)", strlen(params)); + + char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX]; + CHECKRC(ncm_req_build(device, "", "/api/v3/song/detail", json, NCM_CRYPT_EAPI, + body, sizeof body, hdrs, sizeof hdrs, url, sizeof url), + "eapi req_build 100-song"); + CHECK(strstr(body, "params=") == body, "body starts with params="); + CHECK(strlen(body) < NCM_REQ_BODY_MAX, "body fits NCM_REQ_BODY_MAX (got %zu)", strlen(body)); +} + static void test_network(void) { printf("== network (live) ==\n"); int net_ok = 0; @@ -515,6 +544,7 @@ int main(int argc, char **argv) { test_qr(); test_cookie_merge(); test_eapi_musicu_hdr(); + test_eapi_song_detail_batch(); test_network(); test_track_all_parse(); test_vip_parse(); diff --git a/patch_v2/reader_app/app/book.c b/patch_v2/reader_app/app/book.c index 5ed8234..e8df6f3 100644 --- a/patch_v2/reader_app/app/book.c +++ b/patch_v2/reader_app/app/book.c @@ -9,6 +9,7 @@ #include "rdsrc.h" #include "rdtxt.h" #include "seg.h" +#include "rdchapter.h" #include "rdlayout.h" #include "rdimg.h" #include "../engine/rdft.h" @@ -22,6 +23,17 @@ typedef struct seg { int page_count; /* 本段页数 */ } seg_t; +/* Phase 2C:双段 cps 窗口槽——只保留段解码码点(utf8 转完即 free,不再驻留整段 lines/pages)。 */ +typedef struct cps_slot { + int seg; /* 段下标;-1 = 空槽 */ + uint32_t *cps; + size_t n; +} cps_slot; + +/* 滞回释放/预取阈值(按全书页数百分比;真机可调)。 */ +#define RD_WIN_NEAR_PCT 2 /* 距下一段界 ≤2%:预取下一段 */ +#define RD_WIN_FAR_PCT 4 /* >4% 滑出:远槽可被下一段预取替换(由距离最近者策略体现) */ + struct rd_book { char path[400]; char font[400]; @@ -40,17 +52,28 @@ struct rd_book { rd_book_page *pages; int npages; /* flat 页表(文本) */ int cur; - /* 当前渲染段复用缓冲 */ - uint8_t *seg_utf8; size_t seg_utf8_cap; - uint32_t *seg_cps; size_t seg_cps_n, seg_cps_cap; - rd_line *seg_lines; rd_page *seg_pages; int seg_nlines, seg_npages; - int seg_cache_idx; + /* Phase 2C:双段 cps 窗口(渲染微排版取码点用;替换旧单段 seg_lines/seg_pages 缓存) */ + cps_slot win[2]; + + /* Phase 2A:拼接页表滚动状态(上一段的「末页页首→段尾」码点 tail;建下一段时拼接) */ + uint32_t *tail_cps; size_t tail_n; + int tail_seg; /* tail 所属段;-1 = 无 */ + int32_t tail_seg_char; /* tail 在所属段的起始字符偏移 */ + + /* Phase 2C:渲染微排版复用缓冲(拼接页临时码点 + 一页行缓冲,避免每帧 malloc) */ + uint32_t *stitch_cps; size_t stitch_cap; + rd_line *render_lines; int render_lines_cap; /* R3.2 分段流式(TXT):段目录 + 全局段数 + 已建段数 + 段/页表容量(增量补齐用) */ char seg_dir[400]; int seg_n; /* 段文件总数(manifest);fallback = nsegs(全建) */ int seg_built; /* 已布局段数;fallback = nsegs */ int segs_cap, pages_cap; /* segs[]/pages[] 当前容量(只在 seg 路径增量增长) */ + + /* R3.3 章节(TXT):解析结果 + 就绪/无章节标志(延迟解析,不拖慢首屏) */ + rdchapter chaps; + int chaps_ready; /* 1=chaps 已填充(含确无章节) */ + int chaps_none; /* 1=确无章节(扫描过 n==0)或该书不可用 */ }; /* ---- 页索引持久化(MeowData;见 rd_book_set_index_dir) ---- @@ -61,6 +84,7 @@ struct rd_book { * 纯 libc(无 SDK):reader.c 用 meow_config_dir 推导 MeowData dir 注入;冒烟/无卡时 dir 为空 → 直排不持久。 */ static char g_index_dir[400]; /* 索引缓存目录; 空 = 禁用 */ +static int seg_ensure_built(rd_book *b, int upto_seg); /* 段路径补齐(下文定义) */ void rd_book_set_index_dir(const char *dir) { if (dir && dir[0]) { strncpy(g_index_dir, dir, sizeof g_index_dir - 1); g_index_dir[sizeof g_index_dir - 1] = 0; } @@ -80,7 +104,7 @@ void rd_book_set_seg_base(const char *base) #define IDX_MAGIC1 'I' #define IDX_MAGIC2 'D' #define IDX_MAGIC3 'X' -#define IDX_VER 1u +#define IDX_VER 2u static int fio_rd(FILE *f, void *p, size_t n) { return fread(p, 1, n, f) == n; } static int fio_wr(FILE *f, const void *p, size_t n) { return fwrite(p, 1, n, f) == n; } @@ -253,6 +277,267 @@ static void index_cache_save(rd_book *b) if (ok) { rename(tmp, path); } else { remove(tmp); } } +/* ---- R3.3 章节缓存(MeowData/os.meow.reader/index/.chap,键=书字节+编码) ---- + * 章节 char_off/seg_idx/seg_char 仅依赖书字节与编码(不含字号/行距/字体/版面),故失效键 + * (file_size, file_mtime, enc) 即够,与页索引(含 font/fs/ls/cw/ch)分开、独立秒开。 + * 目录为 index 同父;g_index_dir 为空(宿主/无卡)→ 不持久,仅内存。 */ +#define CHAP_MAGIC0 'R' +#define CHAP_MAGIC1 'C' +#define CHAP_MAGIC2 'H' +#define CHAP_MAGIC3 'P' +#define CHAP_VER 1u + +static int chap_cache_path(const rd_book *b, char *out, int outsz) +{ + if (!g_index_dir[0]) return -1; + if (snprintf(out, (size_t)outsz, "%s/%08x.chap", g_index_dir, idx_fnv1a(b->path)) >= outsz) return -1; + return 0; +} + +/* 章节缓存键:书字节 + 编码(含 BOM/编码覆盖),跟页索引键不同(无字体/字号/版面)。 */ +static int chap_cache_load(rd_book *b) +{ + char path[440]; + if (chap_cache_path(b, path, sizeof path) != 0) return 0; + FILE *f = fopen(path, "rb"); + if (!f) return 0; + uint8_t magic[4]; + uint16_t ver = 0; + uint64_t fsize = 0; int64_t fmtime = 0; int32_t fenc = 0; + int32_t nsegs = 0, nch = 0; int64_t total = 0; + int ok = 0; + if (fio_rd(f, magic, sizeof magic) && magic[0] == CHAP_MAGIC0 && magic[1] == CHAP_MAGIC1 && + magic[2] == CHAP_MAGIC2 && magic[3] == CHAP_MAGIC3 && + fio_rd(f, &ver, sizeof ver) && ver == CHAP_VER && + fio_rd(f, &fsize, sizeof fsize) && fio_rd(f, &fmtime, sizeof fmtime) && + fio_rd(f, &fenc, sizeof fenc) && + fio_rd(f, &nsegs, sizeof nsegs) && fio_rd(f, &total, sizeof total) && + fio_rd(f, &nch, sizeof nch) && + nsegs >= 0 && nch >= 0 && nsegs <= 1000000 && nch <= RDCHAP_MAX_ENTRIES) { + struct stat st; + if (stat(b->path, &st) == 0 && (uint64_t)st.st_size == fsize && + (int64_t)st.st_mtime == fmtime && (int32_t)b->enc == fenc) { + rdchapter c; rdchapter_init(&c); + c.seg_char_start = (int64_t *)malloc((size_t)(nsegs + 1) * sizeof(int64_t)); + c.entries = (rdchapter_entry *)malloc((size_t)(nch ? nch : 1) * sizeof(rdchapter_entry)); + if (c.seg_char_start && c.entries && + fio_rd(f, c.seg_char_start, (size_t)(nsegs + 1) * sizeof(int64_t))) { + int eok = 1; + for (int i = 0; i < nch && eok; i++) { + rdchapter_entry *e = &c.entries[i]; + if (!fio_rd(f, &e->char_off, sizeof e->char_off) || + !fio_rd(f, &e->seg_idx, sizeof e->seg_idx) || + !fio_rd(f, &e->seg_char, sizeof e->seg_char) || + !fio_rd(f, &e->number, sizeof e->number) || + !fio_rd(f, e->title, sizeof e->title)) eok = 0; + } + if (eok) { + c.n = nch; c.nsegs = nsegs; + c.has_pre = (nch > 0 && c.entries[0].char_off == 0 && c.entries[0].number == -1) ? 1 : 0; + snprintf(c.pre_title, sizeof c.pre_title, "%s", (nch > 0) ? c.entries[0].title : "卷首"); + b->chaps = c; + b->chaps_ready = 1; + b->chaps_none = (nch == 0); + ok = 1; + } else { rdchapter_free(&c); } + } else { rdchapter_free(&c); } + } + } + fclose(f); + return ok; +} + +static void chap_cache_save(rd_book *b) +{ + if (!b->chaps_ready || b->chaps.nsegs <= 0) return; + char path[440]; + if (chap_cache_path(b, path, sizeof path) != 0) return; + char tmp[480]; + if (snprintf(tmp, sizeof tmp, "%s.tmp", path) >= (int)sizeof tmp) return; + FILE *f = fopen(tmp, "wb"); + if (!f) return; + struct stat st; + uint64_t fsize = 0; int64_t fmtime = 0; + if (stat(b->path, &st) == 0) { fsize = (uint64_t)st.st_size; fmtime = (int64_t)st.st_mtime; } + const uint8_t magic[4] = { CHAP_MAGIC0, CHAP_MAGIC1, CHAP_MAGIC2, CHAP_MAGIC3 }; + uint16_t ver = CHAP_VER; + int32_t nsegs = b->chaps.nsegs, fenc = (int32_t)b->enc, nch = b->chaps.n; + int64_t total = b->chaps.seg_char_start ? b->chaps.seg_char_start[nsegs] : 0; + int ok = 1; + ok = ok && fio_wr(f, magic, sizeof magic) && fio_wr(f, &ver, sizeof ver) && + fio_wr(f, &fsize, sizeof fsize) && fio_wr(f, &fmtime, sizeof fmtime) && + fio_wr(f, &fenc, sizeof fenc) && + fio_wr(f, &nsegs, sizeof nsegs) && fio_wr(f, &total, sizeof total) && + fio_wr(f, &nch, sizeof nch); + if (ok && nsegs > 0) ok = fio_wr(f, b->chaps.seg_char_start, (size_t)(nsegs + 1) * sizeof(int64_t)); + if (ok && nch > 0) { + for (int i = 0; i < nch; i++) { + const rdchapter_entry *e = &b->chaps.entries[i]; + ok = ok && fio_wr(f, &e->char_off, sizeof e->char_off) && + fio_wr(f, &e->seg_idx, sizeof e->seg_idx) && + fio_wr(f, &e->seg_char, sizeof e->seg_char) && + fio_wr(f, &e->number, sizeof e->number) && + fio_wr(f, e->title, sizeof e->title); + } + } + fclose(f); + if (ok) { rename(tmp, path); } else { remove(tmp); } +} + +/* 确保章节解析(幂等):命中缓存 → 就绪;未命中 → 扫描(读全部段文件)。 + * 可靠性(R3.4):仅「扫描全程成功」才落盘(含无章节 n==0);扫描中途出错(段读/解码/内存) + * 不落盘、本次降级无章节,下次重试。返回:0=成功(就绪);-1=不可用(非 TXT/无段目录); + * -2=扫描出错(不落盘)。 */ +static int chapter_ensure(rd_book *b) +{ + if (!b) return -1; + if (b->chaps_ready) return 0; + if (b->is_img || b->fmt != RD_FMT_TXT || !b->seg_dir[0]) { + b->chaps_none = 1; b->chaps_ready = 1; + return -1; + } + if (chap_cache_load(b)) return 0; + int rc = rdchapter_scan(b->seg_dir, b->enc, b->base, &b->chaps); + if (rc != 0) { b->chaps_none = 1; b->chaps_ready = 1; return -2; } /* 扫描失败:不落盘,降级无章节 */ + b->chaps_none = (b->chaps.n == 0); + b->chaps_ready = 1; + chap_cache_save(b); /* 可靠(成功扫描,无论有无章节)→ 落盘 */ + return 0; +} + +int rd_book_chapter_ready(rd_book *b) { return (b && b->chaps_ready) ? 1 : 0; } + +/* R3.4 手动重解析本书章节:先扫描到临时结果,成功才「清旧缓存/旧内存 + 换入新结果 + 落盘」; + * 失败则保留旧缓存/旧内存(绝不产生错位章节),不落盘、返回 -2。返回:n=章节数 / 0=无章节(成功)/ + * -1=不可用(非 TXT+seg)/ -2=扫描失败。幂等。 */ +int rd_book_reparse_chapters(rd_book *b) +{ + if (!b) return -1; + if (b->is_img || b->fmt != RD_FMT_TXT || !b->seg_dir[0]) return -1; + rdchapter tmp; rdchapter_init(&tmp); + int rc = rdchapter_scan(b->seg_dir, b->enc, b->base, &tmp); + if (rc != 0) { rdchapter_free(&tmp); return -2; } /* 失败:保留旧缓存/旧内存,未落盘 */ + rdchapter_free(&b->chaps); /* 清除内存旧解析结果 */ + b->chaps = tmp; + b->chaps_none = (b->chaps.n == 0); + b->chaps_ready = 1; + char path[440]; + if (chap_cache_path(b, path, sizeof path) == 0) remove(path); /* 清缓存文件 */ + chap_cache_save(b); /* 可靠结果 → 落盘 */ + return b->chaps.n; /* >=0 */ +} + +int rd_book_chapter_n(rd_book *b) +{ + if (!b) return -1; + if (b->is_img || b->fmt != RD_FMT_TXT) return -1; + if (chapter_ensure(b) != 0) return -1; + return b->chaps.n; +} + +int rd_book_chapter(rd_book *b, int i, char *title, int tsz, + int64_t *char_off, int *seg, int32_t *seg_char, int *number) +{ + if (!b || !title || i < 0) return -1; + if (chapter_ensure(b) != 0) return -1; + if (i >= b->chaps.n || !b->chaps.entries) return -1; + const rdchapter_entry *e = &b->chaps.entries[i]; + if (tsz > 0) { snprintf(title, (size_t)tsz, "%s", e->title); } + if (char_off) *char_off = e->char_off; + if (seg) *seg = e->seg_idx; + if (seg_char) *seg_char = e->seg_char; + if (number) *number = e->number; + return 0; +} + +int64_t rd_book_page_char_off(rd_book *b, int page) +{ + if (!b || b->npages <= 0 || page < 0 || page >= b->npages) return -1; + if (chapter_ensure(b) != 0) return -1; + if (!b->chaps.seg_char_start || b->chaps.nsegs <= 0) return -1; + int seg = b->pages[page].seg; + if (seg < 0 || seg >= b->chaps.nsegs) return -1; + return b->chaps.seg_char_start[seg] + b->pages[page].seg_char; +} + +int rd_book_page_for_char(rd_book *b, int64_t char_off) +{ + if (!b || b->npages <= 0) return -1; + if (chapter_ensure(b) != 0) return -1; + int nseg = b->chaps.nsegs; + if (!b->chaps.seg_char_start || nseg <= 0) return -1; + int64_t total = b->chaps.seg_char_start[nseg]; + if (total <= 0) return 0; + if (char_off < 0) char_off = 0; + if (char_off >= total) char_off = total - 1; + /* 二分所在段:最大的 i 使 seg_char_start[i] <= char_off */ + int lo = 0, hi = nseg - 1, s = 0; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (b->chaps.seg_char_start[mid] <= char_off) { s = mid; lo = mid + 1; } + else hi = mid - 1; + } + if (seg_ensure_built(b, s) != 0) return -1; + if (s >= b->nsegs) s = b->nsegs - 1; + int local = (int)(char_off - b->chaps.seg_char_start[s]); + int p0 = b->segs[s].page_start, pc = b->segs[s].page_count; + if (pc <= 0) return (p0 < b->npages) ? p0 : (b->npages - 1); + /* 段内二分:最后一个 seg_char <= local 的页 = 含该字符的页 */ + int lo2 = p0, hi2 = p0 + pc - 1, best = p0; + while (lo2 <= hi2) { + int mid = (lo2 + hi2) / 2; + if (b->pages[mid].seg_char <= local) { best = mid; lo2 = mid + 1; } + else hi2 = mid - 1; + } + return (best < b->npages) ? best : (b->npages - 1); +} + +int rd_book_chapter_for_page(rd_book *b, int page, int *idx) +{ + if (!b || !idx) return -1; + if (chapter_ensure(b) != 0) return -1; + if (b->chaps.n <= 0) { *idx = -1; return 0; } + if (page < 0 || page >= b->npages) return -1; + int64_t po = rd_book_page_char_off(b, page); + if (po < 0) { *idx = -1; return 0; } + int lo = 0, hi = b->chaps.n - 1, best = -1; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (b->chaps.entries[mid].char_off <= po) { best = mid; lo = mid + 1; } + else hi = mid - 1; + } + *idx = best; + return 0; +} + +/* ---- 段访问(文件/段模式跳转) ---- */ +int rd_book_seg_n(rd_book *b) +{ + if (!b || b->is_img || b->fmt != RD_FMT_TXT || !b->seg_dir[0]) return -1; + return (b->seg_n > 0) ? b->seg_n : b->nsegs; +} +int rd_book_seg_page(rd_book *b, int seg) +{ + if (!b || seg < 0) return -1; + if (b->seg_dir[0] && b->fmt == RD_FMT_TXT) { + if (seg >= b->seg_n) return -1; + if (seg_ensure_built(b, seg) != 0) return -1; + if (seg >= b->nsegs) return -1; + return b->segs[seg].page_start; + } + return (seg < b->nsegs) ? b->segs[seg].page_start : -1; +} +int rd_book_seg_page_count(rd_book *b, int seg) +{ + if (!b || seg < 0 || seg >= b->nsegs) return -1; + return b->segs[seg].page_count; +} +int rd_book_seg_of_page(rd_book *b, int page) +{ + if (!b || b->npages <= 0 || page < 0 || page >= b->npages) return -1; + return b->pages[page].seg; +} + /* ---- 文本:建索引(逐段 layout) ---- */ static int build_index(rd_book *b) { @@ -288,8 +573,10 @@ static int build_index(rd_book *b) b->segs[seg_idx].page_count = out.npages; for (int p = 0; p < out.npages; p++) { if (b->npages >= (int)cap_pages) { cap_pages *= 2; b->pages = (rd_book_page *)realloc(b->pages, cap_pages * sizeof(rd_book_page)); } + memset(&b->pages[b->npages], 0, sizeof b->pages[b->npages]); b->pages[b->npages].seg = seg_idx; b->pages[b->npages].seg_char = out.pages[p].first_char; + b->pages[b->npages].flags = (uint8_t)(out.lines[out.pages[p].first_line].para_start ? 1u : 0u); b->npages++; } free(lines); free(pages); @@ -300,41 +587,169 @@ static int build_index(rd_book *b) return 0; } -/* ---- R3.2 分段流式(TXT):把一段 UTF-8 布局并追加到 flat 页表 ---- */ -static int append_seg_pages(rd_book *b, int seg_idx, const uint8_t *utf8, size_t ulen) +/* ---- 段解码:把段 idx 的原始字节 → utf8 → 码点(malloc 返回,调用方 free)。 + * seg 路径读 512KB 段文件;非 seg 路径按 rd_src_seg 重显。窗口/建表/tail 重建共用。 ---- */ +static int seg_load_cps(rd_book *b, int seg_idx, uint32_t **cps_out, size_t *n_out) { - uint32_t *cps = NULL; size_t n = 0; - if (rd_utf8_to_cp(utf8, ulen, &cps, &n) != RD_ENC_OK) return -5; - rd_line *lines = (rd_line *)malloc((n ? n : 1) * sizeof(rd_line)); - rd_page *pages = (rd_page *)malloc((n ? n : 1) * sizeof(rd_page)); - if (!lines || !pages) { free(lines); free(pages); free(cps); return -6; } - rd_layout_in in; - rd_layout_out out; - in.chars = cps; in.n = (int32_t)n; in.fs = b->fs; in.line_scale = b->ls; - in.page_w = b->content_w; in.page_h = b->content_h; in.indent = b->fs; - in.meas = book_meas; in.mctx = b; - out.lines = lines; out.pages = pages; out.max_lines = (int)(n ? n : 1); out.max_pages = (int)(n ? n : 1); - int lrc = rd_layout(&in, &out); - free(cps); - if (lrc != RD_LAYOUT_OK) { free(lines); free(pages); return -4; } - b->segs[seg_idx].page_start = b->npages; - b->segs[seg_idx].page_count = out.npages; - for (int p = 0; p < out.npages; p++) { - if (b->npages >= b->pages_cap) { - int nc = b->pages_cap ? b->pages_cap * 2 : 1024; - rd_book_page *np = (rd_book_page *)realloc(b->pages, (size_t)nc * sizeof(rd_book_page)); - if (!np) { free(lines); free(pages); return -6; } - b->pages = np; b->pages_cap = nc; - } - b->pages[b->npages].seg = seg_idx; - b->pages[b->npages].seg_char = out.pages[p].first_char; - b->npages++; + if (!cps_out || !n_out) return -1; + *cps_out = NULL; *n_out = 0; + uint8_t *utf8 = NULL; size_t cap = 0, ulen = 0; + if (b->seg_dir[0] && b->fmt == RD_FMT_TXT) { + size_t rawlen = 0; + if (rdseg_rawlen(b->seg_dir, seg_idx, &rawlen) != 0) return -4; + cap = rawlen * 3 / 2 + 16; + if (cap < RDTXT_SEG_UTF8_MAX + 8) cap = RDTXT_SEG_UTF8_MAX + 8; + utf8 = (uint8_t *)malloc(cap); + if (!utf8) return -6; + uint8_t *raw = (uint8_t *)malloc(rawlen ? rawlen : 1); + if (!raw) { free(utf8); return -6; } + size_t rn = 0; + int rc = rdseg_load(b->seg_dir, seg_idx, raw, rawlen, &rn); + if (rc != 0) { free(raw); free(utf8); return -4; } + int r2 = rd_to_utf8(b->enc, raw, rn, utf8, cap, &ulen); + free(raw); + if (r2 == RD_ENC_ERR_BUFSZ) { free(utf8); return -2; } + if (r2 != RD_ENC_OK) { free(utf8); return -3; } + } else { + cap = RDTXT_SEG_UTF8_MAX + 8; + utf8 = (uint8_t *)malloc(cap); + if (!utf8) return -6; + if (rd_src_seg(b->src, seg_idx, utf8, cap, &ulen) != 0) { free(utf8); return -4; } } - free(lines); free(pages); + uint32_t *cps = NULL; size_t n = 0; + int rc = rd_utf8_to_cp(utf8, ulen, &cps, &n); + free(utf8); + if (rc != RD_ENC_OK) { free(cps); return -5; } + *cps_out = cps; *n_out = n; return 0; } -/* ---- R3.2 段路径:把 [seg_built, upto_seg] 逐段(512KB 段文件 → utf8 → 布局)补齐进 flat 页表。 +/* ---- Phase 2A:拼接页表滚动状态(tail) ---- */ +static void tail_clear(rd_book *b) +{ + free(b->tail_cps); + b->tail_cps = NULL; + b->tail_n = 0; + b->tail_seg = -1; + b->tail_seg_char = 0; +} + +/* 缓存续建时 tail 不在内存(未持久化)→ 由段 seg 的末页首字符重算(读该段 cps 截取末页 tail)。 */ +static int tail_rebuild(rd_book *b, int seg) +{ + if (b->tail_seg == seg && b->tail_cps && b->tail_n > 0) return 0; + tail_clear(b); + if (seg < 0 || seg >= b->seg_built) return -1; + int pc = b->segs[seg].page_count; + if (pc <= 0) return -1; + int lastp = b->segs[seg].page_start + pc - 1; + if (lastp < 0 || lastp >= b->npages) return -1; + int32_t sc = b->pages[lastp].seg_char; + if (sc < 0) return -1; + uint32_t *cps = NULL; size_t n = 0; + if (seg_load_cps(b, seg, &cps, &n) != 0) return -1; + if ((size_t)sc > n) { free(cps); return -1; } + b->tail_n = n - (size_t)sc; + b->tail_cps = (uint32_t *)malloc((b->tail_n ? b->tail_n : 1) * sizeof(uint32_t)); + if (!b->tail_cps) { free(cps); return -6; } + if (b->tail_n) memcpy(b->tail_cps, cps + sc, b->tail_n * sizeof(uint32_t)); + b->tail_seg = seg; + b->tail_seg_char = sc; + free(cps); + return 0; +} + +static int pages_push(rd_book *b, int seg, int32_t seg_char, uint8_t flags) +{ + if (b->npages >= b->pages_cap) { + int nc = b->pages_cap ? b->pages_cap * 2 : 1024; + rd_book_page *np = (rd_book_page *)realloc(b->pages, (size_t)nc * sizeof(rd_book_page)); + if (!np) return -6; + b->pages = np; b->pages_cap = nc; + } + memset(&b->pages[b->npages], 0, sizeof b->pages[b->npages]); + b->pages[b->npages].seg = seg; + b->pages[b->npages].seg_char = seg_char; + b->pages[b->npages].flags = flags; + b->npages++; + return 0; +} + +/* ---- Phase 2A:滚动拼接。把 seg_idx 段码点布局并追加到 flat 页表。 + * 建 N+1 时:删 N 的临时末页 → tail_cps + cps_new 拼接重排 → 首张拼接页回填 N(锚不变), + * 其余页按 first_char − tail_n 归入 N+1。末段/首段无 tail 时按整段直排。 ---- */ +static int append_seg_pages(rd_book *b, int seg_idx, const uint32_t *cps, size_t n) +{ + int have_tail = (seg_idx > 0 && b->tail_seg == seg_idx - 1 && b->tail_cps && b->tail_n > 0); + if (have_tail) { + if (b->segs[seg_idx - 1].page_count <= 0) return -4; + b->npages--; /* 删上一段临时末页(拼接页原位回填) */ + b->segs[seg_idx - 1].page_count--; + } + + size_t stitched_n = have_tail ? b->tail_n + n : n; + uint32_t *scps = NULL; + const uint32_t *chars; + if (have_tail) { + scps = (uint32_t *)malloc((stitched_n ? stitched_n : 1) * sizeof(uint32_t)); + if (!scps) return -6; + memcpy(scps, b->tail_cps, b->tail_n * sizeof(uint32_t)); + memcpy(scps + b->tail_n, cps, n * sizeof(uint32_t)); + chars = scps; + } else { + chars = cps; + } + + rd_line *lines = (rd_line *)malloc((stitched_n ? stitched_n : 1) * sizeof(rd_line)); + rd_page *pages = (rd_page *)malloc((stitched_n ? stitched_n : 1) * sizeof(rd_page)); + if (!lines || !pages) { free(lines); free(pages); free(scps); return -6; } + rd_layout_in in; + rd_layout_out out; + in.chars = chars; in.n = (int32_t)stitched_n; in.fs = b->fs; in.line_scale = b->ls; + in.page_w = b->content_w; in.page_h = b->content_h; in.indent = b->fs; + in.meas = book_meas; in.mctx = b; + out.lines = lines; out.pages = pages; out.max_lines = (int)(stitched_n ? stitched_n : 1); out.max_pages = (int)(stitched_n ? stitched_n : 1); + int lrc = rd_layout(&in, &out); + if (lrc != RD_LAYOUT_OK) { free(lines); free(pages); free(scps); return -4; } + + int first_own = 0; + if (have_tail) { + if (out.npages < 1) { free(lines); free(pages); free(scps); return -4; } + int rc = pages_push(b, seg_idx - 1, b->tail_seg_char, + (uint8_t)(out.lines[out.pages[0].first_line].para_start ? 1u : 0u)); + if (rc != 0) { free(lines); free(pages); free(scps); return rc; } + b->segs[seg_idx - 1].page_count++; + first_own = 1; + } + b->segs[seg_idx].page_start = b->npages; + b->segs[seg_idx].page_count = 0; + for (int p = first_own; p < out.npages; p++) { + int32_t local = out.pages[p].first_char - (have_tail ? (int32_t)b->tail_n : 0); + if (local < 0) local = 0; + int rc = pages_push(b, seg_idx, local, + (uint8_t)(out.lines[out.pages[p].first_line].para_start ? 1u : 0u)); + if (rc != 0) { free(lines); free(pages); free(scps); return rc; } + b->segs[seg_idx].page_count++; + } + + /* 保存本段末页 tail(供建下一段拼接;末段也保存但不会用到)。 */ + tail_clear(b); + if (b->segs[seg_idx].page_count > 0) { + int lastp = b->segs[seg_idx].page_start + b->segs[seg_idx].page_count - 1; + int32_t sc = b->pages[lastp].seg_char; + if (sc < 0 || (size_t)sc > n) { free(lines); free(pages); free(scps); return -4; } + b->tail_n = n - (size_t)sc; + b->tail_cps = (uint32_t *)malloc((b->tail_n ? b->tail_n : 1) * sizeof(uint32_t)); + if (!b->tail_cps) { b->tail_n = 0; free(lines); free(pages); free(scps); return -6; } + if (b->tail_n) memcpy(b->tail_cps, cps + sc, b->tail_n * sizeof(uint32_t)); + b->tail_seg = seg_idx; + b->tail_seg_char = sc; + } + free(lines); free(pages); free(scps); + return 0; +} + +/* ---- R3.2 段路径:把 [seg_built, upto_seg] 逐段(512KB 段文件 → cps → 拼接布局)补齐进 flat 页表。 * 幂等:已建段跳过。开书只建到目标(页 0/保存进度),翻页/跳转再按需补齐 —— 免整书一次性布局。 */ static int seg_ensure_built(rd_book *b, int upto_seg) { @@ -345,8 +760,6 @@ static int seg_ensure_built(rd_book *b, int upto_seg) int built_any = 0; while (b->seg_built <= upto_seg) { int idx = b->seg_built; - size_t rawlen = 0; - if (rdseg_rawlen(b->seg_dir, idx, &rawlen) != 0) break; if (b->segs_cap <= idx) { int nc = b->segs_cap ? b->segs_cap * 2 : 64; if (nc <= idx) nc = idx + 1; @@ -354,24 +767,13 @@ static int seg_ensure_built(rd_book *b, int upto_seg) if (!ns) return -6; b->segs = ns; b->segs_cap = nc; } - size_t cap = rawlen * 3 / 2 + 16; - if (cap < RDTXT_SEG_UTF8_MAX + 8) cap = RDTXT_SEG_UTF8_MAX + 8; - if (!b->seg_utf8 || b->seg_utf8_cap < cap) { - uint8_t *t = (uint8_t *)malloc(cap); - if (!t) return -6; - free(b->seg_utf8); b->seg_utf8 = t; b->seg_utf8_cap = cap; - } - uint8_t *raw = (uint8_t *)malloc(rawlen ? rawlen : 1); - if (!raw) return -6; - size_t rn = 0; - if (rdseg_load(b->seg_dir, idx, raw, rawlen, &rn) != 0) { free(raw); return -4; } - size_t ulen = 0; - int rc = rd_to_utf8(b->enc, raw, rn, b->seg_utf8, cap, &ulen); - free(raw); - if (rc == RD_ENC_ERR_BUFSZ) return -2; - if (rc != RD_ENC_OK) return -3; - int prc = append_seg_pages(b, idx, b->seg_utf8, ulen); - if (prc != 0) return prc; + if (idx > 0 && tail_rebuild(b, idx - 1) != 0) return -4; + uint32_t *cps = NULL; size_t n = 0; + int rc = seg_load_cps(b, idx, &cps, &n); + if (rc != 0) return rc; + rc = append_seg_pages(b, idx, cps, n); + free(cps); + if (rc != 0) return rc; b->seg_built++; built_any = 1; } @@ -380,59 +782,53 @@ static int seg_ensure_built(rd_book *b, int upto_seg) return 0; } -/* ---- 正文重显 + 布局到复用缓冲 ---- */ -static int load_seg(rd_book *b, int seg_idx) +/* ---- Phase 2C:双段 cps 窗口(渲染微排版取码点;替换旧单段整段 lines/pages 缓存) ---- */ +static void win_clear(rd_book *b) { - if (seg_idx == b->seg_cache_idx) return 0; - size_t ulen = 0; - if (b->seg_dir[0] && b->fmt == RD_FMT_TXT) { - /* R3.2 段路径:只读当前页所在 512KB 段文件(有界,不全量读整书) */ - size_t rawlen = 0; - if (rdseg_rawlen(b->seg_dir, seg_idx, &rawlen) != 0) return -4; - size_t cap = rawlen * 3 / 2 + 16; - if (cap < RDTXT_SEG_UTF8_MAX + 8) cap = RDTXT_SEG_UTF8_MAX + 8; - if (!b->seg_utf8 || b->seg_utf8_cap < cap) { - uint8_t *t = (uint8_t *)malloc(cap); - if (!t) return -6; - free(b->seg_utf8); b->seg_utf8 = t; b->seg_utf8_cap = cap; - } - uint8_t *raw = (uint8_t *)malloc(rawlen ? rawlen : 1); - if (!raw) return -6; - size_t rn = 0; - int rc = rdseg_load(b->seg_dir, seg_idx, raw, rawlen, &rn); - if (rc != 0) { free(raw); return -4; } - int r2 = rd_to_utf8(b->enc, raw, rn, b->seg_utf8, cap, &ulen); - free(raw); - if (r2 == RD_ENC_ERR_BUFSZ) return -2; - if (r2 != RD_ENC_OK) return -3; - } else { - size_t cap = RDTXT_SEG_UTF8_MAX + 8; - if (!b->seg_utf8) b->seg_utf8 = (uint8_t *)malloc(cap); - if (!b->seg_utf8) return -6; - if (rd_src_seg(b->src, seg_idx, b->seg_utf8, cap, &ulen) != 0) return -4; + for (int i = 0; i < 2; i++) { + free(b->win[i].cps); + b->win[i].cps = NULL; + b->win[i].n = 0; + b->win[i].seg = -1; } - size_t n = 0; - free(b->seg_cps); b->seg_cps = NULL; - if (rd_utf8_to_cp(b->seg_utf8, ulen, &b->seg_cps, &n) != RD_ENC_OK) return -5; - b->seg_cps_n = n; - if (b->seg_lines) free(b->seg_lines); - if (b->seg_pages) free(b->seg_pages); - b->seg_lines = (rd_line *)malloc((n ? n : 1) * sizeof(rd_line)); - b->seg_pages = (rd_page *)malloc((n ? n : 1) * sizeof(rd_page)); - if (!b->seg_lines || !b->seg_pages) return -6; - rd_layout_in in; - rd_layout_out out; - in.chars = b->seg_cps; in.n = (int32_t)n; in.fs = b->fs; in.line_scale = b->ls; - in.page_w = b->content_w; in.page_h = b->content_h; in.indent = b->fs; - in.meas = book_meas; in.mctx = b; - out.lines = b->seg_lines; out.pages = b->seg_pages; out.max_lines = (int)(n ? n : 1); out.max_pages = (int)(n ? n : 1); - int lrc = rd_layout(&in, &out); - if (lrc != RD_LAYOUT_OK) { b->seg_nlines = b->seg_npages = 0; b->seg_cache_idx = -1; return -4; } - b->seg_nlines = out.nlines; b->seg_npages = out.npages; - b->seg_cache_idx = seg_idx; +} + +static cps_slot *win_find(rd_book *b, int seg) +{ + if (b->win[0].seg == seg) return &b->win[0]; + if (b->win[1].seg == seg) return &b->win[1]; + return NULL; +} + +static int win_load_seg(rd_book *b, int seg_idx, cps_slot *slot) +{ + uint32_t *cps = NULL; size_t n = 0; + int rc = seg_load_cps(b, seg_idx, &cps, &n); + if (rc != 0) return rc; + free(slot->cps); + slot->cps = cps; slot->n = n; slot->seg = seg_idx; return 0; } +/* 确保段 seg_idx 码点在窗口内。双槽都占时,淘汰「离 seg_idx 更远」的槽(距相等淘汰更小段下标, + * 倾向保留下一段);滞回释放由 reader_on_tick 的 2%/4% 预取门控体现。 */ +static int win_ensure(rd_book *b, int seg_idx) +{ + if (seg_idx < 0 || seg_idx >= b->nsegs) return -1; + if (win_find(b, seg_idx)) return 0; + int victim = -1; + if (b->win[0].seg < 0) victim = 0; + else if (b->win[1].seg < 0) victim = 1; + else { + int d0 = b->win[0].seg - seg_idx; if (d0 < 0) d0 = -d0; + int d1 = b->win[1].seg - seg_idx; if (d1 < 0) d1 = -d1; + if (d0 > d1) victim = 0; + else if (d1 > d0) victim = 1; + else victim = (b->win[0].seg < b->win[1].seg) ? 0 : 1; + } + return win_load_seg(b, seg_idx, &b->win[victim]); +} + int rd_book_open(rd_book **b, const char *path, const char *font_path, int fs, int ls, int pw, int ph, int mx, int my, int top) { @@ -465,7 +861,8 @@ int rd_book_open(rd_book **b, const char *path, const char *font_path, if (rdft_init(&x->ft) != 0) goto close; if (font_path && rdft_load_font(x->ft, x->font) != 0) { rdft_free(x->ft); x->ft = NULL; goto close; } if (x->ft) rdft_set_px(x->ft, fs); - x->seg_cache_idx = -1; + x->win[0].seg = x->win[1].seg = -1; + x->tail_seg = -1; int seg_mode = 0; /* R3.2 分段流式(TXT 且段 base 已配置):开书切 512KB 段文件,逐段按需布局(不全量)。 */ if (g_seg_base[0] && x->fmt == RD_FMT_TXT) { @@ -513,10 +910,11 @@ void rd_book_close(rd_book *b) if (!b) return; free(b->segs); free(b->pages); - free(b->seg_utf8); - free(b->seg_cps); - free(b->seg_lines); - free(b->seg_pages); + win_clear(b); + tail_clear(b); + free(b->stitch_cps); + free(b->render_lines); + rdchapter_free(&b->chaps); if (b->ft) rdft_free(b->ft); if (b->src) rd_src_free(b->src); free(b); @@ -563,6 +961,8 @@ int rd_book_set_enc(rd_book *b, rd_enc_t enc) /* 重建索引(复用现值 fs/ls/content_*),再回锚 */ free(b->segs); free(b->pages); b->segs = NULL; b->pages = NULL; b->nsegs = b->npages = 0; b->seg_built = 0; b->segs_cap = b->pages_cap = 0; + win_clear(b); /* 编码变了 → 码点空间不同,窗口/tail 全失效 */ + tail_clear(b); if (b->seg_dir[0]) { /* R3.2 段路径:编码变了 → 段文件边界/解码不同 → 重建段文件 + 按需布局(段 0 起步) */ if (oldenc != b->enc) rdseg_clear(b->seg_dir); @@ -615,27 +1015,104 @@ int rd_book_set_page(rd_book *b, int page) return page; } +/* Phase 2C:空闲预取下一段(reader_on_tick 调)。当前页距下一段界 ≤2% 时把 s+1 段码点 + * 提前解码进窗口(命中/未到阈值则幂等返回),翻到段界即零突发。 */ +int rd_book_prefetch(rd_book *b) +{ + if (!b || b->is_img || b->npages <= 0) return -1; + if (!(b->seg_dir[0] && b->fmt == RD_FMT_TXT)) return 0; /* 仅 TXT 段路径有段界预取意义 */ + int cur = b->cur; if (cur < 0) cur = 0; if (cur >= b->npages) cur = b->npages - 1; + int s = b->pages[cur].seg; + if (s < 0 || s + 1 >= b->nsegs) return 0; + int bnd = b->segs[s + 1].page_start; + int d = cur - bnd; if (d < 0) d = -d; + if ((long long)d * 100 / b->npages > RD_WIN_NEAR_PCT) return 0; + return win_ensure(b, s + 1); +} + +/* ---- Phase 2C:页级微排版。把 page_no 从页首字符起只排一页(复用 rd_layout_page)。 + * 拼接页(TXT 段界末页)在窗口两段 cps 相接的临时缓冲上排,天然连续跨段。 + * 输出:*chars 与 *chars_n = 绘制用码点视图(窗口 cps 或 b->stitch_cps,下次调用前勿 free), + * b->render_lines 填行;*nlines 行数。返回 0=成功,1=空页,负=错误。 */ +static int page_micro_layout(rd_book *b, int page_no, const uint32_t **chars, size_t *chars_n, int *nlines) +{ + *chars = NULL; *chars_n = 0; *nlines = 0; + rd_book_page *pg = &b->pages[page_no]; + int seg_idx = (int)pg->seg; + if (seg_idx < 0 || seg_idx >= b->nsegs) return -1; + + int stitched = (b->seg_dir[0] && b->fmt == RD_FMT_TXT) && + (page_no == b->segs[seg_idx].page_start + b->segs[seg_idx].page_count - 1) && + (seg_idx + 1 < b->nsegs); + if (win_ensure(b, seg_idx) != 0) return -2; + if (stitched && win_ensure(b, seg_idx + 1) != 0) return -2; + + int lh = (b->fs * b->ls) / 100; if (lh < 1) lh = 1; + int max_lines = b->content_h / lh + 2; + if (b->render_lines_cap < max_lines) { + rd_line *t = (rd_line *)realloc(b->render_lines, (size_t)max_lines * sizeof(rd_line)); + if (!t) return -6; + b->render_lines = t; b->render_lines_cap = max_lines; + } + + cps_slot *s0 = win_find(b, seg_idx); + const uint32_t *view; size_t view_n; int32_t start; + if (!stitched) { + view = s0->cps; view_n = s0->n; + start = pg->seg_char; + if (start < 0) start = 0; + if ((size_t)start > view_n) start = (int32_t)view_n; + } else { + cps_slot *s1 = win_find(b, seg_idx + 1); + int32_t tail = pg->seg_char; if (tail < 0) tail = 0; + if ((size_t)tail > s0->n) return 1; + size_t tail_n = s0->n - (size_t)tail; + /* 拼接页只吃「一页能装下」的下一段前缀;按一页行数×行宽×2(零宽字符余量)估上界。 */ + size_t budget = (size_t)(max_lines + 2) * (size_t)(b->content_w + 2) * 2u + 64u; + size_t take = s1->n < budget ? s1->n : budget; + size_t total = tail_n + take; + if (b->stitch_cap < total) { + uint32_t *t = (uint32_t *)realloc(b->stitch_cps, (total ? total : 1) * sizeof(uint32_t)); + if (!t) return -6; + b->stitch_cps = t; b->stitch_cap = total; + } + memcpy(b->stitch_cps, s0->cps + tail, tail_n * sizeof(uint32_t)); + memcpy(b->stitch_cps + tail_n, s1->cps, take * sizeof(uint32_t)); + view = b->stitch_cps; view_n = total; + start = 0; + } + + int mid_para = !(pg->flags & 1u); + rd_layout_in in; + in.chars = view; in.n = (int32_t)view_n; in.fs = b->fs; in.line_scale = b->ls; + in.page_w = b->content_w; in.page_h = b->content_h; in.indent = b->fs; + in.meas = book_meas; in.mctx = b; + int32_t next_start = 0; + int rc = rd_layout_page(&in, start, mid_para, b->render_lines, max_lines, nlines, &next_start); + if (rc == RD_LAYOUT_ERR_OVER) return -4; + if (rc != RD_LAYOUT_OK) return -3; + *chars = view; *chars_n = view_n; + return (*nlines > 0) ? 0 : 1; +} + /* ---- 文本页渲染(FreeType 字形 → 全屏 565) ---- */ static int render_text_page(rd_book *b, int page_no, uint16_t *out, int pitch, uint16_t bg565, uint16_t fg565) { int npix = b->pw * b->ph; for (int i = 0; i < npix; i++) out[i] = bg565; - rd_book_page *pg = &b->pages[page_no]; - int seg_idx = (int)pg->seg; - if (seg_idx < 0 || seg_idx >= b->nsegs) return -1; - if (load_seg(b, seg_idx) != 0) return -2; - int local_page = page_no - b->segs[seg_idx].page_start; - if (local_page < 0 || local_page >= b->seg_npages) return 1; - rd_page *pp = &b->seg_pages[local_page]; + const uint32_t *chars = NULL; size_t chars_n = 0; int nlines = 0; + int rc = page_micro_layout(b, page_no, &chars, &chars_n, &nlines); + if (rc != 0) return rc; int asc = rdft_ascender(b->ft); int screen_x0 = b->mx; int screen_y0 = b->top; - for (int li = 0; li < pp->nlines; li++) { - rd_line *ln = &b->seg_lines[pp->first_line + li]; + for (int li = 0; li < nlines; li++) { + rd_line *ln = &b->render_lines[li]; int pen = screen_x0 + ln->x; int baseline = screen_y0 + ln->y + asc; for (int32_t ci = ln->s; ci < ln->e; ci++) { - uint32_t cp = b->seg_cps[ci]; + if (ci < 0 || (size_t)ci >= chars_n) continue; + uint32_t cp = chars[ci]; int adv = 0, bw = 0, bh = 0, left = 0, btop = 0; const uint8_t *bmp = NULL; rdft_glyph(b->ft, cp, &adv, &bmp, &bw, &bh, &left, &btop); @@ -704,6 +1181,8 @@ int rd_book_jump_to_anchor(rd_book *b, int seg, int32_t ch, int fs, int ls) b->content_w = b->pw - 2 * b->mx; b->content_h = b->ph - b->top - b->my; if (b->ft) rdft_set_px(b->ft, fs); + win_clear(b); /* 页表重建 → 窗口/tail 状态整体复位(码点可复用,但按重建统一清) */ + tail_clear(b); seg_t *nos = b->segs; int n_oldsegs = b->nsegs; rd_book_page *nop = b->pages; int n_oldpages = b->npages; b->segs = NULL; b->pages = NULL; b->nsegs = b->npages = 0; @@ -740,22 +1219,20 @@ int rd_book_jump_to_anchor(rd_book *b, int seg, int32_t ch, int fs, int ls) int rd_book_render_text(rd_book *b, int page_no, rd_book_line_fn fn, void *ctx) { if (!b || !fn || b->is_img || b->npages <= 0 || page_no < 0 || page_no >= b->npages) return -1; - rd_book_page *pg = &b->pages[page_no]; - int seg_idx = (int)pg->seg; - if (seg_idx < 0 || seg_idx >= b->nsegs) return -1; - if (load_seg(b, seg_idx) != 0) return -2; - int local_page = page_no - b->segs[seg_idx].page_start; - if (local_page < 0 || local_page >= b->seg_npages) return 1; - rd_page *pp = &b->seg_pages[local_page]; + const uint32_t *chars = NULL; size_t chars_n = 0; int nlines = 0; + int rc = page_micro_layout(b, page_no, &chars, &chars_n, &nlines); + if (rc != 0) return rc; uint8_t *line_utf8 = (uint8_t *)malloc((size_t)(b->content_w * 4 + 32)); if (!line_utf8) return -6; - for (int li = 0; li < pp->nlines; li++) { - rd_line *ln = &b->seg_lines[pp->first_line + li]; + for (int li = 0; li < nlines; li++) { + rd_line *ln = &b->render_lines[li]; int x = b->mx + ln->x, y = b->top + ln->y; size_t o = 0; for (int32_t ci = ln->s; ci < ln->e; ci++) { + if (ci < 0 || (size_t)ci >= chars_n) continue; uint8_t tmp[4]; - int n = rd_cp_to_utf8(b->seg_cps[ci], tmp); + int n = rd_cp_to_utf8(chars[ci], tmp); + if (o + (size_t)n + 1 > (size_t)(b->content_w * 4 + 32)) break; memcpy(line_utf8 + o, tmp, (size_t)n); o += (size_t)n; } line_utf8[o] = 0; diff --git a/patch_v2/reader_app/app/book.h b/patch_v2/reader_app/app/book.h index 024b54e..995cf1a 100644 --- a/patch_v2/reader_app/app/book.h +++ b/patch_v2/reader_app/app/book.h @@ -24,6 +24,7 @@ typedef struct rd_book rd_book; typedef struct rd_book_page { int32_t seg; /* 段下标 */ int32_t seg_char; /* 段内字符偏移(页首) */ + uint8_t flags; /* bit0 = 首页行是否段首行(缩进/段距判定;Phase 2A/2C) */ } rd_book_page; /* 打开书:path=TXT;font_path=FreeType 字体;geom=版面(fs 字号 / ls 行距% / pw,ph 全屏 / @@ -36,6 +37,7 @@ int rd_book_pages(rd_book *b); int rd_book_current(rd_book *b); /* 当前页 */ int rd_book_turn(rd_book *b, int delta); /* +1/-1;clamp 到 [0,npages-1] */ int rd_book_set_page(rd_book *b, int page); /* 跳到绝对页;clamp;返回实际页(段刮擦用) */ +int rd_book_prefetch(rd_book *b); /* 空闲预取下一段(reader_on_tick 调);0=已备/无需 */ int rd_book_is_image(rd_book *b); /* 1=CBZ 图片页书 */ /* 目录:0=无;i 项标题写到 title,目标段/字符偏移填 *seg 与 *ch。 */ @@ -73,6 +75,25 @@ void rd_book_set_index_dir(const char *dir); * 码位对齐段文件,渲染只加载当前页所在段(不全量读整书)——治大书开书 CPU100%/返回无效。 */ void rd_book_set_seg_base(const char *base); +/* R3.3(TXT 章节):章节规范解析(core/rdchapter.c)+ 章节→页映射。 + * 只对 TXT(seg_mode 分段流式路径)生效;其它格式返回「无章节」,不破坏既有路径。 + * rd_book_chapter_n 触发解析(首次读全部段文件解码扫行,或命中章节缓存秒回), + * 解析/缓存后就绪;顶栏章节名与「当前页属于哪章」据此工作。 */ +int rd_book_chapter_n(rd_book *b); /* 章节数;-1=非 TXT/CBZ(不可用) */ +int rd_book_chapter_ready(rd_book *b); /* 1=章节已解析(含无章节=0 但已确认) */ +int rd_book_reparse_chapters(rd_book *b); /* 手动重解析:清除缓存+重扫+落盘;见 book.c */ +int rd_book_chapter(rd_book *b, int i, char *title, int tsz, + int64_t *char_off, int *seg, int32_t *seg_char, int *number); +int rd_book_chapter_for_page(rd_book *b, int page, int *idx); /* 当前页所属章节下标;-1=无 */ +int rd_book_page_for_char(rd_book *b, int64_t char_off); /* 全书字符偏移 → 页号;-1=不可用 */ +int64_t rd_book_page_char_off(rd_book *b, int page); /* 页首全书字符偏移;-1=不可用 */ + +/* 段(文件/段模式跳转用)。非段模式返回 -1。 */ +int rd_book_seg_n(rd_book *b); /* 段数 */ +int rd_book_seg_page(rd_book *b, int seg); /* 段首页(ensure built);-1 */ +int rd_book_seg_page_count(rd_book *b, int seg); /* 段页数;-1 */ +int rd_book_seg_of_page(rd_book *b, int page); /* 页所属段;-1 */ + /* 字体/几何 */ int rd_book_font(rd_book *b); /* 当前字号 */ int rd_book_ls(rd_book *b); /* 行距% */ diff --git a/patch_v2/reader_app/app/reader.c b/patch_v2/reader_app/app/reader.c index 090abfb..137f43b 100644 --- a/patch_v2/reader_app/app/reader.c +++ b/patch_v2/reader_app/app/reader.c @@ -10,6 +10,7 @@ #include "book.h" #include "progress.h" #include "reader_conf.h" +#include "rdchapter.h" /* RDCHAP_TITLE_MAX(章节标题) */ #include #include #include @@ -18,7 +19,7 @@ #include #define RD_MENU_H 64 -#define RD_MENU_BTN 4 /* 返回 | 目录 | 界面 | 设置(MVP 单条) */ +#define RD_MENU_BTN 5 /* 返回 | 目录 | 跳转 | 界面 | 设置(R3.3 加「跳转」) */ #define RD_HEADER_H 30 /* 页头条高(书名+章节名) */ #define RD_FOOT_H 18 /* 页底条高(章节+页码) */ #define RD_UI_H 268 /* 「界面」排版面板高 */ @@ -36,6 +37,13 @@ static int g_menu_open; static int g_ui_open; /* 「界面」排版面板(B) */ static int g_dir_open; /* 目录覆盖层 */ static int g_dir_top, g_dir_row_h; /* 目录列表几何(rdsrc 目录项) */ +static int g_jump_open; /* 跳转覆盖层(R3.3) */ +static int g_jump_mode; /* 1=章节模式; 2=文件/段模式; 0=未定 */ +static int g_jump_sel; /* 当前选中章节/段下标 */ +static int g_jump_scroll; /* 列表滚动(首行项下标) */ +static char g_jump_buf[8]; /* 章内/段内页码输入缓冲(ASCII 数字) */ +static int g_chap_cache_idx = -1; /* 章节范围缓存(章起/止页;随字号/行距重排失效) */ +static int g_chap_cache_start, g_chap_cache_end; static char g_font_path[400]; static int g_notice_ms; @@ -48,12 +56,13 @@ static int g_anim_dir; /* 1=下一页(旧左移/新右入);- #define RD_SCRUB_W 26 /* 右缘刮擦条宽度 */ static int g_scrub; /* 1=刮擦中 */ static int g_scrub_page; /* 刮擦目标页(实时预览用) */ +static int g_scrub_moved; /* 本次刮擦是否发生了拖动(无拖动=点按,不当翻页跳页) */ /* ---- R3 编码切换(TXT;Big5 繁体书手动选定) ---- */ static int g_enc_idx; /* g_enc_cycle[] 下标(当前有效编码) */ static uint32_t g_page_bg, g_page_fg; /* 正文页底/字色(24bit) */ -static char g_chapter[128]; /* 顶栏章节名(TXT 无章节=文件名) */ +static char g_chapter[RDCHAP_TITLE_MAX]; /* 顶栏章节名(TXT 无章节=文件名) */ static int g_show_header, g_show_footer; /* 页头条/底条显隐(rdconf.rd.header/footer) */ static volatile int g_volpage_pending; /* 系统翻页信号待消费(IPC 线程置位,UI tick 消费,E) */ static int g_prog_dirty; /* 阅读进度变更待写(翻页/刮擦/字号/编码;R3.2 定时落盘防崩溃丢进度) */ @@ -168,7 +177,21 @@ static const char *resolve_font(void) static void chapter_name(void) { - /* TXT 无章节 → 文件名;R1 固定。 */ + /* TXT 有章节(已解析)→ 当前页所属章名;否则文件名(R1 固定)。 + * 仅在章节已就绪时用章名(rd_book_chapter_ready 不触发扫描),首屏/未解析时用文件名, + * 避免开书即整书扫描占 UI 线程。 */ + if (!g_book) { g_chapter[0] = 0; return; } + if (rd_book_chapter_ready(g_book) && !rd_book_is_image(g_book)) { + int page = rd_book_current(g_book); + int idx = -1; + if (rd_book_chapter_for_page(g_book, page, &idx) == 0 && idx >= 0) { + char t[RDCHAP_TITLE_MAX]; + if (rd_book_chapter(g_book, idx, t, sizeof t, NULL, NULL, NULL, NULL) == 0) { + snprintf(g_chapter, sizeof g_chapter, "%s", t); + return; + } + } + } snprintf(g_chapter, sizeof g_chapter, "%s", rd_book_basename(g_book)); } @@ -176,6 +199,7 @@ static void reader_reflow(void) { if (!g_book) return; if (g_anim) { meow_anim_cancel(g_anim); g_anim = NULL; } /* 重排/雷字号时终止翻页动画 */ + g_chap_cache_idx = -1; /* 页索引随字号/行距变 → 章范围缓存失效 */ int seg = 0; int32_t ch = 0; rd_book_anchor(g_book, &seg, &ch); /* 记录当前锚,重排后回锚 */ g_prog_dirty = 1; @@ -266,6 +290,354 @@ static void reader_draw_scrub(const meow_theme *th) } } +/* ---------------- R3.3 跳转弹层(章节模式 / 文件·段模式) ---------------- + * 复用 g_dir 系列「半透明底 + 条目列表 + 当前高亮」;顶部提供:当前章节/段信息、页码输入 + * (自绘数字键盘 0-9 + ⌫ + 跳)、快捷(首 / ±10 / ±100 / 末)。只对 TXT(seg 模式)启用: + * rd_book_chapter_n>0 → 章节模式;否则 rd_book_seg_n>0 → 文件/段模式。 */ +#define J_SHORTCUT_N 6 +#define J_SHORTCUT_X0 16 +#define J_SHORTCUT_GAP 6 +#define J_SHORTCUT_H 32 +#define J_KEY_W 64 +#define J_KEY_H 42 +#define J_KEY_GAP 8 +#define J_ROW 30 +enum { + J_NONE = 0, + J_GO_FIRST = 1, J_GO_M100 = 2, J_GO_M10 = 3, + J_GO_P10 = 4, J_GO_P100 = 5, J_GO_LAST = 6, + J_DIG_0 = 10, J_DIG_1 = 11, J_DIG_2 = 12, J_DIG_3 = 13, J_DIG_4 = 14, + J_DIG_5 = 15, J_DIG_6 = 16, J_DIG_7 = 17, J_DIG_8 = 18, J_DIG_9 = 19, + J_BACK = 30, J_JUMP = 31, + J_ITEM = 100, /* 100 + i = 列表第 i 项 */ +}; + +static int jump_hdr_y(void) { return g_top; } +static int jump_info_y(void) { return g_top + 30; } +static int jump_shortcut_y(void){ return g_top + 56; } +static int jump_input_y(void) { return g_top + 92; } +static int jump_pad_y(void) { return g_top + 128; } +static int jump_list_y(void) { return g_top + 128 + 4 * (J_KEY_H + J_KEY_GAP) + 10; } + +static int chapter_range(int idx, int *start, int *end) +{ + if (!g_book || idx < 0) return -1; + if (g_chap_cache_idx == idx) { if (start) *start = g_chap_cache_start; if (end) *end = g_chap_cache_end; return 0; } + int64_t co = 0; + if (rd_book_chapter(g_book, idx, NULL, 0, &co, NULL, NULL, NULL) != 0) return -1; + int s = rd_book_page_for_char(g_book, co); + if (s < 0) return -1; + int n = rd_book_chapter_n(g_book); + int e; + if (idx + 1 < n) { + int64_t co2 = 0; + if (rd_book_chapter(g_book, idx + 1, NULL, 0, &co2, NULL, NULL, NULL) != 0) return -1; + e = rd_book_page_for_char(g_book, co2) - 1; + } else { + e = rd_book_pages(g_book) - 1; + } + if (e < s) e = s; + g_chap_cache_idx = idx; g_chap_cache_start = s; g_chap_cache_end = e; + if (start) *start = s; + if (end) *end = e; + return 0; +} + +/* 跳转选中项 idx 的页区间(章节模式=章范围;文件模式=段范围)。 */ +static int jump_region(int idx, int *start, int *end, int *inp, int *ofp) +{ + if (!g_book) return -1; + int s, e; + if (g_jump_mode == 1) { + if (chapter_range(idx, &s, &e) != 0) return -1; + } else if (g_jump_mode == 2) { + int nseg = rd_book_seg_n(g_book); + if (idx < 0 || idx >= nseg) return -1; + s = rd_book_seg_page(g_book, idx); + if (s < 0) return -1; + e = s + rd_book_seg_page_count(g_book, idx) - 1; + if (e < s) e = s; + } else return -1; + int cur = rd_book_current(g_book); + if (cur < s) cur = s; + if (cur > e) cur = e; + if (start) *start = s; + if (end) *end = e; + if (inp) *inp = cur - s + 1; + if (ofp) *ofp = e - s + 1; + return 0; +} + +/* 页底条右侧文本:章内 y/Z · 全书 x/M(章节就绪且范围可算时),否则 全书 x/M。 */ +static void footer_progress(char *out, int outsz) +{ + int np = rd_book_pages(g_book); + int page = rd_book_current(g_book); + if (np <= 0) { snprintf(out, outsz, "0/0"); return; } + if (rd_book_chapter_ready(g_book) && !rd_book_is_image(g_book)) { + int idx = -1; + if (rd_book_chapter_for_page(g_book, page, &idx) == 0 && idx >= 0) { + int s = -1, e = -1; + if (chapter_range(idx, &s, &e) == 0 && e >= s) { + int inp = page - s + 1, ofp = e - s + 1; + if (inp > 0 && ofp > 0) { snprintf(out, outsz, "%d/%d · %d/%d", inp, ofp, page + 1, np); return; } + } + } + } + snprintf(out, outsz, "%d/%d", page + 1, np); +} + +static void jump_chip(int x, int y, int w, int h, const char *label, int active, const meow_theme *th) +{ + if (active) meow_ui_fill_rect(x, y, w, h, th->primaryContainer); + else meow_ui_fill_rect(x, y, w, h, th->surfaceContainerHighest); + uint32_t tc = active ? th->onPrimaryContainer : th->fg; + meow_ui_text_center(x + w / 2, y + (h - 20) / 2, label, 20, tc); +} + +static void reader_draw_jump(const meow_theme *th) +{ + if (!g_book) return; + int page = rd_book_current(g_book); + int np = rd_book_pages(g_book); + int n = (g_jump_mode == 1) ? rd_book_chapter_n(g_book) : rd_book_seg_n(g_book); + meow_ui_fill_rect_alpha(0, g_top, g_pw, g_ph - g_top, 0x000000, 150); + + /* 头栏 */ + int y0 = jump_hdr_y(); + meow_ui_fill_rect(0, y0, g_pw, 30, th->surfaceContainer); + char hdr[48]; + snprintf(hdr, sizeof hdr, "跳转 · %s", (g_jump_mode == 1) ? "章节" : "文件/段"); + meow_ui_text(16, y0 + 5, hdr, 20, th->onSurface); + meow_ui_draw_rect(0, y0 + 29, g_pw, 1, th->outlineVariant); + + if (n <= 0) { + meow_ui_text(20, jump_info_y() + 12, "本书无可用跳转目标", 20, th->dim); + meow_ui_text(20, g_ph - 30, "点空白关闭", 16, th->dim); + return; + } + + /* 信息行 */ + char info[256]; info[0] = 0; + int s, e, inp, ofp; + if (g_jump_mode == 1) { + char lab[RDCHAP_TITLE_MAX]; + int num = -1; + if (rd_book_chapter(g_book, g_jump_sel, lab, sizeof lab, NULL, NULL, NULL, &num) == 0 && + jump_region(g_jump_sel, &s, &e, &inp, &ofp) == 0) { + char prefix[24]; prefix[0] = 0; + if (num >= 0) snprintf(prefix, sizeof prefix, "第%d章 ", num); + snprintf(info, sizeof info, "%s%s · 章内 %d/%d · 全书 %d/%d", prefix, lab, inp, ofp, page + 1, np); + } + } else { + if (jump_region(g_jump_sel, &s, &e, &inp, &ofp) == 0) + snprintf(info, sizeof info, "段 %d/%d · 段内 %d/%d · 全书 %d/%d", g_jump_sel + 1, n, inp, ofp, page + 1, np); + } + meow_ui_text(16, jump_info_y() + 3, info[0] ? info : "—", 16, th->fg); + + /* 快捷 chips */ + static const char *sc_lbl[J_SHORTCUT_N] = { "首", "-100", "-10", "+10", "+100", "末" }; + int x = J_SHORTCUT_X0; + int cw = (g_pw - 2 * J_SHORTCUT_X0 - (J_SHORTCUT_N - 1) * J_SHORTCUT_GAP) / J_SHORTCUT_N; + for (int i = 0; i < J_SHORTCUT_N; i++) { + jump_chip(x, jump_shortcut_y(), cw, J_SHORTCUT_H, sc_lbl[i], 0, th); + x += cw + J_SHORTCUT_GAP; + } + + /* 输入行 */ + meow_ui_text(16, jump_input_y() + 5, (g_jump_mode == 1) ? "章内页" : "段内页", 20, th->fg); + char ib[48]; + snprintf(ib, sizeof ib, "%s", g_jump_buf[0] ? g_jump_buf : "_"); + meow_ui_text(120, jump_input_y() + 3, ib, 24, th->primary); + meow_ui_text_right(g_pw - 16, jump_input_y() + 7, "输页码点「跳」 · ⌫退格", 16, th->dim); + + /* 数字键盘 1..9 / ⌫ 0 跳 */ + static const char *pad[4][3] = { + { "1", "2", "3" }, { "4", "5", "6" }, { "7", "8", "9" }, { "⌫", "0", "跳" }, + }; + int pad_w = 3 * J_KEY_W + 2 * J_KEY_GAP; + int pad_x = (g_pw - pad_w) / 2; + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 3; c++) { + int kx = pad_x + c * (J_KEY_W + J_KEY_GAP); + int ky = jump_pad_y() + r * (J_KEY_H + J_KEY_GAP); + jump_chip(kx, ky, J_KEY_W, J_KEY_H, pad[r][c], (r == 3 && c == 2), th); + } + } + + /* 条目列表(当前选中高亮) */ + int top = jump_list_y(); + int maxshow = (g_ph - top - 8) / J_ROW; + if (maxshow < 1) maxshow = 1; + /* 让当前选中项进入可视区 */ + if (g_jump_sel >= 0) { + if (g_jump_sel < g_jump_scroll) g_jump_scroll = g_jump_sel; + else if (g_jump_sel >= g_jump_scroll + maxshow) g_jump_scroll = g_jump_sel - maxshow + 1; + } + if (g_jump_scroll + maxshow > n) g_jump_scroll = (n - maxshow < 0 ? 0 : n - maxshow); + if (g_jump_scroll < 0) g_jump_scroll = 0; + for (int i = 0; i < maxshow; i++) { + int item = g_jump_scroll + i; + if (item >= n) break; + char t[96]; + if (g_jump_mode == 1) { + if (rd_book_chapter(g_book, item, t, sizeof t, NULL, NULL, NULL, NULL) != 0) continue; + } else { + snprintf(t, sizeof t, "段 %d", item + 1); + } + int yy = top + i * J_ROW; + if (item == g_jump_sel) meow_ui_fill_rect(0, yy, g_pw, J_ROW - 4, th->surfaceContainer); + meow_ui_text(24, yy + 4, t, 20, th->fg); + meow_ui_draw_rect(12, yy + J_ROW - 6, g_pw - 24, 1, th->outline); + } + meow_ui_text(20, g_ph - 30, "点条目跳转 · 点空白关闭", 16, th->dim); +} + +static int reader_jump_hit(int x, int y) +{ + int n = (g_jump_mode == 1) ? rd_book_chapter_n(g_book) : rd_book_seg_n(g_book); + if (n <= 0) return J_NONE; + /* 快捷 chips */ + int sy = jump_shortcut_y(); + if (y >= sy && y < sy + J_SHORTCUT_H) { + int cw = (g_pw - 2 * J_SHORTCUT_X0 - (J_SHORTCUT_N - 1) * J_SHORTCUT_GAP) / J_SHORTCUT_N; + if (x >= J_SHORTCUT_X0) { + int k = (x - J_SHORTCUT_X0) / (cw + J_SHORTCUT_GAP); + if (k >= 0 && k < J_SHORTCUT_N) return J_GO_FIRST + k; + } + return J_NONE; + } + /* 键盘 */ + int py = jump_pad_y(); + int pad_w = 3 * J_KEY_W + 2 * J_KEY_GAP; + int pad_x = (g_pw - pad_w) / 2; + for (int r = 0; r < 4; r++) { + int ky = py + r * (J_KEY_H + J_KEY_GAP); + if (y >= ky && y < ky + J_KEY_H) { + for (int c = 0; c < 3; c++) { + int kx = pad_x + c * (J_KEY_W + J_KEY_GAP); + if (x >= kx && x < kx + J_KEY_W) { + if (r == 3) { + if (c == 2) return J_JUMP; + if (c == 0) return J_BACK; + return J_DIG_0; /* 第三行 c==1 → 数字 0 */ + } + return J_DIG_0 + (r * 3 + c) + 1; /* 0..2 行:1 2 3 / 4 5 6 / 7 8 9 */ + } + } + return J_NONE; + } + } + /* 条目列表 */ + int top = jump_list_y(); + if (y >= top) { + int idx = (y - top) / J_ROW + g_jump_scroll; + if (idx < n) return J_ITEM + idx; + return J_NONE; + } + return J_NONE; +} + +static void reader_jump_to(int page) +{ + if (!g_book) return; + int np = rd_book_pages(g_book); + if (np <= 0) return; + int cur = rd_book_current(g_book); + (void)cur; + rd_book_set_page(g_book, page); + g_prog_dirty = 1; + reader_refill_page(); + reader_redraw(); + reader_reflow_persist(); +} + +static void reader_jump_action(int a) +{ + if (a == J_NONE) { g_jump_open = 0; reader_redraw(); return; } + int s, e, inp, ofp; + if (a >= J_GO_FIRST && a <= J_GO_LAST) { + if (jump_region(g_jump_sel, &s, &e, &inp, &ofp) != 0) return; + int target = rd_book_current(g_book); + switch (a) { + case J_GO_FIRST: target = s; break; + case J_GO_M100: target -= 100; break; + case J_GO_M10: target -= 10; break; + case J_GO_P10: target += 10; break; + case J_GO_P100: target += 100; break; + case J_GO_LAST: target = e; break; + } + if (target < s) target = s; + if (target > e) target = e; + reader_jump_to(target); + return; + } + if (a >= J_DIG_0 && a <= J_DIG_9) { + size_t len = strlen(g_jump_buf); + if (len + 1 < sizeof g_jump_buf) { g_jump_buf[len] = (char)('0' + (a - J_DIG_0)); g_jump_buf[len + 1] = 0; } + reader_redraw(); + return; + } + if (a == J_BACK) { + size_t len = strlen(g_jump_buf); + if (len > 0) g_jump_buf[len - 1] = 0; + reader_redraw(); + return; + } + if (a == J_JUMP) { + if (g_jump_buf[0]) { + int tpage = atoi(g_jump_buf); + if (tpage < 1) tpage = 1; + if (jump_region(g_jump_sel, &s, &e, &inp, &ofp) == 0) { + int t = s + (tpage - 1); + if (t < s) t = s; + if (t > e) t = e; + reader_jump_to(t); + } + } + return; + } + if (a >= J_ITEM) { + int item = a - J_ITEM; + if (g_jump_mode == 1) { + if (jump_region(item, &s, &e, &inp, &ofp) == 0) { + g_jump_sel = item; + reader_jump_to(s); + } + } else { + int sp = rd_book_seg_page(g_book, item); + if (sp >= 0) { g_jump_sel = item; reader_jump_to(sp); } + } + return; + } + g_jump_open = 0; reader_redraw(); +} + +/* 打开跳转弹层:解析章节(首次会扫描+缓存),按是否有章节选章节/文件·段模式。 */ +static void reader_jump_open(void) +{ + if (!g_book) return; + g_jump_buf[0] = 0; + g_jump_scroll = 0; + int nc = rd_book_chapter_n(g_book); + if (nc > 0) { + g_jump_mode = 1; + int idx = -1; + rd_book_chapter_for_page(g_book, rd_book_current(g_book), &idx); + g_jump_sel = (idx >= 0) ? idx : 0; + } else if (rd_book_seg_n(g_book) > 0) { + g_jump_mode = 2; + int seg = rd_book_seg_of_page(g_book, rd_book_current(g_book)); + g_jump_sel = (seg >= 0) ? seg : 0; + } else { + g_jump_mode = 2; + g_jump_sel = 0; + } + g_jump_open = 1; + g_menu_open = 0; + reader_redraw(); +} + /* ----------------「界面」排版面板(B:字号/行距/背景/顶底栏/编码) ---------------- * 自绘 + 自制命中(README §5.2 建议:W_CUSTOM 画布 + 自制 hit,零 SDK widget 协同)。 * 几何 draw/hit 共用(常量)。 */ @@ -416,6 +788,7 @@ static void reader_canvas_render(meow_widget *w, const meow_theme *th, void *ctx { (void)w; (void)ctx; meow_ui_clear(g_page_bg); + chapter_name(); /* 每帧同步顶栏/页底章节名(就绪后取章名,否则文件名) */ if (g_book) { int page = rd_book_current(g_book); if ((g_has_font || rd_book_is_image(g_book)) && g_page_buf[RD_PAGE_SLOT_CUR]) { @@ -427,8 +800,6 @@ static void reader_canvas_render(meow_widget *w, const meow_theme *th, void *ctx } /* 页头条(书名 + 章节名;可选,rd.header) */ int sbh = meow_ui_statusbar_height(); - int pages = g_book ? rd_book_pages(g_book) : 0; - int page = g_book ? rd_book_current(g_book) : 0; const char *bk = g_book ? rd_book_basename(g_book) : "阅读"; if (g_show_header) { meow_ui_fill_rect(0, sbh, g_pw, RD_HEADER_H, g_page_bg); @@ -438,24 +809,23 @@ static void reader_canvas_render(meow_widget *w, const meow_theme *th, void *ctx } /* 页底条(章节 + 页码;可选,rd.footer) */ if (g_show_footer) { - char ft[48]; - if (pages > 0) snprintf(ft, sizeof ft, "%d/%d", page + 1, pages); - else snprintf(ft, sizeof ft, "0/0"); + char ft[64]; + footer_progress(ft, sizeof ft); int fy = g_ph - RD_FOOT_H; meow_ui_fill_rect(0, fy, g_pw, RD_FOOT_H, g_page_bg); meow_ui_text(16, fy + 1, g_chapter[0] ? g_chapter : bk, 16, g_page_fg); meow_ui_text_right(g_pw - 16, fy + 1, ft, 16, g_page_fg); } - /* R3 刮擦指示条(非目录/面板覆盖时显示;菜单为底部条,与右缘条并存即可) */ - if (g_book && !g_dir_open && !g_ui_open) reader_draw_scrub(th); + /* R3 刮擦指示条(非目录/面板/跳转覆盖时显示;菜单为底部条,与右缘条并存即可) */ + if (g_book && !g_dir_open && !g_ui_open && !g_jump_open) reader_draw_scrub(th); if (g_menu_open) { int y = g_ph - RD_MENU_H; meow_ui_fill_rect_alpha(0, y, g_pw, RD_MENU_H, 0x000000, 160); - /* 4 按钮(MVP 单条):返回 | 目录 | 界面 | 设置 */ + /* 5 按钮:返回 | 目录 | 跳转 | 界面 | 设置 */ int bw = g_pw / RD_MENU_BTN; - const char *labels[RD_MENU_BTN] = { "返回", "目录", "界面", "设置" }; + const char *labels[RD_MENU_BTN] = { "返回", "目录", "跳转", "界面", "设置" }; for (int i = 0; i < RD_MENU_BTN; i++) { int bx = i * bw; meow_ui_fill_rect(bx + 4, y + 12, bw - 8, RD_MENU_H - 24, th->surfaceContainer); @@ -463,9 +833,13 @@ static void reader_canvas_render(meow_widget *w, const meow_theme *th, void *ctx } } if (g_ui_open && g_book) reader_draw_ui_panel(th); + if (g_jump_open && g_book) reader_draw_jump(th); if (g_dir_open && g_book) { - /* 目录覆盖层:半透明底 + 条目列表(当前行高亮) */ - int n = rd_book_toc_n(g_book); + /* 目录覆盖层:TXT 有章节 → 章节列表(复用 rd_book 章节数据,与跳转共享);否则 rdsrc 目录;无则「无章节」。 */ + int nchap = 0; + if (!rd_book_is_image(g_book) && rd_book_chapter_n(g_book) > 0) nchap = rd_book_chapter_n(g_book); + int ntoc = (nchap == 0) ? rd_book_toc_n(g_book) : 0; + int n = (nchap > 0) ? nchap : ntoc; if (n > 0) { int top = g_top + 8; int row = 34; @@ -474,19 +848,29 @@ static void reader_canvas_render(meow_widget *w, const meow_theme *th, void *ctx if (maxshow > n) maxshow = n; g_dir_top = top; g_dir_row_h = row; meow_ui_fill_rect_alpha(0, g_top, g_pw, g_ph - g_top, 0x000000, 150); - char t[96]; int sg; int32_t cch; + char t[160]; int page = rd_book_current(g_book); + int curchap = -1; + if (nchap > 0) rd_book_chapter_for_page(g_book, page, &curchap); for (int i = 0; i < maxshow; i++) { - if (rd_book_toc(g_book, i, t, sizeof t, &sg, &cch) != 0) continue; + int hl = 0; + if (nchap > 0) { + if (rd_book_chapter(g_book, i, t, sizeof t, NULL, NULL, NULL, NULL) != 0) continue; + hl = (i == curchap); + } else { + int sg = -1; int32_t cch = 0; + if (rd_book_toc(g_book, i, t, sizeof t, &sg, &cch) != 0) continue; + hl = (page == sg); + } int y0 = top + i * row; - if (page == sg) meow_ui_fill_rect(0, y0, g_pw, row - 4, th->surfaceContainer); + if (hl) meow_ui_fill_rect(0, y0, g_pw, row - 4, th->surfaceContainer); meow_ui_text(24, y0, t, 20, th->fg); meow_ui_draw_rect(12, y0 + row - 6, g_pw - 24, 1, th->outline); } meow_ui_text(20, g_ph - 34, "点条目跳转 · 点空白关闭", 16, th->dim); } else { g_dir_open = 0; - meow_ui_text(20, g_top + 40, "本书无目录", 20, th->dim); + meow_ui_text(20, g_top + 40, (rd_book_chapter_n(g_book) == 0) ? "本书无章节" : "本书无目录", 20, th->dim); } } } @@ -629,12 +1013,13 @@ static void reader_scrub_preview(int page) static void reader_scrub_begin(int x, int y) { (void)x; - if (!g_book || g_menu_open || g_dir_open) return; + if (!g_book || g_menu_open || g_dir_open || g_jump_open) return; if (g_pw - x > RD_SCRUB_W) return; if (y < rect_y0() || y >= rect_y1()) return; int np = rd_book_pages(g_book); if (np <= 1) return; g_scrub = 1; + g_scrub_moved = 0; /* 每次触控重置;拖动(touch_move)才置 1,区分点按/刮擦 */ if (g_anim) { meow_anim_cancel(g_anim); g_anim = NULL; } reader_scrub_preview(scrub_page_for_y(y)); } @@ -710,9 +1095,10 @@ int reader_open(const char *path) reader_alloc_page_bufs(); /* 重建 cur+next 两槽;g_anim_old 翻页时懒分配 */ } - /* 复位动画/刮擦状态(跨书) */ + /* 复位动画/刮擦/覆盖层状态(跨书) */ if (g_anim) { meow_anim_cancel(g_anim); g_anim = NULL; } - g_scrub = 0; g_menu_open = 0; g_dir_open = 0; g_ui_open = 0; + g_scrub = 0; g_menu_open = 0; g_dir_open = 0; g_ui_open = 0; g_jump_open = 0; + g_chap_cache_idx = -1; /* 编码:初始 = 打开探测结果;进度里的显式编码(Big5 用户选定)优先重开 */ if (g_book && !rd_book_is_image(g_book)) { @@ -739,6 +1125,23 @@ int reader_open(const char *path) return 0; } +/* ---------- R3.4 章节重解析(app/settings.c 调用) ---------- */ +int reader_has_chapter_book(void) +{ + if (!g_book || rd_book_is_image(g_book)) return 0; + /* TXT + 分段流式(seg 模式)即支持章节解析;用 rd_book_seg_n(不触发扫描)判定。 */ + return (rd_book_seg_n(g_book) > 0) ? 1 : 0; +} + +int reader_reparse_chapters(void) +{ + if (!g_book) return -1; + int rc = rd_book_reparse_chapters(g_book); + if (rc >= 0) g_chap_cache_idx = -1; /* 章节列表变化 → 章范围缓存失效 */ + if (g_reader_active) reader_redraw(); + return rc; +} + /* ---------- 视图生命周期 ---------- */ static void reader_on_create(meow_view *v, void *ctx) { (void)v; (void)ctx; } static void reader_on_enter(meow_view *v, void *ctx) @@ -766,7 +1169,8 @@ static void reader_on_destroy(meow_view *v, void *ctx) if (g_book) rd_book_close(g_book); g_book = NULL; reader_free_page_bufs(); - g_menu_open = 0; g_dir_open = 0; g_ui_open = 0; g_scrub = 0; g_reader_active = 0; + g_menu_open = 0; g_dir_open = 0; g_ui_open = 0; g_jump_open = 0; g_scrub = 0; g_reader_active = 0; + g_chap_cache_idx = -1; } meow_view *reader_view_create(void) @@ -785,28 +1189,52 @@ void reader_on_tap(int x, int y) { (void)x; if (!g_book) { return; } - if (g_scrub) { /* 刮擦抬手 → 跳到目标页收尾 */ + if (g_scrub) { /* 刮擦抬手 → 跳到目标页收尾(仅真实拖动过才跳;点按右缘=翻下一页) */ g_scrub = 0; - reader_scrub_jump(g_scrub_page); + if (g_scrub_moved) { reader_scrub_jump(g_scrub_page); return; } + /* 未拖动:当作点按,落到下方三区翻页逻辑(右 1/3 下一页),避免右缘误触跳远页 */ + } + if (g_jump_open) { + /* 跳转弹层:快捷/键盘/条目 → 执行;点空白关闭 */ + int a = reader_jump_hit(x, y); + reader_jump_action(a); return; } if (g_dir_open) { - /* 目录覆盖层:点条目跳转,点空白关闭 */ - int n = rd_book_toc_n(g_book); + /* 目录覆盖层:TXT 章节(复用 rd_book 章节数据)或 rdsrc 目录;点条目跳转,点空白关闭 */ + int nchap = 0; + if (!rd_book_is_image(g_book) && rd_book_chapter_n(g_book) > 0) nchap = rd_book_chapter_n(g_book); + int n = (nchap > 0) ? nchap : rd_book_toc_n(g_book); if (n > 0 && x >= 0 && y >= g_dir_top) { int row = g_dir_row_h > 0 ? g_dir_row_h : 34; int idx = (y - g_dir_top) / row; if (idx < n && idx >= 0) { - char t[96]; int sg; int32_t cch; - if (rd_book_toc(g_book, idx, t, sizeof t, &sg, &cch) == 0) { - if (g_book && sg >= 0) { - rd_book_jump_to_anchor(g_book, sg, cch, g_fs, g_ls); - g_dir_open = 0; g_menu_open = 0; - snprintf(g_chapter, sizeof g_chapter, "%s", t); - reader_refill_page(); - reader_redraw(); - reader_reflow_persist(); - return; + if (nchap > 0) { + int64_t co = 0; + if (rd_book_chapter(g_book, idx, NULL, 0, &co, NULL, NULL, NULL) == 0) { + int sp = rd_book_page_for_char(g_book, co); + if (sp >= 0) { + rd_book_set_page(g_book, sp); + g_dir_open = 0; g_menu_open = 0; + g_chap_cache_idx = -1; + reader_refill_page(); + reader_redraw(); + reader_reflow_persist(); + return; + } + } + } else { + char t[96]; int sg; int32_t cch; + if (rd_book_toc(g_book, idx, t, sizeof t, &sg, &cch) == 0) { + if (g_book && sg >= 0) { + rd_book_jump_to_anchor(g_book, sg, cch, g_fs, g_ls); + g_dir_open = 0; g_menu_open = 0; + snprintf(g_chapter, sizeof g_chapter, "%s", t); + reader_refill_page(); + reader_redraw(); + reader_reflow_persist(); + return; + } } } } @@ -865,14 +1293,18 @@ void reader_on_tap(int x, int y) reader_reflow_persist(); meow_view_page_pop(); return; - case 1: /* 目录 */ - if (rd_book_toc_n(g_book) > 0) { g_dir_open = 1; g_menu_open = 0; reader_redraw(); } + case 1: /* 目录(TXT 章节 → 章节列表;无章节显示「本书无章节」) */ + g_dir_open = 1; g_menu_open = 0; reader_redraw(); + return; + case 2: /* 跳转(章节/文件·段模式) */ + if (rd_book_chapter_n(g_book) > 0 || rd_book_seg_n(g_book) > 0) { reader_jump_open(); } + else if (rd_book_toc_n(g_book) > 0) { g_dir_open = 1; g_menu_open = 0; reader_redraw(); } else { g_menu_open = 0; reader_redraw(); } return; - case 2: /* 界面(阅读排版设置) */ + case 3: /* 界面(阅读排版设置) */ g_ui_open = 1; g_menu_open = 0; reader_redraw(); return; - case 3: /* 设置 → App 设置页 */ + case 4: /* 设置 → App 设置页 */ reader_reflow_persist(); meow_view_page_push(ACT_SETTINGS); return; @@ -901,6 +1333,7 @@ void reader_on_swipe(int dir, int dx, int dy) * 刮擦(reader_scrub_begin,由 touch_down/move 驱动)两条既有路径。 */ if (g_ui_open) { g_ui_open = 0; reader_redraw(); return; } if (g_dir_open) { g_dir_open = 0; reader_redraw(); return; } + if (g_jump_open) { g_jump_open = 0; reader_redraw(); return; } if (g_menu_open) { g_menu_open = 0; reader_redraw(); return; } if (dir == MEOW_SWIPE_LEFT || dir == MEOW_SWIPE_RIGHT) { reader_reflow_persist(); @@ -913,7 +1346,7 @@ void reader_on_touch_down(int x, int y) { reader_scrub_begin(x, y); } void reader_on_touch_move(int x, int y) { (void)x; - if (g_scrub && g_book) reader_scrub_preview(scrub_page_for_y(y)); + if (g_scrub && g_book) { g_scrub_moved = 1; reader_scrub_preview(scrub_page_for_y(y)); } } /* 系统翻页信号(E):reader_ipc 线程收到 MSG_KEY_PAGEUP/PAGEDOWN 后置 pending 计数; @@ -932,9 +1365,12 @@ void reader_on_tick(uint64_t now) if (g_volpage_pending) { int dir = g_volpage_pending > 0 ? 1 : -1; g_volpage_pending = 0; - if (g_book && !g_menu_open && !g_dir_open && !g_ui_open) + if (g_book && !g_menu_open && !g_dir_open && !g_ui_open && !g_jump_open) reader_turn_animated(dir); } + /* Phase 2C:空闲预取下一段(距段界 ≤2% 时提前解码进窗口,翻段界零突发) */ + if (g_book && !g_menu_open && !g_dir_open && !g_ui_open && !g_jump_open) + rd_book_prefetch(g_book); /* R3.2:进度实时落盘(翻页/刮擦/字号/编码变更后,节流 ~1s 定时写)——意外崩溃重开不丢进度 */ if (g_prog_dirty && now - g_prog_last_save_ms >= 1000) { g_prog_last_save_ms = now; diff --git a/patch_v2/reader_app/app/settings.c b/patch_v2/reader_app/app/settings.c index 94f4d2e..84d38fd 100644 --- a/patch_v2/reader_app/app/settings.c +++ b/patch_v2/reader_app/app/settings.c @@ -20,10 +20,16 @@ static meow_widget *g_appbar; static meow_widget *g_list; static int g_active; -/* 行项枚举(顺序 = 列表顺序) */ -enum { ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_HDR, ITEM_FTR, ITEM_COUNT }; +/* 行项枚举(顺序 = 列表顺序)。ITEM_BASE = 固定的全局项数;重解析本书章节仅在 + * 已打开一本「支持章节解析」(TXT+seg 模式)的书时追加(见 settings_on_enter)。 */ +enum { + ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_HDR, ITEM_FTR, + ITEM_REPARSE, ITEM_COUNT +}; +#define ITEM_BASE ITEM_REPARSE /* 7:不含重解析的固定项数 */ +static int g_nitems = ITEM_BASE; static const char *const g_labels[ITEM_COUNT] = { - "默认字号", "默认行距", "默认背景", "主题", "书架布局", "页头条", "页底条" + "默认字号", "默认行距", "默认背景", "主题", "书架布局", "页头条", "页底条", "重解析本书章节" }; static void settings_redraw(void); @@ -61,6 +67,7 @@ static void item_value(int item, char *out, int outsz) case ITEM_SHELF: snprintf(out, (size_t)outsz, "%s", shelf_label(c)); break; case ITEM_HDR: snprintf(out, (size_t)outsz, "%s", c->header ? "显示" : "隐藏"); break; case ITEM_FTR: snprintf(out, (size_t)outsz, "%s", c->footer ? "显示" : "隐藏"); break; + case ITEM_REPARSE: snprintf(out, (size_t)outsz, "%s", "点按重新扫描章节"); break; default: snprintf(out, (size_t)outsz, "-"); break; } } @@ -69,15 +76,15 @@ static void item_value(int item, char *out, int outsz) static int settings_src(int idx, meow_list_item *out, void *ctx) { (void)ctx; - if (idx < 0 || idx >= ITEM_COUNT) return 0; - static char valbuf[16]; + if (idx < 0 || idx >= g_nitems || idx >= ITEM_COUNT) return 0; + static char valbuf[48]; item_value(idx, valbuf, sizeof valbuf); out->icon_kind = MEOW_LIST_ICON_NONE; out->title = g_labels[idx]; out->subtitle = valbuf; out->right = MEOW_LIST_RIGHT_ARROW; out->on = 0; - out->enabled = 1; + out->enabled = (idx == ITEM_REPARSE) ? reader_has_chapter_book() : 1; return 1; } @@ -147,6 +154,7 @@ static void apply_choice(int item, int sel) /* 打开一个 SELECT 对话框并应用。 */ static void settings_open_choice(int item) { + if (item == ITEM_REPARSE) return; /* 重解析走 settings_reparse(非 SELECT) */ const rdconf *c = rdconf_get(); if (!c) return; const char *title = g_labels[item]; @@ -191,6 +199,25 @@ static void settings_redraw(void) meow_ui_flush(); } +/* R3.4 «重解析本书章节»:确认 → 清缓存+重扫 → 结果对话框(成功/失败/进行中=确认弹窗)。 */ +static void settings_reparse(void) +{ + meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "重解析本书章节?\n将清除章节缓存并重新扫描全书。"); + if (!d) return; + meow_dialog_show(d); + int ok = meow_dialog_result(d); + meow_dialog_free(d); + if (ok != 1) { settings_redraw(); return; } /* 取消 */ + int rc = reader_reparse_chapters(); /* 阻塞:重扫全书段 */ + char msg[120]; + if (rc >= 0) snprintf(msg, sizeof msg, "重解析完成:共识别 %d 章(已保存缓存)", rc); + else if (rc == -2) snprintf(msg, sizeof msg, "重解析失败:扫描出错,已回退为无章节(未保存)"); + else snprintf(msg, sizeof msg, "该书不支持章节解析"); + meow_dialog *r = meow_dialog_create(MEOW_DLG_INFO, msg); + if (r) { meow_dialog_show(r); meow_dialog_result(r); meow_dialog_free(r); } + settings_redraw(); +} + /* view 生命周期 */ static void settings_on_create(meow_view *v, void *ctx) { (void)v; (void)ctx; } static void settings_on_enter(meow_view *v, void *ctx) @@ -203,7 +230,9 @@ static void settings_on_enter(meow_view *v, void *ctx) if (g_appbar) meow_topappbar_set_back(g_appbar, 1); if (g_list) { meow_widget_free(g_list); g_list = NULL; } int ly = sbh + SET_APPBAR_H + 8; - g_list = meow_listview_create(SET_X, ly, SET_W, 720 - ly, ITEM_COUNT, + /* 仅当打开的书「支持章节解析」(TXT+seg)才显示「重解析本书章节」项 */ + g_nitems = ITEM_BASE + (reader_has_chapter_book() ? 1 : 0); + g_list = meow_listview_create(SET_X, ly, SET_W, 720 - ly, g_nitems, settings_src, NULL, MEOW_LIST_SEL_SINGLE); if (g_list) { meow_listview_set_container(g_list, 1); @@ -240,7 +269,8 @@ int settings_on_tap(int x, int y) } if (g_list) { int idx = meow_listview_tap_at(g_list, x, y); - if (idx >= 0 && idx < ITEM_COUNT) { + if (idx >= 0 && idx < g_nitems && idx < ITEM_COUNT) { + if (idx == ITEM_REPARSE) { settings_reparse(); return 1; } settings_open_choice(idx); return 1; } diff --git a/patch_v2/reader_app/build_reader.sh b/patch_v2/reader_app/build_reader.sh index d26637a..8a77613 100755 --- a/patch_v2/reader_app/build_reader.sh +++ b/patch_v2/reader_app/build_reader.sh @@ -95,6 +95,10 @@ echo "==> host seg test (R3.2)" $HOST_CC -O2 -Wall -Wextra -Icore test/seg_smoke.c core/seg.c core/rdenc.c -o test/seg_smoke_host -lm ./test/seg_smoke_host +echo "==> host chapter test (R3.3)" +$HOST_CC -O2 -Wall -Wextra -Icore test/chapter_smoke.c core/rdchapter.c core/seg.c core/rdenc.c -o test/chapter_smoke_host -lm +./test/chapter_smoke_host + echo "==> host loaders test" HOBJ="$OBJ/host"; mkdir -p "$HOBJ" for f in vendor/miniz/miniz_tinfl.c vendor/miniz/miniz_tdef.c vendor/miniz/miniz_zip.c vendor/miniz/miniz.c; do @@ -119,21 +123,21 @@ timeout 120 qemu-mipsel ./test/rdft_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans- # ---------- 6) qemu-mipsel 书桥冒烟(TXT + 四格式) ---------- echo "==> mipsel book smoke (TXT)" $CC -Os -static -Wall -Wextra -Iapp -Icore -Iengine $FT_INCS $COREINC test/book_smoke.c \ - app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \ + app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \ core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c engine/rdft.c \ "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/book_smoke_mipsel timeout 120 qemu-mipsel ./test/book_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" echo "==> mipsel formats smoke" $CC -Os -static -Wall -Wextra -Iapp -Icore -Iengine $FT_INCS $COREINC test/formats_smoke.c \ - app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \ + app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \ core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c engine/rdft.c \ "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/formats_smoke_mipsel timeout 150 qemu-mipsel ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" /tmp/rd_samples # ---------- 7) reader 二进制(mipsel 静态) ---------- echo "==> reader binary" -CORE_L="core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c" +CORE_L="core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c" ENGINE_L="engine/rdft.c" APP_L="app.c app/shelf.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c" $CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \ diff --git a/patch_v2/reader_app/core/rdchapter.c b/patch_v2/reader_app/core/rdchapter.c new file mode 100644 index 0000000..fd5c1c4 --- /dev/null +++ b/patch_v2/reader_app/core/rdchapter.c @@ -0,0 +1,528 @@ +/* core/rdchapter.c — TXT 章节规范解析器实现(见 core/rdchapter.h)。 */ +#include "rdchapter.h" +#include "seg.h" +#include "rdtxt.h" +#include +#include +#include + +/* ---------------- 基础字符工具 ---------------- */ +static int ascii_digit_val(uint32_t cp) +{ + return (cp >= '0' && cp <= '9') ? (int)(cp - '0') : -1; +} + +/* CJK / 全角宽度 = 2,其余 = 1(近似;不含零宽组合符)。 */ +static int cp_width(uint32_t cp) +{ + if (cp == 0x3000 || cp == 0x3001 || cp == 0x3002) return 2; + if ((cp >= 0x2E80 && cp <= 0x303E) || (cp >= 0x3040 && cp <= 0x30FF) || + (cp >= 0x3100 && cp <= 0x318F) || (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) || + (cp >= 0xAC00 && cp <= 0xD7A3) || (cp >= 0xF900 && cp <= 0xFAFF) || + (cp >= 0xFE30 && cp <= 0xFE4F) || (cp >= 0xFF00 && cp <= 0xFF60) || + (cp >= 0xFFE0 && cp <= 0xFFE6)) return 2; + return 1; +} +static int disp_width(const uint32_t *cps, int s, int e) +{ + int w = 0; + for (int i = s; i < e; i++) w += cp_width(cps[i]); + return w; +} + +/* 章节标记字符集:章 回 节 卷 集 部 篇。 */ +static int is_marker(uint32_t cp) +{ + return cp == 0x7AE0 || cp == 0x56DE || cp == 0x8282 || + cp == 0x5377 || cp == 0x96C6 || cp == 0x90E8 || cp == 0x7BC7; +} + +/* 卷 部 集(卷/集类章节前缀)。 */ +static int is_volmark(uint32_t cp) +{ + return cp == 0x5377 /* 卷 */ || cp == 0x90E8 /* 部 */ || cp == 0x96C6 /* 集 */; +} + +/* 中文数字字符(含位权)。 */ +static int is_cn_num_char(uint32_t cp) +{ + return cp == 0x96F6 /* 零 */ || cp == 0x4E00 /* 一 */ || cp == 0x4E8C /* 二 */ || + cp == 0x4E09 /* 三 */ || cp == 0x56DB /* 四 */ || cp == 0x4E94 /* 五 */ || + cp == 0x516D /* 六 */ || cp == 0x4E03 /* 七 */ || cp == 0x516B /* 八 */ || + cp == 0x4E5D /* 九 */ || cp == 0x5341 /* 十 */ || cp == 0x767E /* 百 */ || + cp == 0x5343 /* 千 */ || cp == 0x4E07 /* 万 */ || cp == 0x4E24 /* 两 */; +} +static int cn_digit_val(uint32_t cp) +{ + switch (cp) { + case 0x96F6: return 0; /* 零 */ + case 0x4E00: return 1; /* 一 */ + case 0x4E8C: return 2; /* 二 */ + case 0x4E09: return 3; /* 三 */ + case 0x56DB: return 4; /* 四 */ + case 0x4E94: return 5; /* 五 */ + case 0x516D: return 6; /* 六 */ + case 0x4E03: return 7; /* 七 */ + case 0x516B: return 8; /* 八 */ + case 0x4E5D: return 9; /* 九 */ + case 0x4E24: return 2; /* 两 */ + default: return -1; + } +} +static int cn_weight_val(uint32_t cp) +{ + switch (cp) { + case 0x5341: return 10; /* 十 */ + case 0x767E: return 100; /* 百 */ + case 0x5343: return 1000; /* 千 */ + case 0x4E07: return 10000; /* 万 */ + default: return 0; + } +} + +/* 中文数字子串 [s,e) → 阿拉伯;非法/空返回 -1。实现:digit 累积 + 位权叠加。 */ +static int cn_num_from_cps(const uint32_t *cps, int s, int e) +{ + long total = 0, cur = 0, digit = 0; + int any = 0; + for (int i = s; i < e; i++) { + uint32_t cp = cps[i]; + int dv = cn_digit_val(cp); + if (dv >= 0) { digit = dv; any = 1; continue; } + int w = cn_weight_val(cp); + if (w > 0) { + cur += (digit ? digit : 1) * (long)w; + digit = 0; any = 1; + continue; + } + return -1; /* 非中文数字字符 */ + } + total += cur + digit; + return any ? (int)total : -1; +} + +/* 行内正文标点计数:句号/逗号/顿号/分号/问号/感叹号/省略号 + ASCII . , ; ! ? + * 返回计数(区分「极少」:≤1 视为标题,>1 视为正文)。 */ +static int sent_punct_count(const uint32_t *cps, int s, int e) +{ + int n = 0; + for (int i = s; i < e; i++) { + uint32_t cp = cps[i]; + if (cp == 0x3002 /* 。 */ || cp == 0xFF0C /* , */ || cp == 0x3001 /* 、 */ || + cp == 0xFF1B /* ; */ || cp == 0xFF1F /* ? */ || cp == 0xFF01 /* ! */ || + cp == 0x2026 /* … */ || cp == '.' || cp == ',' || cp == ';' || + cp == '!' || cp == '?') n++; + } + return n; +} + +/* cps[s..] 是否以 utf8 字面量为前缀(逐码点比较)。 */ +static int cps_prefix(const uint32_t *cps, int s, int e, const uint8_t *utf8) +{ + int i = s; + const uint8_t *p = utf8; + while (*p && i < e) { + uint32_t cp; + uint8_t b0 = *p; + int n; + if (b0 < 0x80) { cp = b0; n = 1; } + else if ((b0 & 0xE0) == 0xC0) { cp = ((uint32_t)(b0 & 0x1F) << 6) | (p[1] & 0x3F); n = 2; } + else if ((b0 & 0xF0) == 0xE0) { cp = ((uint32_t)(b0 & 0x0F) << 12) | ((uint32_t)(p[1] & 0x3F) << 6) | (p[2] & 0x3F); n = 3; } + else { cp = ((uint32_t)(b0 & 0x07) << 18) | ((uint32_t)(p[1] & 0x3F) << 12) | ((uint32_t)(p[2] & 0x3F) << 6) | (p[3] & 0x3F); n = 4; } + if (cps[i] != cp) return 0; + i++; p += n; + } + return (*p == 0) && (i > s); +} + +/* ASCII 大小写无关前缀比较(用于 "chapter")。 */ +static int cps_prefix_ci(const uint32_t *cps, int s, int e, const char *ascii) +{ + int i = s; + for (const char *p = ascii; *p; p++) { + if (i >= e) return 0; + uint32_t cp = cps[i++]; + uint32_t lo = (cp >= 'A' && cp <= 'Z') ? cp + 32 : cp; + char ch = *p; + if (lo != (uint32_t)ch) return 0; + } + return 1; +} + +/* 罗马数字 → 阿拉伯(ivxl);解析失败 -1。 */ +static int roman_to_int(const uint32_t *cps, int s, int e, int *end) +{ + struct { uint32_t cp; int v; } tbl[] = { + { 'i', 1 }, { 'v', 5 }, { 'x', 10 }, { 'l', 50 }, { 'c', 100 }, + }; + int vals[32], k = 0; + int i = s; + while (i < e && k < 32) { + uint32_t cp = cps[i]; + if (cp >= 'A' && cp <= 'Z') cp += 32; /* 小写化 */ + int v = 0; + for (int t = 0; t < 5; t++) if (tbl[t].cp == cp) { v = tbl[t].v; break; } + if (v == 0) break; + vals[k++] = v; i++; + } + if (k == 0) return -1; + int total = 0, prev = 0; + for (int j = k - 1; j >= 0; j--) { + int v = vals[j]; + if (v < prev) total -= v; else total += v; + prev = v; + } + *end = i; + return total; +} + +/* ---- 英文数字词(Chapter one / twenty-one / two hundred fifty ...)→ 阿拉伯 ---- */ +static int cps_ascii_word_eq_ci(const uint32_t *cps, int s, int e, const char *w) +{ + int len = (int)strlen(w); + if (e - s != len) return 0; + for (int k = 0; k < len; k++) { + uint32_t c = cps[s + k]; + if (c >= 'A' && c <= 'Z') c += 32; + if (c != (uint32_t)(unsigned char)w[k]) return 0; + } + return 1; +} + +/* 英文数字词一 → 值;不识别返回 -1。覆盖 one..nineteen、twenty..ninety、hundred、thousand。 */ +static int en_word_val(const uint32_t *cps, int s, int e) +{ + static const struct { const char *w; int v; } tbl[] = { + { "one", 1 }, { "two", 2 }, { "three", 3 }, { "four", 4 }, { "five", 5 }, + { "six", 6 }, { "seven", 7 }, { "eight", 8 }, { "nine", 9 }, { "ten", 10 }, + { "eleven", 11 }, { "twelve", 12 }, { "thirteen", 13 }, { "fourteen", 14 }, + { "fifteen", 15 }, { "sixteen", 16 }, { "seventeen", 17 }, { "eighteen", 18 }, + { "nineteen", 19 }, { "twenty", 20 }, { "thirty", 30 }, { "forty", 40 }, + { "fifty", 50 }, { "sixty", 60 }, { "seventy", 70 }, { "eighty", 80 }, + { "ninety", 90 }, { "hundred", 100 }, { "thousand", 1000 }, + }; + for (int t = 0; t < (int)(sizeof tbl / sizeof tbl[0]); t++) + if (cps_ascii_word_eq_ci(cps, s, e, tbl[t].w)) return tbl[t].v; + return -1; +} + +/* 解析一段英文数字词(one / twenty-one / two hundred fifty ...)。*end=末字符位置。 + * 失败(无词/未知词/越界/数值越界)返回 -1。 */ +static int word_num_from_cps(const uint32_t *cps, int s, int e, int *end) +{ + long total = 0, cur = 0; + int i = s, words = 0, last_end = s; + while (i < e) { + while (i < e && (cps[i] == ' ' || cps[i] == '-' || cps[i] == 0x3000 || cps[i] == '\t')) i++; + int ws = i; + while (i < e && ((cps[i] >= 'a' && cps[i] <= 'z') || (cps[i] >= 'A' && cps[i] <= 'Z'))) i++; + if (i == ws) break; + int v = en_word_val(cps, ws, i); + if (v < 0) return -1; + if (v == 100 || v == 1000) { long base = (cur == 0) ? 1 : cur; total += base * (long)v; cur = 0; } + else cur += (long)v; + words++; + last_end = i; + } + if (words == 0) return -1; + long n = total + cur; + if (n <= 0 || n > 100000) return -1; + *end = last_end; + return (int)n; +} + +/* 把 cps[s..e) 转成 UTF-8 到 out(有界)。 */ +static void cps_to_utf8(const uint32_t *cps, int s, int e, char *out, int outsz) +{ + int o = 0; + for (int i = s; i < e; i++) { + uint8_t b[4]; + int n = rd_cp_to_utf8(cps[i], b); + if (o + n + 1 > outsz) break; + for (int k = 0; k < n; k++) out[o++] = (char)b[k]; + } + out[o] = 0; +} + +/* 特殊前缀章节(楔子/序章/序言/引子/前言/序幕/尾声/后记/番外/外传/终章/大结局/正文)。 */ +static const uint8_t *const g_special[] = { + (const uint8_t *)"\xE6\xA5\x94\xE5\xAD\x90", /* 楔子 */ + (const uint8_t *)"\xE5\xBA\x8F\xE7\xAB\xA0", /* 序章 */ + (const uint8_t *)"\xE5\xBA\x8F\xE8\xA8\x80", /* 序言 */ + (const uint8_t *)"\xE5\xBC\x95\xE5\xAD\x90", /* 引子 */ + (const uint8_t *)"\xE5\x89\x8D\xE8\xA8\x80", /* 前言 */ + (const uint8_t *)"\xE5\xBA\x8F\xE5\xB9\x95", /* 序幕 */ + (const uint8_t *)"\xE5\xB0\xBE\xE5\xA3\xB0", /* 尾声 */ + (const uint8_t *)"\xE5\x90\x8E\xE8\xAE\xB0", /* 后记 */ + (const uint8_t *)"\xE7\x95\xAA\xE5\xA4\x96", /* 番外 */ + (const uint8_t *)"\xE5\xA4\x96\xE4\xBC\xA0", /* 外传 */ + (const uint8_t *)"\xE7\xBB\x88\xE7\xAB\xA0", /* 终章 */ + (const uint8_t *)"\xE5\xA4\xA7\xE7\xBB\x93\xE5\xB1\x80", /* 大结局 */ + (const uint8_t *)"\xE6\xAD\xA3\xE6\x96\x87", /* 正文 */ +}; +#define G_SPECIAL_N ((int)(sizeof g_special / sizeof g_special[0])) + +/* 判断一行(cps[ls..le),ls=首个非空白字符)是否为章节标题行。 + * 命中则填 title(完整行)与 *num;返回 1,否则 0。 */ +static int chap_match_line(const uint32_t *cps, int ls, int le, char *title, int outsz, int *num) +{ + if (ls >= le) return 0; + if (disp_width(cps, ls, le) > RDCHAP_DISP_WMAX) return 0; + + /* A/B:`第` + 阿拉伯/中文数字 + 章回节卷集部篇 */ + if (cps[ls] == 0x7B2C /* 第 */) { + int i = ls + 1; + int numv = -1; + int dstart = i; + while (i < le && ascii_digit_val(cps[i]) >= 0) i++; + if (i > dstart) { + if (i - dstart > 4) return 0; + numv = 0; + for (int k = dstart; k < i; k++) numv = numv * 10 + ascii_digit_val(cps[k]); + } else { + int cstart = i; + while (i < le && is_cn_num_char(cps[i])) i++; + if (i == cstart) return 0; + if (i - cstart > 8) return 0; + numv = cn_num_from_cps(cps, cstart, i); + if (numv < 0) return 0; + } + if (i >= le || !is_marker(cps[i])) return 0; + i++; /* 消费标记 */ + if (sent_punct_count(cps, i, le) > 1) return 0; + cps_to_utf8(cps, ls, le, title, outsz); + *num = numv; + return 1; + } + + /* C:阿拉伯数字 + 可选分隔符(、.. 空格 全角空格) + 章回节卷集部篇 */ + if (ascii_digit_val(cps[ls]) >= 0) { + int i = ls, numv = 0, cnt = 0; + while (i < le && ascii_digit_val(cps[i]) >= 0) { numv = numv * 10 + ascii_digit_val(cps[i]); i++; cnt++; } + if (cnt < 1 || cnt > 4) return 0; + if (i < le && (cps[i] == 0x3001 || cps[i] == '.' || cps[i] == ' ' || cps[i] == 0x3000)) i++; + if (i >= le || !is_marker(cps[i])) return 0; + i++; + if (sent_punct_count(cps, i, le) > 1) return 0; + cps_to_utf8(cps, ls, le, title, outsz); + *num = numv; + return 1; + } + + /* F:卷/部/集 + 空白 + 中文数字(卷一 / 卷 一 / 集三) */ + if (is_volmark(cps[ls])) { + int i = ls + 1; + while (i < le && (cps[i] == ' ' || cps[i] == 0x3000)) i++; + int cstart = i; + while (i < le && is_cn_num_char(cps[i])) i++; + if (i == cstart) return 0; + int numv = cn_num_from_cps(cps, cstart, i); + if (numv < 0) return 0; + if (sent_punct_count(cps, i, le) > 1) return 0; + cps_to_utf8(cps, ls, le, title, outsz); + *num = numv; + return 1; + } + + /* E:Chapter N(N=阿拉伯 / 罗马数字 / 英文数字词 one..hundred) */ + if (cps_prefix_ci(cps, ls, le, "chapter")) { + int i = ls + 7; + if (i >= le) return 0; + if (!(cps[i] == ' ' || cps[i] == 0x3000 || cps[i] == '\t')) return 0; /* 必须空白:ChapterX 不认 */ + while (i < le && (cps[i] == ' ' || cps[i] == 0x3000 || cps[i] == '\t')) i++; + int numv; + if (ascii_digit_val(cps[i]) >= 0) { + numv = 0; int cnt = 0; + while (i < le && ascii_digit_val(cps[i]) >= 0) { numv = numv * 10 + ascii_digit_val(cps[i]); i++; cnt++; } + if (cnt < 1 || cnt > 5) return 0; + } else { + int endi = i; + numv = roman_to_int(cps, i, le, &endi); + if (numv < 0) { endi = i; numv = word_num_from_cps(cps, i, le, &endi); } + if (numv < 0) return 0; + i = endi; + } + if (sent_punct_count(cps, i, le) > 1) return 0; + cps_to_utf8(cps, ls, le, title, outsz); + *num = numv; + return 1; + } + + /* D:特殊前缀(楔子/序章/…)——要求整行无正文标点(避免「正文内容。」/「番外正文。」误判) */ + for (int t = 0; t < G_SPECIAL_N; t++) { + if (cps_prefix(cps, ls, le, g_special[t])) { + int i = ls; + for (const uint8_t *p = g_special[t]; *p; ) { /* 消费前缀码点 */ + uint8_t b0 = *p; + int n = (b0 < 0x80) ? 1 : ((b0 & 0xE0) == 0xC0) ? 2 : ((b0 & 0xF0) == 0xE0) ? 3 : 4; + i++; p += n; + } + if (sent_punct_count(cps, i, le) > 0) return 0; + cps_to_utf8(cps, ls, le, title, outsz); + *num = -1; + return 1; + } + } + + return 0; +} + +int rdchapter_scan(const char *seg_dir, rd_enc_t enc, const char *book_basename, rdchapter *c) +{ + if (!c) return -1; + rdchapter_init(c); + if (!seg_dir || !seg_dir[0]) return 0; /* 无段目录 → 无章节 */ + + int nsegs = rdseg_n(seg_dir); + if (nsegs < 0) return -1; + if (nsegs == 0) { c->nsegs = 0; return 0; } + + c->seg_char_start = (int64_t *)malloc((size_t)(nsegs + 1) * sizeof(int64_t)); + if (!c->seg_char_start) return -6; + c->nsegs = nsegs; + + int ent_cap = 0; + int64_t global = 0; + int err = 0; + + uint8_t *raw = NULL; size_t rawcap = 0; + uint8_t *utf8 = NULL; size_t utf8cap = 0; + uint32_t *cps = NULL; + + for (int seg = 0; seg < nsegs && !err; seg++) { + size_t rawlen = 0; + if (rdseg_rawlen(seg_dir, seg, &rawlen) != 0) { err = 1; break; } + if (rawlen > rawcap) { + uint8_t *t = (uint8_t *)realloc(raw, rawlen ? rawlen : 1); + if (!t) { err = -6; break; } + raw = t; rawcap = rawlen; + } + size_t rn = 0; + if (rdseg_load(seg_dir, seg, raw, rawlen, &rn) != 0) { err = 2; break; } + size_t ulen = 0; + size_t need = rawlen * 3 / 2 + 16; + if (need < RDTXT_SEG_UTF8_MAX + 8) need = RDTXT_SEG_UTF8_MAX + 8; + if (need > utf8cap) { + uint8_t *t = (uint8_t *)realloc(utf8, need); + if (!t) { err = -6; break; } + utf8 = t; utf8cap = need; + } + if (rd_to_utf8(enc, raw, rn, utf8, utf8cap, &ulen) != RD_ENC_OK) { err = 3; break; } + size_t n = 0; + /* 解码为码点失败(多为内存不足)→ 视作扫描失败:不落盘坏结果(见 rdchapter_scan 头部注释)。 */ + if (rd_utf8_to_cp(utf8, ulen, &cps, &n) != RD_ENC_OK) { cps = NULL; err = -6; break; } + c->seg_char_start[seg] = global; /* 本段全局字符起点 */ + + if (cps && n > 0) { + int ls = 0; + while (ls < (int)n) { + int le = ls; + while (le < (int)n && cps[le] != '\n') le++; + int indent = 0, first = ls; + while (first < le && (cps[first] == ' ' || cps[first] == '\t' || cps[first] == 0x3000)) { + indent++; first++; + } + int te = le; + while (te > first && (cps[te - 1] == ' ' || cps[te - 1] == '\t' || + cps[te - 1] == '\r' || cps[te - 1] == 0x3000)) te--; + if (indent <= RDCHAP_INDENT_MAX && first < te) { + char tbuf[RDCHAP_TITLE_MAX]; + int num = -1; + if (chap_match_line(cps, first, te, tbuf, sizeof tbuf, &num) && c->n < RDCHAP_MAX_ENTRIES) { + if (ent_cap <= c->n) { + int nc = ent_cap ? ent_cap * 2 : 64; + if (nc > RDCHAP_MAX_ENTRIES) nc = RDCHAP_MAX_ENTRIES; + rdchapter_entry *ne = (rdchapter_entry *)realloc(c->entries, (size_t)nc * sizeof(rdchapter_entry)); + if (!ne) { err = -6; break; } + c->entries = ne; ent_cap = nc; + } + rdchapter_entry *en = &c->entries[c->n]; + memset(en, 0, sizeof *en); + snprintf(en->title, sizeof en->title, "%s", tbuf); + en->char_off = global + first; + en->seg_idx = seg; + en->seg_char = (int32_t)first; + en->number = num; + c->n++; + } + } + if (le >= (int)n) break; + ls = le + 1; + } + free(cps); cps = NULL; + } + global += (int64_t)n; + } + free(cps); + free(raw); + free(utf8); + + if (err) { rdchapter_free(c); return err; } + + /* 段数恒定:seg_char_start[nsegs] = 全书字符数(尾哨兵)。 */ + if (global > (int64_t)0x7fffffffLL) { rdchapter_free(c); return -7; } /* 安全上限 */ + c->seg_char_start[nsegs] = global; + + /* 卷首/前置:第一章节前有内容 → 作为 entries[0](title=文件名或"卷首",号=-1)。 */ + if (c->n > 0 && c->entries[0].char_off > 0) { + if (c->n >= RDCHAP_MAX_ENTRIES) { /* 已满则跳过卷首(理论) */ } + else { + memmove(&c->entries[1], &c->entries[0], (size_t)c->n * sizeof(rdchapter_entry)); + rdchapter_entry *pre = &c->entries[0]; + memset(pre, 0, sizeof *pre); + const char *bn = (book_basename && book_basename[0]) ? book_basename : "卷首"; + strncpy(pre->title, bn, sizeof pre->title - 1); + pre->title[sizeof pre->title - 1] = 0; + pre->char_off = 0; + pre->seg_idx = 0; + pre->seg_char = 0; + pre->number = -1; + c->n++; + c->has_pre = 1; + snprintf(c->pre_title, sizeof c->pre_title, "%s", bn); + } + } else if (book_basename && book_basename[0]) { + snprintf(c->pre_title, sizeof c->pre_title, "%s", book_basename); + } else { + snprintf(c->pre_title, sizeof c->pre_title, "%s", "卷首"); + } + + return 0; +} + +void rdchapter_init(rdchapter *c) +{ + if (!c) return; + memset(c, 0, sizeof *c); +} + +void rdchapter_free(rdchapter *c) +{ + if (!c) return; + free(c->entries); c->entries = NULL; + free(c->seg_char_start); c->seg_char_start = NULL; + c->n = c->nsegs = c->has_pre = 0; + c->pre_title[0] = 0; +} + +int rdchapter_cn_num(const char *s, int len) +{ + if (!s || len <= 0) return -1; + uint32_t local[16]; + int n = 0; + int i = 0; + while (i < len && n < 16) { + uint8_t b0 = (uint8_t)s[i]; + uint32_t cp; int nb; + if (b0 < 0x80) { cp = b0; nb = 1; } + else if ((b0 & 0xE0) == 0xC0) { cp = ((uint32_t)(b0 & 0x1F) << 6) | ((uint8_t)s[i + 1] & 0x3F); nb = 2; } + else if ((b0 & 0xF0) == 0xE0) { cp = ((uint32_t)(b0 & 0x0F) << 12) | ((uint32_t)((uint8_t)s[i + 1] & 0x3F) << 6) | ((uint8_t)s[i + 2] & 0x3F); nb = 3; } + else { cp = ((uint32_t)(b0 & 0x07) << 18) | ((uint32_t)((uint8_t)s[i + 1] & 0x3F) << 12) | ((uint32_t)((uint8_t)s[i + 2] & 0x3F) << 6) | ((uint8_t)s[i + 3] & 0x3F); nb = 4; } + if (!is_cn_num_char(cp)) break; + local[n++] = cp; + i += nb; + } + if (n == 0) return -1; + return cn_num_from_cps(local, 0, n); +} diff --git a/patch_v2/reader_app/core/rdchapter.h b/patch_v2/reader_app/core/rdchapter.h new file mode 100644 index 0000000..28a9a55 --- /dev/null +++ b/patch_v2/reader_app/core/rdchapter.h @@ -0,0 +1,61 @@ +/* core/rdchapter.h — TXT 章节规范解析器:扫描已切好的 512KB 码位对齐段文件(core/seg.c), + * 识别章节标题行,输出章节列表(每项:标题、全书字符偏移、所在段/段内字符偏移、阿拉伯章号)。 + * + * 设计约束: + * - 逐段流式(`rdseg_load` 读一个段 → `rd_to_utf8` 解码 → `rd_utf8_to_cp` 数码点 → 行扫), + * 不整书驻留;单段内存与 book.c `seg_ensure_built` 同量级(~512KB 原文 + ~3 倍 UTF-8)。 + * - 章节标题行约束:行首缩进 ≤4 半角/全角空格;标题显示宽度 ≤ RDCHAP_DISP_WMAX(CJK=2/ASCII=1); + * 行内正文标点 ≤1(句号/逗号/省略号等,容「极少」);行首锚定 + 短 + 无成段标点 → 防误判 + * (「他说第三章内容」不居行首即不命中;目录页交错等已知限制,见报告)。 + * - 中文数字 → 阿拉伯(`rdchapter_cn_num`,用于排序/显示);按 char_off 升序输出。 + * - 开头书名/简介区(第一章节之前的内容)归为「卷首/前置」,作为条目[0](title=文件名或"卷首")。 + * + * 纯 libc + rdenc + seg,无 SDK/FreeType;宿主可测。 + */ +#ifndef RDCHAPTER_H +#define RDCHAPTER_H + +#include "rdenc.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define RDCHAP_TITLE_MAX 160 /* 单个标题字符(UTF-8 字节)上限 */ +#define RDCHAP_DISP_WMAX 40 /* 标题最大显示宽度(CJK=2 / ASCII=1) */ +#define RDCHAP_INDENT_MAX 4 /* 行首缩进上限(空格/全角空格/制表,按字符计) */ +#define RDCHAP_MAX_ENTRIES 200000 /* 章节数上限(防失控内存) */ + +typedef struct rdchapter_entry { + char title[RDCHAP_TITLE_MAX]; /* 完整标题行(如「第一章 风雨」「楔子」),UTF-8 */ + int64_t char_off; /* 全书字符偏移(段 0 起,跨段累计;标题首字符) */ + int32_t seg_idx; /* 所在 512KB 段下标 */ + int32_t seg_char; /* 段内字符偏移(标题首字符) */ + int32_t number; /* 阿拉伯章号;-1=无号(如楔子/卷首/序章) */ +} rdchapter_entry; + +typedef struct rdchapter { + rdchapter_entry *entries; + int n; /* 章节数;0=本书无章节(扫描过) */ + int64_t *seg_char_start;/* seg_char_start[seg]=该段在全书中的字符起点;[nsegs]=全书字符数 */ + int nsegs; /* 段数(= seg_char_start 数组长 -1) */ + char pre_title[RDCHAP_TITLE_MAX]; /* 卷首/前置标题(文件名或"卷首") */ + int has_pre; /* 1=存在卷首/前置(第一章节前有内容) */ +} rdchapter; + +void rdchapter_init(rdchapter *c); +void rdchapter_free(rdchapter *c); + +/* 扫描 seg_dir 下全部 512KB 段文件,识别章节标题。book_basename 用于卷首标题(可为 NULL/"卷首")。 + * 0=成功(c->n=章节数,可为 0);负=错误(seg_dir 无效/无段/解码失败等)。 */ +int rdchapter_scan(const char *seg_dir, rd_enc_t enc, const char *book_basename, rdchapter *c); + +/* 中文数字 → 阿拉伯(「二十三」→23,「一百零一」→101,「两」→2)。s/len 为 UTF-8;非法返回 -1。 */ +int rdchapter_cn_num(const char *s, int len); + +#ifdef __cplusplus +} +#endif +#endif /* RDCHAPTER_H */ diff --git a/patch_v2/reader_app/core/rdenc.c b/patch_v2/reader_app/core/rdenc.c index be7a9bd..0f9b29f 100644 --- a/patch_v2/reader_app/core/rdenc.c +++ b/patch_v2/reader_app/core/rdenc.c @@ -312,7 +312,12 @@ int rd_utf8_to_cp(const uint8_t *utf8, size_t len, uint32_t **cps, size_t *n) size_t cnt = 0, i = 0; while (i < len) { uint8_t b = utf8[i]; - if (b < 0x80) { arr[cnt++] = b; i++; continue; } + if (b < 0x80) { + /* \r\n → \n、孤 \r → \n:TXT 行尾 \r 若进排版,在无 U+000D 映射的字体里落成 + * .notdef(豆腐/占宽);归一化后章节/页表/进度锚同处一个无 \r 码点空间。 */ + if (b == '\r') { arr[cnt++] = '\n'; i++; if (i < len && utf8[i] == '\n') i++; continue; } + arr[cnt++] = b; i++; continue; + } unsigned q, lo, hi; if ((b & 0xE0) == 0xC0) { q = 2; lo = 0x80; hi = 0xBF; } else if ((b & 0xF0) == 0xE0) { q = 3; lo = 0x80; hi = 0xBF; } diff --git a/patch_v2/reader_app/core/rdlayout.c b/patch_v2/reader_app/core/rdlayout.c index 3e681a1..66bdbf9 100644 --- a/patch_v2/reader_app/core/rdlayout.c +++ b/patch_v2/reader_app/core/rdlayout.c @@ -226,6 +226,12 @@ int rd_layout(const rd_layout_in *in, rd_layout_out *out) npages++; page_start = np_start; cur_y = 0; + /* 孤行上推修正:被推行(l-1)成为新页首行 → 按页首行重算其 y 并计入页内高度, + * 否则它带着上一页的 y 残值(渲染错位)且页填充少算一行高(页底溢出)。 */ + if (np_start == l - 1) { + out->lines[l - 1].y = 0; + cur_y = lh; + } } /* 重新计算本行在本页的 top(若 page_start 因孤儿调整而变化,extra 需重算) */ extra = (out->lines[l].para_start && l > page_start) ? pspacing : 0; @@ -245,6 +251,63 @@ int rd_layout(const rd_layout_in *in, rd_layout_out *out) return RD_LAYOUT_OK; } +int rd_layout_page(const rd_layout_in *in, int32_t start, int mid_para, + rd_line *lines, int max_lines, int *nlines, int32_t *next_start) +{ + if (!in || !in->chars || in->n < 0 || !lines || !nlines || !next_start || max_lines <= 0) + return RD_LAYOUT_ERR_INVAL; + if (in->fs <= 0 || in->page_w <= 0 || in->page_h <= 0) return RD_LAYOUT_ERR_INVAL; + if (start < 0) start = 0; + if (start > in->n) start = in->n; + + int lh = (in->fs * in->line_scale) / 100; + if (lh < 1) lh = 1; + int pspacing = lh / 2; + if (pspacing < 0) pspacing = 0; + + int nl = 0; + int y = 0; /* 页内累计(含段距;规则同 rd_layout 阶段 B 单页填充) */ + int32_t ps = start; + int first_para = 1; /* 首段:首行状态由 mid_para 给定(续行不缩进/非段首) */ + + while (ps < in->n) { + while (ps < in->n && (in->chars[ps] == '\n' || in->chars[ps] == '\r')) ps++; + if (ps >= in->n) break; + int32_t pe = ps; + while (pe < in->n && in->chars[pe] != '\n') pe++; + + int32_t pos = ps; + int first_line = first_para ? !mid_para : 1; + first_para = 0; + while (pos < pe) { + rd_line ln; + para_break_line(in, pe, &pos, first_line, &ln); + first_line = 0; + /* 页填充判定(同 rd_layout 阶段 B:页内首行不加段距;溢出即页满) */ + int extra = (ln.para_start && nl > 0) ? pspacing : 0; + if (y + lh + extra > in->page_h && nl > 0) { + /* 孤行守卫:页末行是段首行且段未结束 → 该行随段入下一页(同 rd_layout) */ + int cut = nl; + if (lines[nl - 1].para_start && !lines[nl - 1].para_end) cut = nl - 1; + *nlines = cut; + *next_start = (cut < nl) ? lines[cut].s : ln.s; + return RD_LAYOUT_OK; + } + if (nl >= max_lines) { *nlines = nl; *next_start = ln.s; return RD_LAYOUT_ERR_OVER; } + ln.y = y + extra; + y += lh + extra; + lines[nl++] = ln; + if (ln.para_end) break; + } + ps = pos; + while (ps < in->n && in->chars[ps] == '\n') ps++; + if (pos >= pe && ps >= in->n) break; + } + *nlines = nl; + *next_start = in->n; + return RD_LAYOUT_OK; +} + int rd_layout_page_for_anchor(const rd_layout_out *out, int32_t anchor_char) { if (!out || out->npages <= 0) return 0; diff --git a/patch_v2/reader_app/core/rdlayout.h b/patch_v2/reader_app/core/rdlayout.h index a872164..3b1e7db 100644 --- a/patch_v2/reader_app/core/rdlayout.h +++ b/patch_v2/reader_app/core/rdlayout.h @@ -65,6 +65,14 @@ typedef struct rd_layout_out { int rd_layout(const rd_layout_in *in, rd_layout_out *out); +/* 页级微排版(Phase 2C):从 chars[start](须为行首:start==0 或 chars[start-1]=='\n')起只排一页, + * 填满 page_h 即停。mid_para=1 表示 start 行是段中续行(不缩进、非段首行);=0 表示段首行 + * (缩进 + para_start,与 rd_layout 首行规则一致)。输出 lines[0..*nlines)(内容区坐标, + * 与 rd_layout 同断行/分页/孤行守卫规则);*next_start = 下一页首字符偏移(== in->n 表示文末)。 + * 一致性:同一 (chars, start, mid_para) 下产出 == rd_layout 全排版中该页的行区间(逐行 diff 可证)。 */ +int rd_layout_page(const rd_layout_in *in, int32_t start, int mid_para, + rd_line *lines, int max_lines, int *nlines, int32_t *next_start); + /* 重排锚定:在 layout 输出里找第一个满足 `line.s <= anchor_char < 下个 line.s` 的**行**所属的页。 * 返回页下标(0..npages-1);anchor_char 越界 clamp 到首/末页。 */ int rd_layout_page_for_anchor(const rd_layout_out *out, int32_t anchor_char); diff --git a/patch_v2/reader_app/pack_reader_mpk.sh b/patch_v2/reader_app/pack_reader_mpk.sh index 97e1f3d..d92d87b 100755 --- a/patch_v2/reader_app/pack_reader_mpk.sh +++ b/patch_v2/reader_app/pack_reader_mpk.sh @@ -1,10 +1,11 @@ #!/bin/bash # pack_reader_mpk.sh — 把 os.meow.reader 打成 .mpk(tar.gz),并把 LGPL 合规材料并入包内。 -# .mpk = app.json + reader 二进制 + icon.png + resources/licenses/{NOTICE,GPL-3.0.txt,LGPL-3.0.txt} +# .mpk = app.json + reader 二进制 + icon.png + resources/licenses/{NOTICE,GPL-3.0.txt,LGPL-3.0.txt,FTL.txt} # (不含 data/cache;符合 APPS.md §3 与 mips-repo/mpk/README.md 的 .mpk 规范) # -# 合规目的:让「接收者随包拿到」NOTICE + GPL-3.0 全文 + LGPL-3.0 全文, -# 满足 LGPL-3.0 §4(a)(显著通知)与 §4(b)(随 Combined Work 附 GPL + LGPL 两份文本)。 +# 合规目的:让「接收者随包拿到」NOTICE + GPL-3.0 全文 + LGPL-3.0 全文 + FreeType(FTL) 全文, +# 满足 LGPL-3.0 §4(a)(显著通知)、§4(b)(随 Combined Work 附 GPL + LGPL 两份文本)与 +# FTL 署名条款(随二进制分发附许可文本与版权声明)。 # # 用法: # ./build_reader.sh # 先编出 reader 二进制(本脚本不重编) @@ -31,6 +32,7 @@ cp icon.png "$STAGE/" 2>/dev/null || true cp reader "$STAGE/" mkdir -p "$STAGE/resources/licenses" cp resources/licenses/NOTICE resources/licenses/GPL-3.0.txt resources/licenses/LGPL-3.0.txt \ + resources/licenses/FTL.txt \ "$STAGE/resources/licenses/" # busybox tar + gzip(与 launcher/nyai 同管道语义);-cz 直接出 . @@ -40,6 +42,6 @@ echo "== packed $MPK ==" tar tzvf "$MPK" echo "md5: $(md5sum "$MPK" | cut -d' ' -f1)" echo -echo "含合规材料:resources/licenses/NOTICE + GPL-3.0.txt + LGPL-3.0.txt" +echo "含合规材料:resources/licenses/NOTICE + GPL-3.0.txt + LGPL-3.0.txt + FTL.txt" echo "未签名。正式分发前 run:" echo " ../meow_sign/meow_sign sign $MPK \"\$HOME/Project/R3ProII/.keys/signing.key\"" diff --git a/patch_v2/reader_app/reader_internal.h b/patch_v2/reader_app/reader_internal.h index f15d716..dd25308 100644 --- a/patch_v2/reader_app/reader_internal.h +++ b/patch_v2/reader_app/reader_internal.h @@ -34,6 +34,11 @@ void reader_on_touch_move(int x, int y); * 返回 0=成功(书已打开、页位图已备、可 push);非 0=失败(未能打开,调用方不 push)。 */ int reader_open(const char *path); +/* R3.4 章节重解析(app/settings.c 调用):是否打开了支持章节解析的书(TXT+seg 模式)。 + * reader_reparse_chapters 返回 n=章节数 / 0=无章节(成功)/ -1=不可用 / -2=扫描失败(未落盘)。 */ +int reader_has_chapter_book(void); +int reader_reparse_chapters(void); + /* ---- 全局设置页(app/settings.c,C)---- */ meow_view *settings_view_create(void); int settings_is_active(void); diff --git a/patch_v2/reader_app/test/book_smoke.c b/patch_v2/reader_app/test/book_smoke.c index 2b6bbcb..84505fb 100644 --- a/patch_v2/reader_app/test/book_smoke.c +++ b/patch_v2/reader_app/test/book_smoke.c @@ -20,10 +20,82 @@ static void write_txt(const char *path) "它包含汉字、标点,以及一些 English words 混合排版。" "断行、避头尾与分页行为都要正确。\n", i + 1); } - fprintf(f, "\nChapter two: the quick brown fox jumps over the lazy dog.\n"); + fprintf(f, "\n第二章 尾声\n这是一段章尾正文,用于验证章节跳转与章内翻页。\n"); fclose(f); } +/* ---- Phase 2A/2C:跨段拼接页连续性(硬用例)。 + * 构造恰好 512KB 的段 0(5242×100B + 88B,末行为短段落),段 1 开头放唯一标记 SEG1MARKER; + * 建段 1 触发滚动拼接后,段界页(段 0 末页)应包含段 1 开头的文本(拼接页含两段文本), + * 且跨段往返翻页后锚 (seg,ch) 稳定。 ---- */ +typedef struct { char *buf; size_t len, cap; } linecol; +static void linecol_add(int x, int y, const char *utf8, int len, void *ctx) +{ + (void)x; (void)y; + linecol *lc = (linecol *)ctx; + if (lc->len + (size_t)len + 2 > lc->cap) { + size_t nc = lc->cap ? lc->cap * 2 : 4096; + while (nc < lc->len + (size_t)len + 2) nc *= 2; + char *nb = (char *)realloc(lc->buf, nc); + if (!nb) return; + lc->buf = nb; lc->cap = nc; + } + memcpy(lc->buf + lc->len, utf8, (size_t)len); + lc->len += (size_t)len; + lc->buf[lc->len++] = '\n'; + lc->buf[lc->len] = 0; +} + +static void phase2a_stitch(const char *font) +{ + const char *txt = "/tmp/rdbook_stitch.txt"; + FILE *f = fopen(txt, "wb"); + char line[100]; + memset(line, 'a', 99); line[99] = '\n'; + for (int i = 0; i < 5242; i++) fwrite(line, 1, 100, f); /* 524200 B */ + for (int i = 0; i < 88; i++) fputc('a', f); /* 共 524288 B = 512KB 码位边界 */ + fprintf(f, "SEG1MARKER 段一开头唯一标记,证明拼接页跨段包含两段文本。\n"); + for (int i = 0; i < 2000; i++) + fprintf(f, "段一正文第%d行:滚动拼接后段界无短页,页表与进度锚稳定。\n", i + 1); + fclose(f); + + rd_book_set_seg_base("/tmp/meow_seg_stitch"); + rd_book_set_index_dir("/tmp/meow_seg_stitch_index"); + rd_book *b = NULL; + int rc = rd_book_open(&b, txt, font, 20, 130, 480, 720, 16, 20, 62); + CHECK(rc == 0, "stitch open"); + if (rc != 0) { remove(txt); return; } + int nseg = rd_book_seg_n(b); + CHECK(nseg >= 2, "stitch multi-seg"); + if (nseg >= 2) { + int p1 = rd_book_seg_page(b, 1); /* 建段 1 → 触发段 0 末页拼接 */ + CHECK(p1 >= 0, "stitch seg1 built"); + int ps = rd_book_seg_page(b, 0); + int pc = rd_book_seg_page_count(b, 0); + CHECK(ps == 0 && pc > 0, "stitch seg0 pages"); + int bnd = ps + pc - 1; + linecol lc = { NULL, 0, 0 }; + int rr = rd_book_render_text(b, bnd, linecol_add, &lc); + CHECK(rr == 0, "stitch boundary render_text ok"); + CHECK(lc.buf && strstr(lc.buf, "SEG1MARKER") != NULL, "stitch boundary contains seg1 text"); + free(lc.buf); + /* 跨段往返:段界页翻前/后,锚 (seg,ch) 不变 */ + int as = -1; int32_t ach = -1; + rd_book_set_page(b, bnd); + rd_book_anchor(b, &as, &ach); + CHECK(as == 0 && ach >= 0, "stitch boundary anchor seg0"); + int cur = rd_book_current(b); + int fwd = rd_book_turn(b, 1); + int back = rd_book_turn(b, -1); + CHECK(fwd == cur + 1 && back == cur, "stitch boundary turn roundtrip"); + int as2 = -1; int32_t ach2 = -1; + rd_book_anchor(b, &as2, &ach2); + CHECK(as2 == as && ach2 == ach, "stitch boundary anchor stable"); + } + rd_book_close(b); + remove(txt); +} + int main(int argc, char **argv) { const char *font = (argc > 1) ? argv[1] : "/home/Aska/Project/R3ProII/font/src/MiSans-Regular.otf"; @@ -82,8 +154,40 @@ int main(int argc, char **argv) int np3 = rd_book_set_size(b, 20, 150); CHECK(np3 >= 0 && np3 < rd_book_pages(b), "set_size reflow ok"); + /* R3.3 章节:解析 + 章节→页映射 + 章内翻页单调(页号递增不跳变) */ + int nch = rd_book_chapter_n(b); + printf(" chapters=%d\n", nch); + CHECK(nch == 2, "book has 2 chapters"); + if (nch == 2) { + char t0[160], t1[160]; int n0 = -1, n1 = -1; int64_t c0 = -1, c1 = -1; + CHECK(rd_book_chapter(b, 0, t0, sizeof t0, &c0, NULL, NULL, &n0) == 0, "chapter0 get"); + CHECK(n0 == 1, "chapter0 number 1"); + CHECK(c0 == 0, "chapter0 char_off 0"); + int p0 = rd_book_page_for_char(b, c0); + CHECK(p0 == 0 && p0 < rd_book_pages(b), "chapter0 start page 0"); + CHECK(rd_book_chapter(b, 1, t1, sizeof t1, &c1, NULL, NULL, &n1) == 0, "chapter1 get"); + CHECK(n1 == 2, "chapter1 number 2"); + int p1 = rd_book_page_for_char(b, c1); + CHECK(p1 >= p0 && p1 < rd_book_pages(b), "chapter1 start page in range"); + printf(" chapter1('%s') at page %d (of %d)\n", t1, p1, rd_book_pages(b)); + /* 跳到章二首页,验证章内翻页 +1 单调(不越过章界仍递增) */ + int landed = rd_book_set_page(b, p1); + CHECK(landed == p1, "jump lands on chapter1 start"); + int cur1 = rd_book_current(b); + int nxt1 = rd_book_turn(b, 1); + CHECK((nxt1 == cur1 + 1) || (nxt1 == cur1), "chapter-internal turn +1 (clamp-safe)"); + /* R3.4 手动重解析(清 .chap 缓存 + 重扫)应幂等返回同章数,且章节仍可用 */ + int nrep = rd_book_reparse_chapters(b); + CHECK(nrep == nch, "reparse returns same chapter count"); + CHECK(rd_book_chapter_n(b) == nch, "reparse keeps chapters"); + } + free(buf); rd_book_close(b); + + /* Phase 2A/2C:跨段拼接页连续性 + 段界锚稳定(另开一本 >512KB 的多段书) */ + phase2a_stitch(font); + printf("book_smoke: %s\n", fails ? "FAIL" : "PASS"); return fails ? 1 : 0; } diff --git a/patch_v2/reader_app/test/build_turn_repro.sh b/patch_v2/reader_app/test/build_turn_repro.sh new file mode 100644 index 0000000..0c7983f --- /dev/null +++ b/patch_v2/reader_app/test/build_turn_repro.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# test/build_turn_repro.sh — cross-compile turn_repro for mipsel (mirrors book_smoke_mipsel link line). +set -e +cd "$(dirname "$0")/.." +ROOT="$HOME/Project/R3ProII" +CC="$ROOT/.tools/zig-mipsel-cc" +SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf" +VENDOR="$PWD/vendor" +OBJ="$VENDOR/build" +FT_BUILD="$VENDOR/freetype-build" +FT_SRC="$VENDOR/freetype-src" +FT_INCS="-I$FT_BUILD -I$FT_BUILD/include -I$FT_SRC/include" +COREINC="-Icore -Ivendor/miniz -Ivendor/stb -Ivendor/libmobi/src -Ivendor/zlib" +$CC -Os -static -Wall -Wextra -Iapp -Icore -Iengine $FT_INCS $COREINC \ + test/turn_repro.c \ + app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \ + core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c engine/rdft.c \ + "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" \ + -o test/turn_repro_mipsel +echo "build OK -> test/turn_repro_mipsel" diff --git a/patch_v2/reader_app/test/chapter_smoke.c b/patch_v2/reader_app/test/chapter_smoke.c new file mode 100644 index 0000000..9747399 --- /dev/null +++ b/patch_v2/reader_app/test/chapter_smoke.c @@ -0,0 +1,275 @@ +/* test/chapter_smoke.c — os.meow.reader 章节规范解析器宿主单测(纯 C,无 SDK/Freetype)。 + * + * 覆盖(按需求 5 样本 + 跨段 + 中文数字转换): + * C1 标准中文数字章(第一章/第二章/第十一章 → 1/2/11,char_off 单调) + * C2 阿拉伯章(第1章/第2章/第3章 → 1/2/3) + * C3 楔子/序章/尾声/番外(无号,number==-1,char_off 单调) + * C4 无章节纯文本(n==0,seg_char_start 仍完整) + * C5 开头长书名+简介再进第一章(卷首识别 as entries[0]、章号、防误判正文行 ) + * C6 跨段:>512KB 书,章落在后段(seg_idx>=1,char_off 跨段单调) + * C7 中文数字转换:十一/二十三/一百零一/一千零一/两 + * + * 跑法:build_reader.sh 内 `test/chapter_smoke_host`;本文件只依赖 rdchapter + seg + rdenc。 + */ +#include "rdchapter.h" +#include "seg.h" +#include "rdenc.h" +#include +#include +#include + +static int g_pass = 0, g_fail = 0; +#define CHECK(cond, msg) do { if (cond) { g_pass++; } else { g_fail++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, msg); } } while (0) + +static char g_dir[600]; + +static void write_file(const char *path, const char *data) +{ + FILE *f = fopen(path, "wb"); + fwrite(data, 1, strlen(data), f); + fclose(f); +} + +/* 建段目录 + 切段,返回段数(<0=错误)。 */ +static int seg_for(const char *path, const char *base) +{ + rdseg_set_base(base); + char dir[600]; + if (rdseg_dir_for(path, dir, sizeof dir) != 0) return -1; + strcpy(g_dir, dir); + return rdseg_build(path, RD_ENC_UTF8, dir); +} + +static int scan_book(rdchapter *c, const char *basename) +{ + return rdchapter_scan(g_dir, RD_ENC_UTF8, basename, c); +} + +static void test_c1(void) +{ + const char *txt = "/tmp/ch_s1.txt"; + write_file(txt, + "第一章 风起\n" + "这是第一章的正文内容。\n" + "第二章 云涌\n" + "这是第二章的正文内容。\n" + "第十一章 破晓\n" + "这是第十一章的正文内容。\n"); + CHECK(seg_for(txt, "/tmp/ch_base1") >= 1, "c1 seg build"); + rdchapter c; rdchapter_init(&c); + CHECK(scan_book(&c, "s1") == 0, "c1 scan"); + CHECK(c.n == 3, "c1 n==3"); + if (c.n == 3) { + CHECK(c.entries[0].number == 1, "c1 #1"); + CHECK(c.entries[1].number == 2, "c1 #2"); + CHECK(c.entries[2].number == 11, "c1 #11"); + CHECK(c.entries[0].char_off == 0, "c1 first at 0 (no preface)"); + CHECK(c.entries[0].char_off < c.entries[1].char_off && + c.entries[1].char_off < c.entries[2].char_off, "c1 char_off monotonic"); + CHECK(c.entries[0].seg_idx == 0 && c.entries[0].seg_char == 0, "c1 seg0 char0"); + CHECK(strcmp(c.entries[0].title, "第一章 风起") == 0, "c1 title"); + CHECK(c.has_pre == 0, "c1 no preface"); + } + rdchapter_free(&c); + remove(txt); +} + +static void test_c2(void) +{ + const char *txt = "/tmp/ch_s2.txt"; + write_file(txt, + "第1章 起源\n" + "正文A。\n" + "第2章 发展\n" + "正文B。\n" + "第3章 终局\n" + "正文C。\n"); + CHECK(seg_for(txt, "/tmp/ch_base2") >= 1, "c2 seg build"); + rdchapter c; rdchapter_init(&c); + CHECK(scan_book(&c, "s2") == 0, "c2 scan"); + CHECK(c.n == 3, "c2 n==3"); + if (c.n == 3) { + CHECK(c.entries[0].number == 1, "c2 #1"); + CHECK(c.entries[1].number == 2, "c2 #2"); + CHECK(c.entries[2].number == 3, "c2 #3"); + CHECK(c.entries[0].char_off < c.entries[1].char_off && + c.entries[1].char_off < c.entries[2].char_off, "c2 char_off monotonic"); + } + rdchapter_free(&c); + remove(txt); +} + +static void test_c3(void) +{ + const char *txt = "/tmp/ch_s3.txt"; + write_file(txt, + "楔子\n" + "一段开篇。\n" + "第一章 正传\n" + "正文内容。\n" + "尾声\n" + "后记文字。\n" + "番外 归途\n" + "番外正文。\n"); + CHECK(seg_for(txt, "/tmp/ch_base3") >= 1, "c3 seg build"); + rdchapter c; rdchapter_init(&c); + CHECK(scan_book(&c, "s3") == 0, "c3 scan"); + CHECK(c.n == 4, "c3 n==4"); + if (c.n == 4) { + CHECK(c.entries[0].number == -1, "c3 楔子 number -1"); + CHECK(strcmp(c.entries[0].title, "楔子") == 0, "c3 楔子 title"); + CHECK(c.entries[1].number == 1, "c3 第一章 number 1"); + CHECK(c.entries[2].number == -1, "c3 尾声 number -1"); + CHECK(c.entries[3].number == -1, "c3 番外 number -1"); + CHECK(c.entries[0].char_off < c.entries[1].char_off && + c.entries[1].char_off < c.entries[2].char_off && + c.entries[2].char_off < c.entries[3].char_off, "c3 char_off monotonic"); + } + rdchapter_free(&c); + remove(txt); +} + +static void test_c4(void) +{ + const char *txt = "/tmp/ch_s4.txt"; + write_file(txt, + "这是一个没有章节标题的长篇小说文本。\n" + "它只有连续不断的段落,没有任何章回节的标识。\n" + "所有的文字都是纯粹的故事内容。\n"); + CHECK(seg_for(txt, "/tmp/ch_base4") >= 1, "c4 seg build"); + rdchapter c; rdchapter_init(&c); + CHECK(scan_book(&c, "s4") == 0, "c4 scan"); + CHECK(c.n == 0, "c4 no chapters"); + CHECK(c.nsegs == 1, "c4 nsegs==1"); + CHECK(c.seg_char_start && c.seg_char_start[0] == 0 && c.seg_char_start[1] > 0, "c4 seg_char_start"); + rdchapter_free(&c); + remove(txt); +} + +static void test_c5(void) +{ + const char *txt = "/tmp/ch_s5.txt"; + write_file(txt, + "《大漠孤烟》这是一本很长的书名,作者著。\n" + "简介:这是一个故事的开头,讲述少年成长。\n" + "第一章风雨欲来,全书开场,命运由此转折。\n" + "第一章 离家\n" + "正文内容。\n" + "第二章 远行\n" + "正文内容。\n"); + CHECK(seg_for(txt, "/tmp/ch_base5") >= 1, "c5 seg build"); + rdchapter c; rdchapter_init(&c); + CHECK(scan_book(&c, "sample5") == 0, "c5 scan"); + CHECK(c.n == 3, "c5 n==3 (preface + 2 chapters)"); + if (c.n == 3) { + CHECK(c.has_pre == 1, "c5 has preface"); + CHECK(c.entries[0].number == -1, "c5 preface number -1"); + CHECK(strcmp(c.entries[0].title, "sample5") == 0, "c5 preface title = basename"); + CHECK(c.entries[0].char_off == 0, "c5 preface char_off 0"); + CHECK(c.entries[1].number == 1 && c.entries[2].number == 2, "c5 chapter numbers 1/2"); + CHECK(c.entries[1].char_off > 0, "c5 ch1 after preface"); + CHECK(c.entries[0].char_off < c.entries[1].char_off && + c.entries[1].char_off < c.entries[2].char_off, "c5 monotonic"); + /* 防误判:正文行「第一章风雨欲来,全书开场。」带两个正文标点 → 未被识别为章 */ + CHECK(c.entries[1].seg_char > 0, "c5 decoy line not counted (ch1 seg_char>0)"); + } + rdchapter_free(&c); + remove(txt); +} + +static void test_c6(void) +{ + /* 跨段:写 ~600KB filler,在 ~600KB 处放一个章 → 应落在后段(seg_idx>=1) */ + const char *txt = "/tmp/ch_s6.txt"; + FILE *f = fopen(txt, "wb"); + const char *para = "这是一段用于填充页面的中文正文,包含标点,中文English混合,反复出现,占据空间。\n"; + size_t plen = strlen(para); + size_t total = 0; + while (total < 600 * 1024) { + fwrite(para, 1, plen, f); total += plen; + } + fwrite("第二百章 跨段检测\n", 1, strlen("第二百章 跨段检测\n"), f); + const char *tail = "这是跨段之后的一小段正文。\n"; + fwrite(tail, 1, strlen(tail), f); + fclose(f); + CHECK(seg_for(txt, "/tmp/ch_base6") >= 2, "c6 seg build >=2"); + rdchapter c; rdchapter_init(&c); + CHECK(scan_book(&c, "s6") == 0, "c6 scan"); + /* 前置 filler(600KB 无章节)→ entries[0]=卷首;真正章节在后段 */ + CHECK(c.n == 2, "c6 n==2 (preface + 1 chapter)"); + if (c.n == 2) { + CHECK(c.entries[0].number == -1, "c6 preface number -1"); + CHECK(c.entries[1].number == 200, "c6 number 200"); + CHECK(c.entries[1].seg_idx >= 1, "c6 chapter in later segment"); + CHECK(c.entries[1].seg_idx < c.nsegs, "c6 seg_idx in range"); + CHECK(c.entries[1].seg_char >= 0, "c6 seg_char >=0"); + CHECK(c.seg_char_start[0] == 0 && + c.seg_char_start[0] < c.seg_char_start[1] && + c.seg_char_start[c.nsegs - 1] < c.seg_char_start[c.nsegs], "c6 seg_char_start monotonic"); + /* 跨段 char_off:章首偏移应在段 1 起点之后 */ + CHECK(c.entries[1].char_off >= c.seg_char_start[c.entries[1].seg_idx], "c6 char_off within seg"); + } + rdchapter_free(&c); + remove(txt); +} + +static void test_c7(void) +{ + CHECK(rdchapter_cn_num("十一", 6) == 11, "c7 十一=11"); + CHECK(rdchapter_cn_num("二十三", 9) == 23, "c7 二十三=23"); + CHECK(rdchapter_cn_num("一百零一", 12) == 101, "c7 一百零一=101"); + CHECK(rdchapter_cn_num("一千零一", 12) == 1001, "c7 一千零一=1001"); + CHECK(rdchapter_cn_num("两", 3) == 2, "c7 两=2"); + CHECK(rdchapter_cn_num("一百", 6) == 100, "c7 一百=100"); + CHECK(rdchapter_cn_num("foobar", 6) == -1, "c7 invalid -1"); +} + +static void test_c8(void) +{ + /* R3.4 英文章节:文字数字(one/twenty-one/two hundred fifty)+ 罗马(IV)+ 拒绝无空格(ChapterX) */ + const char *txt = "/tmp/ch_s8.txt"; + write_file(txt, + "Chapter one\n" + "正文。\n" + "Chapter IV\n" + "正文。\n" + "Chapter twenty-one\n" + "正文。\n" + "Chapter two hundred fifty\n" + "正文。\n" + "ChapterX\n" + "正文。\n" + "Chapter twelve\n" + "正文。\n"); + CHECK(seg_for(txt, "/tmp/ch_base8") >= 1, "c8 seg build"); + rdchapter c; rdchapter_init(&c); + CHECK(scan_book(&c, "s8") == 0, "c8 scan"); + /* ChapterX 不认(无空格);其余 5 个认 */ + CHECK(c.n == 5, "c8 n==5"); + if (c.n == 5) { + static const int exp[5] = { 1, 4, 21, 250, 12 }; + for (int i = 0; i < 5; i++) CHECK(c.entries[i].number == exp[i], "c8 word number value"); + CHECK(c.entries[0].char_off < c.entries[1].char_off && + c.entries[1].char_off < c.entries[2].char_off && + c.entries[2].char_off < c.entries[3].char_off && + c.entries[3].char_off < c.entries[4].char_off, "c8 char_off monotonic"); + CHECK(strcmp(c.entries[0].title, "Chapter one") == 0, "c8 title Chapter one"); + CHECK(strcmp(c.entries[3].title, "Chapter two hundred fifty") == 0, "c8 title two hundred fifty"); + } + rdchapter_free(&c); + remove(txt); +} + +int main(void) +{ + test_c1(); + test_c2(); + test_c3(); + test_c4(); + test_c5(); + test_c6(); + test_c7(); + test_c8(); + printf("chapter_smoke: %d pass, %d fail\n", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/reader_app/test/test_reader_core.c b/patch_v2/reader_app/test/test_reader_core.c index 68f9f90..5ce0f2f 100644 --- a/patch_v2/reader_app/test/test_reader_core.c +++ b/patch_v2/reader_app/test/test_reader_core.c @@ -341,10 +341,121 @@ static void t13(void) CHECK(rd_enc_detect(gbk, sizeof gbk, NULL) == RD_ENC_GB18030, "t13 gbk still gb18030"); } +/* t14 — TXT `\r` 归一化(READER_ADVANCED §3-1):`\r\n`→`\n`、孤 `\r`→`\n`, + * 码点流不含 0x0D(行尾 \r 在无 U+000D 映射的字体里落成 .notdef 豆腐;MiSans 虽映射到 + * 零宽空字形不可见,归一化后对所有字体稳健,且章节/页表/进度锚同处无 \r 码点空间)。 */ +static void t14(void) +{ + const uint8_t u8[] = "ab\r\ncd\ref\n\r\ngh\r"; + uint32_t *cps = NULL; size_t n = 0; + CHECK(rd_utf8_to_cp(u8, sizeof u8 - 1, &cps, &n) == RD_ENC_OK, "t14 utf8→cp ok"); + const uint32_t expect[] = { 'a','b','\n','c','d','\n','e','f','\n','\n','g','h','\n' }; + CHECK(n == sizeof expect / sizeof expect[0], "t14 count"); + int no_cr = 1; + for (size_t i = 0; i < n; i++) if (cps[i] == 0x0D) no_cr = 0; + CHECK(no_cr, "t14 no 0x0D in cps"); + CHECK(n == 13 && memcmp(cps, expect, n * sizeof(uint32_t)) == 0, "t14 normalized sequence"); + free(cps); + /* GB18030 路径同样归一化(rd_to_utf8 → rd_utf8_to_cp) */ + const uint8_t gbk[] = { 0xC4,0xE3,0xBA,0xC3, 0x0D,0x0A, 0xCA,0xC0,0xBD,0xE7 }; /* 你好\r\n世界 */ + uint8_t out[64]; size_t olen = 0; + CHECK(rd_to_utf8(RD_ENC_GB18030, gbk, sizeof gbk, out, sizeof out, &olen) == RD_ENC_OK, "t14 gbk decode"); + uint32_t *c2 = NULL; size_t n2 = 0; + CHECK(rd_utf8_to_cp(out, olen, &c2, &n2) == RD_ENC_OK, "t14 gbk→cp"); + CHECK(n2 == 5 && c2[0] == 0x4F60 && c2[2] == '\n' && c2[3] == 0x4E16, "t14 gbk crlf→lf"); + free(c2); +} + +/* t15 — 页级微排版 vs 全排版逐行一致(Phase 2C 硬用例): + * 全排版 rd_layout → 对每页 first_char 起 rd_layout_page,断言行区间(s/e/x/y/w/para 标志) + * 与该页完全一致,且 next_start == 下一页 first_char(或文末)。含伪随机长文覆盖段界/空段/ + * Kinsoku/长词/页满边界;显式构造 mid_para=1(段中续行起始)。 */ +static uint32_t g_lcg = 0x12345678u; +static uint32_t lcg(void) { g_lcg = g_lcg * 1664525u + 1013904223u; return g_lcg >> 8; } + +static void t15_check_text(const char *txt, int fs, int ls, int pw, int ph, const char *tag) +{ + uint32_t *cps = NULL; size_t n = 0; + rd_utf8_to_cp((const uint8_t *)txt, strlen(txt), &cps, &n); + rd_line *fl = (rd_line *)malloc((n + 1) * sizeof(rd_line)); + rd_page *fp = (rd_page *)malloc((n + 1) * sizeof(rd_page)); + rd_line *ml = (rd_line *)malloc((size_t)(ph + 4) * sizeof(rd_line)); + CHECK(fl && fp && ml, "t15 alloc"); + rd_layout_in in = { cps, (int32_t)n, fs, ls, pw, ph, fs, meas_mock, NULL }; + rd_layout_out out; + out.lines = fl; out.pages = fp; out.max_lines = (int)n + 1; out.max_pages = (int)n + 1; + int rc = rd_layout(&in, &out); + CHECK(rc == RD_LAYOUT_OK, tag); + if (rc != RD_LAYOUT_OK) { free(cps); free(fl); free(fp); free(ml); return; } + for (int p = 0; p < out.npages; p++) { + int32_t start = out.pages[p].first_char; + int mid = !(start == 0 || cps[start - 1] == '\n'); + int mnl = -1; int32_t nxt = -1; + int mrc = rd_layout_page(&in, start, mid, ml, ph + 3, &mnl, &nxt); + CHECK(mrc == RD_LAYOUT_OK, "t15 micro rc"); + CHECK(mnl == out.pages[p].nlines, "t15 page nlines equal"); + if (mnl > 0 && mnl == out.pages[p].nlines) { + for (int li = 0; li < mnl; li++) { + const rd_line *a = &out.lines[out.pages[p].first_line + li]; + const rd_line *b = &ml[li]; + CHECK(a->s == b->s && a->e == b->e && a->x == b->x && a->y == b->y && + a->w == b->w && a->para_start == b->para_start && a->para_end == b->para_end, + "t15 line-for-line equal"); + } + } + int32_t want_next = (p + 1 < out.npages) ? out.pages[p + 1].first_char : (int32_t)n; + CHECK(nxt == want_next, "t15 next_start == next page first_char"); + } + free(cps); free(fl); free(fp); free(ml); +} + +static void t15(void) +{ + /* 固定样本:多段/空段/中英混排/标点/长词/末尾换行 */ + t15_check_text("第一章 开始\n\n这是一段正文,包含 English words 与标点。第二句继续填充," + "直到页面溢出产生多页。\n另一段(左括号测试)右标点,簇!?混合。" + "supercalifragilisticexpialidocious longword\n\n\n末段无换行结尾", + 20, 130, 440, 300, "t15 fixed layout"); + /* 伪随机长文(确定性种子):40+ 页,覆盖段中页首/页满边界 */ + size_t cap = 65536; + char *buf = (char *)malloc(cap); + size_t o = 0; + static const char *cjk[] = { "的","一","是","不","了","人","我","在","有","他","这","中","大", + "来","上","国","个","到","说","们","为","子","和","你","地","出" }; + static const char *lat[] = { "a","test","word","x","hello","longwordish","b","qk","reader","zz" }; + static const char *pnc[] = { "。",",","、","?","!","(",")"," ", " ", ".", "," }; + g_lcg = 0xC0FFEE; + while (o + 16 < cap) { + uint32_t r = lcg() % 100; + const char *s; + if (r < 55) s = cjk[lcg() % (sizeof cjk / sizeof cjk[0])]; + else if (r < 80) s = lat[lcg() % (sizeof lat / sizeof lat[0])]; + else if (r < 92) s = pnc[lcg() % (sizeof pnc / sizeof pnc[0])]; + else if (r < 97) s = "\n"; + else s = "\n\n"; + size_t sl = strlen(s); + memcpy(buf + o, s, sl); o += sl; + } + buf[o] = 0; + t15_check_text(buf, 20, 130, 440, 640, "t15 random layout A"); + t15_check_text(buf, 17, 150, 200, 300, "t15 random layout B"); /* 窄页小字:更多断行/页 */ + free(buf); + /* 段中续行起始显式用例:整段一行到底(无 \n),页首必为 mid_para=1 */ + { + char *one = (char *)malloc(16384); + size_t k = 0; + for (int i = 0; i < 400; i++) { const char *w = "持续不断的正文"; size_t wl = strlen(w); memcpy(one + k, w, wl); k += wl; } + one[k] = 0; + t15_check_text(one, 20, 130, 440, 640, "t15 single-para layout"); + free(one); + } +} + int main(void) { t1(); t2(); t3(); t4(); t5(); t6(); t7(); t8(); t9(); t10(); t11(); t12(); t13(); + t14(); t15(); printf("test_reader_core: %d pass, %d fail\n", g_pass, g_fail); return g_fail ? 1 : 0; } diff --git a/patch_v2/reader_app/test/turn_repro.c b/patch_v2/reader_app/test/turn_repro.c new file mode 100644 index 0000000..fb3d5bf --- /dev/null +++ b/patch_v2/reader_app/test/turn_repro.c @@ -0,0 +1,83 @@ +/* test/turn_repro.c — 复现「翻页跳页」(R3.2 分段流式)。 + * 模拟真机序列:open → (可选 set_enc 到 UTF-16LE) → 跳页/翻页 → 渲染校验(非空/seg 边界正确)。 + * 跑于 qemu-mipsel(静态)。 + * 用法:qemu-mipsel ./test/turn_repro_mipsel [enc] [turns] + */ +#include "book.h" +#include +#include +#include + +static int render_buf(uint16_t *buf, int W, int H, rd_book *b, int page, uint16_t bg, uint16_t fg) +{ + for (int i = 0; i < W * H; i++) buf[i] = bg; + int rc = rd_book_render(b, page, buf, W * 2, bg, fg); + if (rc != 0) return -100 + rc; + int ink = 0; + for (int i = 0; i < W * H; i++) if (buf[i] != bg) ink++; + return ink; +} + +int main(int argc, char **argv) +{ + const char *font = (argc > 1) ? argv[1] : "/home/Aska/Project/R3ProII/font/src/MiSans-Regular.otf"; + const char *book = (argc > 2) ? argv[2] : "/tmp/rdsample/tongyi.txt"; + const char *pref = (argc > 3) ? argv[3] : "/tmp/rdsample/repro"; + int force_enc = (argc > 4) ? atoi(argv[4]) : 0; + int turns = (argc > 5) ? atoi(argv[5]) : 200; + char segbase[420], idxdir[420]; + snprintf(segbase, sizeof segbase, "%s/segbase", pref); + snprintf(idxdir, sizeof idxdir, "%s/index", pref); + rd_book_set_seg_base(segbase); + rd_book_set_index_dir(idxdir); + + int fs = 16, ls = 135, pw = 480, ph = 724, mx = 16, my = 20, top = 62; + uint16_t *buf = (uint16_t *)malloc((size_t)pw * ph * 2); + + rd_book *b = NULL; + int rc = rd_book_open(&b, book, font, fs, ls, pw, ph, mx, my, top); + printf("open rc=%d enc=%s\n", rc, rc == 0 ? rd_book_encname(b) : "-"); + if (rc != 0) return 1; + if (force_enc) { printf("set_enc(%d)->rc=%d\n", force_enc, rd_book_set_enc(b, (rd_enc_t)force_enc)); } + int np = rd_book_pages(b); + printf("npages=%d\n", np); + + /* ① 翻页序列:跨段边界观察 cur 跳变 */ + rd_book_set_page(b, 2); + int prev = rd_book_current(b), jumps = 0; + for (int i = 0; i < turns; i++) { + int nxt = rd_book_turn(b, 1); + int d = nxt - prev; + if (d != 1) { printf(" JUMP turn#%d %d->%d d=%d npages=%d\n", i, prev, nxt, d, rd_book_pages(b)); if (d > 1) jumps++; } + prev = nxt; + if (rd_book_pages(b) < 1) break; + } + printf("turn jumps>1=%d\n", jumps); + + /* ② 渲染首页/第3页/中间页 校验非空 */ + int targets[3] = { 0, 2, np > 3 ? np / 2 : 0 }; + for (int t = 0; t < 3; t++) { + if (targets[t] >= rd_book_pages(b)) continue; + int ink = render_buf(buf, pw, ph, b, targets[t], 0xFFFF, 0x0000); + printf("render page %d -> %s (ink=%d)\n", targets[t], ink < 0 ? "ERR" : (ink > 50 ? "OK" : "BLANK"), ink); + } + + /* ③ set_page 到远页(触发按需补段),渲染校验 + 观察是否落到目标 */ + int far = 316; + int got = rd_book_set_page(b, far); + printf("set_page(%d) -> cur=%d npages=%d\n", far, got, rd_book_pages(b)); + int ink = render_buf(buf, pw, ph, b, got, 0xFFFF, 0x0000); + printf("render page %d -> %s (ink=%d)\n", got, ink < 0 ? "ERR" : (ink > 50 ? "OK" : "BLANK"), ink); + + /* ④ 跳到更远(超过当前 npages,强制补段到 seg 20) */ + int far2 = 4000; + int got2 = rd_book_set_page(b, far2); + printf("set_page(%d) -> cur=%d npages=%d\n", far2, got2, rd_book_pages(b)); + ink = render_buf(buf, pw, ph, b, got2, 0xFFFF, 0x0000); + printf("render page %d -> %s (ink=%d)\n", got2, ink < 0 ? "ERR" : (ink > 50 ? "OK" : "BLANK"), ink); + + free(buf); + rd_book_close(b); + printf("done\n"); + return 0; +} diff --git a/patch_v2/reader_app/tools/check_lgpl_release.sh b/patch_v2/reader_app/tools/check_lgpl_release.sh new file mode 100755 index 0000000..740200f --- /dev/null +++ b/patch_v2/reader_app/tools/check_lgpl_release.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# tools/check_lgpl_release.sh — 发布前合规校验闸(只读,不改任何产物)。 +# +# 用法(在 reader_app 目录外也可): +# ./tools/check_lgpl_release.sh [--skip-net] +# +# 校验项(对齐 devdocs/plan/FUTURE_LIBMOBI.md §4-④ P0): +# 1) os.meow.reader.mpk 必含 resources/licenses/{NOTICE,GPL-3.0.txt,LGPL-3.0.txt,FTL.txt} +# 2) build_reader.sh 锁定的 libmobi commit 与 NOTICE「Locked version」一致(防锁定值漂移) +# 3) [联网] codeload tarball sha256 与 NOTICE 记录指纹一致(未记录则报告,属 P1 补强,不判失败) +# 4) [联网] 主仓 git ls-remote 公开可读(LGPL §4(d) 应用源码可达性) +# +# 退出码:0 = 通过;1 = 硬失败(缺许可证文件 / commit 漂移 / tarball 漂移 / 主仓不可达)。 +set -u +cd "$(dirname "$0")/.." # 切到 reader_app 根(build_reader.sh/NOTICE/mpk 都在此) + +SKIP_NET=0 +[ "${1:-}" = "--skip-net" ] && SKIP_NET=1 + +MPK="os.meow.reader.mpk" +NOTICE="NOTICE" +BUILD="build_reader.sh" +LIC="resources/licenses" +MAIN_REPO="https://git.yeij.top/AskaEth/MeowOS-For-R3P2.git" +fail=0 + +say() { printf '%s\n' "$*"; } +hard() { printf 'FAIL %s\n' "$*"; fail=1; } +ok() { printf 'ok %s\n' "$*"; } +warn() { printf 'warn %s\n' "$*"; } + +say "== 合规校验(reader_app) ==" + +# ---------- 1) 包内许可证文件 ---------- +if [ ! -f "$MPK" ]; then + hard "找不到 $MPK —— 请先 ./build_reader.sh 再 ./pack_reader_mpk.sh" +else + for f in NOTICE GPL-3.0.txt LGPL-3.0.txt FTL.txt; do + if tar tzf "$MPK" 2>/dev/null | grep -qx "resources/licenses/$f"; then + ok "包内含 resources/licenses/$f" + else + hard "包内缺 resources/licenses/$f" + fi + done +fi + +# ---------- 2) libmobi 锁定 commit 一致性 ---------- +LM_URL="$(sed -n 's/^LM_URL="\([^"]*\)"/\1/p' "$BUILD" | head -1)" +LM_COMMIT="$(printf '%s' "$LM_URL" | sed 's#.*/##')" +NOTICE_COMMIT="$(sed -n 's/^[[:space:]]*\([0-9a-f]\{40\}\).*/\1/p' "$NOTICE" | head -1)" +if [ -z "$LM_COMMIT" ]; then + hard "build_reader.sh 未解析到 LM_URL 锁定 commit" +elif [ "$LM_COMMIT" != "$NOTICE_COMMIT" ]; then + hard "libmobi 锁定 commit 漂移:build=$LM_COMMIT NOTICE=$NOTICE_COMMIT" +else + ok "libmobi 锁定 commit 一致 ($LM_COMMIT)" +fi + +# ---------- 3) [联网] tarball sha256 ---------- +NOTICE_SHA="$(sed -n 's/^[[:space:]]*Tarball sha256[^:]*:[[:space:]]*\([0-9a-f]\{64\}\).*/\1/p' "$NOTICE" | head -1)" +LIVE_SHA="" +if [ "$SKIP_NET" = 1 ]; then + warn "跳过在线 tarball 校验 (--skip-net)" + if [ -n "$NOTICE_SHA" ]; then ok "NOTICE 已记录 tarball sha256 ($NOTICE_SHA)"; else warn "NOTICE 未记录 tarball sha256(P1 补强项)"; fi +else + if command -v curl >/dev/null 2>&1 && command -v sha256sum >/dev/null 2>&1 && [ -n "$LM_URL" ]; then + LIVE_SHA="$(curl -fsSL "$LM_URL" 2>/dev/null | sha256sum | awk '{print $1}')" + if [ -z "$LIVE_SHA" ]; then + warn "在线取 tarball 失败(网络不可用)—— 未校验 sha256" + elif [ -n "$NOTICE_SHA" ]; then + if [ "$LIVE_SHA" = "$NOTICE_SHA" ]; then ok "tarball sha256 与 NOTICE 一致 ($LIVE_SHA)"; else hard "tarball sha256 漂移:live=$LIVE_SHA NOTICE=$NOTICE_SHA"; fi + else + say "info tarball sha256=$LIVE_SHA (NOTICE 未记录指纹,属 P1 补强项,不判失败)" + fi + else + warn "无 curl/sha256sum 或未解析 LM_URL —— 跳过在线 tarball 校验" + fi +fi + +# ---------- 4) [联网] 主仓公开可读 ---------- +if [ "$SKIP_NET" = 1 ]; then + warn "跳过主仓可读性校验 (--skip-net)" +else + if command -v git >/dev/null 2>&1; then + if git ls-remote "$MAIN_REPO" HEAD >/dev/null 2>&1; then + ok "主仓公开可读 (git ls-remote HEAD)" + else + hard "主仓 git ls-remote 失败(LGPL §4(d) 应用源码可达性)" + fi + else + warn "无 git —— 跳过主仓可读性校验" + fi +fi + +# ---------- 5) 合规清单 ---------- +say "== 合规清单 ==" +say "libmobi commit : ${LM_COMMIT:-?}" +say "libmobi tarball : ${LIVE_SHA:-${NOTICE_SHA:-未校验}}" +say "包内许可证 :" +if [ -f "$MPK" ]; then tar tzf "$MPK" 2>/dev/null | grep 'resources/licenses/' || true; fi + +if [ "$fail" = 1 ]; then say "结果:FAIL"; exit 1; fi +say "结果:PASS" diff --git a/patch_v2/sdk/meow_ui_ime.c b/patch_v2/sdk/meow_ui_ime.c index 2de7979..675d83e 100644 --- a/patch_v2/sdk/meow_ui_ime.c +++ b/patch_v2/sdk/meow_ui_ime.c @@ -75,6 +75,16 @@ static int g_ime_done = 0; static int g_ime_result = 0; static int g_shift = 0; +/* WiFi 设置页 bug3(2026-09-15):IME 打开瞬间 TAP 冷却窗。触发打开的那次点按,其「抬起 + * TAP」(含 goodix 抬起长尾)可能由 SDK 输入线程在打开 drain 之后才读出并入队(子页自有 + * tfd 与 SDK 输入线程独立读同一 evdev,节奏可滞后 ~20ms+)——阻塞循环把它当「点背景=取消」 + * 瞬间关键盘(ytype == MEOW_EV_TAP && g_ime_open_ms && + meow_ui_time_ms() - g_ime_open_ms < IME_TAP_COOLDOWN_MS) + return 1; switch (e->type) { case MEOW_EV_TAP: return meow_ime_handle_tap(e->a, e->b); case MEOW_EV_TOUCH_DOWN: g_ime_swipe_y = e->b; return 1; /* 记录压点(候选栏滑动判定);覆盖层消费 */ @@ -605,6 +620,11 @@ void meow_ui_text_input_async(meow_ime_ctx *ctx) ime_capture(); /* M2.71-bug7:打开前拍下键盘区背景(关闭即还原) */ meow_input_start(); meow_input_drain(); + /* bug3 加固(对齐 s_dd_modal/subpage 吸收 goodix 抬起长尾的「排空→40ms→再排空」惯例): + * 第一遍 drain 后等 40ms 让输入线程读出迟到事件,第二遍 drain 连状态机一并复位。 */ + usleep(40000); + meow_input_drain(); + g_ime_open_ms = meow_ui_time_ms(); /* TAP 冷却窗起点(见 IME_TAP_COOLDOWN_MS) */ } int meow_ui_text_input(meow_ime_ctx *ctx) diff --git a/patch_v2/sdk/meow_ui_input.c b/patch_v2/sdk/meow_ui_input.c index b3648e7..4fe2038 100644 --- a/patch_v2/sdk/meow_ui_input.c +++ b/patch_v2/sdk/meow_ui_input.c @@ -68,6 +68,8 @@ /* §6.4 事件分发形式化:手势捕获终点前向声明(meow_input_drain 之前定义后置,见下方实现)。 */ void meow_ui_gesture_end(void); +/* 触摸状态机复位(定义在触摸状态机变量之后,meow_input_drain 内调用)。 */ +void meow_input_touch_reset(void); /* ── 内部事件:系统级「下拉展开通知中心」(M2.5x 接线)── * 触摸状态机在抬手判定处识别「SWIPE_DOWN 起始于顶部热区」后压入;事件循环按系统手势处理 @@ -212,6 +214,10 @@ void meow_input_drain(void) g_qh = 0; g_qt = 0; pthread_mutex_unlock(&g_qm); meow_ui_gesture_end(); /* §6.4:清空队列=丢弃未完成手势(如开 IME/模态前),释放捕获防悬垂 */ + /* WiFi 设置页 bug3(2026-09-15):drain 同时复位触摸状态机——语义自洽:drain=丢弃在途 + * 手势。否则 drain 前已开始、drain 后才被输入线程读出的「抬起」仍会按残留原点产生 + * 一个迟到 TAP 入队(IME 打开瞬间被当「点背景=取消」或幻影按键,键盘开即关)。 */ + meow_input_touch_reset(); } static void push_ev(int type, int a, int b) @@ -260,6 +266,19 @@ static int t_mv_x = -1, t_mv_y = -1; /* §6.3 通用长按:本次按压是否已触发过长按(触发后抬手不再产生 TAP/SWIPE)。 */ static int t_long_fired = 0; +/* 触摸状态机整体复位:丢弃在途手势(down/原点/坐标/长按触发标记全部归零)。 + * 调用点:① meow_input_drain(drain=丢弃在途手势,IME/模态打开前);② §10.5 叠加抑制 + * 进入沿(防抑制期丢失抬手致 t_down 卡死)。效果:复位前已开始、复位后才抬起的按压 + * 不再产生 TAP/SWIPE(WiFi 设置页 bug3:IME 打开 drain 之后才入队的迟到「抬起 TAP」 + * 曾把键盘当「点背景=取消」瞬间关闭)。 */ +void meow_input_touch_reset(void) +{ + t_down = 0; t_press_valid = 0; t_origin_valid = 0; + t_cx = -1; t_cy = -1; t_dx = 0; t_dy = 0; t_down_ms = 0; + t_mv_x = -1; t_mv_y = -1; + t_long_fired = 0; +} + /* §6.3 通用长按:阈值(默认 MEOW_LONG_PRESS_MS;App 可经 meow_ui_set_long_press_ms 配)。 */ static int g_long_press_ms = MEOW_LONG_PRESS_MS; /* §6.3 注册的长按回调(NULL=未注册;触发时以 (x,y) 调用,坐标=触发时刻按下点)。 */ @@ -592,9 +611,7 @@ static void *input_thread(void *arg) if (overlay_input_suppressed()) { if (!overlay_was) { /* 进入沿:复位触摸状态机,防抑制期丢失抬手致 t_down 卡死 */ overlay_was = 1; - t_down = 0; t_press_valid = 0; t_origin_valid = 0; - t_cx = t_cy = -1; t_dx = t_dy = 0; t_down_ms = 0; t_mv_x = t_mv_y = -1; - t_long_fired = 0; + meow_input_touch_reset(); } if (r > 0) for (int i = 0; i < nfd; i++) { diff --git a/patch_v2/sdk/meow_ui_internal.h b/patch_v2/sdk/meow_ui_internal.h index 162dd2c..a6457f5 100644 --- a/patch_v2/sdk/meow_ui_internal.h +++ b/patch_v2/sdk/meow_ui_internal.h @@ -207,8 +207,10 @@ void meow_input_start(void); int meow_input_pop(meow_input_ev *ev); /* 注入一条语义事件(宿主验证手势分发/进度条用;设备上无调用方)。 */ void meow_input_inject(int type, int a, int b); -/* 清空队列(IME 打开前丢弃残留触摸,避免误敲)。 */ +/* 清空队列(IME 打开前丢弃残留触摸,避免误敲);同时复位触摸状态机(drain=丢弃在途手势)。 */ void meow_input_drain(void); +/* 触摸状态机整体复位(丢弃在途手势;drain/叠加抑制进入沿调用;宿主单测可直调)。 */ +void meow_input_touch_reset(void); /* ---- M2.71-bug5:App 活动 → 通知 launcher 重置息屏计时 ---- * launcher 前台 app 会话的 app_last_input 只在物理键边沿重置(它看不到应用内触摸)。SDK 在 diff --git a/patch_v2/sdk/test_ime_fix.c b/patch_v2/sdk/test_ime_fix.c index fb321e0..9c11896 100644 --- a/patch_v2/sdk/test_ime_fix.c +++ b/patch_v2/sdk/test_ime_fix.c @@ -3,14 +3,18 @@ * ② K_ENTER 只提交不关(meow_ime_active() 仍真),ESC 后关; * ③ 阻塞 IME 循环喂 on_tick(动态内容重绘)且 IME 面板仍在最上 * (On_tick 在 IME 面板区画红色哨兵行,IME 绘制后应盖回面板色,不被 App 覆盖)。 + * ④ bug3(2026-09-15):IME 打开 ~200ms TAP 冷却窗——打开后立即注入的 stray-TAP + * (背景/键区)被吞不取消;冷却窗后背景 TAP=取消语义保留。 * * 编译(宿主 gcc,链真实 gfx/sys/theme/vol/log/widget/input/activity/dialog/ime + 打桩 font/global): * gcc -Wall -Wextra -o test_ime_fix test_ime_fix.c test_host_stub.c \ * meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \ * meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \ * meow_ui_draw.c meow_ui_view.c meow_ui_img.c meow_notify_ipc.c stb_image.c \ + * meow_ui_anim.c meow_ui_media.c meow_ui_nav.c meow_pinyin.c meow_ui_box.c meow_ui_scroll.c \ * -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm - * (meow_ui_widget.c 新 W_LISTVIEW 引用 meow_ui_img_scaled → 需链 meow_ui_img.c+stb_image.c+-lm) + * (meow_ui_widget.c 新 W_LISTVIEW 引用 meow_ui_img_scaled → 需链 meow_ui_img.c+stb_image.c+-lm; + * widget/input/ime/view 后续又引入 anim/media/nav/pinyin/box/scroll 依赖,一并链接) */ #include "meow_ui.h" #include "meow_ui_internal.h" @@ -54,6 +58,38 @@ static int near(int a, int b) { int d = a - b; if (d < 0) d = -d; return d <= 14 /* ---- ① 异步 shift 测试(直接 handle_tap,确定语义,无阻塞循环) ---- */ static char g_echo[64] = ""; static void ime_cb(const char *t, void *ctx) { (void)ctx; snprintf(g_echo, sizeof g_echo, "%s", t); } + +/* ④ WiFi 设置页 bug3(2026-09-15):IME 打开 ~200ms TAP 冷却窗——触发打开那次点按的迟到 + * 「抬起 TAP」(goodix 长尾,经 SDK 输入线程 drain 后才入队)不得把键盘当「点背景=取消」 + * 瞬间关掉;冷却窗后点背景=取消的语义保留。 */ +static void test_stray_tap(void) +{ + meow_ime_ctx c; memset(&c, 0, sizeof c); + c.mode = MEOW_IME_ASCII; c.maxlen = 32; c.on_change = ime_cb; + meow_ui_text_input_async(&c); + assert(meow_ime_active() == 1); + + /* 打开后立即注入背景 TAP(y