From cca3906c8052119c1ce31eccc6579690304733bb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 21:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20M5=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E9=98=85=E8=AF=BB=E6=96=B9=E5=90=91/PIN/?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E7=8A=B6=E6=80=81=EF=BC=89=E4=B8=8E=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=20PIN=20=E9=94=81=E5=B1=8F=EF=BC=9B=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E6=96=B9=E5=90=91=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=88=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logic/pin(纯 C):加盐迭代哈希(FNV-1a ×1000 轮 + 每次设置新随机盐,16 位 hex)经 meow_config_* 持久化 pin.enabled/pin.hash/pin.salt;提供 set/verify/change/disable 与 4-8 位纯数字校验;enabled=1 但盐/哈希残缺时 fail-closed;落盘半途失败会还原旧盐,不留 「新盐+旧哈希」的双失效状态。 ui/settings(ACT_SETTINGS):MD3 分组表单(meow_listview 容器圆角卡 + 段头标签)—— 阅读方向 RTL/LTR 走 mr_reader_set_direction;启动 PIN 开关与修改(开启/修改均两遍 MEOW_IME_NUM 确认);版本与主题状态行(跟随系统 + 当前明暗,随 app.c 主题 listener 整帧重绘)。控件一律在 on_enter 建(栈顶=ACT_SETTINGS),避免 §6.1 owner 绑到 ACT_LIBRARY 的跨页命中泄漏。 ui/lock(ACT_LOCK):lock_view_should_show() 读 logic/pin;启用时启动先入锁屏,验证前 不绘制任何书库内容;「输入 PIN」按钮弹系统数字键盘(MEOW_IME_NUM),错 PIN 就地提示 重试(无惩罚性锁定),正确则 page_pop 回书库;on_event 消费 SWIPE——左缘返回不能绕过 PIN 或退出 App(meow_view_dispatch_event 先于 app.c 的 on_swipe)。 logic/reader(评审 P1 修复,最小持久化):mr_reader_set_direction 成功切换后 best-effort 写 meow_config("reader.dir","rtl"/"ltr"),写失败只打 WARN 不阻断本次切换; mr_reader_get_direction 首次调用惰性读一次配置恢复(无配置/非法值 → 默认 RTL),之后 纯内存快路径。宿主测试经 -DMR_READER_TEST_HOOKS 的 mr_reader_test_reset() 模拟重启。 测试/构建:test_pin(85 项,PIN 全分支,含单键半途落盘失败);test_settings_reader_persist (19 项:默认/落盘/重启恢复/坏值/非法值/只读一次/写失败只告警);可选宿主集成 test_settings_lock_host(真实 SDK view/activity/widget + IME 注入,端到端验证错/对 PIN 解锁与设置页两遍开启;SDK 无源码时 SKIP);run_tests.sh 保持 46/46 并纳入上述测试 (reader.c 的配置/日志依赖由 test/settings_reader_stub.c 存根),build_tuku.sh 加入 logic/pin.c。交叉构建静态 MIPS ELF(zig-mipsel)零警告。 安全口径:PIN 防随手翻看,不是安全边界——TF 卡可拔出直读、配置可改;存储不使用加密库, 锁屏与设置页文案均如实标注该局限。 --- tuku/build_tuku.sh | 2 +- tuku/logic/pin.c | 211 +++++++++++ tuku/logic/pin.h | 62 ++++ tuku/logic/reader.c | 51 ++- tuku/test/run_tests.sh | 51 ++- tuku/test/settings_reader_stub.c | 92 +++++ tuku/test/settings_reader_stub.h | 24 ++ tuku/test/test_pin.c | 320 ++++++++++++++++ tuku/test/test_settings_lock_host.c | 229 ++++++++++++ tuku/test/test_settings_reader_persist.c | 131 +++++++ tuku/ui/lock/lock.c | 194 ++++++++-- tuku/ui/settings/settings.c | 442 +++++++++++++++++++++-- 12 files changed, 1749 insertions(+), 60 deletions(-) create mode 100644 tuku/logic/pin.c create mode 100644 tuku/logic/pin.h create mode 100644 tuku/test/settings_reader_stub.c create mode 100644 tuku/test/settings_reader_stub.h create mode 100644 tuku/test/test_pin.c create mode 100644 tuku/test/test_settings_lock_host.c create mode 100644 tuku/test/test_settings_reader_persist.c diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh index bccc665..602ceec 100755 --- a/tuku/build_tuku.sh +++ b/tuku/build_tuku.sh @@ -12,7 +12,7 @@ SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" "$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ - logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c logic/pin.c \ "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser ls -la picbrowser file picbrowser diff --git a/tuku/logic/pin.c b/tuku/logic/pin.c new file mode 100644 index 0000000..da43786 --- /dev/null +++ b/tuku/logic/pin.c @@ -0,0 +1,211 @@ +/* logic/pin.c — APP 启动 PIN:加盐迭代哈希 + meow_config 持久化(纯 C)。 + * + * 设计取舍(见 pin.h 头注释):PIN 防随手翻看,不是安全边界。本文件不引入加密库, + * 只做「每次设置新随机盐 + FNV-1a 迭代 1000 轮」的轻量哈希,避免明文 PIN 直接躺在 + * 配置里。配置落 TF 数据目录,卡被拔走即可读/改——这是产品口径接受的局限。 + * + * 依赖面:只 include include/meow_ui_config.h(get/set/remove/int 声明),不 include + * meow_ui.h、不碰 UI,宿主 gcc 可直接编译 + 测试存根替换配置层。 + */ +#include "pin.h" +#include "include/meow_ui_config.h" + +#include +#include +#include +#include +#include +#include + +#define PIN_KEY_ENABLED "pin.enabled" +#define PIN_KEY_HASH "pin.hash" +#define PIN_KEY_SALT "pin.salt" + +#define PIN_HEX_LEN 16 /* 64-bit 摘要/盐的 hex 字符数 */ +#define PIN_HEX_BUF 17 /* 16 + NUL */ +#define PIN_ROUNDS 1000 /* 迭代轮数(防直接反查,不防暴力枚举) */ +#define PIN_SALT_BYTES 8 /* 8 字节盐 → 16 位 hex */ + +/* ---------- 哈希 ---------- */ + +/* FNV-1a(64-bit)逐段吸收,返回新状态。 */ +static uint64_t fnv1a_update(uint64_t h, const void *data, size_t n) +{ + const unsigned char *p = (const unsigned char *)data; + for (size_t i = 0; i < n; i++) { + h ^= (uint64_t)p[i]; + h *= 1099511628211ULL; + } + return h; +} + +/* pin → 16 位 hex 摘要:FNV-1a(salt, pin) 起手,再做 PIN_ROUNDS 轮 + * 「上一轮摘要(大端 8 字节)+ 盐」的 FNV-1a。同一 PIN 每次设置因盐不同而摘要不同。 */ +static void pin_digest(const char *pin, const char *salt, char out[PIN_HEX_BUF]) +{ + uint64_t h = 1469598103934665603ULL; /* FNV-1a 64-bit offset basis */ + h = fnv1a_update(h, salt, strlen(salt)); + h = fnv1a_update(h, pin, strlen(pin)); + for (int r = 0; r < PIN_ROUNDS; r++) { + unsigned char be[8]; + for (int i = 0; i < 8; i++) + be[i] = (unsigned char)((h >> (8 * (7 - i))) & 0xFF); + h = fnv1a_update(h, be, sizeof be); + h = fnv1a_update(h, salt, strlen(salt)); + } + snprintf(out, PIN_HEX_BUF, "%016llx", (unsigned long long)h); +} + +/* 生成 16 位 hex 随机盐:优先 /dev/urandom;无设备时用时间/pid/地址/计数器做降级熵 + * (宿主测试与裁剪系统仍可用——降级盐的强度如实标注:不构成密码学随机)。 */ +static void pin_gen_salt(char out[PIN_HEX_BUF]) +{ + unsigned char b[PIN_SALT_BYTES]; + int ok = 0; + + FILE *f = fopen("/dev/urandom", "rb"); + if (f) { + ok = (fread(b, 1, sizeof b, f) == sizeof b); + fclose(f); + } + if (!ok) { + static uint64_t ctr; + uint64_t x = (uint64_t)time(NULL) + ^ ((uint64_t)clock() << 21) + ^ ((uint64_t)getpid() << 40) + ^ (uint64_t)(uintptr_t)&b + ^ (++ctr * 0x9E3779B97F4A7C15ULL); + for (int i = 0; i < PIN_SALT_BYTES; i++) + b[i] = (unsigned char)((x >> (8 * i)) & 0xFF); + } + + for (int i = 0; i < PIN_SALT_BYTES; i++) + snprintf(out + i * 2, 3, "%02x", b[i]); +} + +/* ---------- 配置层小包装 ---------- */ +static int cfg_get(const char *key, char *out, int outsz) +{ + return meow_config_get(key, out, outsz) == MEOW_CFG_OK ? 0 : -1; +} + +static int cfg_set(const char *key, const char *val) +{ + return meow_config_set(key, val) == MEOW_CFG_OK ? 0 : -1; +} + +static int is_hex16(const char *s) +{ + if (!s) return 0; + for (int i = 0; i < PIN_HEX_LEN; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return 0; + } + return s[PIN_HEX_LEN] == '\0'; +} + +/* ---------- 公共 API ---------- */ + +int mr_pin_valid(const char *pin) +{ + if (!pin) return 0; + int n = 0; + while (pin[n]) { + if (pin[n] < '0' || pin[n] > '9') return 0; + n++; + if (n > MR_PIN_MAX_LEN) return 0; + } + return n >= MR_PIN_MIN_LEN; +} + +int mr_pin_enabled(void) +{ + char hash[PIN_HEX_BUF + 8]; + char salt[PIN_HEX_BUF + 8]; + + if (meow_config_int(PIN_KEY_ENABLED, 0) != 1) return 0; + if (cfg_get(PIN_KEY_HASH, hash, (int)sizeof hash) != 0) return 0; + if (cfg_get(PIN_KEY_SALT, salt, (int)sizeof salt) != 0) return 0; + return is_hex16(hash) && is_hex16(salt); /* 配置残缺 → fail-closed */ +} + +int mr_pin_set(const char *pin) +{ + char salt[PIN_HEX_BUF]; + char hash[PIN_HEX_BUF]; + char old_salt[PIN_HEX_BUF + 8]; + int had_old_salt; + + if (!mr_pin_valid(pin)) return MR_PIN_ERR_FORMAT; + + pin_gen_salt(salt); + pin_digest(pin, salt, hash); + had_old_salt = (cfg_get(PIN_KEY_SALT, old_salt, (int)sizeof old_salt) == 0); + + /* 先写盐与摘要、最后打开 enabled:中途失败只会留下「未启用」,不会出现 + * enabled=1 但缺少盐/哈希的半残状态。摘要写失败时把盐还原成旧值,使已启用的旧 + * PIN 仍可校验(否则「新盐+旧哈希」会让新旧 PIN 都验不过)。 */ + if (cfg_set(PIN_KEY_SALT, salt) != 0) return MR_PIN_ERR_STORE; + if (cfg_set(PIN_KEY_HASH, hash) != 0) { + if (had_old_salt) cfg_set(PIN_KEY_SALT, old_salt); + else meow_config_remove(PIN_KEY_SALT); + return MR_PIN_ERR_STORE; + } + if (cfg_set(PIN_KEY_ENABLED, "1") != 0) { + /* 重设时 enabled 本来就是 "1":新盐/哈希已一致生效,按成功返回; + * 首次开启时 enabled 仍为 0 → 如实报落盘失败。 */ + return meow_config_int(PIN_KEY_ENABLED, 0) == 1 ? MR_PIN_OK : MR_PIN_ERR_STORE; + } + return MR_PIN_OK; +} + +int mr_pin_verify(const char *pin) +{ + char hash[PIN_HEX_BUF + 8]; + char salt[PIN_HEX_BUF + 8]; + char calc[PIN_HEX_BUF]; + + if (!mr_pin_valid(pin)) return 0; + if (!mr_pin_enabled()) return 0; + if (cfg_get(PIN_KEY_HASH, hash, (int)sizeof hash) != 0) return 0; + if (cfg_get(PIN_KEY_SALT, salt, (int)sizeof salt) != 0) return 0; + + pin_digest(pin, salt, calc); + unsigned diff = 0; + for (int i = 0; i < PIN_HEX_LEN; i++) + diff |= (unsigned)((unsigned char)hash[i] ^ (unsigned char)calc[i]); + diff |= (unsigned)(hash[PIN_HEX_LEN] != '\0'); + return diff == 0; +} + +int mr_pin_change(const char *old_pin, const char *new_pin) +{ + if (!mr_pin_enabled()) return MR_PIN_ERR_STATE; + if (!mr_pin_valid(new_pin)) return MR_PIN_ERR_FORMAT; + if (!mr_pin_verify(old_pin)) return MR_PIN_ERR_WRONG; + return mr_pin_set(new_pin); +} + +int mr_pin_disable(void) +{ + meow_config_remove(PIN_KEY_HASH); + meow_config_remove(PIN_KEY_SALT); + if (cfg_set(PIN_KEY_ENABLED, "0") != 0) return MR_PIN_ERR_STORE; + return MR_PIN_OK; +} + +int mr_pin_get_salt(char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + if (cfg_get(PIN_KEY_SALT, out, outsz) != 0) return 0; + return out[0] != '\0'; +} + +int mr_pin_get_hash(char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + if (cfg_get(PIN_KEY_HASH, out, outsz) != 0) return 0; + return out[0] != '\0'; +} diff --git a/tuku/logic/pin.h b/tuku/logic/pin.h new file mode 100644 index 0000000..af3fc47 --- /dev/null +++ b/tuku/logic/pin.h @@ -0,0 +1,62 @@ +/* logic/pin.h — APP 启动 PIN(纯 C;宿主可裸测)。 + * + * 定位(诚实标注):PIN 只挡「随手翻看」——TF 卡可拔出直读,数据目录明文可改, + * 故不引入加密库、不宣称安全边界。存储口径:加盐迭代哈希(FNV-1a ×1000 轮, + * 每次设置新随机盐,hex 输出),落在 meow_config_*(App 自有 KV → TF MeowData)。 + * 盐/哈希只做「不知道 PIN 无法直接重算」的轻量门槛,防不了离线暴力或改配置。 + * + * 配置键(meow_config_*,值均为字符串): + * pin.enabled = "1"/"0" + * pin.hash = 16 位 hex(64-bit 摘要) + * pin.salt = 16 位 hex(每次设置重新生成) + * enabled=1 但 hash/salt 缺失或格式不合法时一律按「未启用」处理(fail-closed)。 + * + * 本模块只依赖 include/meow_ui_config.h 的三个声明(get/set/remove/int),不碰 UI; + * 宿主测试链 test_pin.c 内的 meow_config_* 存根即可,无需 SDK/设备。 + */ +#ifndef MR_PIN_H +#define MR_PIN_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_PIN_MIN_LEN 4 /* 允许的最短位数 */ +#define MR_PIN_MAX_LEN 8 /* 允许的最长位数 */ + +/* 返回码(set/change/disable)。verify 用 1/0 表示对/错,不返回错误码。 */ +#define MR_PIN_OK 0 /* 成功 */ +#define MR_PIN_ERR_FORMAT (-1) /* PIN 非法:非 4-8 位纯数字(NULL/空/符号/超长) */ +#define MR_PIN_ERR_STATE (-2) /* 状态不满足:change 时尚未启用 */ +#define MR_PIN_ERR_WRONG (-3) /* change 时原 PIN 校验失败 */ +#define MR_PIN_ERR_STORE (-4) /* meow_config_* 落盘失败(无数据目录/只读/IO) */ + +/* 4-8 位纯 ASCII 数字('0'-'9')→ 1;其余(含 NULL、空串、符号、全角数字)→ 0。 */ +int mr_pin_valid(const char *pin); + +/* 当前是否启用:1=已启用且 hash/salt 完整合法;0=未启用或配置损坏(fail-closed)。 */ +int mr_pin_enabled(void); + +/* 设置/重设 PIN(生成新盐 + 迭代哈希并立即落盘)。成功即生效(下次启动进锁屏)。 + * 返回 MR_PIN_OK / MR_PIN_ERR_FORMAT / MR_PIN_ERR_STORE。 */ +int mr_pin_set(const char *pin); + +/* 校验:1=匹配;0=不匹配/未启用/输入非法(不区分,避免侧信道式提示)。 */ +int mr_pin_verify(const char *pin); + +/* 修改:先校验 old,再以新盐重设 new。失败时旧 PIN 保持有效(先校验后写盘)。 + * 返回 MR_PIN_OK / MR_PIN_ERR_STATE / MR_PIN_ERR_FORMAT / MR_PIN_ERR_WRONG / MR_PIN_ERR_STORE。 */ +int mr_pin_change(const char *old_pin, const char *new_pin); + +/* 关闭 PIN:删 hash/salt 并把 enabled 置 0(幂等;调用方已通过锁屏验证时无需再输 PIN)。 + * 返回 MR_PIN_OK / MR_PIN_ERR_STORE。 */ +int mr_pin_disable(void); + +/* 诊断/测试用:读回当前 hex 盐/哈希。返回 1=读成功,0=未设。 */ +int mr_pin_get_salt(char *out, int outsz); +int mr_pin_get_hash(char *out, int outsz); + +#ifdef __cplusplus +} +#endif +#endif /* MR_PIN_H */ diff --git a/tuku/logic/reader.c b/tuku/logic/reader.c index d84d5d0..9945090 100644 --- a/tuku/logic/reader.c +++ b/tuku/logic/reader.c @@ -1,14 +1,61 @@ -/* logic/reader.c — 阅读方向状态(默认 RTL;M4 的阅读器直接复用)。 */ +/* logic/reader.c — 阅读方向状态(默认 RTL;M4/M6 的阅读器直接复用)。 + * + * M5 评审 P1 修复:方向设置要「下次启动生效」。最小持久化(不新增任何方向状态): + * - mr_reader_set_direction 成功切换后 best-effort 写 meow_config("reader.dir","rtl"/"ltr"); + * 写失败只打 WARN,不阻断切换(本次运行仍生效); + * - mr_reader_get_direction 首次调用惰性读一次配置恢复;无配置/值非法 → 默认 RTL; + * 之后走纯内存快路径(不再读配置)。 + * 依赖面:只 include 配置/日志两个模块头(不含 UI);宿主测试用 + * test/settings_reader_stub.c 顶替 meow_config_* 与 meow_log。 + */ #include "reader.h" +#include "include/meow_ui_config.h" +#include "include/meow_ui_log.h" + +#include + +#define MR_DIR_CFG_KEY "reader.dir" +#define MR_DIR_CFG_LTR "ltr" +#define MR_DIR_CFG_RTL "rtl" static int g_direction = MR_DIR_RTL; +static int g_restored; /* 0=尚未从配置恢复(首次 get/set 时读一次) */ + +static void reader_restore_once(void) +{ + char buf[8]; + + if (g_restored) return; + g_restored = 1; /* 无配置也只读一次:之后纯内存 */ + if (meow_config_get(MR_DIR_CFG_KEY, buf, sizeof buf) != MEOW_CFG_OK) return; + if (strcmp(buf, MR_DIR_CFG_LTR) == 0) g_direction = MR_DIR_LTR; + else if (strcmp(buf, MR_DIR_CFG_RTL) == 0) g_direction = MR_DIR_RTL; + /* 其它值:忽略,保持默认 RTL */ +} int mr_reader_get_direction(void) { + reader_restore_once(); return g_direction; } void mr_reader_set_direction(int dir) { - if (dir == MR_DIR_RTL || dir == MR_DIR_LTR) g_direction = dir; + if (dir != MR_DIR_RTL && dir != MR_DIR_LTR) return; + + reader_restore_once(); /* 先恢复:避免「没 get 过就 set」把配置里的旧方向当默认值 */ + if (g_direction == dir) return; /* 无变化不写盘 */ + g_direction = dir; + + if (meow_config_set(MR_DIR_CFG_KEY, dir == MR_DIR_LTR ? MR_DIR_CFG_LTR : MR_DIR_CFG_RTL) + != MEOW_CFG_OK) + meow_log(MEOW_LOG_WARN, "reader.dir: save failed (best-effort, kept in memory)"); } + +#ifdef MR_READER_TEST_HOOKS +void mr_reader_test_reset(void) +{ + g_direction = MR_DIR_RTL; + g_restored = 0; +} +#endif diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 917ff29..8be17e3 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,13 +1,54 @@ #!/bin/bash -# run_tests.sh — 宿主 gcc 构建并运行图库 logic 层测试(不链 SDK、不需要设备)。 +# run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 +# - test_library:书库扫描/自然排序/阅读方向(46 项) +# - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ +# 坏值/非法值/只读一次/写失败只告警) +# - test_pin:启动 PIN 格式/设置/校验/修改/禁用/残缺配置/落盘失败(logic/pin.c 为纯 C, +# 只引用 meow_config_* 声明,测试内以内存 KV 存根顶替 → 不链 SDK)。 +# - test_settings_lock_host(可选):设置页/锁屏与 SDK view/activity/widget 接线的宿主集成 +# 验证(owner 归属 + 锁屏 SWIPE 拦截)。需要 SDK **源码树**(不只 meow_ui.a); +# 交付环境只有库+头时自动 SKIP。 +# reader.c 的方向持久化只 include 配置/日志两个模块头;宿主测试用 test/settings_reader_stub.c +# 顶替 meow_config_* 与 meow_log(所以下面各条都带 -I"$SDK" 与该存根)。 set -e cd "$(dirname "$0")/.." -gcc -Wall -Wextra -O1 -I. \ - test/test_library.c logic/library.c logic/reader.c \ +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" + +gcc -Wall -Wextra -O1 -I. -I"$SDK" \ + test/test_library.c logic/library.c logic/reader.c test/settings_reader_stub.c \ -o test/test_library ./test/test_library -gcc -Wall -Wextra -O1 -I. \ + +gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_reader_core.c logic/library.c logic/reader.c \ - logic/reader/page_source.c logic/reader/nav.c \ + logic/reader/page_source.c logic/reader/nav.c test/settings_reader_stub.c \ -o test/test_reader_core ./test/test_reader_core + +gcc -Wall -Wextra -O1 -DMR_READER_TEST_HOOKS -I. -I"$SDK" \ + test/test_settings_reader_persist.c logic/reader.c test/settings_reader_stub.c \ + -o test/test_settings_reader_persist +./test/test_settings_reader_persist + +gcc -Wall -Wextra -O1 -I. -I"$SDK" \ + test/test_pin.c logic/pin.c \ + -o test/test_pin +./test/test_pin + +if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ + test/test_settings_lock_host.c ui/settings/settings.c ui/lock/lock.c \ + logic/pin.c logic/reader.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -o test/test_settings_lock_host + ./test/test_settings_lock_host +else + echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" +fi diff --git a/tuku/test/settings_reader_stub.c b/tuku/test/settings_reader_stub.c new file mode 100644 index 0000000..c4d8ea3 --- /dev/null +++ b/tuku/test/settings_reader_stub.c @@ -0,0 +1,92 @@ +/* settings_reader_stub.c — 宿主测试用 meow_config_* 与 meow_log 最小实现(内存 KV)。 + * + * 只为让 logic/reader.c 在不链 SDK 的宿主测试里可编译/可断言: + * - meow_config_get/set:8 个键的内存表,可注入写失败(模拟无卡/只读); + * - meow_log:只统计 WARN 次数(验证「写失败只告警、不阻断切换」)。 + * 与 test_pin.c 自带存根同思路,但抽成共用文件供多个 reader 相关测试链接。 + */ +#include "settings_reader_stub.h" +#include "include/meow_ui_config.h" +#include "include/meow_ui_log.h" + +#include +#include + +#define ST_MAX 8 +static struct { char key[64]; char val[64]; int used; } g_kv[ST_MAX]; +static int g_fail_writes; +static int g_warns; + +static int kv_find(const char *key) +{ + for (int i = 0; i < ST_MAX; i++) + if (g_kv[i].used && strcmp(g_kv[i].key, key) == 0) return i; + return -1; +} + +void mr_test_cfg_clear(void) +{ + memset(g_kv, 0, sizeof g_kv); + g_fail_writes = 0; +} + +void mr_test_cfg_put(const char *key, const char *val) +{ + if (!key || !key[0]) return; + int i = kv_find(key); + if (i < 0) { + for (i = 0; i < ST_MAX; i++) if (!g_kv[i].used) break; + if (i >= ST_MAX) return; + g_kv[i].used = 1; + snprintf(g_kv[i].key, sizeof g_kv[i].key, "%s", key); + } + snprintf(g_kv[i].val, sizeof g_kv[i].val, "%s", val ? val : ""); +} + +int mr_test_cfg_get(const char *key, char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + int i = kv_find(key); + if (i < 0) return 0; + snprintf(out, (size_t)outsz, "%s", g_kv[i].val); + return 1; +} + +void mr_test_cfg_fail_writes(int on) +{ + g_fail_writes = on ? 1 : 0; +} + +int mr_test_log_warns(void) +{ + return g_warns; +} + +void mr_test_log_clear(void) +{ + g_warns = 0; +} + +/* ---- 被测代码调用的 SDK 接口 ---- */ +int meow_config_set(const char *key, const char *val) +{ + if (!key || !key[0]) return MEOW_CFG_ERR_INVAL; + if (g_fail_writes) return MEOW_CFG_ERR_IO; + mr_test_cfg_put(key, val); + return MEOW_CFG_OK; +} + +int meow_config_get(const char *key, char *out, int outsz) +{ + if (!out || outsz <= 0) return MEOW_CFG_ERR_INVAL; + out[0] = '\0'; + if (!mr_test_cfg_get(key, out, outsz)) return MEOW_CFG_ERR_NOENT; + return MEOW_CFG_OK; +} + +void meow_log(meow_log_level lvl, const char *fmt, ...) +{ + (void)fmt; + if (lvl >= MEOW_LOG_WARN) g_warns++; +} diff --git a/tuku/test/settings_reader_stub.h b/tuku/test/settings_reader_stub.h new file mode 100644 index 0000000..356e643 --- /dev/null +++ b/tuku/test/settings_reader_stub.h @@ -0,0 +1,24 @@ +/* settings_reader_stub.h — 宿主测试用 meow_config_* 与 meow_log 存根(logic/reader.c 的依赖面)。 + * + * 内存 KV(不落盘)+ 测试辅助:读/写/清配置、注入写失败、统计 WARN 次数。 + * 供 test_library、test_reader_core、test_settings_reader_persist 共用—— + * 这几个测试都不链 SDK,reader.c 的持久化失败/成功路径靠这里可控。 + */ +#ifndef SETTINGS_READER_STUB_H +#define SETTINGS_READER_STUB_H + +#ifdef __cplusplus +extern "C" { +#endif + +void mr_test_cfg_clear(void); /* 清空全部键 */ +void mr_test_cfg_put(const char *key, const char *val); /* 直接写入(可注入坏值) */ +int mr_test_cfg_get(const char *key, char *out, int outsz); /* 1=键存在 */ +void mr_test_cfg_fail_writes(int on); /* 1=后续 set 全部失败 */ +int mr_test_log_warns(void); /* meow_log(WARN..) 次数 */ +void mr_test_log_clear(void); + +#ifdef __cplusplus +} +#endif +#endif /* SETTINGS_READER_STUB_H */ diff --git a/tuku/test/test_pin.c b/tuku/test/test_pin.c new file mode 100644 index 0000000..dfa555b --- /dev/null +++ b/tuku/test/test_pin.c @@ -0,0 +1,320 @@ +/* test_pin.c — 启动 PIN(logic/pin)宿主测试:纯 C,不链 SDK。 + * + * 用内存 KV 存根顶替 meow_config_get/set/int/remove(与 SDK 语义一致: + * 未设 → MEOW_CFG_ERR_NOENT;写失败可由 g_fail_write 注入)。 + * 覆盖:格式校验边界(4/8 位合法;3/9 位、空串、NULL、符号、全角数字非法)、 + * 设置/校验、错 PIN、重设换盐、change 全分支、disable、配置残缺 fail-closed、 + * 落盘失败、落盘布局(pin.enabled/hash/salt)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I"$SDK" test/test_pin.c logic/pin.c -o test/test_pin + * 或直接 test/run_tests.sh。 + */ +#include "logic/pin.h" +#include "include/meow_ui_config.h" + +#include +#include +#include + +#define KV_MAX 8 +static struct { char key[64]; char val[128]; int used; } g_kv[KV_MAX]; +static int g_fail_write; /* 1=模拟全部落盘失败 */ +static const char *g_fail_key; /* 非 NULL=只让该键写失败(模拟半途失败) */ + +static int kv_find(const char *key) +{ + for (int i = 0; i < KV_MAX; i++) + if (g_kv[i].used && strcmp(g_kv[i].key, key) == 0) return i; + return -1; +} + +static void kv_clear(void) +{ + memset(g_kv, 0, sizeof g_kv); + g_fail_write = 0; + g_fail_key = NULL; +} + +int meow_config_set(const char *key, const char *val) +{ + if (!key || !key[0]) return MEOW_CFG_ERR_INVAL; + if (g_fail_write || (g_fail_key && strcmp(g_fail_key, key) == 0)) return MEOW_CFG_ERR_IO; + int i = kv_find(key); + if (i < 0) { + for (i = 0; i < KV_MAX; i++) if (!g_kv[i].used) break; + if (i >= KV_MAX) return MEOW_CFG_ERR_IO; + g_kv[i].used = 1; + snprintf(g_kv[i].key, sizeof g_kv[i].key, "%s", key); + } + snprintf(g_kv[i].val, sizeof g_kv[i].val, "%s", val ? val : ""); + return MEOW_CFG_OK; +} + +int meow_config_get(const char *key, char *out, int outsz) +{ + if (!out || outsz <= 0) return MEOW_CFG_ERR_INVAL; + out[0] = '\0'; + int i = kv_find(key); + if (i < 0) return MEOW_CFG_ERR_NOENT; + snprintf(out, (size_t)outsz, "%s", g_kv[i].val); + return MEOW_CFG_OK; +} + +int meow_config_int(const char *key, int def) +{ + char buf[32]; + char *end = NULL; + long v; + if (meow_config_get(key, buf, sizeof buf) != MEOW_CFG_OK) return def; + v = strtol(buf, &end, 10); + if (end == buf) return def; + return (int)v; +} + +int meow_config_bool(const char *key, int def) +{ + return meow_config_int(key, def) != 0; +} + +void meow_config_remove(const char *key) +{ + int i = kv_find(key); + if (i >= 0) g_kv[i].used = 0; +} + +const char *meow_config_dir(void) { return "/tmp/meowcfg_test"; } + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int is_hex16(const char *s) +{ + if (!s) return 0; + if (strlen(s) != 16) return 0; + for (int i = 0; i < 16; i++) + if (!((s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f'))) return 0; + return 1; +} + +static void test_format(void) +{ + CHECK(mr_pin_valid(NULL) == 0, "NULL invalid"); + CHECK(mr_pin_valid("") == 0, "empty invalid"); + CHECK(mr_pin_valid("123") == 0, "3 digits invalid"); + CHECK(mr_pin_valid("1234") == 1, "4 digits valid (min)"); + CHECK(mr_pin_valid("12345") == 1, "5 digits valid"); + CHECK(mr_pin_valid("12345678") == 1, "8 digits valid (max)"); + CHECK(mr_pin_valid("123456789") == 0, "9 digits invalid"); + CHECK(mr_pin_valid("12a4") == 0, "letter invalid"); + CHECK(mr_pin_valid("12 4") == 0, "space invalid"); + CHECK(mr_pin_valid(" 1234") == 0, "leading space invalid"); + CHECK(mr_pin_valid("1234 ") == 0, "trailing space invalid"); + CHECK(mr_pin_valid("-123") == 0, "sign invalid"); + /* 全角数字(UTF-8 EF BC 9x)不是 ASCII 数字 */ + CHECK(mr_pin_valid("\xEF\xBC\x91\xEF\xBC\x92\xEF\xBC\x93\xEF\xBC\x94") == 0, + "fullwidth digits invalid"); +} + +static void test_default_state(void) +{ + kv_clear(); + CHECK(mr_pin_enabled() == 0, "default disabled"); + CHECK(mr_pin_verify("1234") == 0, "verify on disabled = 0"); + CHECK(mr_pin_set("123") == MR_PIN_ERR_FORMAT, "set short -> format error"); + CHECK(mr_pin_enabled() == 0, "failed set keeps disabled"); +} + +static void test_set_verify(void) +{ + kv_clear(); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "set 1234 ok"); + CHECK(mr_pin_enabled() == 1, "enabled after set"); + CHECK(mr_pin_verify("1234") == 1, "verify correct"); + CHECK(mr_pin_verify("4321") == 0, "verify wrong"); + CHECK(mr_pin_verify("123") == 0, "verify short"); + CHECK(mr_pin_verify("12345") == 0, "verify longer"); + CHECK(mr_pin_verify("") == 0, "verify empty"); + CHECK(mr_pin_verify(NULL) == 0, "verify NULL"); + CHECK(mr_pin_verify("12a4") == 0, "verify non-digit"); + + char salt[32] = "", hash[32] = ""; + CHECK(mr_pin_get_salt(salt, sizeof salt) == 1, "salt present"); + CHECK(mr_pin_get_hash(hash, sizeof hash) == 1, "hash present"); + CHECK(is_hex16(salt), "salt is 16 hex (got %s)", salt); + CHECK(is_hex16(hash), "hash is 16 hex (got %s)", hash); + CHECK(strcmp(hash, "1234") != 0, "hash is not the raw pin"); + + /* 落盘布局:enabled="1"(明文 PIN 不落盘:配置里只有 16 位 hex 摘要与盐) */ + char buf[64] = ""; + CHECK(meow_config_get("pin.enabled", buf, sizeof buf) == MEOW_CFG_OK && strcmp(buf, "1") == 0, + "pin.enabled=1 on disk"); +} + +static void test_reset_new_salt(void) +{ + kv_clear(); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "set first"); + char salt1[32] = "", hash1[32] = ""; + mr_pin_get_salt(salt1, sizeof salt1); + mr_pin_get_hash(hash1, sizeof hash1); + + CHECK(mr_pin_set("5678") == MR_PIN_OK, "set second (overwrite)"); + char salt2[32] = "", hash2[32] = ""; + mr_pin_get_salt(salt2, sizeof salt2); + mr_pin_get_hash(hash2, sizeof hash2); + CHECK(strcmp(salt1, salt2) != 0, "new salt on each set"); + CHECK(strcmp(hash1, hash2) != 0, "new hash on each set"); + CHECK(mr_pin_verify("5678") == 1, "new pin verifies"); + CHECK(mr_pin_verify("1234") == 0, "old pin rejected after overwrite"); + + /* 同一 PIN 重设:盐不同 → 摘要也不同(防跨设备彩虹表/同值比对)。 */ + CHECK(mr_pin_set("5678") == MR_PIN_OK, "re-set same pin"); + char salt3[32] = "", hash3[32] = ""; + mr_pin_get_salt(salt3, sizeof salt3); + mr_pin_get_hash(hash3, sizeof hash3); + CHECK(strcmp(salt2, salt3) != 0, "salt differs for same pin"); + CHECK(strcmp(hash2, hash3) != 0, "hash differs for same pin"); + CHECK(mr_pin_verify("5678") == 1, "same pin still verifies"); +} + +static void test_change(void) +{ + kv_clear(); + CHECK(mr_pin_change("1234", "5678") == MR_PIN_ERR_STATE, "change while disabled -> state"); + + CHECK(mr_pin_set("1234") == MR_PIN_OK, "setup 1234"); + CHECK(mr_pin_change("9999", "5678") == MR_PIN_ERR_WRONG, "change wrong old -> wrong"); + CHECK(mr_pin_verify("1234") == 1, "old still valid after wrong-change"); + CHECK(mr_pin_verify("5678") == 0, "new not applied after wrong-change"); + + CHECK(mr_pin_change("1234", "12") == MR_PIN_ERR_FORMAT, "change to short new -> format"); + CHECK(mr_pin_verify("1234") == 1, "old still valid after bad-new"); + + CHECK(mr_pin_change("1234", "12345678") == MR_PIN_OK, "change ok (8 digits)"); + CHECK(mr_pin_verify("12345678") == 1, "new valid after change"); + CHECK(mr_pin_verify("1234") == 0, "old invalid after change"); + CHECK(mr_pin_enabled() == 1, "still enabled after change"); + + CHECK(mr_pin_change(NULL, "0000") == MR_PIN_ERR_WRONG, "change NULL old -> wrong"); + CHECK(mr_pin_change("12345678", NULL) == MR_PIN_ERR_FORMAT, "change NULL new -> format"); +} + +static void test_disable(void) +{ + kv_clear(); + CHECK(mr_pin_disable() == MR_PIN_OK, "disable while disabled is idempotent"); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "setup for disable"); + CHECK(mr_pin_disable() == MR_PIN_OK, "disable ok"); + CHECK(mr_pin_enabled() == 0, "disabled after disable"); + CHECK(mr_pin_verify("1234") == 0, "verify fails after disable"); + + char buf[32] = ""; + CHECK(meow_config_get("pin.hash", buf, sizeof buf) == MEOW_CFG_ERR_NOENT, "hash key removed"); + CHECK(meow_config_get("pin.salt", buf, sizeof buf) == MEOW_CFG_ERR_NOENT, "salt key removed"); + CHECK(meow_config_get("pin.enabled", buf, sizeof buf) == MEOW_CFG_OK && strcmp(buf, "0") == 0, + "enabled=0 on disk"); + + /* 禁用后可重新开启(新盐)。 */ + CHECK(mr_pin_set("8765") == MR_PIN_OK, "re-enable after disable"); + CHECK(mr_pin_verify("8765") == 1, "re-enabled pin verifies"); +} + +static void test_corrupt_state(void) +{ + /* enabled=1 但缺 hash → fail-closed */ + kv_clear(); + meow_config_set("pin.enabled", "1"); + meow_config_set("pin.salt", "0011223344556677"); + CHECK(mr_pin_enabled() == 0, "enabled without hash -> fail closed"); + CHECK(mr_pin_verify("1234") == 0, "verify fail closed"); + + /* hash 长度/字符非法 */ + kv_clear(); + meow_config_set("pin.enabled", "1"); + meow_config_set("pin.salt", "0011223344556677"); + meow_config_set("pin.hash", "zz"); + CHECK(mr_pin_enabled() == 0, "garbage hash -> fail closed"); + + /* salt 非法 */ + kv_clear(); + meow_config_set("pin.enabled", "1"); + meow_config_set("pin.salt", ""); + meow_config_set("pin.hash", "0011223344556677"); + CHECK(mr_pin_enabled() == 0, "empty salt -> fail closed"); + + /* change 在残缺状态 → state 错误(不误判为已启用) */ + CHECK(mr_pin_change("1234", "5678") == MR_PIN_ERR_STATE, "change on corrupt -> state"); +} + +static void test_store_failure(void) +{ + kv_clear(); + g_fail_write = 1; + CHECK(mr_pin_set("1234") == MR_PIN_ERR_STORE, "set with failing store -> store error"); + CHECK(mr_pin_enabled() == 0, "still disabled after store failure"); + g_fail_write = 0; + + /* 写盘失败时不得把状态改成「已启用」——set 先写盐/摘要、最后写 enabled。 */ + CHECK(mr_pin_set("1234") == MR_PIN_OK, "set ok after store recovers"); + g_fail_write = 1; + CHECK(mr_pin_disable() == MR_PIN_ERR_STORE, "disable with failing store -> store error"); + g_fail_write = 0; +} + +static void test_partial_write(void) +{ + /* 摘要写失败:把盐还原成旧值,已启用的旧 PIN 仍可校验(不会新旧都验不过)。 */ + kv_clear(); + CHECK(mr_pin_set("1234") == MR_PIN_OK, "setup 1234"); + g_fail_key = "pin.hash"; + CHECK(mr_pin_set("5678") == MR_PIN_ERR_STORE, "hash write failure -> store error"); + CHECK(mr_pin_enabled() == 1, "still enabled after partial write"); + CHECK(mr_pin_verify("1234") == 1, "old PIN still valid after partial write"); + CHECK(mr_pin_verify("5678") == 0, "new PIN not applied after partial write"); + g_fail_key = NULL; + + /* 重设时 enabled 已是 "1":该键写失败不影响一致性 → 按成功返回。 */ + g_fail_key = "pin.enabled"; + CHECK(mr_pin_set("4321") == MR_PIN_OK, "enabled-already-1 write failure is consistent"); + CHECK(mr_pin_verify("4321") == 1, "new PIN active"); + g_fail_key = NULL; + + /* 首次开启时 enabled 写失败 → 未启用(fail-closed)。 */ + kv_clear(); + g_fail_key = "pin.enabled"; + CHECK(mr_pin_set("1111") == MR_PIN_ERR_STORE, "first enable with enabled-write failure"); + CHECK(mr_pin_enabled() == 0, "not enabled after enabled-write failure"); + g_fail_key = NULL; +} + +static void test_verify_does_not_mutate(void) +{ + kv_clear(); + CHECK(mr_pin_set("2468") == MR_PIN_OK, "setup"); + char salt1[32] = "", salt2[32] = ""; + mr_pin_get_salt(salt1, sizeof salt1); + (void)mr_pin_verify("0000"); + (void)mr_pin_verify("2468"); + mr_pin_get_salt(salt2, sizeof salt2); + CHECK(strcmp(salt1, salt2) == 0, "verify does not change salt"); +} + +int main(void) +{ + test_format(); + test_default_state(); + test_set_verify(); + test_reset_new_salt(); + test_change(); + test_disable(); + test_corrupt_state(); + test_store_failure(); + test_partial_write(); + test_verify_does_not_mutate(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_settings_lock_host.c b/tuku/test/test_settings_lock_host.c new file mode 100644 index 0000000..0d3fb60 --- /dev/null +++ b/tuku/test/test_settings_lock_host.c @@ -0,0 +1,229 @@ +/* test_settings_lock_host.c — 设置页/锁屏与 SDK 骨架的宿主集成测试(可选,需 SDK 源码树)。 + * + * 目的:钉住 M3 骨架评审点名的两条坑(M5 桩注释①/②),这两条只有把真实 SDK + * view/activity/widget 链起来才能验证: + * ① 非根页控件必须在 on_enter 建(此时 activity 栈顶=本页)——否则 owner 绑到 + * ACT_LIBRARY,跨页命中泄漏; + * ② 锁屏必须在自身 view 的 on_event 消费 SWIPE(meow_view_dispatch_event 先于 app.c + * 的 on_swipe),否则左缘返回会把 ACT_LOCK page_pop 掉、PIN 被绕过。 + * 另外覆盖:设置页回到书库(topbar 返回)、控件跨进出复用不重建、阅读方向经 + * mr_reader_set_direction 切换、lock_view_should_show() 随 PIN 状态变化、 + * 主题模式切换后整帧重绘不崩,以及**真实 MEOW_IME_NUM 键盘**驱动的两条端到端路径: + * 锁屏错 PIN(留在锁屏)→ 对 PIN(page_pop 回书库)、设置页两遍输入开启 PIN。 + * + * 编译:见 test/run_tests.sh —— 它把本测试、settings/lock/pin 源码与 SDK 源码 + * (-DMEOW_UI_HOST_TEST,附 test_host_stub.c 打桩 font/global)一起编;SDK 对象清单在脚本里。 + * 只发布 meow_ui.a(无 SDK 源码)的交付环境,run_tests.sh 自动 SKIP 本测试。 + */ +#include "tuku_internal.h" +#include "logic/pin.h" +#include "meow_ui_internal.h" /* meow_widget_owner(宿主测试专用内部头) */ + +#include +#include +#include +#include +#include + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +/* app.c 的导航上报在测试里不需要网络侧实现。 */ +void mr_nav_report(void) {} + +static int g_lib_enters; +static void lib_enter(meow_view *v, void *c) { (void)v; (void)c; g_lib_enters++; } +static void lib_leave(meow_view *v, void *c) { (void)v; (void)c; } + +/* 清掉本次测试写入的三个小配置并删临时目录(meow_config 每键一文件)。 */ +static void cleanup_cfg(const char *dir) +{ + static const char *keys[3] = { "pin.enabled.cfg", "pin.hash.cfg", "pin.salt.cfg" }; + char p[512]; + for (int i = 0; i < 3; i++) { + snprintf(p, sizeof p, "%s/%s", dir, keys[i]); + unlink(p); + } + rmdir(dir); +} + +/* ============================================================ */ +/* 数字键盘注入(宿主没有真实触摸):按 MEOW_IME_NUM 键位投递 TAP,关闭键盘后由被测代码校验。 + * NUM 布局(sdk/meow_ui_ime.c build_kb):KB_TOP=420;首行 470 是符号行 "-/.:*#+=", + * 数字九键行为 518/566/614(x0=70/190/310,键宽 100),底行(ESC|0|退格+OK)为 662; + * 点 y<420 的背景=关闭键盘。 */ +/* ============================================================ */ +typedef struct { const char *digits; int passes; } pin_job; + +static void key_tap(char d) +{ + int row, col; + if (d == '0') { row = 3; col = 1; } + else { + int v = d - '1'; + if (v < 0 || v > 8) return; + row = v / 3; + col = v % 3; + } + /* 数字行 y=518/566/614(首行 470 是符号行 "-/.:*#+="),底行 662;列心 120/240/360。 */ + meow_input_inject(MEOW_EV_TAP, 70 + col * 120 + 50, 518 + row * 48 + 18); +} + +static void *pin_injector(void *arg) +{ + pin_job *job = (pin_job *)arg; + usleep(150000); /* 等 text_input 开键盘并 drain 完残留事件 */ + for (int p = 0; p < job->passes; p++) { + if (p) usleep(300000); /* 两遍输入之间等第二遍开键盘 */ + for (const char *s = job->digits; *s; s++) { + key_tap(*s); + usleep(50000); + } + usleep(50000); + meow_input_inject(MEOW_EV_TAP, 240, 100); /* y<420:关闭键盘(非空→校验) */ + } + return NULL; +} + +static void lock_try_pin(const char *digits) +{ + int sb = meow_ui_statusbar_height(); + int cy = sb + (meow_ui_height() - sb) / 2; + pin_job job = { digits, 1 }; + pthread_t th; + if (pthread_create(&th, NULL, pin_injector, &job) != 0) return; + meow_view_dispatch_event(MEOW_EV_TAP, 240, cy + 40); /* 点「输入 PIN」按钮 */ + pthread_join(th, NULL); +} + +static void settings_tap_pin_row(void){ + /* 布局:sb + 56 顶栏 + 6 + 段头34 + 方向行64 + 12 + 段头34 → PIN 列表首行中心 +32 */ + int y = meow_ui_statusbar_height() + 56 + 6 + 34 + 64 + 12 + 34 + 32; + pin_job job = { "2468", 2 }; + pthread_t th; + if (pthread_create(&th, NULL, pin_injector, &job) != 0) return; + meow_view_dispatch_event(MEOW_EV_TAP, 200, y); + pthread_join(th, NULL); +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_settings_host_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { .on_enter = lib_enter, .on_leave = lib_leave }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *settings = settings_view_create(); + meow_view *lock = lock_view_create(); + int sb = meow_ui_statusbar_height(); + + meow_view_page_push(ACT_LIBRARY); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "start at library"); + + /* ---- 设置页:on_enter 建控件 + owner 归属 ---- */ + meow_view_page_push(ACT_SETTINGS); + CHECK(meow_ui_activity_active() == ACT_SETTINGS, "settings active"); + CHECK(settings->nwidgets == 5, "settings built 5 widgets (got %d)", settings->nwidgets); + int owner_ok = 1; + for (int i = 0; i < settings->nwidgets; i++) + if (meow_widget_owner(settings->widgets[i]) != ACT_SETTINGS) owner_ok = 0; + CHECK(owner_ok, "settings widgets owned by ACT_SETTINGS (no cross-page leak)"); + + /* 布局几何:顶栏贴状态栏、三组列表不越界、页脚在屏内。 */ + int r_tb[4] = { 0 }, r_about[4] = { 0 }; + meow_widget_rect(settings->widgets[0], r_tb); + meow_widget_rect(settings->widgets[3], r_about); + CHECK(r_tb[1] == sb && r_tb[3] == 56, "topappbar sits below statusbar"); + CHECK(r_about[1] + r_about[3] < meow_ui_height() - 60, + "about list leaves room for footer (bottom=%d)", r_about[1] + r_about[3]); + printf(" geometry: topbar y=%d, about list y=%d h=%d (screen %dx%d)\n", + r_tb[1], r_about[1], r_about[3], meow_ui_width(), meow_ui_height()); + + /* 阅读方向行:段头 34 + 行中心。 */ + int dir_y = sb + 56 + 6 + 34 + 32; + int d0 = mr_reader_get_direction(); + CHECK(meow_view_dispatch_event(MEOW_EV_TAP, 120, dir_y) == 1, + "tap on direction row consumed by settings on_event"); + CHECK(mr_reader_get_direction() != d0, + "direction toggled through mr_reader_set_direction"); + + /* topbar 返回 → page_pop 回书库 */ + CHECK(meow_view_dispatch_event(MEOW_EV_TAP, 22, sb + 28) == 1, "back tap consumed"); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back popped to library"); + CHECK(g_lib_enters == 2, "library re-entered after pop (enters=%d)", g_lib_enters); + + /* 再次进入:控件复用(不在 on_enter 重复创建/泄漏) */ + meow_view_page_push(ACT_SETTINGS); + CHECK(settings->nwidgets == 5, "settings widgets reused (got %d)", settings->nwidgets); + CHECK(meow_ui_activity_active() == ACT_SETTINGS, "settings active again"); + + /* 设置页不消费 SWIPE:交给 app.c 的 page_pop */ + CHECK(meow_view_dispatch_event(MEOW_EV_SWIPE, MEOW_SWIPE_LEFT, 200) == 0, + "settings leaves SWIPE to app.c (back = pop)"); + meow_view_page_pop(); /* 手动模拟 app.c 的返回行为,回到书库 */ + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "manual pop back to library"); + + /* ---- 锁屏:PIN 状态驱动 + SWIPE 拦截(坑②) ---- */ + mr_pin_disable(); + CHECK(lock_view_should_show() == 0, "lock hidden when PIN disabled"); + CHECK(mr_pin_set("1357") == MR_PIN_OK, "enable PIN"); + CHECK(lock_view_should_show() == 1, "lock shown when PIN enabled"); + + meow_view_page_push(ACT_LOCK); + CHECK(meow_ui_activity_active() == ACT_LOCK, "lock active"); + CHECK(lock->nwidgets == 1, "lock built its button on enter (got %d)", lock->nwidgets); + CHECK(meow_widget_owner(lock->widgets[0]) == ACT_LOCK, "lock button owned by ACT_LOCK"); + + CHECK(meow_view_dispatch_event(MEOW_EV_SWIPE, MEOW_SWIPE_LEFT, 240) == 1, + "lock consumes SWIPE (left-edge back cannot bypass PIN)"); + CHECK(meow_ui_activity_active() == ACT_LOCK, "still locked after swipe"); + CHECK(meow_view_dispatch_event(MEOW_EV_TAP, 5, 400) == 1, "lock consumes stray taps"); + CHECK(meow_ui_activity_active() == ACT_LOCK, "still locked after stray tap"); + + CHECK(mr_pin_verify("1357") == 1, "stored PIN verifies"); + CHECK(mr_pin_verify("1358") == 0, "wrong PIN rejected"); + + /* ---- 锁屏真解锁路径:点按钮 → MEOW_IME_NUM 输入 → 校验 → pop(错误则留在锁屏) ---- */ + lock_try_pin("1358"); /* 错 PIN:留在锁屏 */ + CHECK(meow_ui_activity_active() == ACT_LOCK, "wrong PIN keeps lock (retry allowed)"); + lock_try_pin("1357"); /* 正确:page_pop 回书库 */ + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "correct PIN pops lock to library"); + + /* ---- 设置页两遍输入开启 PIN(MEOW_IME_NUM) ---- */ + mr_pin_disable(); + meow_view_page_push(ACT_SETTINGS); + CHECK(meow_ui_activity_active() == ACT_SETTINGS, "settings active for PIN enable"); + settings_tap_pin_row(); + CHECK(mr_pin_enabled() == 1, "two-pass entry enabled the PIN"); + CHECK(mr_pin_verify("2468") == 1, "PIN entered in settings verifies"); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back from settings to library"); + + /* 主题切换:app.c listener 整帧 invalidate;页面按 meow_theme_get() 重画不崩。 */ + meow_theme_set_mode(MEOW_THEME_LIGHT); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + meow_theme_set_mode(MEOW_THEME_DARK); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + CHECK(1, "theme switch repaint does not crash"); + + mr_pin_disable(); + meow_view_free(lock); + meow_view_free(settings); + meow_view_free(lib); + meow_ui_shutdown(); + cleanup_cfg(data); + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "integration checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_settings_reader_persist.c b/tuku/test/test_settings_reader_persist.c new file mode 100644 index 0000000..b275adf --- /dev/null +++ b/tuku/test/test_settings_reader_persist.c @@ -0,0 +1,131 @@ +/* test_settings_reader_persist.c — 阅读方向持久化宿主测试(M5 评审 P1 修复)。 + * + * 被测:logic/reader.c 的 meow_config("reader.dir") 持久化。 + * 「新进程语义」用 reader.c 的测试钩子 mr_reader_test_reset()(-DMR_READER_TEST_HOOKS) + * 模拟:清掉静态恢复标记,配置存根保留——等价于重启后首次 get 从配置恢复。 + * + * 覆盖: + * T1 无配置默认 RTL,且 get 不写配置; + * T2 set LTR 落盘 + 内存立即生效;T3 reset 后从配置恢复 LTR; + * T4 set RTL 落盘 + reset 恢复 RTL; + * T5 坏值("sideways")忽略 → 默认 RTL,且 get 不回写坏值; + * T6 非法方向(99)忽略且不落盘; + * T7 坏值后 set 合法方向会覆盖配置; + * T8 配置只读一次(首次 get 后配置再变不影响本次内存态 = 快路径); + * T9 写失败:切换仍生效(内存)、只打一条 WARN、配置未变。 + * + * 编译(tuku/ 下,SDK 仅需公共头): + * gcc -Wall -Wextra -O1 -DMR_READER_TEST_HOOKS -I. -I"$SDK" \ + * test/test_settings_reader_persist.c logic/reader.c test/settings_reader_stub.c \ + * -o test/test_settings_reader_persist && ./test/test_settings_reader_persist + */ +#include "logic/reader.h" +#include "test/settings_reader_stub.h" + +#include +#include + +/* reader.c 在 -DMR_READER_TEST_HOOKS 下导出(正式构建无此符号,故不写进 reader.h, + * 避免生产头暴露测试入口)。 */ +extern void mr_reader_test_reset(void); + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int cfg_is(const char *want) +{ + char buf[32] = ""; + if (!mr_test_cfg_get("reader.dir", buf, (int)sizeof buf)) return 0; + return strcmp(buf, want) == 0; +} + +static int cfg_has(const char *key) +{ + char buf[32]; + return mr_test_cfg_get(key, buf, (int)sizeof buf); +} + +static void test_default_and_ltr(void) +{ + /* T1 无配置 → 默认 RTL;get 是只读的。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T1 default RTL with no config"); + CHECK(!cfg_has("reader.dir"), "T1 get does not write config"); + + /* T2 set LTR:内存立即生效 + 落盘 "ltr"。 */ + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T2 memory updated immediately"); + CHECK(cfg_is("ltr"), "T2 set LTR persists reader.dir=ltr"); + + /* T3 模拟重启 → 从配置恢复 LTR。 */ + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T3 restore LTR after restart"); + + /* T4 反向:set RTL 落盘并恢复。 */ + mr_reader_set_direction(MR_DIR_RTL); + CHECK(cfg_is("rtl"), "T4 set RTL persists reader.dir=rtl"); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T4 restore RTL after restart"); +} + +static void test_bad_values(void) +{ + /* T5 坏值忽略 → 默认 RTL,且 get 不改写坏值。 */ + mr_test_cfg_clear(); + mr_test_cfg_put("reader.dir", "sideways"); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T5 bad value ignored -> default RTL"); + CHECK(cfg_is("sideways"), "T5 get leaves bad value untouched"); + + /* T6 非法方向:忽略且不落盘。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + mr_reader_set_direction(99); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T6 invalid dir ignored (memory)"); + CHECK(!cfg_has("reader.dir"), "T6 invalid dir writes nothing"); + + /* T7 坏值后 set 合法方向 → 覆盖配置并在重启后生效。 */ + mr_test_cfg_clear(); + mr_test_cfg_put("reader.dir", "bogus"); + mr_reader_test_reset(); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(cfg_is("ltr"), "T7 valid set overwrites bad config"); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T7 restored after overwrite"); +} + +static void test_read_once_and_failure(void) +{ + /* T8 配置只读一次:首次 get 之后配置变化不影响内存(快路径)。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T8 first get with no config"); + mr_test_cfg_put("reader.dir", "ltr"); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "T8 config read only once (fast path)"); + + /* T9 写失败:切换仍生效、WARN 一条、配置不变。 */ + mr_test_cfg_clear(); + mr_reader_test_reset(); + mr_test_log_clear(); + mr_test_cfg_fail_writes(1); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "T9 switch still applies on write failure"); + CHECK(mr_test_log_warns() == 1, "T9 one WARN on write failure (got %d)", mr_test_log_warns()); + CHECK(!cfg_has("reader.dir"), "T9 config unchanged on failure"); + mr_test_cfg_fail_writes(0); + mr_reader_set_direction(MR_DIR_RTL); + CHECK(cfg_is("rtl") && mr_test_log_warns() == 1, "T9 recovery writes rtl, no extra WARN"); +} + +int main(void) +{ + test_default_and_ltr(); + test_bad_values(); + test_read_once_and_failure(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/lock/lock.c b/tuku/ui/lock/lock.c index a188f10..37827c8 100644 --- a/tuku/ui/lock/lock.c +++ b/tuku/ui/lock/lock.c @@ -1,49 +1,185 @@ -/* ui/lock/lock.c — 锁屏页(ACT_LOCK)M3 桩 + M5 接缝。 +/* ui/lock/lock.c — 锁屏页(ACT_LOCK):APP 启动 PIN。 * - * M5 在此实现「APP 启动 PIN」:lock_view_should_show() 读配置决定是否锁, - * 锁屏页负责 PIN 输入/校验后 page_pop 回书库。本批桩恒返回 0(不锁),页面仅可编译占位。 + * 流程(用户原话「允许用户在APP设置中设置APP启动 PIN」): + * - lock_view_should_show() = logic/pin 的 mr_pin_enabled();app.c 据此在启动时先 push + * 本页,验证通过前书库不进入/不绘制(view 注册表只画已进入的页,widget 还按 owner 过滤); + * - 本页 on_enter 建「输入 PIN」按钮(owner=ACT_LOCK),点按弹系统数字键盘(MEOW_IME_NUM), + * 校验通过 → page_pop 回书库;错误 → 就地提示重试(不做惩罚性锁定/计数); + * - 左缘返回**必须**由本页 on_event 消费 SWIPE(meow_view_dispatch_event 先于 app.c 的 + * on_swipe;否则 app.c 对非 library 页无条件 page_pop,PIN 会被绕过); + * - PIN 未启用时 app.c 直接 push 书库,本页不会被进入。 * - * 接缝注意(M5): - * ① meow_view 的 on_create 在 meow_view_create 内运行(此时 activity 栈空);本页是非根页, - * 控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 - * meow_ui_activity_push(ACT_LOCK) → 建控件 → meow_ui_activity_pop() - * (examples/hello/ui/demo/demo.c:515-522);否则 owner 绑到 ACT_LIBRARY, - * 锁屏控件会从书库页被命中(§6.1 跨页泄漏)。 - * ② 锁屏必须经自身 view 的 on_event 消费 SWIPE:meow_view_dispatch_event 先于 App 的 - * on_swipe(sdk/meow_ui_view.h:83-88);否则 app.c 对非 library 页的左缘滑动无条件 - * page_pop,PIN 会被绕过。 + * 安全口径(诚实标注):PIN 防的是随手翻看,不是安全边界——TF 卡可拔出直读,配置目录 + * 可改。存储为加盐迭代哈希(logic/pin.c),不引入加密库;锁屏文案也如实告知用户。 + * + * 控件归属(§6.1,M3 桩注释①):on_create 时 activity 栈为空,控件一律在 on_enter 建。 */ #include "tuku_internal.h" +#include "logic/pin.h" + +#include +#include static mr_page_ctrl g_ctrl; +static meow_widget *g_btn; + +static char g_msg[64]; +static int g_msg_err; +static int g_waiting; /* 1=数字键盘打开中(上部提示改为输入说明) */ +static char g_ime_pin[MR_PIN_MAX_LEN + 12]; + +/* meow_view on_event 的事件码:公共伞头未导出(sdk/meow_ui_internal.h: + * MEOW_EV_TAP=2 / MEOW_EV_SWIPE=3,值稳定),此处按契约镜像。 */ +#define MR_VEV_TAP 2 +#define MR_VEV_SWIPE 3 + int lock_view_should_show(void) { - return 0; /* M5:读 APP 启动 PIN 配置后返回 1 */ + return mr_pin_enabled(); } +/* ============================================================ */ +/* PIN 输入(系统数字键盘) */ +/* ============================================================ */ +static void ime_pin_change(const char *text, void *ctx) +{ + (void)ctx; + snprintf(g_ime_pin, sizeof g_ime_pin, "%s", text ? text : ""); +} + +/* 阻塞数字键盘;返回 1=收集到非空文本(关闭键盘即结束/校验)。 + * SDK 现状:键盘「确定」只置内部 submit 标记不关闭,关闭路径(取消/点背景)又把标记清 0, + * 故 meow_ui_text_input 的返回值不可作“确认”判据;文本经 on_change 实时收集,空=取消。 */ +static int lock_read_pin(char *out, int outsz) +{ + meow_ime_ctx c; + memset(&c, 0, sizeof c); + c.mode = MEOW_IME_NUM; + c.maxlen = (int)sizeof g_ime_pin; + c.on_change = ime_pin_change; + c.ctx = NULL; + + g_ime_pin[0] = '\0'; + g_msg[0] = '\0'; + g_msg_err = 0; + g_waiting = 1; /* 锁屏上部提示改为输入说明 */ + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); /* 键盘区背景快照取当前帧 */ + (void)meow_ui_text_input(&c); + g_waiting = 0; + + snprintf(out, (size_t)outsz, "%s", g_ime_pin); + return out[0] != '\0'; +} + +static void lock_attempt(void) +{ + char pin[32] = ""; + if (!lock_read_pin(pin, (int)sizeof pin)) { + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + return; /* 关闭键盘未输入:留在锁屏 */ + } + + if (!mr_pin_verify(pin)) { + snprintf(g_msg, sizeof g_msg, "PIN 错误,请重试"); + g_msg_err = 1; + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + return; + } + + g_msg[0] = '\0'; + g_msg_err = 0; + meow_view_page_pop(); /* 通过 → 回书库(ACT_LIBRARY on_enter) */ +} + +/* ============================================================ */ +/* 布局/绘制 */ +/* ============================================================ */ static void lock_draw(meow_view *v, const meow_theme *th, void *ctx) { (void)v; (void)ctx; int w = meow_ui_width(); int h = meow_ui_height(); - int cy = h / 2; + int sb = meow_ui_statusbar_height(); + int cy = sb + (h - sb) / 2; - meow_ui_text_center(w / 2, cy - 80, "LOCKED", th->font_title, th->primary); - meow_ui_text_center(w / 2, cy - 36, "Startup PIN (M5)", th->font_body, th->onSurface); - meow_ui_text_center(w / 2, cy + 8, "PIN entry will be implemented in M5", - th->font_body, th->onSurfaceVariant); + /* 自绘整页底色:验证前不露任何书库内容(本页是顶层唯一 entered 页)。 */ + meow_ui_fill_rect(0, sb, w, h - sb, th->bg); + + meow_ui_text_center(w / 2, cy - 150, "图库已锁定", th->font_title, th->onSurface); + if (g_waiting) { + meow_ui_text_center(w / 2, cy - 104, "输入 4-8 位数字后关闭键盘", th->font_body, + th->onSurface); + meow_ui_text_center(w / 2, cy - 76, "空输入 = 取消,回到锁屏", th->font_body, + th->onSurfaceVariant); + } else { + meow_ui_text_center(w / 2, cy - 104, "输入启动 PIN 继续", th->font_body, + th->onSurfaceVariant); + meow_ui_text_center(w / 2, cy - 76, "4-8 位数字 · 系统数字键盘", th->font_body, + th->onSurfaceVariant); + } + + if (g_msg[0]) { + meow_ui_text_center(w / 2, cy - 26, g_msg, th->font_body, + g_msg_err ? th->error : th->primary); + } + + /* 安全口径(如实告知):PIN 不是安全边界。 */ + meow_ui_text_center(w / 2, h - 86, "PIN 仅用于防止随手查看,", th->font_body, + th->onSurfaceVariant); + meow_ui_text_center(w / 2, h - 58, "TF 卡可被直接读取,不构成安全边界。", th->font_body, + th->onSurfaceVariant); } +static void lock_place(meow_view *v) +{ + int w = meow_ui_width(); + int h = meow_ui_height(); + int sb = meow_ui_statusbar_height(); + int cy = sb + (h - sb) / 2; + + v->x = 0; v->y = 0; v->w = w; v->h = h; + + int bw = 220, bh = 52; + if (g_btn) meow_widget_set_rect(g_btn, (w - bw) / 2, cy + 20, bw, bh); +} + +/* ============================================================ */ +/* 事件 */ +/* ============================================================ */ +static int lock_on_event(meow_view *v, int ev, int a, int b, void *ctx) +{ + (void)v; (void)ctx; + + /* 左缘返回(以及任何滑动)一律吞掉:锁屏不允许退出 App,也不允许绕过 PIN。 */ + if (ev == MR_VEV_SWIPE) return 1; + + if (ev == MR_VEV_TAP) { + int x = a, y = b; + if (meow_widget_hit(g_btn, x, y)) lock_attempt(); + return 1; /* 锁屏页消费点击,不向 App 其它页透传 */ + } + return 0; +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ static void lock_page_enter(meow_view *v, void *ctx) { ((mr_page_ctrl *)ctx)->active = 1; - v->x = 0; - v->y = 0; - v->w = meow_ui_width(); - v->h = meow_ui_height(); meow_ui_set_fullscreen(0); meow_ui_set_page_bg(meow_theme_get()->bg); + + g_msg[0] = '\0'; + g_msg_err = 0; + + if (!g_btn) { + /* page_push 已先 meow_ui_activity_push(ACT_LOCK) → 控件 owner 正确。 */ + g_btn = meow_btn_create_variant(0, 0, 220, 52, "输入 PIN", MEOW_BTN_FILLED); + if (g_btn) meow_view_add_widget(v, g_btn); + } + lock_place(v); mr_nav_report(); meow_ui_paint_full(); } @@ -54,12 +190,20 @@ static void lock_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; } +static void lock_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + g_btn = NULL; /* 控件随 meow_view_free 释放(v->widgets) */ +} + meow_view *lock_view_create(void) { static const meow_view_hooks h = { - .on_enter = lock_page_enter, - .on_leave = lock_page_leave, - .on_draw = lock_draw, + .on_enter = lock_page_enter, + .on_leave = lock_page_leave, + .on_destroy = lock_page_destroy, + .on_draw = lock_draw, + .on_event = lock_on_event, }; return meow_view_create(ACT_LOCK, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); } diff --git a/tuku/ui/settings/settings.c b/tuku/ui/settings/settings.c index ef92ca2..efbf3ed 100644 --- a/tuku/ui/settings/settings.c +++ b/tuku/ui/settings/settings.c @@ -1,45 +1,420 @@ -/* ui/settings/settings.c — 设置页(ACT_SETTINGS)M3 骨架。 +/* ui/settings/settings.c — 设置页(ACT_SETTINGS):MD3 分组表单。 * - * M5 在此实现 APP 启动 PIN 等设置项;本批只落可编译桩,入口为书库 TopAppBar ⚙。 - * 返回:左缘返回(app.c SWIPE_LEFT → page_pop)。 + * 内容(用户原话「允许用户在APP设置中设置APP启动 PIN」+「同步系统主题状态」): + * 阅读:阅读方向 RTL/LTR —— 直接读写 M3 的 mr_reader_get/set_direction(不另立状态); + * 安全:启动 PIN 开关(开启需两遍 MEOW_IME_NUM 输入确认)、修改 PIN; + * 关于:版本号、主题状态(跟随系统 + 当前明暗,随系统实时同步)。 * - * 接缝注意(M5):meow_view 的 on_create 在 meow_view_create 内运行(activity 栈空); - * 本页是非根页,控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 - * meow_ui_activity_push(ACT_SETTINGS) → 建控件 → meow_ui_activity_pop() - * (examples/hello/ui/demo/demo.c:515-522);否则 owner 绑到 ACT_LIBRARY,设置控件会从 - * 书库页被命中(§6.1 跨页泄漏)。 + * 观感/主题:SDK 的 meow_listview 容器模式(MD3 surfaceContainer 圆角卡 + 行间空隙) + * + 段头标签;开关/箭头由 SDK renderer 用主题语义色绘制;所有颜色取 meow_theme_get(), + * 不缓存颜色——app.c 的主题 listener 整帧 invalidate,本页 on_draw/src 回调下一帧即用新色。 + * + * 控件归属(§6.1,M3 桩注释①):本页非根页,控件**必须**在 on_enter 里建(此时 + * activity 栈顶=ACT_SETTINGS),否则 owner 会绑到 ACT_LIBRARY 造成跨页命中。 + * + * 返回:左缘返回由 app.c 的 on_swipe 统一 page_pop(本页 on_event 不消费 SWIPE)。 + * PIN 输入:SDK 系统数字键盘(MEOW_IME_NUM),不自绘键盘;文本经 on_change 实时收集, + * 「非空 + 关闭键盘 = 校验、空 = 取消」(SDK 阻塞 IME 的返回值不能当确认判据,见 + * settings_read_pin 注释);键盘打开期间步骤提示画在键盘面板上方(置顶 widget)。 */ #include "tuku_internal.h" +#include "logic/pin.h" + +#include +#include + +#define ST_TOP_H 56 +#define ST_SEC_H 34 +#define ST_ROW_H 64 +#define ST_GAP 12 + +/* meow_view on_event 的事件码:公共伞头未导出(定义在 sdk/meow_ui_internal.h: + * MEOW_EV_TAP=2 / MEOW_EV_SWIPE=3,值稳定)。此处按契约镜像,避免 App include SDK 内部头。 + * (已在交付报告里作为 SDK 缺口提出:建议公共头导出事件枚举。) */ +#define MR_VEV_TAP 2 + +/* PIN 行下标(g_lv_pin) */ +#define ST_PIN_ROW_SWITCH 0 +#define ST_PIN_ROW_CHANGE 1 static mr_page_ctrl g_ctrl; +static meow_widget *g_tb; +static meow_widget *g_lv_dir; /* 阅读:1 行 */ +static meow_widget *g_lv_pin; /* 安全:2 行 */ +static meow_widget *g_lv_about; /* 关于:2 行 */ +static meow_widget *g_overlay; /* 置顶提示条(仅拨号键盘期间显示步骤文案) */ + +static int g_yl_dir, g_yl_pin, g_yl_about; /* 段头标签 y */ +static int g_note_y; +static int g_ov_y, g_ov_h; /* 提示条几何(App 自记;widget 句柄不透明) */ + +static char g_note[96]; +static int g_note_err; +static int g_pin_on; /* 缓存的 PIN 启用态(避免每帧读 3 个小配置文件的 TF IO) */ +static char g_theme_line[64]; +static char g_prompt[64]; /* 非空=正在输入 PIN(键盘打开期间顶部提示条) */ +static char g_ime_pin[MR_PIN_MAX_LEN + 12]; + +/* ============================================================ */ +/* 文案/状态 */ +/* ============================================================ */ +static int theme_is_dark(const meow_theme *th) +{ + int r = (int)((th->surface >> 16) & 0xFF); + int g = (int)((th->surface >> 8) & 0xFF); + int b = (int)(th->surface & 0xFF); + return (r * 299 + g * 587 + b * 114) / 1000 < 128; +} + +static void theme_line_update(void) +{ + const meow_theme *th = meow_theme_get(); + meow_theme_mode m = meow_theme_get_mode(); + const char *mode = (m == MEOW_THEME_AUTO) ? "跟随系统" + : (m == MEOW_THEME_DARK) ? "固定深色" : "固定浅色"; + snprintf(g_theme_line, sizeof g_theme_line, "%s · 当前%s", mode, + theme_is_dark(th) ? "深色" : "浅色"); +} + +static void settings_note(const char *msg, int is_err) +{ + snprintf(g_note, sizeof g_note, "%s", msg ? msg : ""); + g_note_err = is_err; +} + +static void settings_repaint(void) +{ + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +/* ============================================================ */ +/* PIN 输入(系统数字键盘) */ +/* ============================================================ */ +static void ime_pin_change(const char *text, void *ctx) +{ + (void)ctx; + snprintf(g_ime_pin, sizeof g_ime_pin, "%s", text ? text : ""); +} + +/* 阻塞弹出 MEOW_IME_NUM 收集 4-8 位数字。返回 1=收到非空文本;0=空(视为取消)。 + * 说明(SDK 现状,如实记录):阻塞 IME 只以「关闭键盘」结束;数字键盘的「确定」只置内部 + * submit 标记而不关闭,随后「取消/点背景」关闭时该标记又被清 0(sdk/meow_ui_ime.c 的 + * K_ESC/背景路径),故 meow_ui_text_input 的返回值不可作为「确认」判据。本页改用 + * on_change 实时收集文本:非空 + 关闭键盘 = 校验;空 = 取消(提示条照此告知用户)。 + * 键盘面板覆盖屏底(y≥420),步骤提示画在其上方,否则打字时看不见当前步骤。 */ +static int settings_read_pin(const char *prompt, char *out, int outsz) +{ + meow_ime_ctx c; + memset(&c, 0, sizeof c); + c.mode = MEOW_IME_NUM; + c.maxlen = (int)sizeof g_ime_pin; + c.on_change = ime_pin_change; + c.ctx = NULL; + + g_ime_pin[0] = '\0'; + settings_note("", 0); + snprintf(g_prompt, sizeof g_prompt, "%s", prompt); + settings_repaint(); /* 先画提示条(键盘区背景快照从当前帧取) */ + (void)meow_ui_text_input(&c); + g_prompt[0] = '\0'; + + snprintf(out, (size_t)outsz, "%s", g_ime_pin); + return out[0] != '\0'; +} + +/* 两遍输入(开启/修改共用):1=一致且合法(写入 out);0=取消/不一致/非法(已置 note)。 */ +static int settings_pin_two_pass(const char *prompt, char *out, int outsz) +{ + char a[32] = "", b[32] = ""; + + if (!settings_read_pin(prompt, a, (int)sizeof a)) { + settings_note("已取消", 0); + return 0; + } + if (!mr_pin_valid(a)) { + settings_note("PIN 需为 4-8 位数字,未保存", 1); + return 0; + } + if (!settings_read_pin("再次输入以确认", b, (int)sizeof b)) { + settings_note("已取消", 0); + return 0; + } + if (strcmp(a, b) != 0) { + settings_note("两次输入不一致,未保存", 1); + return 0; + } + snprintf(out, (size_t)outsz, "%s", a); + return 1; +} + +static void settings_toggle_pin(void) +{ + if (!g_pin_on) { + char pin[32] = ""; + if (!settings_pin_two_pass("设置启动 PIN(4-8 位数字)", pin, (int)sizeof pin)) return; + int r = mr_pin_set(pin); + if (r == MR_PIN_OK) settings_note("启动 PIN 已开启 · 下次启动生效", 0); + else settings_note("保存失败:数据目录不可写", 1); + g_pin_on = mr_pin_enabled(); + return; + } + + int r = mr_pin_disable(); + if (r == MR_PIN_OK) settings_note("启动 PIN 已关闭", 0); + else settings_note("关闭失败:配置不可写", 1); + g_pin_on = mr_pin_enabled(); +} + +static void settings_change_pin(void) +{ + if (!g_pin_on) { + settings_note("请先开启启动 PIN", 0); + return; + } + char pin[32] = ""; + if (!settings_pin_two_pass("设置新 PIN(4-8 位数字)", pin, (int)sizeof pin)) return; + int r = mr_pin_set(pin); /* 重设即换新盐;旧 PIN 失效 */ + if (r == MR_PIN_OK) settings_note("PIN 已更新 · 下次启动生效", 0); + else settings_note("保存失败:数据目录不可写", 1); + g_pin_on = mr_pin_enabled(); +} + +/* ============================================================ */ +/* 行数据源(src2:每帧重取 → 开关/副标题随状态即时刷新) */ +/* ============================================================ */ +static int src_dir(int idx, meow_list_item *it, void *ctx) +{ + (void)ctx; + if (idx != 0) return 0; + memset(it, 0, sizeof *it); + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + it->title = "阅读方向"; + it->subtitle = rtl ? "右到左 · 日式漫画(RTL)" : "左到右 · 常规漫画(LTR)"; + it->right = MEOW_LIST_RIGHT_SWITCH; + it->on = rtl; + it->enabled = 1; + return 1; +} + +static int src_pin(int idx, meow_list_item *it, void *ctx) +{ + (void)ctx; + memset(it, 0, sizeof *it); + int on = g_pin_on; + if (idx == ST_PIN_ROW_SWITCH) { + it->title = "启动 PIN"; + it->subtitle = on ? "已启用 · 启动时先输入 PIN" + : "未设置 · 启动直接进入书库"; + it->right = MEOW_LIST_RIGHT_SWITCH; + it->on = on; + it->enabled = 1; + return 1; + } + if (idx == ST_PIN_ROW_CHANGE) { + it->title = "修改 PIN"; + it->subtitle = on ? "重新设置 4-8 位数字" : "开启后可修改"; + it->right = MEOW_LIST_RIGHT_ARROW; + it->enabled = on; /* 未启用时变淡且不响应命中 */ + return 1; + } + return 0; +} + +static int src_about(int idx, meow_list_item *it, void *ctx) +{ + (void)ctx; + memset(it, 0, sizeof *it); + if (idx == 0) { + it->title = "版本"; + it->subtitle = "0.1 · os.meow.picbrowser"; + it->enabled = 1; + return 1; + } + if (idx == 1) { + theme_line_update(); + it->title = "主题"; + it->subtitle = g_theme_line; + it->enabled = 1; + return 1; + } + return 0; +} + +/* ============================================================ */ +/* 布局 */ +/* ============================================================ */ +/* 置顶提示条(z=MEOW_Z_TOP):键盘面板从屏底覆盖,步骤文案必须画在其上方; + * 仅 g_prompt 非空时绘制,否则透明不挡顶栏。 + * 注:meow_widget 对 App 不透明(公共头只给句柄),坐标由本文件自己记录。 */ +static void settings_overlay_render(meow_widget *w, const meow_theme *th, void *ctx) +{ + (void)w; (void)ctx; + if (!g_prompt[0]) return; + int wd = meow_ui_width(); + meow_ui_fill_rect(0, g_ov_y, wd, g_ov_h, th->surfaceContainerHighest); + meow_ui_text_center(wd / 2, g_ov_y + 28, g_prompt, th->font_title, th->onSurface); + meow_ui_text_center(wd / 2, g_ov_y + 76, "输入后关闭键盘即校验 · 空输入=取消", + th->font_body, th->onSurfaceVariant); + meow_ui_fill_rect(0, g_ov_y + g_ov_h - 1, wd, 1, th->outlineVariant); +} + +static void settings_build(void) +{ + /* 控件先以占位矩形创建,统一由 settings_place() 摆位(避免两处布局数学不一致)。 */ + g_tb = meow_topappbar_create(0, 0, 1, 1, "设置"); + if (g_tb) meow_topappbar_set_back(g_tb, 1); + + g_lv_dir = meow_listview_create(0, 0, 1, 1, 1, src_dir, NULL, MEOW_LIST_SEL_NONE); + g_lv_pin = meow_listview_create(0, 0, 1, 1, 2, src_pin, NULL, MEOW_LIST_SEL_NONE); + g_lv_about = meow_listview_create(0, 0, 1, 1, 2, src_about, NULL, MEOW_LIST_SEL_NONE); + + /* 列表:MD3 容器卡 + 双行(标题/副标题)+ 右尾件(开关/箭头)。 + * 容器模式由 SDK 默认 renderer 绘制(surfaceContainer 圆角卡 + 行间空隙)。 */ + meow_widget *lvs[3] = { g_lv_dir, g_lv_pin, g_lv_about }; + for (int i = 0; i < 3; i++) { + if (!lvs[i]) continue; + meow_listview_set_item_h(lvs[i], ST_ROW_H); + meow_listview_set_item_renderer(lvs[i], 2, 0, 1); + meow_listview_set_container(lvs[i], 1); + } + + g_overlay = meow_widget_custom_create(0, 0, 1, 1, + settings_overlay_render, NULL, NULL); + if (g_overlay) meow_widget_set_z(g_overlay, MEOW_Z_TOP); +} + +/* 布局参数(窗口可能变化;每次进入重新摆位)。 */ +static void settings_place(meow_view *v) +{ + int w = meow_ui_width(); + int sb = meow_ui_statusbar_height(); + + v->x = 0; v->y = 0; v->w = w; v->h = meow_ui_height(); + + int lx = 8, lw = w - 16; + int y = sb + ST_TOP_H + 6; + + if (g_tb) meow_widget_set_rect(g_tb, 0, sb, w, ST_TOP_H); + + g_yl_dir = y; y += ST_SEC_H; + if (g_lv_dir) meow_widget_set_rect(g_lv_dir, lx, y, lw, ST_ROW_H); + y += ST_ROW_H + ST_GAP; + + g_yl_pin = y; y += ST_SEC_H; + if (g_lv_pin) meow_widget_set_rect(g_lv_pin, lx, y, lw, ST_ROW_H * 2); + y += ST_ROW_H * 2 + ST_GAP; + + g_yl_about = y; y += ST_SEC_H; + if (g_lv_about) meow_widget_set_rect(g_lv_about, lx, y, lw, ST_ROW_H * 2); + y += ST_ROW_H * 2 + 14; + + g_note_y = y; + + g_ov_y = sb; + g_ov_h = 128; + if (g_overlay) meow_widget_set_rect(g_overlay, 0, g_ov_y, w, g_ov_h); +} + +static void settings_section_label(int y, const char *text, const meow_theme *th) +{ + meow_ui_text(24, y + 6, text, th->font_body, th->primary); +} + +/* ============================================================ */ +/* 绘制 */ +/* ============================================================ */ static void settings_draw(meow_view *v, const meow_theme *th, void *ctx) { (void)v; (void)ctx; int w = meow_ui_width(); - int sb = meow_ui_statusbar_height(); - meow_ui_fill_rect(0, sb, w, 56, th->surfaceContainer); - meow_ui_text(w / 2, sb + 16, "Settings", th->font_title, th->onSurface); - meow_ui_text_center(w / 2, sb + 100, "APP options", th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, sb + 140, "Startup PIN — M5", th->font_body, th->onSurface); - meow_ui_text_center(w / 2, sb + 180, "Reader direction — M4", th->font_body, - th->onSurface); - meow_ui_text_center(w / 2, sb + 240, "Theme follows the system automatically", - th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, sb + 300, "left-edge swipe = back", th->font_body, - th->onSurfaceVariant); + settings_section_label(g_yl_dir, "阅读", th); + settings_section_label(g_yl_pin, "安全", th); + settings_section_label(g_yl_about, "关于", th); + + if (g_note[0]) { + meow_ui_text(24, g_note_y, g_note, th->font_body, + g_note_err ? th->error : th->primary); + } + int hy = g_note_y + (g_note[0] ? 30 : 0); + meow_ui_text(24, hy, "改动即时保存,下次启动生效", th->font_body, th->onSurfaceVariant); + meow_ui_text(24, hy + 28, "PIN 仅防随手查看,非安全边界", th->font_body, + th->onSurfaceVariant); + meow_ui_fill_rect(0, meow_ui_height() - 1, w, 1, th->outlineVariant); } +/* ============================================================ */ +/* 事件 */ +/* ============================================================ */ +static int settings_on_event(meow_view *v, int ev, int a, int b, void *ctx) +{ + (void)v; (void)ctx; + if (ev == MR_VEV_TAP) { + int x = a, y = b; + if (g_tb) { + int hit = meow_topappbar_hit(g_tb, x, y); + if (hit == MEOW_TOPBAR_BACK) { meow_view_page_pop(); return 1; } + if (hit >= 0) return 1; + } + if (g_lv_dir) { + int idx = meow_listview_hit(g_lv_dir, x, y); + if (idx == 0) { + mr_reader_set_direction(mr_reader_get_direction() == MR_DIR_RTL + ? MR_DIR_LTR : MR_DIR_RTL); + settings_note(mr_reader_get_direction() == MR_DIR_RTL + ? "阅读方向:右到左(RTL)" + : "阅读方向:左到右(LTR)", 0); + settings_repaint(); + return 1; + } + } + if (g_lv_pin) { + int idx = meow_listview_hit(g_lv_pin, x, y); + if (idx == ST_PIN_ROW_SWITCH) { + settings_toggle_pin(); + settings_repaint(); + return 1; + } + if (idx == ST_PIN_ROW_CHANGE) { + settings_change_pin(); + settings_repaint(); + return 1; + } + } + if (g_lv_about) { + int idx = meow_listview_hit(g_lv_about, x, y); + if (idx == 1) { + settings_note("主题跟随系统设置,App 内不可改", 0); + settings_repaint(); + return 1; + } + if (idx == 0) return 1; /* 版本行:仅展示 */ + } + return 0; + } + /* SWIPE 不消费:app.c 对非 library 页的左缘返回统一 page_pop。 */ + return 0; +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ static void settings_page_enter(meow_view *v, void *ctx) { ((mr_page_ctrl *)ctx)->active = 1; - v->x = 0; - v->y = 0; - v->w = meow_ui_width(); - v->h = meow_ui_height(); - meow_ui_set_fullscreen(0); + meow_ui_set_fullscreen(0); /* 保留系统状态栏(与书库一致) */ meow_ui_set_page_bg(meow_theme_get()->bg); + + if (!g_tb) { + /* 栈顶此时已是 ACT_SETTINGS(page_push 先 push 再 on_enter)→ 控件 owner 正确。 */ + settings_build(); + meow_widget *all[5] = { g_tb, g_lv_dir, g_lv_pin, g_lv_about, g_overlay }; + for (int i = 0; i < 5; i++) + if (all[i]) meow_view_add_widget(v, all[i]); + } + settings_place(v); + g_pin_on = mr_pin_enabled(); /* 进入页时读一次;页内改动由操作函数同步 */ mr_nav_report(); meow_ui_paint_full(); } @@ -50,12 +425,25 @@ static void settings_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; } +static void settings_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + /* 控件随 meow_view_free 统一释放(v->widgets);这里只清指针,避免悬垂。 */ + g_tb = NULL; + g_lv_dir = NULL; + g_lv_pin = NULL; + g_lv_about = NULL; + g_overlay = NULL; +} + meow_view *settings_view_create(void) { static const meow_view_hooks h = { - .on_enter = settings_page_enter, - .on_leave = settings_page_leave, - .on_draw = settings_draw, + .on_enter = settings_page_enter, + .on_leave = settings_page_leave, + .on_destroy = settings_page_destroy, + .on_draw = settings_draw, + .on_event = settings_on_event, }; return meow_view_create(ACT_SETTINGS, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); }