diff --git a/.agents/skills/background-ui-debug/SKILL.md b/.agents/skills/background-ui-debug/SKILL.md index c2b785589..0be9e9af3 100644 --- a/.agents/skills/background-ui-debug/SKILL.md +++ b/.agents/skills/background-ui-debug/SKILL.md @@ -34,7 +34,8 @@ UI 自动化的目标是证明用户可达状态,不是表演鼠标操作。 - 只能用 `testTag`、文本、content description 等语义选择器操作;找不到目标就是失败,不能退回坐标猜测。 - 截图必须来自离屏 Compose 画布,不得捕获用户桌面或其他应用。 - 每次动作前先读取当前语义状态,动作后等待明确终态并再次读取;超时、异常、空白画面和状态未变化都算失败。 -- 调试二进制必须在组合 UI 前创建唯一的临时数据根;账号、Cookie、设置、历史、数据库和下载文件只能读写该目录,退出后删除。协议必须报告 `dataMode=isolated`,且不提供切换到生产数据的参数。 +- 调试二进制必须在组合 UI 前创建唯一的临时数据根;默认账号、Cookie、设置、历史、数据库和下载文件只能读写该目录,退出后删除。只有用户明确授权真实账号验收时,才允许通过显式的 `--use-real-account` 将本机账号文件复制到该临时根;仍不得直接读写生产文件,且协议必须报告 `dataMode=isolated`。禁止增加默认使用生产数据的路径。 +- 纯 UI 布局和动画验收应优先提供 debug-only fixture 页面,不应为了进入目标页面要求真实登录;真实账号只用于必须验证认证、会话或线上数据契约的场景。 - 默认不执行远端副作用。涉及发布、关注、投票、删除等动作时只能验证到提交前状态;即使任务授权了真实副作用,也必须换用独立测试账号和单独执行面,不能解除本调试器的数据隔离。 ## 工作流 diff --git a/.agents/skills/zhihu-parallel-pr-workflow/SKILL.md b/.agents/skills/zhihu-parallel-pr-workflow/SKILL.md index e5a0f7780..41a2d8c38 100644 --- a/.agents/skills/zhihu-parallel-pr-workflow/SKILL.md +++ b/.agents/skills/zhihu-parallel-pr-workflow/SKILL.md @@ -1,81 +1,37 @@ --- name: zhihu-parallel-pr-workflow -description: Coordinate Zhihu++ issue and PR implementation through subagents with isolated git worktrees, appropriately selected Android AVD validation, real screenshots, Chinese PR creation, and main-agent-only review/coordination. Use when the user asks to fan out issues, maximize parallel subagent work, implement multiple Zhihu++ features or fixes, optionally validate on `$off-android-avd-ci-debug`, or automatically open PRs from worker branches. +description: Coordinate Zhihu++ issue implementation and pull requests when the user explicitly asks for subagents or when multiple independent issues or scopes have real parallel value. Covers issue trust gates, isolated worktrees, ownership-aware process handling, build and AVD validation, review, Chinese PR publication, and final evidence. Do not use merely because one narrow issue needs one PR. --- -# Zhihu Parallel PR Workflow +# Zhihu++ Parallel PR Workflow -## Trigger Boundary +Use this workflow to shorten independent Zhihu++ issue work without weakening evidence or ownership boundaries. -This workflow is only for work with real parallel value: multiple independent issues or PRs, an explicit user request for subagents/delegation, or independently executable scopes that materially reduce wall-clock time. Do not trigger it merely because a task references one GitHub issue or will end in one PR. A single narrow issue should stay with the main agent, including implementation, validation, screenshots, and PR publication, unless the user explicitly asks to delegate it. +## Decide whether to delegate -When the user explicitly requests implementing the latest independent issues in parallel, and -those issues contain owner-authored (`to agent:`) directions, parallel delegation is the -default: assign one complete issue/capability to each worker and keep the main agent focused on -coordination, review, and final acceptance. This default does not relax any trust, version, -reproduction, validation, or publication gates below. Mentioning this skill alone is not -authorization to use subagents. +- Keep one narrow issue in the main agent unless the user explicitly requests delegation. +- Delegate only independent scopes that can progress concurrently. One cross-platform capability is one scope; do not split its common declaration, callers, and platform implementations among workers. +- Give each worker exactly one issue or tightly coupled capability and one worktree. +- If work becomes serial, end coordination overhead and let the main agent continue, unless the user explicitly assigned implementation or PR publication to the worker. +When the user explicitly requests implementing the latest independent issues in parallel, and those issues contain owner-authored (`to agent:`) directions, parallel delegation is the default. Assign one complete issue/capability to each worker and keep the main agent focused on coordination, review, and final acceptance. This does not relax any trust, version, reproduction, validation, or publication gates below. Mentioning this skill alone is not authorization to use subagents. -Example: adding one menu action and adjusting that menu's sheet behavior is one bounded UI change. Spawning a worker only adds handoff and review overhead, so the main agent should complete it directly. +## Apply project gates first -## Core Contract - -Use this skill for high-throughput Zhihu++ issue work. The main agent must coordinate; worker subagents must implement. - -- The main agent may triage issues/PRs, assign scopes, create worktrees, track remote AVD capacity, review returned diffs, and summarize status. -- The main agent must not directly implement code, commit, push, or create PRs for a worker-owned issue unless the user explicitly overrides this boundary. -- Each worker owns exactly one issue or one tightly related issue bundle, in exactly one branch/worktree. -- Workers are not alone in the codebase: tell them not to revert unrelated changes, and to keep write scopes disjoint from other workers. -- Prefer many independent workers over one broad worker, but do not assign overlapping files or features to multiple workers. - -## Handoff Exit - -Parallel coordination must end when it no longer shortens the work. If execution has converged to one worker and the main agent is repeatedly sending that same worker sequential follow-ups for implementation, validation, CI, screenshots, or PR edits, the main agent must interrupt the worker and take over the remaining serial work. Do not preserve worker ownership as ceremony after parallel value has disappeared. - -Example: after several independent audits finish, one worker may initially consolidate the feature. Once only that branch remains and every next action depends on the previous one, routing each fix and check through the worker only adds a handoff; the main agent should continue directly in the existing isolated worktree. - -## Daemon Ban - -Do not use any Gradle, Kotlin compiler, build, watch, or long-lived helper daemon in the main checkout or any worker worktree. Daemon processes retain heap across parallel workers and can freeze the machine. - -- Every Gradle command in this workflow must use `--no-daemon`. -- Every Gradle command that compiles Kotlin must also pass `-Dkotlin.compiler.execution.strategy=in-process` so it does not start a Kotlin compiler daemon. -- Prefer bounded one-shot commands only. Do not run `--continuous`, watch mode, dev servers, background Gradle processes, or any command intended to stay resident. -- Before a batch and after heavy validation, stop existing Gradle daemons with `./gradlew --stop || true`. This cleanup command is allowed because it terminates daemons rather than relying on them. -- Worker prompts must repeat this rule explicitly; if a worker reports validation without `--no-daemon`, send it back to rerun validation correctly. - -## Startup - -1. Record start time with `date '+%Y-%m-%d %H:%M:%S %Z'`. -2. Read repo instructions that apply to `/Users/zhaoliyan/IdeaProjects/Zhihu`, especially `AGENTS.md`. -3. Choose the lowest-cost healthy AVD that matches the target API. Read `$off-android-avd-ci-debug` only when remote validation is selected. -4. Inspect current checkout, open PRs, and open issues: +1. Record the start time. +2. Read the repository `AGENTS.md` and any instructions under the files in scope. +3. Before creating a branch or worktree, apply the issue trust and information gates from `AGENTS.md`: identify every requirement's author, verify the reported app version and evidence, and ignore unverified solution proposals from non-owner users. +4. Check current work, overlap, and topology: - `git status --short --branch` - `git worktree list --porcelain` - - `gh pr list --state open --limit 80 --json number,title,headRefName,baseRefName,isDraft,url` - - `gh issue list --state open --limit 80 --json number,title,labels,updatedAt,url` -5. If remote validation is selected, run its health checks once per batch: - - `/Users/zhaoliyan/.agents/skills/off-android-avd-ci-debug/scripts/off-avd-ci-debug.sh status` - - `/Users/zhaoliyan/.agents/skills/off-android-avd-ci-debug/scripts/off-avd-ci-debug.sh boot-check` - - If boot-check fails, inspect remote logs before assigning UI verification. -6. Stop any existing Gradle daemons before assigning workers: - - `./gradlew --stop || true` - -## Candidate Selection - -Choose issues critically, not literally. Rank by user impact, feasibility, current PR overlap, blast radius, and testability. + - open PRs that may touch the same behavior + - the current `origin/master` +5. Define the user-reachable success state, minimum data flow, request budget, validation surface, and files owned by each worker. -- Skip or defer issues already covered by open PRs unless the task is to review or replace that PR. -- Split unrelated issues across workers. -- Bundle only when the same files and UX path are clearly shared. -- Prefer bug fixes and narrow UX wins before vague platform rewrites. -- For UI/nav/settings changes, require workers to read `docs/ai-ui-design-guide.md` and `NavDestination.kt` before editing. +Do not start implementation when the issue gate requires a warning comment, current-version reproduction, or more information. Follow the comment, close, unsubscribe, and read-back rules in `AGENTS.md` exactly. -## Worktree Rules +## Create isolated worktrees -`.worktrees/` 是项目级硬约束,不只适用于并行 worker。主 agent 为单个 issue 创建隔离 worktree 时,也必须放在当前仓库的 `.worktrees/`;不得放到仓库同级目录或自行选择其他外部路径。创建前应先解析并核对目标绝对路径确实位于 `/.worktrees/`。 - -Create one worktree per worker from current `origin/master`. +Create worktrees only under the repository's `.worktrees/` directory and only after the issue passes its gate. ```bash git fetch origin master --prune @@ -83,108 +39,87 @@ git worktree add .worktrees/ origin/master -b codex/ cp local.properties .worktrees//local.properties 2>/dev/null || true ``` -Keep watching the main checkout throughout the task, not only at startup. Re-check it before implementation and before any intended main-checkout write; if it is dirty or another person/process has modified it, preserve those changes and create or use a fresh worktree instead. +Resolve the absolute target before creation and verify it is inside `/.worktrees/`. Never edit the user's dirty main checkout for issue implementation. + +## Assign ownership -Use unique branch names such as: +Tell every worker: -- `codex/issue-444-account-history` -- `codex/issue-445-main-tab-reselect` -- `codex/issue-440-content-block-spacing` +- the absolute worktree path, issue, acceptance criteria, and owned files or capability; +- to read `AGENTS.md` before editing; +- that other people and agents share the repository, so it must preserve unrelated changes and never revert work it does not own; +- to read `docs/ai-ui-design-guide.md` and `NavDestination.kt` before UI, navigation, button, or settings changes; +- to keep one complete cross-platform contract in one worker; +- to remove thin forwarding helpers and avoid speculative compatibility branches; +- to return the diff, validation evidence, risks, and screenshot path before committing so the main agent can review. -Never give two workers the same worktree. Never let a worker edit the main checkout unless the user explicitly says so. +The main agent coordinates and reviews worker-owned scopes. It must not silently implement, commit, push, or publish a worker-owned scope when the user explicitly assigned those actions to the worker. -## Worker Prompt Template +## Handle processes by ownership -Give each worker a self-contained prompt: +Process cleanup is an ownership decision, not an executable-name blacklist. -```text -Use the Zhihu++ repo at . You own issue #: . +- Never run `gradle --stop` or `./gradlew --stop`; their scope can include builds owned by other agents. +- Never terminate a process merely because it is named Gradle, Java, Kotlin, emulator, or ADB. +- It is valid to interrupt or terminate a process proven to belong to this task by its exact PTY session, PID and parent tree, unique worktree path, or unique command signature. +- Prefer interrupting the exact foreground session. Use `pkill` only when its pattern uniquely identifies this task and cannot match another worker. +- Before and after termination, read back the target process state. Do not claim cleanup from the command exit code alone. +- Do not add build flags, environment overrides, or cache isolation rules without evidence that they solve a real failure. In particular, do not prescribe a Gradle user home, daemon mode, or Kotlin compiler execution strategy in this workflow. -Hard rules: -- Read AGENTS.md before editing. -- For UI/nav/settings work, read docs/ai-ui-design-guide.md and NavDestination.kt before editing. -- Do not touch unrelated files or revert changes you did not make. -- Keep helpers only when they carry real behavior; remove thin wrappers. -- Implement in this worktree only. -- Do not use any daemon: no Gradle daemon, Kotlin compiler daemon, build daemon, watch mode, dev server, or long-lived helper process. -- Validate with ./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process assembleLiteDebug, then ./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintFormat. -- If you run any additional Gradle command, it must also include --no-daemon and -Dkotlin.compiler.execution.strategy=in-process. -- If UI changed, use a healthy local or remote AVD for install/launch/UI dump or screenshot. After choosing off, keep all ADB commands in the remote environment. -- Produce a real screenshot for PR description if the UI is visible. -- Commit, push, and open a draft PR with Chinese title/body. Title must start with feat:, fix:, or refactor:. Include Resolves #<number> when appropriate. +## Implement and validate -Task: -<issue body, product judgement, acceptance criteria, known open PR overlaps, disjoint write scope> +Use the repository's required order: -Return: -- branch, commit, PR URL -- files changed -- validation commands and results -- screenshot path or PR asset URL -- risks or intentionally deferred parts +```bash +./gradlew assembleLiteDebug +./gradlew ktlintFormat ``` -## AVD Validation +- Add only the smallest focused compile or test task needed for the changed behavior. +- Do not run the complete instrument test suite locally unless the user explicitly requests it. Use targeted device tests or CI for device-only behavior. +- Separate one-time acceptance evidence from durable regression coverage. Verify low-risk visual styling once with the real UI and a screenshot; add an instrument test only when repeated device execution protects a stable behavior contract whose regression risk justifies emulator cost and timing fragility. +- Do not add Gradle flags as ceremony. Use a flag only when current evidence requires it, and report its effect. +- For API-dependent features, trigger `zhihu-reproduce` and obtain the real request/response and decode matrix required by `AGENTS.md` before designing fallbacks. +- For UI changes, use a healthy matching AVD, install the built APK, restore the approved test login state when needed, dump semantics before interaction, verify the changed state after interaction, and capture a real final screenshot. +- Read `off-android-avd-ci-debug` only if the remote AVD is actually selected. Keep all ADB work on the selected host and clean up only the emulator session owned by this task. +- Do not substitute build success, a non-empty file, process liveness, health checks, or a single HTTP 200 for the requested product success state. + +## Review before commit -Choose an existing healthy local AVD when it is faster or better matches the target API. Choose `off` when remote isolation, KVM resources, API 35, or reduced local pressure materially helps. If `off` is selected, use it as a short-lived runner only and do not run multiple emulator sessions concurrently. +Review the complete diff against `origin/master` before approving a commit: -Minimum UI validation for each UI worker: +Never amend or rebase a commit that is already present on a pull-request branch. Fix follow-ups with a new commit so the PR history remains auditable; only rewrite an unshared local commit when no PR or remote branch contains it. -1. Build APK locally in the worker worktree with: - `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process assembleLiteDebug` -2. Start the selected local AVD, or a short-lived remote AVD session using `$off-android-avd-ci-debug`. -3. Install `app/build/outputs/apk/lite/debug/app-lite-debug.apk`. -4. Launch `com.github.zly2006.zhplus.lite`. -5. Wait for content; if login/disclaimer blocks the path, restore account JSON using the project launch-on-device instructions or state the blocker precisely. -6. Use `.agents/skills/ui-test/llm_test_helper.py dump` before taps when practical. -7. Capture a screenshot from the actual AVD and place it in the worker worktree, for example `artifacts/issue-<n>-final.png`. -8. If `off` was selected, kill the remote emulator after validation: - `/Users/zhaoliyan/.agents/skills/off-android-avd-ci-debug/scripts/off-avd-ci-debug.sh kill` +- intended behavior and default values; +- duplicated logic, unused code, and thin helpers; +- settings keys with real runtime reads and searchable/highlightable UI entries; +- navigation and back-stack semantics; +- visible labels or counts whose destination URL was dropped; +- title or content truncation introduced while adding controls; +- cross-platform implementations required by a common contract; +- tests that prove the changed behavior rather than only setup or persistence; +- unexpected binaries, generated artifacts, secrets, or unrelated files. -If the selected runner cannot perform a needed step, switch to another healthy AVD when practical or report the exact validation gap; do not add remote infrastructure solely to satisfy a preference rule. +When merging overlapping visual regression tests, reconcile their asserted pixel regions with the final layout before keeping both assertions. An overlay such as a badge may intentionally cover a corner of its parent content box; the parent-background assertion must exclude the covered region while the overlay gets its own geometry and pixel assertions. Do not combine test bodies mechanically and claim the feature is preserved without checking the final bounds relationship. -## PR Requirements +Return blocking findings to the same worker. After approval, let the owner commit and continue publication. +## Publish the PR + +- Base the branch on current `origin/master` and keep unrelated feature branches out. +- Use a Chinese title and body. Prefix the title with `feat:`, `fix:`, or `refactor:` according to the product change relative to baseline. +- Include `Resolves #<issue>` when the PR resolves the supplied issue. +- For visible UI changes, include a screenshot from the actual app, AVD, or reproducible UI render. +- Describe the behavior, reason, scope, validation commands and results, screenshot source, and any genuine boundary. +- Read the PR back and verify title prefix and language, head/base, issue linkage, screenshot rendering, and check status. Never describe checks that are still running as green. - For every added or changed test, the PR body must explicitly record whether red-to-green verification against the pre-fix and fixed implementations was completed, including commands and terminal results or a precise blocker. Do not describe compilation or a pending CI check as red-to-green evidence. Workers create draft PRs themselves. -- Base must be `master`. -- Sync from latest `origin/master` before branch creation. -- PR title/body must be Chinese. -- Title prefix must be `feat:`, `fix:`, or `refactor:`. -- PR 标题的信息密度必须与改动广度匹配。当一个 PR 横跨多个可独立识别的用户能力时,标题应列出主要能力及其关系,不能用“完善 X 功能”“支持 X”等泛词把宽改动压缩成单一概念。例如,一个 PR 同时增加原生浏览页、多入口跳转和创作时的数据绑定,标题应明确写出这三类主要能力,而不是只写“完善内容功能”。 -- After creating or editing a PR, read it back with `gh pr view` and explicitly verify the title prefix instead of trusting the worker's summary. -- Include `Resolves #<issue>` when the PR closes the issue. -- UI PR descriptions must include final effect screenshots from a real app run, AVD, or reproducible UI render. -- Do not create one mega PR for unrelated issue work. - -When an upstream UI module supplies both display data and a destination URL, review the state projection and interaction together. Do not preserve only the visible label or count while silently discarding the action target; keep the URL in UI state, make the relevant container actionable through the project's established URL opener, and verify click semantics. For example, a social profile row with a count and profile link is incomplete if it renders the count but cannot open the profile. - -## Main-Agent Review - -After a worker returns: - -1. Inspect `git status`, `git show --stat`, and the PR diff. -2. Check for overlap with other worker branches and open PRs. -3. Review for: - - thin helper/wrapper regressions - - duplicated logic - - stale comments or wrong doc-comment style - - broken navigation semantics - - settings keys without runtime reads - - UI text truncation or layout regressions -4. Confirm validation evidence and screenshot are real. -5. Read the published PR back and verify its title matches `^(feat|fix|refactor): `, its body language and issue linkage are correct, and any visible data carrying a destination URL remains actionable. -6. If a worker missed requirements, send it back to fix in the same worktree. - -## Shutdown - -1. If `off` was used, ensure remote AVD cleanup: - `/Users/zhaoliyan/.agents/skills/off-android-avd-ci-debug/scripts/off-avd-ci-debug.sh kill` -2. Stop Gradle daemons from the main checkout and every worker worktree: - `./gradlew --stop || true` -3. Record end time with `date '+%Y-%m-%d %H:%M:%S %Z'`. -4. If total runtime exceeds 5 minutes, notify: - `terminal-notifier -message "已完成 Zhihu++ 并行 PR 工作" -sound default` -5. Final response must list each worker branch/PR, validation state, screenshots, and any blocked items. +## Finish + +1. Clean up only task-owned AVD sessions and foreground processes; preserve other agents' work. +2. Recheck the worker worktree, published commit, remote branch, PR body, and validation evidence. +3. Record the end time and calculate elapsed runtime. +4. If runtime exceeds five minutes, send the repository-required `terminal-notifier` message. +5. Report each branch and PR, validation terminal state, screenshot, and any unresolved evidence gap. diff --git a/.agents/skills/zhihu-parallel-pr-workflow/agents/openai.yaml b/.agents/skills/zhihu-parallel-pr-workflow/agents/openai.yaml index 1ebfc44a9..ea0979c3b 100644 --- a/.agents/skills/zhihu-parallel-pr-workflow/agents/openai.yaml +++ b/.agents/skills/zhihu-parallel-pr-workflow/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Zhihu Parallel PR Workflow" - short_description: "Parallel issue PR workflow" - default_prompt: "Use $zhihu-parallel-pr-workflow to dispatch Zhihu++ issue work to subagents with worktrees, off AVD screenshots, and draft PRs." + short_description: "Parallel Zhihu++ issue and PR coordination" + default_prompt: "Use $zhihu-parallel-pr-workflow to coordinate independent Zhihu++ issues through isolated worktrees, evidence-based validation, review, and Chinese PRs." diff --git a/.agents/skills/zhihu-pp-ai-slop-cleaner/SKILL.md b/.agents/skills/zhihu-pp-ai-slop-cleaner/SKILL.md index f478fda31..f3b68452a 100644 --- a/.agents/skills/zhihu-pp-ai-slop-cleaner/SKILL.md +++ b/.agents/skills/zhihu-pp-ai-slop-cleaner/SKILL.md @@ -9,6 +9,11 @@ description: Use for Zhihu++ maintenance work that scans Kotlin main sources for Treat low call count as a queue for review, not proof of deletion. +Use non-test production line count as a hard refactor check, measured per independent change cluster rather than +only across the whole commit. Deleting one large client must not hide line growth caused by new owners, adapters, +constructor parameters, or initialization glue elsewhere. A cleanup that preserves behavior but materially grows +the production implementation is presumed wrong unless the added lines encode a demonstrated new contract. + Do not treat unit-test reachability as a production contract. Before merging or preserving tests for a pure function, inspect whether the test caused production code to expose an `internal` helper, accept extra parameters, or retain test-only branches and fallback values. When the behavior is simple and already exercised through its diff --git a/CLAUDE.md b/CLAUDE.md index 4596c3c8b..12d276209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,10 +70,26 @@ KMP 迁移后的回归不能只看 common 层调用链,还必须检查平台 ### KMP 桌面 UI 一致性 +用户明确要求最终 UI 验收时,必须同时保存并核对展开态与收起态的最终截图;只保存修改前基线或只看语义树,不能证明最终渲染没有重叠、断行或动画终点错误。截图必须来自目标页面,并记录关键控件位置。 + +当标准 TopAppBar 的 title 固有高度阻止收起态达到产品契约时,不能继续叠加坐标常量修补;应把资料头部、标签栏和操作按钮放进一个自定义可折叠 AppBar,由同一布局状态决定高度和归属。验证重点是收起后真实布局高度,而不是按钮坐标看起来接近目标。 + +折叠式 TopAppBar 的操作按钮必须分别核对展开态和收起态的视觉契约;用户只要求收起后并入右侧时,不能把按钮永久移入 `actions` 而改变展开态布局。应保留展开态位置,并根据 `collapsedFraction` 连续移动到收起态工具栏位置,避免用离散条件切换造成跳变。 + +涉及状态栏、TopAppBar 或折叠动画的 UI 改动,不能只凭布局代码判断间距;必须通过离屏 UI 调试器截图检查展开态和收起态,确认控件没有贴到系统状态栏或被挤出工具栏。 + +离屏调试器未进入目标页面时,不得把登录页或其他页面的 PNG 说成目标页面截图验证;必须先证明目标页面语义节点和登录状态已经出现。隔离数据模式没有现成会话时,应明确报告无法完成目标页面截图,不能用未登录态替代。 + 把现有桌面端迁移到新的原生运行时前,必须先确认 UI 复用契约;当目标要求与既有桌面端完全一致并继续使用同一 UI 入口时,原生平台只能替换窗口宿主和平台能力实现,不能另写一套原生控件页面或复制业务模型。即使用户允许不受当前框架限制,也不能把它解释成允许分叉 UI;应先证明同一棵 UI 能在新后端运行,必要时改造渲染后端或 source set 边界。例子:已有公共 UI 入口同时承载首页、详情和设置时,新平台应直接挂载这个入口,并只为不支持的平台能力提供明确降级,而不是先造一个外观相似的侧栏和页面占位。 用户指出平台迁移方向错误后,纠错必须立即恢复一个可编译的唯一入口,不能只删除错误分支,却把仍引用已删除类型的启动文件留在工作树里。应先把平台宿主收缩到调用共享 UI,再沿真实编译错误补齐依赖和平台实现;任何阶段性提交或交付都不能同时存在两套 UI,或只有一套已经断裂的 UI。例子:撤销一套错误的原生页面后,应在同一次修正中让原生窗口直接挂载公共主界面,而不是留下等待以后再改的悬空导航引用。 +平台原生 chrome 与共享 UI 之间也必须保持声明式 UI 边界。不会重复出现的 label、图标、选中态和 action 应在同一个不可变 UI 元素声明中直接表达,不能先把 action 注册进一个长期存活的可变 state/controller,再由字符串标识或无参方法间接转发;这种回调槽会让展示与行为分散、生命周期不清,并掩盖重复导航或过期闭包。AppKit target/action 只应作为原生控件内部的事件适配,最终直接调用当前声明项携带的 action。例子:侧栏行应同时声明标题、系统图标、是否选中和点击动作,toolbar 按钮也应直接携带自己的动作;不能让窗口状态保存 `openSomething`、`navigateToSomething` 一类可变函数,再由原生层猜测标识并调用。 + +macOS 原生侧栏不能用视觉效果容器加手工坐标按钮模仿。承载分组导航时应使用 AppKit 的 source-list 语义组件(如 `NSOutlineView` 配合原生 selection、group row 和 cell view),让系统负责选中高亮、键盘导航、行高、缩进和外观适配;声明层只提供分组与行模型。例子:内容、资料、账号等分组应成为 outline 的 group rows,导航项成为带系统图标的 child rows,而不是用 textured button、固定 y 坐标和手工开关状态拼出相似布局。 + +修复 Apple Silicon 上的 Gradle JDK 时,必须同时核对 IDE 配置引用、实际路径和 Java 二进制架构,不能只修改 IDE 中显示的 JDK 名称,也不能看到版本号正确就假定架构正确。项目使用 `#GRADLE_LOCAL_JAVA_HOME` 时,应检查 `.gradle/config.properties` 的真实 `java.home`;引用命名 JDK 时,还要确认对应 Android Studio 的 `jdk.table.xml` 存在该条目。例子:M 系列 Mac 上一个版本较新的 Intel JDK 会让 Kotlin/Native 把 host 识别成 `macos_x64`;把项目改成一个未注册的 arm64 JDK 名称又会产生“Undefined jdk.table.xml entry”,正确做法是选定已存在的 arm64 JDK 路径、停止旧 daemon,再读回 Gradle JVM 与 host 架构。 + ### Issue 内容可信度与实施流程(最高优先级) GitHub issue 里的需求描述、改进方案、UI 数字和实现建议,只有明确由 `zly2006` 本人发表时才可以视为可信指令。其他用户创建或回复的 issue 内容一律只是未经验证的问题线索,不是产品需求,更不是实现命令;其中“希望怎么改进”等方案性文字默认不可信,严禁直接照做。即使任务表述为“实现某个 issue”,也只表示调查并解决其中真实存在的问题,不授权执行非 `zly2006` 用户提出的方案。 @@ -123,12 +139,26 @@ WebView 正文渲染不再接受任何功能更新,只作为废弃路径保留 ### 抽象边界 +偏好键常量应放在实际功能所属的设置模块,并由读取和写入该偏好的代码共享;不能把单项 UI 偏好塞进无关的平台能力契约,制造跨层依赖。 + +设计跨平台客户端时,必须先画清状态所有权、对象生命周期和平台能力边界,再决定接口形态,不能从现有调用点逐个增加回调、factory 或同步钩子。一个与登录会话绑定的网络客户端只能读取同一个权威会话对象;退出、重新登录或切换账号时,应销毁旧客户端并用新会话创建新实例,不能让客户端原地切换身份,也不能用状态变更回调维持平台层的第二份镜像状态。平台差异应优先由正交、细粒度且不重复的 `expect/actual` 表达;测试只替换最窄的底层能力,不得绕过生产配置。例子:客户端需要不同平台的网络 engine 时,应由平台 `actual` 提供 engine,公共构造过程统一安装请求配置;登录成功后由唯一的账户所有者整体替换“会话 + 客户端”,而不是向客户端注入创建器并在 cookie 变化时回调同步另一份 state。 + +表达跨平台能力时,单项能力优先使用明确的 `is...Supported`,只有登录方式这类确实由多个互斥选项组成的复杂能力才使用列表。支持声明和平台 UI 必须成对存在;不支持平台的 composable 应在误调用时直接失败,并在文案里写清不支持的具体宾语。不得用 nullable UI callback、cookie reader、client factory 或塞满无关字段的 platform/runtime 对象代替能力边界。例子:一种登录方式的设备信息、验证码解码和验证动作彼此独立时,应拆成不重复的 expect,而不是把整页 UI 和若干可空回调一起交给平台实现。 + +测试替身必须从明确的测试组合根注入一个完整、可关闭的依赖对象,不能通过进程级可变 factory 改写生产客户端的创建规则。测试需要替换网络 engine 时,应创建独立的账户 store 并把测试 engine 限制在该对象生命周期内;不能提供全局 override/reset 接口,让并发测试或生产调用读到测试状态。 + +网络 client 只应封装连接配置、凭据和生命周期,不能为少量单点业务请求再建立一层按接口命名的 client wrapper。一个 URL 只在某个 ViewModel 或状态对象使用时,应在该真实调用处直接用账户 client 发请求并解析响应;不要让调用链经过 environment getter、业务 client 和转发方法后才到 HttpClient。只有多个独立调用方共享完整协议、不变量或错误语义时,才值得提取协议对象。例子:某个账号管理页面独占列表、创建和切换请求时,应在该页面状态中直接写 URL、请求体和会话替换,而不是创建一个只被它使用的账号管理 client,再由 platform environment 返回这个 client。 + +用户指出一个无价值业务 client 时,必须把它当成架构类别的样本,对整个项目执行同类审查,不能只删除被点名的类。提交前应列出所有 `Client`、`Api`、`Repository` 以及 environment 中返回它们的 getter,逐项核对调用方数量、是否仅转发请求、是否真正共享协议不变量;单调用点的 URL 包装和直通 getter 应一并删除并内联。例子:发现一个页面专用 client 后,还要检查发布、上传、通知、身份和更新等模块是否存在相同的“页面 → environment → client → HttpClient”链路,不能改完一个名字就停止。 + 清理或新增 UI 辅助函数时,不能把只转发一次调用、没有分支、状态、契约隔离或复用收益的包装层保留下来。例子:一个正文渲染函数如果只是把参数原样传给底层渲染组件,调用点也只有少数几处,就应该在调用点直接使用底层组件;只有当它承载平台分支、设置读取、状态保存或跨页面统一语义时,才值得独立成函数。 代码 review 不能只确认 helper 行为正确,还必须检查新增调用层是否有存在价值。若一个成员函数只是把参数原样转发给同文件里的扩展函数,且没有隐藏状态转换、线程约束、平台差异或 API 稳定性收益,应直接在调用点使用真正承载语义的函数。例子:列表合并逻辑已经由扩展函数完整表达时,再包一层类成员只会制造假抽象,让 review 误以为那里有额外契约。 每写一个helper函数,扇自己是个耳光。扇了之后还是觉得他有价值,才能保留! +把粗粒度平台 UI 拆成正交的细粒度 expect/actual 后,必须立即复查原 UI 包装层是否还承担独立契约。如果剩余私有 composable 只接收这些平台依赖并转发给唯一调用点,应在同一次重构中内联,不能保留一个换名后的中间层。例子:页面原先由一个平台 composable 同时准备设备信息、图片解码和网络对象;拆成独立 expect 后,共享页面应直接取得这些依赖并渲染,不能再套一层只转交 client、verifier 和回调的内容函数。 + 修复 review 反馈后,必须把“删除无价值包装层”作为提交前检查项,而不是只看测试和行为是否通过。例子:一个列表状态更新函数如果已经能直接在调用点修改状态列表,再保留同名成员方法转发,只会增加维护面;提交前应主动删掉这类转发层。 当用户明确要求删除某个抽象并在调用点使用更底层能力时,不能把原抽象换成一组私有 helper 或同形 adapter。即使 helper 只有文件内可见,只要它仍然是在替原调用点封装同一段直通逻辑,就违背了“删除抽象”的目标。例子:一个导航流程原来通过仓库接口取数据,用户要求直接使用环境对象和数据库;正确做法是在导航流程需要的位置直接读取环境和数据库,而不是新建 `fetchSomething()`、`toSomething()` 这类薄包装把同样的转发藏起来。 @@ -218,7 +248,7 @@ Compose 页面需要在进入前台时刷新数据,应优先让协程直接跟 - 不要手动转换或在 data class 中使用 snake_case ### HTTP 客户端 -- 使用 `AccountData.httpClient(context)` 获取配置好的客户端 +- 业务请求使用当前账户 store 持有的客户端;Compose 代码通过公共账户 store,Android 非 Compose 入口通过 Android 组合根获取同一实例 - Web API 需要 `signFetchRequest(context)` 用于 zse96 v2 签名 - Android API 使用 `AccountData.ANDROID_HEADERS` 和 `ANDROID_USER_AGENT` @@ -374,6 +404,16 @@ python3 .agents/skills/ui-review-memory/memory_store.py update-status \ ## Pull Requests +### 平台相关默认设置 + +平台差异应落在设置的默认值和平台能力层,而不是在展示组件里硬编码覆盖用户设置。macOS 的原生 toolbar 必须消费与设置页相同的选项状态;如果 macOS 允许更多入口,应让设置默认值按平台自适应,并继续尊重用户保存的选择。不能为了让某些入口默认出现,直接在 toolbar 层固定全量目的地,否则会造成 UI 与设置状态分叉。 + +### 平台特有组件的通用边界 + +抽取 macOS 原生 toolbar、侧栏、Liquid Glass 宿主或其他平台特有组件时,组件本身只负责渲染通用模型、选中态和回调,不得把某个产品的目的地名称、分组、图标映射、默认选择或业务规则写死在组件内部。平台差异和产品差异都应由调用侧投影成通用模型;以后增加入口、调整排序、替换动作或接入另一处业务时,只改调用,不改组件本身。例子:侧栏组件接收带标题、图标、分组和稳定标识的导航项列表,调用侧决定哪些项目出现;不能让侧栏内部再维护一份产品目的地到中文标题的映射。这个边界同样适用于 Android、iOS、Windows 等平台专有控件,不能只在 macOS 上遵守。 + +Compose/Skia 的根 content view 已经由渲染宿主持有,原生侧栏接入时不能为了制造分栏而替换这个根视图或把它塞进新的 split view;这样会在 AppKit 调整 frame 时丢失渲染层的 content scale 并触发运行时崩溃。需要叠加原生 chrome 时,应保留 Compose 根视图对象,只把可独立管理的原生视图作为子视图挂载,并让生命周期、回调和销毁路径对称。 + 当我要求你发 PR 的时候,PR 的title必须以feat: /fix: /refactor: 开头,标题和内容必须用中文写。 提交PR前,先更新master与远程同步或领先,并确保当前分支基于master,而不包括其他feature branch的内容。 如果一开始给你的提示词包括了issue链接,并且此PR解决了这个issue,应该写上Resolves #issue_number在PR描述里,这样GitHub会自动关联并在PR合并时关闭这个issue。 diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleExportEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleExportEnvironmentInstrumentedTest.kt index 4d92606d3..85edd48e9 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleExportEnvironmentInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleExportEnvironmentInstrumentedTest.kt @@ -66,12 +66,7 @@ class ArticleExportEnvironmentInstrumentedTest { fun articleImageRendererUsesStableContentHeightBeforeFullLayout() = runBlocking { val context = InstrumentationRegistry.getInstrumentation().targetContext val environment = SharedAndroidPaginationEnvironment(context, allowGuestAccess = true) - val renderer = environment.articleImageExportRenderer { fileName -> - context.assets - .open(fileName) - .bufferedReader() - .use { it.readText() } - } + val renderer = environment.articleImageExportRenderer() val prepared = renderer.prepareExportWebView( htmlContent = environment.buildArticleExportHtml( content = sampleLongAnswerContent(), diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt index ea9ce487a..df5493f78 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt @@ -35,10 +35,8 @@ import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color @@ -67,6 +65,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.text.TextLayoutResult @@ -95,11 +94,10 @@ import com.github.zly2006.zhihu.ui.ARTICLE_USE_WEBVIEW_PREFERENCE_KEY import com.github.zly2006.zhihu.ui.AnswerDoubleTapAction import com.github.zly2006.zhihu.ui.ArticleScreen import com.github.zly2006.zhihu.ui.PREFERENCE_NAME -import com.github.zly2006.zhihu.ui.TtsState import com.github.zly2006.zhihu.ui.article.ArticleActionsMenu -import com.github.zly2006.zhihu.ui.rememberArticleTtsState import com.github.zly2006.zhihu.viewmodel.ArticleViewModel import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState import com.hrm.markdown.renderer.MarkdownImageData import io.ktor.client.HttpClient import org.junit.After @@ -124,6 +122,7 @@ class ArticleScreenInstrumentedTest { @Before fun setUp() { AndroidReadingPlayerBridge.publish(ReadingPlayerState()) + sharedArticleAnswerSwitchState.reset() composeRule.resetAppPreferences() composeRule.activity .getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) @@ -147,8 +146,8 @@ class ArticleScreenInstrumentedTest { ReadingQueueSourceRegistry.register(FULL_ORIGIN_SOURCE_ID, emptyList()) ReadingQueueSourceRegistry.register(PARTIAL_ORIGIN_SOURCE_ID, emptyList()) composeRule.runOnIdle { - composeRule.activity.articleAnswerSwitchState.navigator = null - composeRule.activity.articleAnswerSwitchState.pendingNavigator = null + sharedArticleAnswerSwitchState.navigator = null + sharedArticleAnswerSwitchState.pendingNavigator = null } } @@ -1321,27 +1320,13 @@ class ArticleScreenInstrumentedTest { } } - composeRule.onNodeWithText("话题收录 我的开源名片").assertIsDisplayed() - composeRule.onNodeWithText("创作声明: 内容包含剧透").assertIsDisplayed() - composeRule.onNodeWithText("收录于话题: 科技").assertIsDisplayed() - } - - /** - * Contract: https://github.com/zly2006/zhihu-plus-plus/issues/550 - * Introduced by: https://github.com/zly2006/zhihu-plus-plus/pull/552 - */ - @Test - fun articleTtsStateReadsFromMainActivityHost() { - composeRule.activity.runOnUiThread { - composeRule.activity.forceTtsStateForTest(TtsState.Ready) - } - - composeRule.setScreenContent { - val ttsState = rememberArticleTtsState() - Text("tts=$ttsState") + listOf( + "话题收录 我的开源名片", + "创作声明: 内容包含剧透", + "收录于话题: 科技", + ).forEach { endorsement -> + composeRule.onNodeWithText(endorsement).performScrollTo().assertIsDisplayed() } - - composeRule.onNodeWithText("tts=Ready").assertIsDisplayed() } /** @@ -1423,7 +1408,7 @@ class ArticleScreenInstrumentedTest { } } composeRule.activity.runOnUiThread { - composeRule.activity.articleAnswerSwitchState.pendingNavigator = sharedNavigator + sharedArticleAnswerSwitchState.pendingNavigator = sharedNavigator } composeRule.setScreenContent { Scaffold( @@ -1463,7 +1448,7 @@ class ArticleScreenInstrumentedTest { commentCount = 3, ) composeRule.activity.runOnUiThread { - composeRule.activity.articleAnswerSwitchState.pendingNavigator = object : AnswerNavigator( + sharedArticleAnswerSwitchState.pendingNavigator = object : AnswerNavigator( sourceName = "此问题", environment = NO_OP_API_ENVIRONMENT, ) { @@ -1675,13 +1660,6 @@ class ArticleScreenInstrumentedTest { return viewModel } - @Suppress("UNCHECKED_CAST") - private fun MainActivity.forceTtsStateForTest(state: TtsState) { - val ttsStateField = MainActivity::class.java.getDeclaredField("_ttsState") - ttsStateField.isAccessible = true - (ttsStateField.get(this) as MutableState<TtsState>).value = state - } - private fun ArticleViewModel.forceAnswerNextIdsForTest(ids: List<Long>) { val setter = ArticleViewModel::class.java.getDeclaredMethod("setAnswerNextIds", List::class.java) setter.isAccessible = true diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/BlocklistSettingsScreenInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/BlocklistSettingsScreenInstrumentedTest.kt index 29b449452..eb6376dc9 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/BlocklistSettingsScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/BlocklistSettingsScreenInstrumentedTest.kt @@ -17,8 +17,9 @@ package com.github.zly2006.zhihu -import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag @@ -33,14 +34,11 @@ import com.github.zly2006.zhihu.test.performVerticalSwipeCycle import com.github.zly2006.zhihu.test.resetAppPreferences import com.github.zly2006.zhihu.test.setScreenContent import com.github.zly2006.zhihu.ui.BlocklistSettingsScreen -import com.github.zly2006.zhihu.ui.BlocklistSettingsTestConfig import com.github.zly2006.zhihu.ui.BlocklistSettingsTestTags -import com.github.zly2006.zhihu.viewmodel.filter.BlockedKeyword import com.github.zly2006.zhihu.viewmodel.filter.BlockedQuestionAuthor -import com.github.zly2006.zhihu.viewmodel.filter.BlockedTopic -import com.github.zly2006.zhihu.viewmodel.filter.BlockedUser +import com.github.zly2006.zhihu.viewmodel.filter.getContentFilterDatabase +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -54,6 +52,12 @@ class BlocklistSettingsScreenInstrumentedTest { @Before fun setUp() { composeRule.resetAppPreferences() + runBlocking { + getContentFilterDatabase(composeRule.activity).blockedQuestionAuthorDao().apply { + clearAllUsers() + seededQuestionAuthors().forEach { insertUser(it) } + } + } } /** @@ -68,24 +72,18 @@ class BlocklistSettingsScreenInstrumentedTest { * visibility under swipe cycles, and keep the add FAB visible for this tab. * 2. Clicking a row must navigate to that asker's Person destination with the seeded id, * urlToken, and display name. - * 3. Individual delete and clear-all actions must call their injected callbacks instead of - * mutating the persistent blocklist. + * 3. Individual delete, clear-all, and add actions must mutate the real persistent + * blocklist used by the production screen. * 4. The add dialog should support both cancel and confirm paths deterministically. */ - val addedUsers = mutableListOf<Pair<String, String>>() - val deletedUserIds = mutableListOf<String>() - var clearCount = 0 - val navigator = setScreen( - testConfig = BlocklistSettingsTestConfig( - blockedQuestionAuthors = seededQuestionAuthors(), - onAddQuestionAuthor = { userId, userName -> addedUsers += userId to userName }, - onDeleteQuestionAuthor = { user -> deletedUserIds += user.userId }, - onClearQuestionAuthors = { clearCount++ }, - ), - ) + val dao = getContentFilterDatabase(composeRule.activity).blockedQuestionAuthorDao() + val navigator = setScreen() - composeRule.onNodeWithTag("blocklistSettings:tab:3").performClick() + composeRule.onNode(hasText("屏蔽提问者") and hasClickAction()).performClick() composeRule.onNodeWithTag(BlocklistSettingsTestTags.FAB).assertIsDisplayed() + composeRule.waitUntil(5_000) { + composeRule.onAllNodesWithTag(BlocklistSettingsTestTags.QUESTION_AUTHOR_LIST).fetchSemanticsNodes().isNotEmpty() + } composeRule.onNodeWithTag(BlocklistSettingsTestTags.QUESTION_AUTHOR_LIST).assertIsDisplayed() composeRule.onNodeWithTag(BlocklistSettingsTestTags.QUESTION_AUTHOR_LIST).performVerticalSwipeCycle() composeRule.onNodeWithTag(BlocklistSettingsTestTags.QUESTION_AUTHOR_LIST).performHorizontalSwipeCycle() @@ -103,49 +101,29 @@ class BlocklistSettingsScreenInstrumentedTest { ) composeRule.onNodeWithTag("blocklistSettings:questionAuthors:delete:offline-asker-1").performClick() + composeRule.waitUntil(5_000) { runBlocking { dao.getUserCount() == 1 } } composeRule.onNodeWithTag(BlocklistSettingsTestTags.QUESTION_AUTHOR_CLEAR_BUTTON).performClick() - assertEquals(listOf("offline-asker-1"), deletedUserIds) - assertEquals(1, clearCount) + composeRule.waitUntil(5_000) { runBlocking { dao.getUserCount() == 0 } } composeRule.onNodeWithTag(BlocklistSettingsTestTags.FAB).performClick() composeRule.onNodeWithTag(BlocklistSettingsTestTags.USER_DIALOG_DISMISS).performClick() - assertTrue(addedUsers.isEmpty()) + assertEquals(0, runBlocking { dao.getUserCount() }) composeRule.onNodeWithTag(BlocklistSettingsTestTags.FAB).performClick() composeRule.onNodeWithTag(BlocklistSettingsTestTags.USER_DIALOG_ID_INPUT).performTextInput("new-asker-id") composeRule.onNodeWithTag(BlocklistSettingsTestTags.USER_DIALOG_NAME_INPUT).performTextInput("新提问者") composeRule.onNodeWithTag(BlocklistSettingsTestTags.USER_DIALOG_CONFIRM).performClick() - assertEquals(listOf("new-asker-id" to "新提问者"), addedUsers) - } - - private fun setScreen(testConfig: BlocklistSettingsTestConfig): RecordingNavigator = composeRule.setScreenContent { - BlocklistSettingsScreen( - testConfig = testConfig, + composeRule.waitUntil(5_000) { runBlocking { dao.getUserCount() == 1 } } + assertEquals( + listOf("new-asker-id" to "新提问者"), + runBlocking { dao.getAllUsers().map { it.userId to it.userName } }, ) } - private fun assertTagAbsent(tag: String) { - composeRule.onAllNodesWithTag(tag, useUnmergedTree = true).assertCountEquals(0) + private fun setScreen(): RecordingNavigator = composeRule.setScreenContent { + BlocklistSettingsScreen() } - private fun seededKeywords(): List<BlockedKeyword> = listOf( - BlockedKeyword(id = 1, keyword = "标题党"), - BlockedKeyword(id = 2, keyword = "剧透", caseSensitive = true), - ) - - private fun seededUsers(): List<BlockedUser> = listOf( - BlockedUser( - userId = "offline-user-1", - userName = "离线用户一", - urlToken = "offline-user-token-1", - ), - BlockedUser( - userId = "offline-user-2", - userName = "离线用户二", - urlToken = "offline-user-token-2", - ), - ) - private fun seededQuestionAuthors(): List<BlockedQuestionAuthor> = listOf( BlockedQuestionAuthor( userId = "offline-asker-1", @@ -158,9 +136,4 @@ class BlocklistSettingsScreenInstrumentedTest { urlToken = "offline-asker-token-2", ), ) - - private fun seededTopics(): List<BlockedTopic> = listOf( - BlockedTopic(topicId = "topic-1", topicName = "离线主题一"), - BlockedTopic(topicId = "topic-2", topicName = "离线主题二"), - ) } diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/CollectionScreenInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/CollectionScreenInstrumentedTest.kt index c5ff5a3e3..a02b15964 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/CollectionScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/CollectionScreenInstrumentedTest.kt @@ -27,9 +27,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.zly2006.zhihu.data.Collection import com.github.zly2006.zhihu.test.MainActivityComposeRule import com.github.zly2006.zhihu.test.resetAppPreferences +import com.github.zly2006.zhihu.test.seedViewModel import com.github.zly2006.zhihu.test.setScreenContent import com.github.zly2006.zhihu.ui.CollectionBrowseScreen import com.github.zly2006.zhihu.ui.CollectionScreen +import com.github.zly2006.zhihu.viewmodel.CollectionsViewModel import org.junit.Before import org.junit.Rule import org.junit.Test @@ -110,10 +112,10 @@ class CollectionScreenInstrumentedTest { id = "direct-deletable", title = "直达待删除收藏夹", ) + seedCollectionsViewModel(listOf(defaultCollection, deletableCollection)) composeRule.setScreenContent { CollectionBrowseScreen( - urlToken = "offline-test-user", - testCollections = listOf(defaultCollection, deletableCollection), + urlToken = null, ) } @@ -137,11 +139,19 @@ class CollectionScreenInstrumentedTest { .assertIsDisplayed() } - private fun setCollectionScreen(testCollections: List<Collection>) = composeRule.setScreenContent { - CollectionScreen( - urlToken = "offline-test-user", - testCollections = testCollections, - ) + private fun setCollectionScreen(collections: List<Collection>) { + seedCollectionsViewModel(collections) + composeRule.setScreenContent { + CollectionScreen( + urlToken = null, + ) + } + } + + private fun seedCollectionsViewModel(collections: List<Collection>) { + composeRule.seedViewModel<CollectionsViewModel> { + CollectionsViewModel("").apply { allData.addAll(collections) } + } } private fun seedCollections(count: Int): List<Collection> = List(count) { index -> diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/CommentScreenInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/CommentScreenInstrumentedTest.kt index 93b3a2c3c..69ee8be61 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/CommentScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/CommentScreenInstrumentedTest.kt @@ -18,13 +18,11 @@ package com.github.zly2006.zhihu import android.content.Context -import android.content.ContextWrapper import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertCountEquals @@ -61,15 +59,13 @@ import com.github.zly2006.zhihu.test.mockRootComments import com.github.zly2006.zhihu.test.resetAppPreferences import com.github.zly2006.zhihu.test.seedViewModel import com.github.zly2006.zhihu.test.setScreenContent -import com.github.zly2006.zhihu.ui.ArticleHost +import com.github.zly2006.zhihu.ui.AndroidArticleNavigationHandoff import com.github.zly2006.zhihu.ui.COMMENT_EMOJI_BUTTON_TAG import com.github.zly2006.zhihu.ui.COMMENT_EMOJI_ITEM_TAG_PREFIX import com.github.zly2006.zhihu.ui.COMMENT_EMOJI_PICKER_TAG import com.github.zly2006.zhihu.ui.COMMENT_INPUT_TAG import com.github.zly2006.zhihu.ui.COMMENT_SCREEN_LIST_TAG -import com.github.zly2006.zhihu.ui.CommentEmoji import com.github.zly2006.zhihu.ui.CommentScreen -import com.github.zly2006.zhihu.ui.CommentScreenTestOverrides import com.github.zly2006.zhihu.ui.components.CommentScreenComponent import com.github.zly2006.zhihu.viewmodel.CommentItem import com.github.zly2006.zhihu.viewmodel.PaginationEnvironment @@ -137,23 +133,14 @@ class CommentScreenInstrumentedTest { urlPrefix = "https://www.zhihu.com/api/v4/comment_v5/comment/liked-root-comment/child_comment", commentId = "other-child-comment", ) - val pendingCommentHost = object : ContextWrapper(composeRule.activity), ArticleHost by composeRule.activity { - private var pendingCommentId: String? = "liked-child-comment" - - override fun consumePendingCommentId(destination: NavDestination): String? { - if (destination != ROOT_ARTICLE) return null - return pendingCommentId.also { pendingCommentId = null } - } - } + AndroidArticleNavigationHandoff.prepareComment(CommentHolder("liked-child-comment", ROOT_ARTICLE)) composeRule.setScreenContent { - CompositionLocalProvider(LocalContext provides pendingCommentHost) { - CommentScreenComponent( - showComments = false, - onDismiss = {}, - content = ROOT_ARTICLE, - ) - } + CommentScreenComponent( + showComments = false, + onDismiss = {}, + content = ROOT_ARTICLE, + ) } composeRule.waitUntil("Expected pending child comment holder to open both comment sheets", timeoutMillis = 5_000) { @@ -182,18 +169,8 @@ class CommentScreenInstrumentedTest { */ @Test fun emojiPickerInsertsPlaceholderAtCursor() { - val viewModel = seedRootCommentViewModel(seedRootComments(count = 1)) - setCommentScreen( - testOverrides = CommentScreenTestOverrides( - viewModel = viewModel, - commentEmojis = listOf( - CommentEmoji( - placeholder = "[惊喜]", - inlineKey = "emoji_test", - ), - ), - ), - ) + seedRootCommentViewModel(seedRootComments(count = 1)) + setCommentScreen() composeRule.onNodeWithTag(COMMENT_INPUT_TAG).performTextInput("已有草稿") composeRule @@ -230,9 +207,12 @@ class CommentScreenInstrumentedTest { paragraphId = "CANw6uZN", ) val currentFragment = mutableStateOf<NavDestination>(firstFragment) - val viewModel = SeededRootCommentViewModel( - article = firstFragment, - seededComments = seedRootComments(count = 1), + val urlPrefix = + "https://www.zhihu.com/api/v4/comment_v5/answers/${firstFragment.contentId}/segment/root_comment" + + "?segment_id=${firstFragment.segmentId}" + mockRootComments( + urlPrefix = urlPrefix, + commentId = "root-1", ) composeRule.setScreenContent { @@ -242,7 +222,6 @@ class CommentScreenInstrumentedTest { onChildCommentClick = {}, commentInput = commentInput.value, onCommentInputChange = { commentInput.value = it }, - testOverrides = CommentScreenTestOverrides(viewModel = viewModel), ) } composeRule.onNodeWithTag("comment_row_root-1").assertIsDisplayed() @@ -250,6 +229,7 @@ class CommentScreenInstrumentedTest { composeRule.runOnIdle { currentFragment.value = secondFragment } composeRule.onNodeWithTag("comment_row_root-1").assertIsDisplayed() + assertEquals(1, ZhihuMockApi.requestCount(HttpMethod.Get, urlPrefix)) } /** @@ -359,11 +339,13 @@ class CommentScreenInstrumentedTest { */ val childEntryCommentIds = mutableListOf<String>() val seededComments = seedRootComments(count = 4) - val viewModel = seedRootCommentViewModel(seededComments) + mockRootComments( + urlPrefix = "https://www.zhihu.com/api/v4/comment_v5/answers/9001/root_comment", + comments = seededComments, + ) setCommentScreen( onChildCommentClick = { childEntryCommentIds += it.item.id }, - testOverrides = CommentScreenTestOverrides(viewModel = viewModel), ) composeRule @@ -405,9 +387,6 @@ class CommentScreenInstrumentedTest { ) composeRule.onNodeWithTag("comment_reply_button_root-1").performClick() composeRule.onNodeWithTag("comment_like_button_root-1").performClick() - composeRule.waitUntil(timeoutMillis = 5_000) { - seededComments.first().likeCount == 6 && seededComments.first().liked - } assertEquals(listOf("root-1"), childEntryCommentIds) } @@ -424,14 +403,13 @@ class CommentScreenInstrumentedTest { * 2. Kept root comments should also drop embedded child comments from blocked users before * the screen receives them. */ - val viewModel = seedRootCommentViewModel(emptyList()) runBlocking { val database = getContentFilterDatabase(composeRule.activity) database.blockedUserDao().insertUser(BlockedUser("blocked-root-author", "被屏蔽根评论作者")) database.blockedUserDao().insertUser(BlockedUser("blocked-child-author", "被屏蔽子评论作者")) - viewModel.processForTest( - composeRule.activity, - listOf( + mockRootComments( + urlPrefix = "https://www.zhihu.com/api/v4/comment_v5/answers/9001/root_comment", + comments = listOf( seedComment( id = "blocked-root", authorId = "blocked-root-author", @@ -463,9 +441,7 @@ class CommentScreenInstrumentedTest { ) } - setCommentScreen( - testOverrides = CommentScreenTestOverrides(viewModel = viewModel), - ) + setCommentScreen() composeRule.onNodeWithTag("comment_row_allowed-root").assertIsDisplayed() composeRule.onNodeWithText("可见根评论作者").assertIsDisplayed() @@ -480,7 +456,6 @@ class CommentScreenInstrumentedTest { content: NavDestination = ROOT_ARTICLE, activeCommentItem: CommentItem? = null, onChildCommentClick: (CommentItem) -> Unit = {}, - testOverrides: CommentScreenTestOverrides? = null, ): RecordingNavigator = composeRule.setScreenContent { val commentInput = remember { mutableStateOf("") } CommentScreen( @@ -489,7 +464,6 @@ class CommentScreenInstrumentedTest { onChildCommentClick = onChildCommentClick, commentInput = commentInput.value, onCommentInputChange = { commentInput.value = it }, - testOverrides = testOverrides, ) } diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/HomeNotificationPixelInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/HomeNotificationPixelInstrumentedTest.kt index f21353bdf..1f65e54a9 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/HomeNotificationPixelInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/HomeNotificationPixelInstrumentedTest.kt @@ -18,7 +18,10 @@ package com.github.zly2006.zhihu import android.content.Context +import android.graphics.Bitmap +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -26,12 +29,14 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.github.zly2006.zhihu.data.ZHIHU_ME_URL import com.github.zly2006.zhihu.test.MainActivityComposeRule import com.github.zly2006.zhihu.test.ZhihuMockApi import com.github.zly2006.zhihu.test.resetAppPreferences import com.github.zly2006.zhihu.test.setScreenContent import com.github.zly2006.zhihu.ui.HOME_NOTIFICATION_BADGE_TAG +import com.github.zly2006.zhihu.ui.HOME_NOTIFICATION_BUTTON_CONTENT_TAG import com.github.zly2006.zhihu.ui.HomeScreen import com.github.zly2006.zhihu.ui.PREFERENCE_NAME import io.ktor.http.HttpMethod @@ -40,8 +45,12 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.io.File +import java.io.FileOutputStream +import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor +import kotlin.math.roundToInt @RunWith(AndroidJUnit4::class) class HomeNotificationPixelInstrumentedTest { @@ -70,8 +79,7 @@ class HomeNotificationPixelInstrumentedTest { composeRule.setScreenContent { HomeScreen( scrollToTopTrigger = 0, - innerPadding = androidx.compose.foundation.layout - .PaddingValues(), + innerPadding = PaddingValues(), ) } } @@ -93,25 +101,79 @@ class HomeNotificationPixelInstrumentedTest { .onNodeWithTag(HOME_NOTIFICATION_BADGE_TAG, useUnmergedTree = true) .fetchSemanticsNode() .boundsInRoot - val image = composeRule.onRoot().captureToImage() - val pixels = image.toPixelMap() - val left = floor(badgeBounds.left).toInt().coerceIn(0, pixels.width - 1) - val right = ceil(badgeBounds.right).toInt().minus(1).coerceIn(left, pixels.width - 1) - val top = floor(badgeBounds.top).toInt().coerceIn(0, pixels.height - 1) - val bottom = ceil(badgeBounds.bottom).toInt().minus(1).coerceIn(top, pixels.height - 1) - val centerX = (left + right) / 2 - val centerY = (top + bottom) / 2 - val foreground = { x: Int, y: Int -> isBadgeForeground(pixels[x, y]) } + val contentBounds = composeRule + .onNodeWithTag(HOME_NOTIFICATION_BUTTON_CONTENT_TAG, useUnmergedTree = true) + .fetchSemanticsNode() + .boundsInRoot + val rootImage = composeRule.onRoot().captureToImage() + val screenshot = File( + requireNotNull(InstrumentationRegistry.getInstrumentation().targetContext.getExternalFilesDir(null)), + "home-notification-button-content.png", + ) + FileOutputStream(screenshot).use { stream -> + rootImage.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, stream) + } + + val pixels = rootImage.toPixelMap() + val left = contentBounds.left.roundToInt().coerceIn(0, pixels.width - 1) + val top = contentBounds.top.roundToInt().coerceIn(0, pixels.height - 1) + val right = ceil(contentBounds.right).toInt().coerceIn(left + 1, pixels.width) - 1 + val bottom = ceil(contentBounds.bottom).toInt().coerceIn(top + 1, pixels.height) - 1 + val backgroundX = (left - 12).coerceAtLeast(0) + val backgroundY = ((top + bottom) / 2).coerceIn(0, pixels.height - 1) + val background = pixels[backgroundX, backgroundY] + + val contentCorners = listOf( + left + 1 to top + 1, + right - 1 to top + 1, + left + 1 to bottom - 1, + right - 1 to bottom - 1, + ).filterNot { (x, y) -> + x.toFloat() in badgeBounds.left..badgeBounds.right && + y.toFloat() in badgeBounds.top..badgeBounds.bottom + } + contentCorners.forEach { (x, y) -> + val corner = pixels[x.coerceIn(0, pixels.width - 1), y.coerceIn(0, pixels.height - 1)] + assertTrue( + "Notification icon content box corners must stay on the app-bar background; corner=$corner background=$background screenshot=${screenshot.absolutePath}", + isCloseTo(corner, background), + ) + } - assertTrue("badge must have a visible fill", foreground(centerX, centerY)) - assertTrue("badge top edge must be rendered", foreground(centerX, top)) - assertTrue("badge bottom edge must be rendered", foreground(centerX, bottom)) - assertTrue("badge left edge must be rendered", foreground(left, centerY)) - assertTrue("badge right edge must be rendered", foreground(right, centerY)) - assertTrue("badge top-left corner must remain rounded", !foreground(left, top)) - assertTrue("badge top-right corner must remain rounded", !foreground(right, top)) + val foregroundPixels = (top + 2..bottom - 2).sumOf { y -> + (left + 2..right - 2).count { x -> + !isCloseTo(pixels[x, y], background) + } + } + assertTrue( + "Notification icon content box must contain visible foreground pixels for the icon/badge; found $foregroundPixels screenshot=${screenshot.absolutePath}", + foregroundPixels >= 80, + ) + val badgeLeft = floor(badgeBounds.left).toInt().coerceIn(0, pixels.width - 1) + val badgeRight = ceil(badgeBounds.right).toInt().minus(1).coerceIn(badgeLeft, pixels.width - 1) + val badgeTop = floor(badgeBounds.top).toInt().coerceIn(0, pixels.height - 1) + val badgeBottom = ceil(badgeBounds.bottom).toInt().minus(1).coerceIn(badgeTop, pixels.height - 1) + val badgeCenterX = (badgeLeft + badgeRight) / 2 + val badgeCenterY = (badgeTop + badgeBottom) / 2 + val foreground = { x: Int, y: Int -> isBadgeForeground(pixels[x, y]) } + assertTrue("badge must have a visible fill", foreground(badgeCenterX, badgeCenterY)) + assertTrue("badge top edge must be rendered", foreground(badgeCenterX, badgeTop)) + assertTrue("badge bottom edge must be rendered", foreground(badgeCenterX, badgeBottom)) + assertTrue("badge left edge must be rendered", foreground(badgeLeft, badgeCenterY)) + assertTrue("badge right edge must be rendered", foreground(badgeRight, badgeCenterY)) + assertTrue("badge top-left corner must remain rounded", !foreground(badgeLeft, badgeTop)) + assertTrue("badge top-right corner must remain rounded", !foreground(badgeRight, badgeTop)) } + private fun isCloseTo( + first: androidx.compose.ui.graphics.Color, + second: androidx.compose.ui.graphics.Color, + ): Boolean = + abs(first.red - second.red) < 0.08f && + abs(first.green - second.green) < 0.08f && + abs(first.blue - second.blue) < 0.08f && + abs(first.alpha - second.alpha) < 0.08f + private fun isBadgeForeground(color: Color): Boolean = color.alpha > 0.5f && color.red > color.green + 0.12f && diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/QuestionScreenInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/QuestionScreenInstrumentedTest.kt index 7ee18d384..6bffc4c93 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/QuestionScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/QuestionScreenInstrumentedTest.kt @@ -57,6 +57,7 @@ import com.github.zly2006.zhihu.viewmodel.feed.QuestionFeedViewModel import com.github.zly2006.zhihu.viewmodel.filter.BlockedUser import com.github.zly2006.zhihu.viewmodel.filter.getContentFilterDatabase import com.github.zly2006.zhihu.viewmodel.paginationEnvironment +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState import io.ktor.http.HttpMethod import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString @@ -150,7 +151,7 @@ class QuestionScreenInstrumentedTest { listOf(Article(type = ArticleType.Answer, id = 7003L)), navigator.destinations, ) - val pendingNavigator = composeRule.activity.articleAnswerSwitchState.pendingNavigator + val pendingNavigator = sharedArticleAnswerSwitchState.pendingNavigator assertEquals(7002L, pendingNavigator?.previousAnswerPreview?.article?.id) assertEquals(7004L, runBlocking { pendingNavigator?.loadNext()?.id }) } diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/SearchScreenInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/SearchScreenInstrumentedTest.kt index f3e10cb84..96e99af70 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/SearchScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/SearchScreenInstrumentedTest.kt @@ -134,7 +134,6 @@ class SearchScreenInstrumentedTest { restrictedMemberHashId = "member-hash-id", restrictedMemberName = "离线用户", ), - testHotSearchQueries = listOf("全站热搜"), ) } @@ -180,7 +179,6 @@ class SearchScreenInstrumentedTest { composeRule.setScreenContent { SearchScreen( search = Search(query = "已有关键词"), - testHotSearchQueries = emptyList(), ) } diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/SystemBarsInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/SystemBarsInstrumentedTest.kt index 5d4b82abe..9828f6063 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/SystemBarsInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/SystemBarsInstrumentedTest.kt @@ -17,10 +17,6 @@ package com.github.zly2006.zhihu -import android.app.Activity -import android.app.Application -import android.graphics.Bitmap -import android.os.Bundle import android.os.SystemClock import androidx.activity.compose.setContent import androidx.compose.foundation.background @@ -37,17 +33,11 @@ import com.github.zly2006.zhihu.test.resetAppPreferences import com.github.zly2006.zhihu.theme.AndroidThemeSettings import com.github.zly2006.zhihu.theme.ThemeMode import com.github.zly2006.zhihu.theme.ZhihuTheme -import com.github.zly2006.zhihu.viewmodel.PaginationEnvironment -import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.io.FileOutputStream -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference import androidx.compose.ui.graphics.Color as ComposeColor @RunWith(AndroidJUnit4::class) @@ -66,71 +56,18 @@ class SystemBarsInstrumentedTest { * Fixed by: https://github.com/zly2006/zhihu-plus-plus/pull/571 */ @Test - fun identityChangeRestartKeepsDarkThemeStatusBarEdgeToEdge() { + fun darkThemeStatusBarIsEdgeToEdge() { val instrumentation = InstrumentationRegistry.getInstrumentation() val originalActivity = composeRule.activity - val environment = AtomicReference<PaginationEnvironment>() instrumentation.runOnMainSync { originalActivity.setContent { ZhihuTheme { - environment.set(rememberPaginationEnvironment(allowGuestAccess = false)) SolidThemeSurface() } } } composeRule.waitForIdle() - waitUntilStatusBarColor(originalActivity, "before identity restart") - - val relaunchedActivity = AtomicReference<MainActivity>() - val resumedLatch = CountDownLatch(1) - val callbacks = object : Application.ActivityLifecycleCallbacks { - override fun onActivityResumed(activity: Activity) { - if (activity is MainActivity && activity !== originalActivity) { - relaunchedActivity.set(activity) - resumedLatch.countDown() - } - } - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit - - override fun onActivityStarted(activity: Activity) = Unit - - override fun onActivityPaused(activity: Activity) = Unit - - override fun onActivityStopped(activity: Activity) = Unit - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - - override fun onActivityDestroyed(activity: Activity) = Unit - } - originalActivity.application.registerActivityLifecycleCallbacks(callbacks) - try { - instrumentation.runOnMainSync { - environment.get().restartApplication() - } - assertTrue( - "Identity restart did not launch a fresh MainActivity", - resumedLatch.await(10, TimeUnit.SECONDS), - ) - } finally { - originalActivity.application.unregisterActivityLifecycleCallbacks(callbacks) - } - - val activity = checkNotNull(relaunchedActivity.get()) - instrumentation.runOnMainSync { - activity.setContent { - ZhihuTheme { - SolidThemeSurface() - } - } - } - instrumentation.waitForIdleSync() - waitUntilStatusBarColor(activity, "after identity restart") - - val screenshot = instrumentation.uiAutomation.takeScreenshot() - FileOutputStream(activity.cacheDir.resolve("system-bars-after-identity-restart.png")).use { - screenshot.compress(Bitmap.CompressFormat.PNG, 100, it) - } + waitUntilStatusBarColor(originalActivity, "dark theme") } private fun waitUntilStatusBarColor( diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/ZhihuMainNavigationInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/ZhihuMainNavigationInstrumentedTest.kt index f02d12099..823103ba1 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/ZhihuMainNavigationInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/ZhihuMainNavigationInstrumentedTest.kt @@ -45,6 +45,7 @@ import com.github.zly2006.zhihu.navigation.OnlineHistory import com.github.zly2006.zhihu.test.MainActivityComposeRule import com.github.zly2006.zhihu.test.resetAppPreferences import com.github.zly2006.zhihu.test.setZhihuMainContent +import com.github.zly2006.zhihu.ui.AndroidArticleNavigationHandoff import com.github.zly2006.zhihu.ui.FOLLOW_SCREEN_PAGER_TAG import com.github.zly2006.zhihu.ui.PREFERENCE_NAME import com.github.zly2006.zhihu.ui.subscreens.BOTTOM_BAR_ITEMS_PREFERENCE_KEY @@ -139,7 +140,7 @@ class ZhihuMainNavigationInstrumentedTest { var openFrom: String? = null composeRule.runOnIdle { composeRule.activity.navigate(article) - openFrom = composeRule.activity.consumePendingContentOpenFrom(article) + openFrom = AndroidArticleNavigationHandoff.consumeContentOpenFrom(article) } assertEquals(ContentOpenFrom.HOME_FEED, openFrom) diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/test/CommentMockFixtures.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/test/CommentMockFixtures.kt index 03f5bec2c..7ee69d8d2 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/test/CommentMockFixtures.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/test/CommentMockFixtures.kt @@ -26,15 +26,19 @@ fun mockRootComments( urlPrefix: String, commentId: String = "offline-root-comment", resourceType: String = "answer", +) = mockRootComments(urlPrefix, listOf(offlineComment(commentId, resourceType))) + +fun mockRootComments( + urlPrefix: String, + comments: List<DataHolder.Comment>, ) { - val comment = offlineComment(commentId, resourceType) ZhihuMockApi.mockJsonPrefix( method = HttpMethod.Get, urlPrefix = urlPrefix, body = """ { - "data": [${ZhihuJson.json.encodeToString(comment)}], + "data": ${ZhihuJson.json.encodeToString(comments)}, "paging": { "is_end": true, "is_start": true, diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/test/ComposeScreenTestHost.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/test/ComposeScreenTestHost.kt index f5eafd2a7..eba150d08 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/test/ComposeScreenTestHost.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/test/ComposeScreenTestHost.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeUp +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import androidx.test.ext.junit.rules.ActivityScenarioRule @@ -84,7 +85,10 @@ fun MainActivityComposeRule.setScreenContent( waitForIdle() activity.setContent { ZhihuTheme { - CompositionLocalProvider(LocalNavigator provides recordingNavigator.asNavigator()) { + CompositionLocalProvider( + LocalNavigator provides recordingNavigator.asNavigator(), + LocalViewModelStoreOwner provides activity, + ) { content() } } diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/test/ZhihuMockApi.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/test/ZhihuMockApi.kt index f0df91ac1..181da3379 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/test/ZhihuMockApi.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/test/ZhihuMockApi.kt @@ -17,8 +17,8 @@ package com.github.zly2006.zhihu.test -import android.content.Context -import com.github.zly2006.zhihu.data.AccountData +import com.github.zly2006.zhihu.account.accountHttpClientEngineForTesting +import com.github.zly2006.zhihu.account.replaceAndroidZhihuAccountStoreForTesting import com.github.zly2006.zhihu.notification.ZHIHU_PLUS_PLUS_HOME_NOTIFICATIONS_URL import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.MockRequestHandleScope @@ -51,17 +51,15 @@ object ZhihuMockApi { fun install(enabled: Boolean = true) { this.enabled = enabled if (!enabled) { - AccountData.overrideHttpClientFactoryForTesting(null) + replaceAndroidZhihuAccountStoreForTesting(null) + accountHttpClientEngineForTesting = null routes.clear() requests.clear() return } - AccountData.overrideHttpClientFactoryForTesting { context: Context, cookies -> - AccountData.createConfiguredHttpClient( - context = context, - cookies = cookies, - engine = mockEngine(), - ) + if (accountHttpClientEngineForTesting == null) { + replaceAndroidZhihuAccountStoreForTesting(null) + accountHttpClientEngineForTesting = mockEngine() } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 91a108370..cb342dd54 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -81,9 +81,6 @@ <data android:scheme="zhihu"/> </intent-filter> </activity> - <activity - android:name=".LoginActivity" - android:exported="false"/> <activity android:name=".WebviewActivity" android:exported="false"/> diff --git a/app/src/main/java/com/github/zly2006/zhihu/LoginActivity.kt b/app/src/main/java/com/github/zly2006/zhihu/LoginActivity.kt deleted file mode 100644 index 1c0a9ebe3..000000000 --- a/app/src/main/java/com/github/zly2006/zhihu/LoginActivity.kt +++ /dev/null @@ -1,451 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Intent -import android.os.Bundle -import android.webkit.CookieManager -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.lifecycle.lifecycleScope -import com.github.zly2006.zhihu.account.SharedQrLoginPane -import com.github.zly2006.zhihu.account.ZHIHU_DESKTOP_USER_AGENT -import com.github.zly2006.zhihu.account.ZHIHU_HOME_URL -import com.github.zly2006.zhihu.account.ZHIHU_SIGNIN_URL -import com.github.zly2006.zhihu.account.ZhihuMobileLoginToken -import com.github.zly2006.zhihu.account.parseCookieAssignments -import com.github.zly2006.zhihu.data.AccountData -import com.github.zly2006.zhihu.theme.ZhihuTheme -import com.github.zly2006.zhihu.ui.components.WebviewComp -import com.github.zly2006.zhihu.ui.components.setupUpWebviewClient -import com.github.zly2006.zhihu.util.enableEdgeToEdgeCompat -import com.github.zly2006.zhihu.util.luoTianYiUrlLauncher -import com.github.zly2006.zhihu.util.telemetry -import kotlinx.coroutines.launch - -private const val LOGIN_MODE_WEB = 0 -private const val LOGIN_MODE_QR = 1 -private const val LOGIN_MODE_PHONE = 2 - -class LoginActivity : ComponentActivity() { - private var isCompletingLogin = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdgeCompat() - - setContent { - ZhihuTheme { - var currentNoticeStep by rememberSaveable { mutableIntStateOf(0) } - var loginMode by rememberSaveable { mutableIntStateOf(LOGIN_MODE_PHONE) } - - Surface( - modifier = Modifier - .fillMaxSize() - .semantics { testTagsAsResourceId = true } - .systemBarsPadding(), - ) { - if (currentNoticeStep >= 3) { - LoginModeScreen( - activity = this@LoginActivity, - loginMode = loginMode, - onModeChanged = { loginMode = it }, - ) - } else { - when (currentNoticeStep) { - 0 -> - LoginNoticeScreen( - stepTag = "login_notice_step_1", - message = "我清楚,本应用由开源社区开发和维护,不由知乎官方开发并运营,也不受到知乎官方的承认或支持,使用本应用的一切后果由我本人承担。我可以在 https://www.zhihu.com/app/ 下载官方应用。", - secondaryButtonText = "下载官方App", - onSecondaryAction = { - luoTianYiUrlLauncher( - this@LoginActivity, - "https://www.zhihu.com/app/".toUri(), - ) - }, - onConfirm = { - currentNoticeStep = 1 - }, - ) - 1 -> - LoginNoticeScreen( - stepTag = "login_notice_step_2", - message = "在使用本应用的过程中,我承诺遵守知乎使用协议 https://www.zhihu.com/term/zhihu-terms 。我保证在使用过程中不侵犯知乎及其他作者的著作权,使用本应用产生的一切输出仅用于个人浏览和备份,不会进行传播等其他影响作者著作权的行为。", - secondaryButtonText = "查看协议", - onSecondaryAction = { - luoTianYiUrlLauncher( - this@LoginActivity, - "https://www.zhihu.com/term/zhihu-terms".toUri(), - ) - }, - onConfirm = { - currentNoticeStep = 2 - }, - ) - else -> - LoginNoticeScreen( - stepTag = "login_notice_step_3", - message = "我知晓,本应用可能会收集部分匿名化的使用信息来确定使用人数,我可以在设置中随时关闭此项遥测。", - secondaryButtonText = "查看设置", - onSecondaryAction = { - startActivity( - Intent(this@LoginActivity, MainActivity::class.java), - ) - }, - onConfirm = { - currentNoticeStep = 3 - }, - ) - } - } - } - } - } - } - - @SuppressLint("SetJavaScriptEnabled") - fun configureWebLogin(webView: WebView) { - webView.setupUpWebviewClient() - webView.settings.javaScriptEnabled = true - webView.webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest, - ): Boolean { - if (request.url.toString() == ZHIHU_HOME_URL) { - webView.settings.userAgentString = AccountData.ANDROID_USER_AGENT - } - return request.url?.scheme == "zhihu" - } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - if (url == ZHIHU_HOME_URL) { - val cookies = parseCookieAssignments( - CookieManager.getInstance().getCookie(ZHIHU_HOME_URL).orEmpty(), - ) - lifecycleScope.launch { - finalizeLoginFromCookies(cookies) - } - } - } - } - CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) - if (webView.url.isNullOrEmpty()) { - CookieManager.getInstance().removeAllCookies { } - webView.loadUrl(ZHIHU_SIGNIN_URL) - } - } - - @SuppressLint("SetJavaScriptEnabled") - fun configureRiskControlWebView( - webView: WebView, - url: String, - cookies: Map<String, String>, - onCookiesChanged: (Map<String, String>) -> Unit, - ) { - webView.setupUpWebviewClient() - webView.settings.javaScriptEnabled = true - webView.settings.userAgentString = ZHIHU_DESKTOP_USER_AGENT - val cookieManager = CookieManager.getInstance() - cookieManager.setAcceptThirdPartyCookies(webView, true) - cookies.forEach { (name, value) -> - cookieManager.setCookie(ZHIHU_HOME_URL, "$name=$value; Domain=.zhihu.com; Path=/") - } - cookieManager.flush() - webView.webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest, - ): Boolean = request.url?.scheme == "zhihu" - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - onCookiesChanged( - readWebViewCookies(url), - ) - } - } - if (webView.url != url) { - webView.loadUrl(url) - } - } - - suspend fun finalizeLoginFromCookies(cookies: Map<String, String>): Boolean = - finalizeLogin { - AccountData.verifyLogin(this, cookies) - } - - suspend fun finalizeMobileLogin(token: ZhihuMobileLoginToken): Boolean = - finalizeLogin { - AccountData.verifyMobileLogin(this, token) - } - - private suspend fun finalizeLogin(verifyLogin: suspend () -> Boolean): Boolean { - if (isCompletingLogin) { - return false - } - isCompletingLogin = true - return try { - if (verifyLogin()) { - val data = AccountData.loadData(this) - AlertDialog - .Builder(this) - .apply { - setTitle("登录成功") - setMessage("欢迎回来,${data.username}") - setPositiveButton("OK") { _, _ -> } - setOnDismissListener { - finish() - } - }.create() - .show() - telemetry(this, "login") - true - } else { - AlertDialog - .Builder(this) - .apply { - setTitle("登录失败") - setMessage("请重试") - setPositiveButton("OK") { _, _ -> } - }.create() - .show() - false - } - } finally { - isCompletingLogin = false - } - } -} - -@Composable -private fun LoginModeScreen( - activity: LoginActivity, - loginMode: Int, - onModeChanged: (Int) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - LoginModeButton( - text = "手机号登录", - selected = loginMode == LOGIN_MODE_PHONE, - tag = "login_mode_phone", - modifier = Modifier.weight(1f), - onClick = { onModeChanged(LOGIN_MODE_PHONE) }, - ) - LoginModeButton( - text = "扫码登录", - selected = loginMode == LOGIN_MODE_QR, - tag = "login_mode_qr", - modifier = Modifier.weight(1f), - onClick = { onModeChanged(LOGIN_MODE_QR) }, - ) - LoginModeButton( - text = "备用网页登录", - selected = loginMode == LOGIN_MODE_WEB, - tag = "login_mode_web", - modifier = Modifier.weight(1f), - onClick = { onModeChanged(LOGIN_MODE_WEB) }, - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) { - when (loginMode) { - LOGIN_MODE_WEB -> { - WebviewComp( - modifier = Modifier.fillMaxSize(), - onLoad = { webView -> - activity.configureWebLogin(webView) - }, - ) - } - - LOGIN_MODE_QR -> QrLoginPane(activity = activity) - LOGIN_MODE_PHONE -> PhoneLoginPane(activity = activity) - } - } - } -} - -@Composable -private fun LoginModeButton( - text: String, - selected: Boolean, - tag: String, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - if (selected) { - Button( - onClick = onClick, - modifier = modifier.testTag(tag), - ) { - Text(text) - } - } else { - OutlinedButton( - onClick = onClick, - modifier = modifier.testTag(tag), - ) { - Text(text) - } - } -} - -@Composable -private fun QrLoginPane(activity: LoginActivity) { - val context = LocalContext.current - SharedQrLoginPane( - createClient = { cookies -> AccountData.httpClient(context, cookies) }, - onLoginSuccess = { cookies -> activity.finalizeLoginFromCookies(cookies) }, - readRiskControlCookies = ::readWebViewCookies, - riskControlContent = { url, cookies, onCookiesChanged -> - WebviewComp( - modifier = Modifier - .fillMaxSize() - .testTag("qr_risk_control_webview"), - onLoad = { webView -> - activity.configureRiskControlWebView( - webView = webView, - url = url, - cookies = cookies, - onCookiesChanged = { updatedCookies -> - onCookiesChanged(cookies + updatedCookies) - }, - ) - }, - ) - }, - ) -} - -@Composable -private fun LoginNoticeScreen( - stepTag: String, - message: String, - secondaryButtonText: String, - onConfirm: () -> Unit, - onSecondaryAction: () -> Unit, -) { - Box( - modifier = Modifier - .fillMaxSize() - .testTag(stepTag), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Text( - text = message, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - ) - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = onSecondaryAction, - modifier = Modifier - .fillMaxWidth() - .testTag("login_notice_secondary_action"), - ) { - Text(secondaryButtonText) - } - Button( - onClick = onConfirm, - modifier = Modifier - .fillMaxWidth() - .testTag("login_notice_confirm"), - ) { - Text("确认") - } - } - } - } -} - -private fun readWebViewCookies(url: String?): Map<String, String> { - val cookieManager = CookieManager.getInstance() - val cookies = mutableMapOf<String, String>() - cookies.putAll(parseCookieAssignments(cookieManager.getCookie(ZHIHU_HOME_URL).orEmpty())) - if (!url.isNullOrBlank()) { - cookies.putAll(parseCookieAssignments(cookieManager.getCookie(url).orEmpty())) - } - return cookies -} diff --git a/app/src/main/java/com/github/zly2006/zhihu/MainActivity.kt b/app/src/main/java/com/github/zly2006/zhihu/MainActivity.kt index b03a1970f..6555ee0a8 100644 --- a/app/src/main/java/com/github/zly2006/zhihu/MainActivity.kt +++ b/app/src/main/java/com/github/zly2006/zhihu/MainActivity.kt @@ -26,7 +26,6 @@ import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.compose.foundation.layout.Box import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -34,8 +33,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController @@ -49,8 +46,6 @@ import coil3.request.crossfade import com.github.zly2006.zhihu.data.AccountData import com.github.zly2006.zhihu.data.HistoryStorage import com.github.zly2006.zhihu.filter.ContentOpenEventSupport -import com.github.zly2006.zhihu.filter.ContentOpenFrom -import com.github.zly2006.zhihu.filter.TrackedContentIdentity import com.github.zly2006.zhihu.navigation.Article import com.github.zly2006.zhihu.navigation.ArticleType import com.github.zly2006.zhihu.navigation.CollectionContent @@ -71,24 +66,12 @@ import com.github.zly2006.zhihu.nlp.NlpServiceKeywordSemanticMatcher import com.github.zly2006.zhihu.nlp.SentenceEmbeddingManager import com.github.zly2006.zhihu.platform.androidSettingsStore import com.github.zly2006.zhihu.platform.androidUserMessageSink -import com.github.zly2006.zhihu.reading.AndroidReadingPlayerBridge import com.github.zly2006.zhihu.reading.ContentReadingService -import com.github.zly2006.zhihu.reading.ReadingContentType -import com.github.zly2006.zhihu.reading.ReadingPlaybackStatus -import com.github.zly2006.zhihu.reading.ReadingPreferences -import com.github.zly2006.zhihu.reading.ReadingQueueItem -import com.github.zly2006.zhihu.reading.ReadingStartRequest -import com.github.zly2006.zhihu.reading.ReadingTemplateField -import com.github.zly2006.zhihu.reading.loadReadingPlaybackSpeed import com.github.zly2006.zhihu.theme.AndroidThemeSettings import com.github.zly2006.zhihu.theme.ZhihuTheme +import com.github.zly2006.zhihu.ui.AndroidArticleNavigationHandoff import com.github.zly2006.zhihu.ui.AndroidZhihuMain -import com.github.zly2006.zhihu.ui.ArticleAnswerSwitchState -import com.github.zly2006.zhihu.ui.ArticleHost -import com.github.zly2006.zhihu.ui.TtsState import com.github.zly2006.zhihu.ui.components.getHighestQualityVideoUrl -import com.github.zly2006.zhihu.ui.subscreens.DeveloperRuntimeInfo -import com.github.zly2006.zhihu.ui.subscreens.DeveloperRuntimeInfoProvider import com.github.zly2006.zhihu.updater.UpdateManager import com.github.zly2006.zhihu.util.ContinuousUsageReminderManager import com.github.zly2006.zhihu.util.EmojiManager @@ -99,7 +82,6 @@ import com.github.zly2006.zhihu.util.clearShareImageCache import com.github.zly2006.zhihu.util.clipboardManager import com.github.zly2006.zhihu.util.enableEdgeToEdgeCompat import com.github.zly2006.zhihu.util.telemetry -import com.github.zly2006.zhihu.viewmodel.ArticleAnswerSwitchData import com.github.zly2006.zhihu.viewmodel.filter.ContentFilterManager import com.github.zly2006.zhihu.viewmodel.filter.androidKeywordSemanticMatcher import com.github.zly2006.zhihu.viewmodel.filter.androidKeywordWeightExtractor @@ -110,50 +92,18 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit -class MainActivity : - ComponentActivity(), - ArticleHost, - DeveloperRuntimeInfoProvider { - class SharedData : ViewModel() { - var clipboardDestination: NavDestination? = null - } - - val sharedData by viewModels<SharedData>() - override val articleNavController: NavHostController - get() = navController - override val articleAnswerSwitchState: ArticleAnswerSwitchState - get() = ViewModelProvider(this)[ArticleAnswerSwitchData::class.java] - override val articleTtsState: TtsState - get() = ttsState - override var clipboardDestination: NavDestination? - get() = sharedData.clipboardDestination - set(value) { - sharedData.clipboardDestination = value - } +class MainActivity : ComponentActivity() { lateinit var history: HistoryStorage - val httpClient by lazy { - AccountData.httpClient(this) - } - - private val _ttsState = mutableStateOf(TtsState.Ready) - var ttsState: TtsState - get() = _ttsState.value - private set(value) { - if (_ttsState.value != value) { - val oldState = _ttsState.value - _ttsState.value = value - Log.i(TAG, "TTS State: $oldState -> $value") - } - } + val httpClient + get() = com.github.zly2006.zhihu.account + .androidZhihuAccountStore(this) + .client + .httpClient() lateinit var navController: NavHostController private lateinit var continuousUsageReminderManager: ContinuousUsageReminderManager - private var pendingContentOpenIdentity: TrackedContentIdentity? = null - private var pendingContentOpenFrom: String? = null - private var pendingCommentHolder: CommentHolder? = null private var currentMainTabOpenFrom: String? = null var mainTabNavigationTarget by mutableStateOf<TopLevelDestination?>(null) private set @@ -209,10 +159,8 @@ class MainActivity : Log.i(TAG, "Zhihu token refreshed successfully") } catch (e: Exception) { Log.e(TAG, "Failed to refresh Zhihu token", e) - withContext(Dispatchers.Main) { - androidUserMessageSink(this@MainActivity) - .showLongMessage("刷新登录状态失败,如多次看到此提示请重新登录") - } + androidUserMessageSink(this@MainActivity) + .showLongMessage("刷新登录状态失败,如多次看到此提示请重新登录") } if (!PowerSaveModeCompat.getPowerSaveMode(this@MainActivity).isPowerSaveMode) { try { @@ -305,19 +253,6 @@ class MainActivity : } } - lifecycleScope.launch { - AndroidReadingPlayerBridge.state.collect { state -> - ttsState = when (state.status) { - ReadingPlaybackStatus.Idle -> TtsState.Ready - ReadingPlaybackStatus.Initializing -> TtsState.Initializing - ReadingPlaybackStatus.Loading -> TtsState.LoadingText - ReadingPlaybackStatus.Playing -> TtsState.Speaking - ReadingPlaybackStatus.Paused -> TtsState.Paused - ReadingPlaybackStatus.Error -> TtsState.Error - } - } - } - // 自动检查更新(在应用启动时) if (savedInstanceState == null) { @OptIn(DelicateCoroutinesApi::class) @@ -341,15 +276,6 @@ class MainActivity : super.onStop() } - override val developerRuntimeInfo: DeveloperRuntimeInfo - get() = DeveloperRuntimeInfo( - continuousUsageDurationMs = continuousUsageReminderManager.currentElapsedForegroundMs(), - ttsState = ttsState, - currentTtsEngineLabel = AndroidReadingPlayerBridge.state.value.engineLabel - .ifBlank { "按需初始化" }, - availableTtsEngineLabels = AndroidReadingPlayerBridge.state.value.availableEngineLabels, - ) - override fun onWindowFocusChanged(hasFocus: Boolean) { if (hasFocus) { if (!handleIntentData(intent)) { @@ -362,8 +288,8 @@ class MainActivity : val destination = regex.findAll(text).firstNotNullOfOrNull { resolveContent(it.value) } - if (destination != null && destination != sharedData.clipboardDestination) { - sharedData.clipboardDestination = destination + if (destination != null && destination != AndroidArticleNavigationHandoff.clipboardDestination) { + AndroidArticleNavigationHandoff.markClipboardDestination(destination) navigate(destination, popup = true) } } @@ -389,8 +315,8 @@ class MainActivity : Log.i(TAG, "Intent data: $data") val destination = resolveContent(data.toString()) if (destination != null) { - if (forceNavigation || destination != sharedData.clipboardDestination) { - sharedData.clipboardDestination = destination + if (forceNavigation || destination != AndroidArticleNavigationHandoff.clipboardDestination) { + AndroidArticleNavigationHandoff.markClipboardDestination(destination) navigate(destination, popup = true) } } else { @@ -408,13 +334,11 @@ class MainActivity : fun navigate(route: NavDestination, popup: Boolean = false) { if (route is CommentHolder) { - pendingCommentHolder = route + AndroidArticleNavigationHandoff.prepareComment(route) navigate(route.article, popup) return } - if (pendingCommentHolder?.article != route) { - pendingCommentHolder = null - } + AndroidArticleNavigationHandoff.clearCommentUnless(route) preparePendingContentOpen(route) history.add(route) if (route is Video) { @@ -470,32 +394,8 @@ class MainActivity : } } - override fun consumePendingContentOpenFrom(destination: NavDestination): String { - val identity = ContentOpenEventSupport.toTrackedContentIdentity(destination) ?: return ContentOpenFrom.UNKNOWN - if (identity != pendingContentOpenIdentity) { - return ContentOpenFrom.UNKNOWN - } - val openFrom = pendingContentOpenFrom ?: ContentOpenFrom.UNKNOWN - pendingContentOpenIdentity = null - pendingContentOpenFrom = null - return openFrom - } - - override fun consumePendingCommentId(destination: NavDestination): String? { - val holder = pendingCommentHolder?.takeIf { it.article == destination } ?: return null - pendingCommentHolder = null - return holder.commentId - } - private fun preparePendingContentOpen(target: NavDestination) { - val identity = ContentOpenEventSupport.toTrackedContentIdentity(target) - if (identity == null) { - pendingContentOpenIdentity = null - pendingContentOpenFrom = null - return - } - pendingContentOpenIdentity = identity - pendingContentOpenFrom = if ( + val openFrom = if ( runCatching { navController.currentBackStackEntry?.toRoute<MainTabs>() }.getOrNull() != null ) { currentMainTabOpenFrom @@ -503,6 +403,7 @@ class MainActivity : null } ?: ContentOpenEventSupport.inferOpenFrom(currentContentOpenSource(), target) + AndroidArticleNavigationHandoff.prepareContentOpen(target, openFrom) } private fun navigateToMainTabs() { @@ -542,38 +443,6 @@ class MainActivity : }.getOrNull() } - override fun postHistoryDestination(destination: NavDestination) { - history.add(destination) - } - - override fun speakArticleText( - text: String, - title: String, - ) { - val request = ReadingStartRequest( - queue = listOf( - ReadingQueueItem( - contentType = ReadingContentType.Article, - id = title.hashCode().toLong() and 0xffffffffL, - title = title, - bodyHtml = text, - ), - ), - preferences = ReadingPreferences( - fieldOrder = listOf(ReadingTemplateField.Body), - enabledFields = setOf(ReadingTemplateField.Body), - queueLimit = 1, - transitionText = "", - ), - playbackSpeed = loadReadingPlaybackSpeed(androidSettingsStore(this)), - ) - AndroidReadingPlayerBridge.start(this, request) - } - - override fun stopArticleSpeaking() { - startService(ContentReadingService.commandIntent(this, ContentReadingService.ACTION_STOP)) - } - override fun onDestroy() { continuousUsageReminderManager.onDestroy() super.onDestroy() diff --git a/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidContent.kt b/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidContent.kt index 0bb4bd5ea..fa4f0103d 100644 --- a/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidContent.kt +++ b/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidContent.kt @@ -30,13 +30,12 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.github.zly2006.zhihu.MainActivity import com.github.zly2006.zhihu.platform.androidUserMessageSink -import com.github.zly2006.zhihu.viewmodel.ArticleAnswerSwitchData import com.github.zly2006.zhihu.viewmodel.ArticleViewModel +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState /** * Android 平台的 Zhihu++ 主界面入口。 @@ -56,12 +55,7 @@ fun AndroidZhihuMain(navController: NavHostController) { preferenceState = rememberAndroidZhihuMainPreferenceState(), isDarkTheme = com.github.zly2006.zhihu.theme.ThemeManager.isDarkTheme, articleEnterTransition = { - val sharedData = try { - ViewModelProvider(activity)[ArticleAnswerSwitchData::class.java] - } catch (_: Exception) { - null - } - when (sharedData?.answerTransitionDirection) { + when (sharedArticleAnswerSwitchState.answerTransitionDirection) { ArticleAnswerTransitionDirection.VERTICAL_NEXT -> slideInVertically(tween(300)) { it } + fadeIn(tween(300)) ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> @@ -74,12 +68,7 @@ fun AndroidZhihuMain(navController: NavHostController) { } }, articleExitTransition = { - val sharedData = try { - ViewModelProvider(activity)[ArticleAnswerSwitchData::class.java] - } catch (_: Exception) { - null - } - when (sharedData?.answerTransitionDirection) { + when (sharedArticleAnswerSwitchState.answerTransitionDirection) { ArticleAnswerTransitionDirection.VERTICAL_NEXT -> slideOutVertically(tween(300)) { -it } + fadeOut(tween(300)) ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> diff --git a/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidState.kt b/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidState.kt index ff65a6a64..f20804b14 100644 --- a/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidState.kt +++ b/app/src/main/java/com/github/zly2006/zhihu/ui/ZhihuMainAndroidState.kt @@ -30,6 +30,7 @@ import com.github.zly2006.zhihu.navigation.HotList import com.github.zly2006.zhihu.navigation.MyCollections import com.github.zly2006.zhihu.navigation.OnlineHistory import com.github.zly2006.zhihu.platform.androidSettingsStore +import com.github.zly2006.zhihu.platform.platformBottomBarItemLimit import com.github.zly2006.zhihu.ui.subscreens.BOTTOM_BAR_ITEMS_PREFERENCE_KEY import com.github.zly2006.zhihu.ui.subscreens.BOTTOM_BAR_ITEM_ORDER_PREFERENCE_KEY import com.github.zly2006.zhihu.ui.subscreens.COLLECTION_DIRECT_BROWSE_PREFERENCE_KEY @@ -60,10 +61,11 @@ fun rememberAndroidZhihuMainPreferenceState(): ZhihuMainPreferenceState { val selectedKeys = normalizeBottomBarSelection( settings.getStringSet( BOTTOM_BAR_ITEMS_PREFERENCE_KEY, - defaultBottomBarSelectionKeys(duo3HomeAccount), + defaultBottomBarSelectionKeys(duo3HomeAccount, platformBottomBarItemLimit), ), duo3HomeAccount, enforceMinimumSelection = true, + maximumSelection = platformBottomBarItemLimit, ) val orderedSelectedKeys = bottomBarItemOrderFromPreference( settings.getStringOrNull(BOTTOM_BAR_ITEM_ORDER_PREFERENCE_KEY), diff --git a/desktopApp/src/main/kotlin/com/github/zly2006/zhihu/desktop/Main.kt b/desktopApp/src/main/kotlin/com/github/zly2006/zhihu/desktop/Main.kt index aa6c05bb1..ff39e18cf 100644 --- a/desktopApp/src/main/kotlin/com/github/zly2006/zhihu/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/com/github/zly2006/zhihu/desktop/Main.kt @@ -20,7 +20,8 @@ package com.github.zly2006.zhihu.desktop import androidx.compose.ui.res.painterResource import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.github.zly2006.zhihu.desktop.DesktopQrLoginScreen +import com.github.zly2006.zhihu.theme.ZhihuTheme +import com.github.zly2006.zhihu.ui.DesktopZhihuMain fun main() { System.setProperty("java.awt.im.style", "below-the-spot") @@ -30,7 +31,9 @@ fun main() { title = "Zhihu++", icon = painterResource("desktop-icon.png"), ) { - DesktopQrLoginScreen() + ZhihuTheme { + DesktopZhihuMain() + } } } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 463ff7c2f..99020a4e7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=30000 retries=3 retryBackOffMs=500 diff --git a/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/MacosNativeSidebar.kt b/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/MacosNativeSidebar.kt new file mode 100644 index 000000000..247dfa0d3 --- /dev/null +++ b/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/MacosNativeSidebar.kt @@ -0,0 +1,309 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +@file:OptIn( + kotlinx.cinterop.BetaInteropApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package com.github.zly2006.zhihu.macos + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.zly2006.zhihu.ui.MacosWindowChrome +import com.github.zly2006.zhihu.ui.MacosWindowNavigationItem +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.ObjCSignatureOverride +import platform.AppKit.NSColor +import platform.AppKit.NSImage +import platform.AppKit.NSImageView +import platform.AppKit.NSOutlineView +import platform.AppKit.NSOutlineViewDataSourceProtocol +import platform.AppKit.NSOutlineViewDelegateProtocol +import platform.AppKit.NSScrollView +import platform.AppKit.NSTableCellView +import platform.AppKit.NSTableColumn +import platform.AppKit.NSTableViewRowSizeStyleDefault +import platform.AppKit.NSTableViewStyle.NSTableViewStyleSourceList +import platform.AppKit.NSTextField +import platform.AppKit.NSView +import platform.AppKit.NSViewHeightSizable +import platform.AppKit.NSViewWidthSizable +import platform.AppKit.NSVisualEffectMaterialSidebar +import platform.AppKit.NSVisualEffectView +import platform.AppKit.NSWindow +import platform.AppKit.labelWithString +import platform.CoreGraphics.CGRectGetHeight +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSIndexSet +import platform.Foundation.NSSelectorFromString +import platform.darwin.NSObject + +private const val SIDEBAR_WIDTH = 240.0 +private const val SIDEBAR_COLUMN_IDENTIFIER = "com.github.zly2006.zhihu.macos.sidebar.column" + +@Composable +internal fun MacosNativeWindowChrome( + window: NSWindow, + chrome: MacosWindowChrome, + content: @Composable (Modifier) -> Unit, +) { + var sidebarVisible by remember { mutableStateOf(true) } + + Box(Modifier.fillMaxSize()) { + content( + if (sidebarVisible) { + Modifier.padding(start = SIDEBAR_WIDTH.dp) + } else { + Modifier + }, + ) + } + + MacosNativeSidebar( + window = window, + items = chrome.navigationItems, + visible = sidebarVisible, + ) + MacosNativeToolbar( + window = window, + leadingActions = listOf( + MacosNativeToolbarAction( + identifier = "sidebar", + label = "侧栏", + systemSymbolName = "sidebar.left", + action = { sidebarVisible = !sidebarVisible }, + ), + ), + trailingActions = chrome.trailingToolbarItems.map { item -> + MacosNativeToolbarAction( + identifier = item.identifier, + label = item.label, + systemSymbolName = item.systemSymbolName, + action = item.action, + ) + }, + ) +} + +@Composable +private fun MacosNativeSidebar( + window: NSWindow, + items: List<MacosWindowNavigationItem>, + visible: Boolean, +) { + val controller = remember(window) { MacosNativeSidebarController(window) } + + SideEffect { + controller.update(items, visible) + } + + DisposableEffect(window, controller) { + controller.update(items, visible) + controller.attach() + onDispose(controller::detach) + } +} + +internal class MacosNativeSidebarController( + private val window: NSWindow, +) : NSObject(), + NSOutlineViewDataSourceProtocol, + NSOutlineViewDelegateProtocol { + private class SectionNode( + val title: String, + val items: List<ItemNode>, + ) : NSObject() + + private class ItemNode( + val model: MacosWindowNavigationItem, + ) : NSObject() + + private data class VisualItem( + val identifier: String, + val title: String, + val systemSymbolName: String, + val sectionTitle: String, + val selected: Boolean, + ) + + private var sections: List<SectionNode> = emptyList() + private var items: List<MacosWindowNavigationItem> = emptyList() + private var visualItems: List<VisualItem> = emptyList() + private var attached = false + + private val tableColumn = NSTableColumn(SIDEBAR_COLUMN_IDENTIFIER) + internal val outlineView = NSOutlineView().apply { + addTableColumn(tableColumn) + outlineTableColumn = tableColumn + headerView = null + style = NSTableViewStyleSourceList + rowSizeStyle = NSTableViewRowSizeStyleDefault + floatsGroupRows = false + allowsMultipleSelection = false + allowsEmptySelection = false + backgroundColor = NSColor.clearColor + target = this@MacosNativeSidebarController + action = NSSelectorFromString("selectNavigation:") + setDataSource(this@MacosNativeSidebarController) + setDelegate(this@MacosNativeSidebarController) + } + private val scrollView = NSScrollView().apply { + drawsBackground = false + hasVerticalScroller = true + autohidesScrollers = true + documentView = outlineView + setAutoresizingMask(NSViewWidthSizable or NSViewHeightSizable) + } + private val sidebarView = NSVisualEffectView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)).apply { + material = NSVisualEffectMaterialSidebar + setAutoresizingMask(NSViewHeightSizable) + addSubview(scrollView) + } + + fun update( + items: List<MacosWindowNavigationItem>, + visible: Boolean, + ) { + this.items = items + sidebarView.setHidden(!visible) + val newVisualItems = items.map { item -> + VisualItem( + identifier = item.identifier, + title = item.title, + systemSymbolName = item.systemSymbolName, + sectionTitle = item.sectionTitle, + selected = item.selected, + ) + } + if (newVisualItems == visualItems) return + visualItems = newVisualItems + sections = items + .groupBy(MacosWindowNavigationItem::sectionTitle) + .map { (title, sectionItems) -> + SectionNode(title, sectionItems.map(::ItemNode)) + } + if (attached) reload() + } + + fun attach() { + if (attached || sections.isEmpty()) return + val contentView = window.contentView ?: return + val contentBounds = contentView.bounds + sidebarView.frame = CGRectMake(0.0, 0.0, SIDEBAR_WIDTH, CGRectGetHeight(contentBounds)) + scrollView.frame = sidebarView.bounds + contentView.addSubview(sidebarView) + attached = true + reload() + } + + fun detach() { + if (!attached) return + attached = false + outlineView.setDelegate(null) + outlineView.setDataSource(null) + sidebarView.removeFromSuperview() + } + + @ObjCSignatureOverride + override fun outlineView( + outlineView: NSOutlineView, + numberOfChildrenOfItem: Any?, + ): Long = when (numberOfChildrenOfItem) { + null -> sections.size.toLong() + is SectionNode -> numberOfChildrenOfItem.items.size.toLong() + else -> 0L + } + + @ObjCSignatureOverride + override fun outlineView( + outlineView: NSOutlineView, + child: Long, + ofItem: Any?, + ): Any = when (ofItem) { + null -> sections[child.toInt()] + is SectionNode -> ofItem.items[child.toInt()] + else -> error("Unsupported sidebar parent") + } + + @ObjCSignatureOverride + override fun outlineView( + outlineView: NSOutlineView, + isItemExpandable: Any, + ): Boolean = isItemExpandable is SectionNode + + @ObjCSignatureOverride + override fun outlineView( + outlineView: NSOutlineView, + isGroupItem: Any, + ): Boolean = isGroupItem is SectionNode + + @ObjCSignatureOverride + override fun outlineView( + outlineView: NSOutlineView, + shouldSelectItem: Any, + ): Boolean = shouldSelectItem is ItemNode + + override fun outlineView( + outlineView: NSOutlineView, + viewForTableColumn: NSTableColumn?, + item: Any, + ): NSView = when (item) { + is SectionNode -> NSTextField.labelWithString(item.title) + is ItemNode -> NSTableCellView().apply { + val imageView = NSImageView().apply { + image = NSImage.imageWithSystemSymbolName(item.model.systemSymbolName, item.model.title) + } + val label = NSTextField.labelWithString(item.model.title) + this.imageView = imageView + textField = label + addSubview(imageView) + addSubview(label) + } + else -> error("Unsupported sidebar item") + } + + @ObjCAction + fun selectNavigation(sender: NSObject) { + val selectedItem = outlineView.itemAtRow(outlineView.selectedRow) as? ItemNode ?: return + items.firstOrNull { it.identifier == selectedItem.model.identifier }?.action?.invoke() + } + + private fun reload() { + outlineView.reloadData() + sections.forEach(outlineView::expandItem) + val selectedNode = sections + .flatMap(SectionNode::items) + .firstOrNull { it.model.selected } + ?: return + val selectedRow = outlineView.rowForItem(selectedNode) + if (selectedRow >= 0) { + outlineView.selectRowIndexes(NSIndexSet.indexSetWithIndex(selectedRow.toULong()), false) + outlineView.scrollRowToVisible(selectedRow) + } + } +} diff --git a/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/MacosNativeToolbar.kt b/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/MacosNativeToolbar.kt new file mode 100644 index 000000000..e13900bdb --- /dev/null +++ b/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/MacosNativeToolbar.kt @@ -0,0 +1,146 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +@file:OptIn( + kotlinx.cinterop.BetaInteropApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package com.github.zly2006.zhihu.macos + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import kotlinx.cinterop.ObjCAction +import platform.AppKit.NSImage +import platform.AppKit.NSToolbar +import platform.AppKit.NSToolbarDelegateProtocol +import platform.AppKit.NSToolbarFlexibleSpaceItemIdentifier +import platform.AppKit.NSToolbarItem +import platform.AppKit.NSToolbarItemVisibilityPriorityHigh +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowToolbarStyle.NSWindowToolbarStyleUnified +import platform.Foundation.NSOperationQueue +import platform.Foundation.NSSelectorFromString +import platform.darwin.NSObject + +private const val TOOLBAR_IDENTIFIER = "com.github.zly2006.zhihu.macos.toolbar" + +internal data class MacosNativeToolbarAction( + val identifier: String, + val label: String, + val systemSymbolName: String, + val action: () -> Unit, +) + +@Composable +internal fun MacosNativeToolbar( + window: NSWindow, + leadingActions: List<MacosNativeToolbarAction>, + trailingActions: List<MacosNativeToolbarAction>, +) { + val actionIdentifiers = (leadingActions + trailingActions).map(MacosNativeToolbarAction::identifier) + val controller = remember(window, actionIdentifiers) { + MacosNativeToolbarController(leadingActions, trailingActions) + } + + SideEffect { + controller.updateActions(leadingActions, trailingActions) + } + + DisposableEffect(window, controller) { + val previousToolbar = window.toolbar + val previousToolbarStyle = window.toolbarStyle + var active = true + NSOperationQueue.mainQueue.addOperationWithBlock { + if (active && window.toolbar !== controller.toolbar) { + window.toolbarStyle = NSWindowToolbarStyleUnified + window.toolbar = controller.toolbar + } + } + + onDispose { + active = false + NSOperationQueue.mainQueue.addOperationWithBlock { + if (window.toolbar === controller.toolbar) { + window.toolbar = previousToolbar + window.toolbarStyle = previousToolbarStyle + } + controller.toolbar.delegate = null + } + } + } +} + +internal class MacosNativeToolbarController( + leadingActions: List<MacosNativeToolbarAction>, + trailingActions: List<MacosNativeToolbarAction>, +) : NSObject(), + NSToolbarDelegateProtocol { + private var leadingActions = leadingActions + private var trailingActions = trailingActions + + val toolbar = NSToolbar(TOOLBAR_IDENTIFIER).apply { + delegate = this@MacosNativeToolbarController + allowsUserCustomization = false + autosavesConfiguration = false + showsBaselineSeparator = false + } + + override fun toolbarDefaultItemIdentifiers(toolbar: NSToolbar): List<*> = buildList { + addAll(leadingActions.map(MacosNativeToolbarAction::identifier)) + if (leadingActions.isNotEmpty() && trailingActions.isNotEmpty()) { + add(NSToolbarFlexibleSpaceItemIdentifier) + } + addAll(trailingActions.map(MacosNativeToolbarAction::identifier)) + } + + override fun toolbarAllowedItemIdentifiers(toolbar: NSToolbar): List<*> = toolbarDefaultItemIdentifiers(toolbar) + + override fun toolbar( + toolbar: NSToolbar, + itemForItemIdentifier: String?, + willBeInsertedIntoToolbar: Boolean, + ): NSToolbarItem? = (leadingActions + trailingActions) + .firstOrNull { it.identifier == itemForItemIdentifier } + ?.let(::buttonItem) + + @ObjCAction + fun selectAction(sender: NSObject) { + val identifier = (sender as? NSToolbarItem)?.itemIdentifier ?: return + (leadingActions + trailingActions).firstOrNull { it.identifier == identifier }?.action?.invoke() + } + + fun updateActions( + leadingActions: List<MacosNativeToolbarAction>, + trailingActions: List<MacosNativeToolbarAction>, + ) { + this.leadingActions = leadingActions + this.trailingActions = trailingActions + } + + private fun buttonItem(action: MacosNativeToolbarAction): NSToolbarItem = NSToolbarItem(action.identifier).apply { + label = action.label + paletteLabel = action.label + toolTip = action.label + image = NSImage.imageWithSystemSymbolName(action.systemSymbolName, action.label) + target = this@MacosNativeToolbarController + this.action = NSSelectorFromString("selectAction:") + visibilityPriority = NSToolbarItemVisibilityPriorityHigh + } +} diff --git a/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/Main.kt b/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/Main.kt index 1d53b93d8..06591691c 100644 --- a/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/Main.kt +++ b/macosApp/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/Main.kt @@ -20,12 +20,19 @@ package com.github.zly2006.zhihu.macos import androidx.compose.ui.window.Window -import com.github.zly2006.zhihu.account.MacosQrLoginScreen +import com.github.zly2006.zhihu.platform.MacosUserMessageHost +import com.github.zly2006.zhihu.platform.isMacosQuitOnWindowCloseEnabled +import com.github.zly2006.zhihu.theme.ZhihuTheme +import com.github.zly2006.zhihu.ui.MacosZhihuMain import kotlinx.cinterop.autoreleasepool import platform.AppKit.NSApplication import platform.AppKit.NSApplicationActivationPolicy +import platform.AppKit.NSApplicationDelegateProtocol import platform.AppKit.NSImage +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowDelegateProtocol import platform.Foundation.NSBundle +import platform.darwin.NSObject import kotlin.experimental.ExperimentalNativeApi import kotlin.native.setUnhandledExceptionHook @@ -41,6 +48,8 @@ fun main() { autoreleasepool { val application = NSApplication.sharedApplication() application.setActivationPolicy(NSApplicationActivationPolicy.NSApplicationActivationPolicyRegular) + val applicationDelegate = MacosApplicationDelegate(application) + application.delegate = applicationDelegate NSBundle.mainBundle .pathForResource("desktop-icon", ofType = "png") ?.let { iconPath -> NSImage(contentsOfFile = iconPath) } @@ -48,9 +57,51 @@ fun main() { Window( title = "Zhihu++", ) { - MacosQrLoginScreen() + applicationDelegate.window = window + window.delegate = applicationDelegate + ZhihuTheme { + MacosUserMessageHost { + MacosZhihuMain { chrome, content -> + MacosNativeWindowChrome( + window = window, + chrome = chrome, + content = content, + ) + } + } + } } application.activateIgnoringOtherApps(true) application.run() } } + +private class MacosApplicationDelegate( + private val application: NSApplication, +) : NSObject(), + NSApplicationDelegateProtocol, + NSWindowDelegateProtocol { + var window: NSWindow? = null + + override fun applicationShouldHandleReopen( + sender: NSApplication, + hasVisibleWindows: Boolean, + ): Boolean { + if (!hasVisibleWindows) { + window?.let { + it.makeKeyAndOrderFront(null) + application.activateIgnoringOtherApps(true) + } + } + return true + } + + override fun windowShouldClose(sender: NSWindow): Boolean { + if (isMacosQuitOnWindowCloseEnabled()) { + application.terminate(null) + return false + } + sender.orderOut(null) + return false + } +} diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.jvm.kt b/macosApp/src/macosTest/kotlin/com/github/zly2006/zhihu/macos/MacosNativeSidebarTest.kt similarity index 52% rename from shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.jvm.kt rename to macosApp/src/macosTest/kotlin/com/github/zly2006/zhihu/macos/MacosNativeSidebarTest.kt index 95cbe24aa..8d1677419 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.jvm.kt +++ b/macosApp/src/macosTest/kotlin/com/github/zly2006/zhihu/macos/MacosNativeSidebarTest.kt @@ -15,16 +15,29 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package com.github.zly2006.zhihu.editor +@file:OptIn( + kotlinx.cinterop.BetaInteropApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.github.zly2006.zhihu.viewmodel.DesktopPaginationEnvironment +package com.github.zly2006.zhihu.macos -@Composable -actual fun rememberZhihuAnswerPublisher(): ZhihuAnswerPublisher { - val environment = remember { DesktopPaginationEnvironment() } - return remember(environment) { - ZhihuApiAnswerPublisher(environment) +import kotlinx.cinterop.autoreleasepool +import platform.AppKit.NSWindow +import kotlin.test.Test +import kotlin.test.assertEquals + +class MacosNativeSidebarTest { + @Test + fun dataSourceCanQueryControllerDuringConstruction() = autoreleasepool { + val controller = MacosNativeSidebarController(NSWindow()) + + assertEquals( + 0L, + controller.outlineView( + outlineView = controller.outlineView, + numberOfChildrenOfItem = null, + ), + ) } } diff --git a/macosApp/src/macosTest/kotlin/com/github/zly2006/zhihu/macos/MacosNativeToolbarTest.kt b/macosApp/src/macosTest/kotlin/com/github/zly2006/zhihu/macos/MacosNativeToolbarTest.kt new file mode 100644 index 000000000..00b85ee93 --- /dev/null +++ b/macosApp/src/macosTest/kotlin/com/github/zly2006/zhihu/macos/MacosNativeToolbarTest.kt @@ -0,0 +1,79 @@ +@file:OptIn(kotlinx.cinterop.BetaInteropApi::class) + +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package com.github.zly2006.zhihu.macos + +import kotlinx.cinterop.autoreleasepool +import platform.AppKit.NSToolbarItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MacosNativeToolbarTest { + @Test + fun toolbarUsesIndependentNativeActions() = autoreleasepool { + val selectedActions = mutableListOf<String>() + val controller = MacosNativeToolbarController( + leadingActions = listOf( + MacosNativeToolbarAction("sidebar", "侧栏", "sidebar.left") { + selectedActions += "sidebar" + }, + ), + trailingActions = listOf( + MacosNativeToolbarAction("search", "搜索", "magnifyingglass") { + selectedActions += "search" + }, + MacosNativeToolbarAction("notifications", "通知", "bell") { + selectedActions += "notifications" + }, + ), + ) + + val identifiers = controller.toolbarDefaultItemIdentifiers(controller.toolbar) + assertEquals(4, identifiers.size) + assertTrue( + controller.toolbar(controller.toolbar, identifiers[0] as String, false) is NSToolbarItem, + ) + assertTrue( + controller.toolbar(controller.toolbar, identifiers[2] as String, false) is NSToolbarItem, + ) + assertTrue( + controller.toolbar(controller.toolbar, identifiers[3] as String, false) is NSToolbarItem, + ) + + val sidebarItem = controller.toolbar(controller.toolbar, identifiers[0] as String, false) + val searchItem = controller.toolbar(controller.toolbar, identifiers[2] as String, false) + val notificationsItem = controller.toolbar(controller.toolbar, identifiers[3] as String, false) + controller.selectAction(sidebarItem!!) + controller.selectAction(searchItem!!) + controller.selectAction(notificationsItem!!) + assertEquals(listOf("sidebar", "search", "notifications"), selectedActions) + + controller.updateActions( + leadingActions = emptyList(), + trailingActions = listOf( + MacosNativeToolbarAction("search", "搜索", "magnifyingglass") { + selectedActions += "updated search" + }, + ), + ) + controller.selectAction(searchItem) + assertEquals("updated search", selectedActions.last()) + } +} diff --git a/macosUiDebug/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/debug/Main.kt b/macosUiDebug/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/debug/Main.kt index fa0713ceb..b84d4223b 100644 --- a/macosUiDebug/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/debug/Main.kt +++ b/macosUiDebug/src/macosMain/kotlin/com/github/zly2006/zhihu/macos/debug/Main.kt @@ -52,7 +52,7 @@ import androidx.compose.ui.test.swipeUp import androidx.navigationevent.DirectNavigationEventInput import androidx.navigationevent.NavigationEventDispatcher import androidx.navigationevent.NavigationEventDispatcherOwner -import com.github.zly2006.zhihu.account.MacosQrLoginScreen +import com.github.zly2006.zhihu.account.LoginScreen import com.github.zly2006.zhihu.data.BACKGROUND_UI_DEBUG_DATA_HOME_ENV import com.github.zly2006.zhihu.data.macosBackgroundUiDebugDataDirectoryPath import com.github.zly2006.zhihu.platform.MacosUserMessageHost @@ -81,6 +81,7 @@ import org.jetbrains.skia.impl.use import platform.Foundation.NSBundle import platform.Foundation.NSData import platform.Foundation.NSFileManager +import platform.Foundation.NSHomeDirectory import platform.Foundation.NSTemporaryDirectory import platform.Foundation.NSUUID import platform.Foundation.dataWithBytes @@ -100,10 +101,11 @@ private const val PROTOCOL = "ZHPP_BACKGROUND_UI_DEBUG_V1" private const val DEFAULT_TIMEOUT_MS = 5_000L fun main(args: Array<String>) { - require(args.all { it == "--root=main" || it == "--root=login" }) { - "Only --root=main and --root=login are supported" + require(args.all { it == "--root=main" || it == "--root=login" || it == "--use-real-account" }) { + "Only --root=main, --root=login, and --use-real-account are supported" } val rootName = if ("--root=login" in args) "login" else "main" + val useRealAccount = "--use-real-account" in args val isolatedDataHome = "${NSTemporaryDirectory().trimEnd('/')}/zhihupp-ui-debug-${NSUUID().UUIDString}" check( @@ -116,6 +118,21 @@ fun main(args: Array<String>) { ) { "Cannot create isolated background UI debug data directory" } + if (useRealAccount) { + val sourceAccountFile = "${NSHomeDirectory()}/.zhihu-plus-plus/account.json" + check(NSFileManager.defaultManager.fileExistsAtPath(sourceAccountFile)) { + "Cannot use real account: account file does not exist" + } + check( + NSFileManager.defaultManager.copyItemAtPath( + sourceAccountFile, + toPath = "$isolatedDataHome/account.json", + error = null, + ), + ) { + "Cannot copy real account into isolated debug data home" + } + } try { check(setenv(BACKGROUND_UI_DEBUG_DATA_HOME_ENV, isolatedDataHome, 1) == 0) { "Cannot configure isolated background UI debug data directory" @@ -144,7 +161,14 @@ fun main(args: Array<String>) { backController.connect(navigationEventDispatcherOwner) } if (rootName == "login") { - MacosQrLoginScreen() + ZhihuTheme { + MacosUserMessageHost { + LoginScreen( + onLoginComplete = {}, + onOpenTelemetrySettings = {}, + ) + } + } } else { ZhihuTheme { MacosUserMessageHost { diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.android.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.android.kt new file mode 100644 index 000000000..535699d42 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.android.kt @@ -0,0 +1,6 @@ +package com.github.zly2006.zhihu.account + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.android.Android + +internal actual val accountHttpClientEngineFactory: HttpClientEngineFactory<*> = Android diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AndroidAccountStore.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AndroidAccountStore.kt new file mode 100644 index 000000000..a70d977de --- /dev/null +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AndroidAccountStore.kt @@ -0,0 +1,62 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * Licensed under the GNU Affero General Public License version 3. + */ + +package com.github.zly2006.zhihu.account + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import java.io.File + +private var currentAccountStore: ZhihuAccountStore? = null +private var currentApplicationContext: Context? = null + +@Synchronized +fun androidZhihuAccountStore(context: Context): ZhihuAccountStore { + currentApplicationContext = context.applicationContext + currentAccountStore?.let { return it } + return ZhihuAccountStore( + repository = androidAccountRepository(checkNotNull(currentApplicationContext)), + ).also { currentAccountStore = it } +} + +internal fun currentAndroidZhihuAccountStore(): ZhihuAccountStore = checkNotNull(currentAccountStore) + +internal fun currentAndroidApplicationContext(): Context = checkNotNull(currentApplicationContext) + +@Synchronized +@VisibleForTesting +fun replaceAndroidZhihuAccountStoreForTesting(store: ZhihuAccountStore?) { + currentAccountStore?.close() + currentAccountStore = store +} + +@Composable +actual fun rememberZhihuAccountStore(): ZhihuAccountStore { + val context = LocalContext.current.applicationContext + return remember(context) { androidZhihuAccountStore(context) } +} + +private fun androidAccountRepository(context: Context) = ZhihuAccountRepository( + AndroidAccountSessionStore(File(context.filesDir, "account.json")), +) + +private class AndroidAccountSessionStore( + private val file: File, +) : ZhihuAccountSessionStore { + override fun readText(): String? = if (file.exists()) file.readText() else null + + override fun writeText(text: String) { + file.parentFile?.mkdirs() + file.writeText(text) + } + + override fun delete() { + file.delete() + } +} diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AndroidLoginUi.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AndroidLoginUi.kt new file mode 100644 index 000000000..1c8dd48fa --- /dev/null +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/account/AndroidLoginUi.kt @@ -0,0 +1,235 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package com.github.zly2006.zhihu.account + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.util.Base64 +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.NotificationManagerCompat +import com.github.zly2006.zhihu.data.AccountData +import com.github.zly2006.zhihu.ui.components.WebviewComp +import com.github.zly2006.zhihu.ui.components.setupUpWebviewClient +import com.github.zly2006.zhihu.util.telemetry +import io.ktor.client.HttpClient +import kotlinx.coroutines.launch +import java.util.TimeZone + +actual val supportedLoginMethods: List<LoginMethod> = listOf( + LoginMethod.Phone, + LoginMethod.Qr, + LoginMethod.Web, +) + +actual val isLoginRiskControlSupported: Boolean = true + +@Composable +actual fun rememberLoginHttpClient(cookies: MutableMap<String, String>): HttpClient { + val context = LocalContext.current + val httpClient = remember(context) { androidZhihuAccountStore(context).client.temporaryHttpClient(cookies) } + DisposableEffect(httpClient) { + onDispose(httpClient::close) + } + return httpClient +} + +actual val phoneLoginDeviceInfo: ZhihuPhoneLoginDeviceInfo + get() = currentAndroidApplicationContext().phoneLoginDeviceInfo() + +actual fun decodePhoneLoginCaptchaImage(content: String) = runCatching { + val encoded = content.substringAfter("base64,", content) + val bytes = Base64.decode(encoded, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() +}.getOrNull() + +@Composable +actual fun QrLoginPane(onLoginSuccess: (String) -> Unit) { + val context = LocalContext.current + val accountStore = rememberZhihuAccountStore() + SharedQrLoginPane( + onLoginSuccess = { cookies -> + if (accountStore.login(cookies.toMutableMap())) { + telemetry(context, "login") + onLoginSuccess(accountStore.session.username) + true + } else { + false + } + }, + initialCookies = AccountData.data.cookies, + ) +} + +@Composable +actual fun WebLoginPane(onLoginSuccess: (String) -> Unit) { + val context = LocalContext.current + val accountStore = rememberZhihuAccountStore() + val scope = rememberCoroutineScope() + var isVerifying by remember { mutableStateOf(false) } + + WebviewComp( + modifier = Modifier.fillMaxSize(), + onLoad = { webView -> + configureWebLogin(webView) { cookies -> + if (!isVerifying) { + isVerifying = true + scope.launch { + try { + if (accountStore.login(cookies.toMutableMap())) { + telemetry(context, "login") + onLoginSuccess(accountStore.session.username) + } + } finally { + isVerifying = false + } + } + } + } + }, + ) +} + +@Composable +actual fun LoginRiskControlPane( + url: String, + cookies: Map<String, String>, + onCookiesChanged: (Map<String, String>) -> Unit, +) { + WebviewComp( + modifier = Modifier.fillMaxSize(), + onLoad = { webView -> + configureRiskControlWebView( + webView = webView, + url = url, + cookies = cookies, + onCookiesChanged = onCookiesChanged, + ) + }, + ) +} + +@SuppressLint("SetJavaScriptEnabled") +private fun configureWebLogin( + webView: WebView, + onCookiesReady: (Map<String, String>) -> Unit, +) { + webView.setupUpWebviewClient() + webView.settings.javaScriptEnabled = true + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest, + ): Boolean { + if (request.url.toString() == ZHIHU_HOME_URL) { + webView.settings.userAgentString = AccountData.ANDROID_USER_AGENT + } + return request.url?.scheme == "zhihu" + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (url == ZHIHU_HOME_URL) { + onCookiesReady(readWebViewCookies(url)) + } + } + } + CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) + if (webView.url.isNullOrEmpty()) { + CookieManager.getInstance().removeAllCookies { webView.loadUrl(ZHIHU_SIGNIN_URL) } + } +} + +@SuppressLint("SetJavaScriptEnabled") +private fun configureRiskControlWebView( + webView: WebView, + url: String, + cookies: Map<String, String>, + onCookiesChanged: (Map<String, String>) -> Unit, +) { + webView.setupUpWebviewClient() + webView.settings.javaScriptEnabled = true + webView.settings.userAgentString = ZHIHU_DESKTOP_USER_AGENT + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptThirdPartyCookies(webView, true) + cookies.forEach { (name, value) -> + cookieManager.setCookie(ZHIHU_HOME_URL, "$name=$value; Domain=.zhihu.com; Path=/") + } + cookieManager.flush() + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest, + ): Boolean = request.url?.scheme == "zhihu" + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + onCookiesChanged(readWebViewCookies(url)) + } + } + if (webView.url != url) { + webView.loadUrl(url) + } +} + +private fun readWebViewCookies(url: String?): Map<String, String> = + parseCookieAssignments( + CookieManager.getInstance().getCookie(url ?: ZHIHU_HOME_URL).orEmpty(), + ) + +private fun Context.phoneLoginDeviceInfo(): ZhihuPhoneLoginDeviceInfo { + val runtime = Runtime.getRuntime() + val storage = StatFs(Environment.getDataDirectory().path) + val installTime = runCatching { + packageManager.getPackageInfo(packageName, 0).firstInstallTime + }.getOrDefault(0L) + return ZhihuPhoneLoginDeviceInfo( + timezoneOffsetSeconds = TimeZone.getDefault().rawOffset / 1_000L, + appInstallTimeMillis = installTime, + notificationEnabled = NotificationManagerCompat.from(this).areNotificationsEnabled(), + bluetoothAvailable = packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH), + phoneBrand = Build.BRAND, + phoneModel = Build.MODEL, + androidRelease = Build.VERSION.RELEASE, + cpuType = Build.SUPPORTED_ABIS.firstOrNull().orEmpty(), + cpuCount = runtime.availableProcessors(), + cpuUsage = "0.0", + totalMemoryMegabytes = (runtime.totalMemory() / 1_048_576L).toInt(), + freeMemoryMegabytes = (runtime.freeMemory() / 1_048_576L).toInt(), + totalStorageMegabytes = (storage.totalBytes / 1_048_576L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + freeStorageMegabytes = (storage.availableBytes / 1_048_576L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + ) +} diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AccountData.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AccountData.kt index 8c3455f7d..a39d7cf16 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AccountData.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AccountData.kt @@ -17,37 +17,24 @@ package com.github.zly2006.zhihu.data +import android.annotation.SuppressLint import android.content.Context import android.util.Log -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.core.content.ContextCompat -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import com.github.zly2006.zhihu.account.DEFAULT_ZHIHU_USER_AGENT -import com.github.zly2006.zhihu.account.ZhihuAccountClient import com.github.zly2006.zhihu.account.ZhihuAccountProfileSnapshot -import com.github.zly2006.zhihu.account.ZhihuAccountRepository import com.github.zly2006.zhihu.account.ZhihuAccountSession -import com.github.zly2006.zhihu.account.ZhihuAccountSessionStore -import com.github.zly2006.zhihu.account.ZhihuIdentityClient -import com.github.zly2006.zhihu.account.ZhihuMobileLoginToken +import com.github.zly2006.zhihu.account.androidZhihuAccountStore +import com.github.zly2006.zhihu.account.currentAndroidZhihuAccountStore import com.github.zly2006.zhihu.data.Person import com.github.zly2006.zhihu.data.ZhihuJson -import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.HttpClientEngineConfig -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement -import java.io.File +@SuppressLint("StaticFieldLeak") object AccountData { val json = ZhihuJson.json @@ -75,135 +62,15 @@ object AccountData { ) fun loadData(context: Context): Data { - dataState.value = accountClient(context).load().toAndroidData() + androidZhihuAccountStore(context) return data } val data: Data - get() = dataState.value - private val dataState = MutableStateFlow<Data>(Data()) - - @Composable - fun asState() = dataState.collectAsState() + get() = currentAndroidZhihuAccountStore().session.toAndroidData() fun saveData(context: Context, data: Data) { - dataState.value = data - accountClient(context).save(data.toSession()) - } - - private var accountClient: ZhihuAccountClient? = null - private var observedLifecycleClient: HttpClient? = null - private var httpClientFactoryOverride: ((Context, MutableMap<String, String>?) -> HttpClient)? = null - - private fun <T : HttpClientEngineConfig> HttpClientConfig<T>.applyCommonConfiguration( - context: Context, - cookies: MutableMap<String, String>?, - onCookieChanged: (() -> Unit)? = null, - ) { - installZhihuCommonClientConfig( - cookies = cookies ?: data.cookies, - userAgent = data.userAgent, - onCookieChanged = { - if (onCookieChanged != null) { - onCookieChanged() - } else if (cookies == null) { - saveData(context, data) - } - }, - enableHttpCache = true, - ) - } - - fun createConfiguredHttpClient( - context: Context, - cookies: MutableMap<String, String>? = null, - engine: HttpClientEngine? = null, - onCookieChanged: (() -> Unit)? = null, - ): HttpClient = if (engine == null) { - HttpClient { - applyCommonConfiguration(context, cookies, onCookieChanged) - } - } else { - HttpClient(engine) { - applyCommonConfiguration(context, cookies, onCookieChanged) - } - } - - @Synchronized - fun overrideHttpClientFactoryForTesting(factory: ((Context, MutableMap<String, String>?) -> HttpClient)?) { - accountClient?.invalidateHttpClient() - accountClient = null - observedLifecycleClient = null - httpClientFactoryOverride = factory - } - - fun httpClient(context: Context, cookies: MutableMap<String, String>? = null): HttpClient { - if (cookies != null) { - return httpClientFactoryOverride?.invoke(context, cookies) - ?: accountClient(context).temporaryHttpClient(cookies) - } - val client = accountClient(context).httpClient() - if (context is LifecycleOwner && observedLifecycleClient !== client) { - ContextCompat.getMainExecutor(context).execute { - context.lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - accountClient(context).invalidateHttpClient() - observedLifecycleClient = null - } - }) - observedLifecycleClient = client - } - } - return client - } - - suspend fun verifyLogin(context: Context, cookies: Map<String, String>): Boolean = - accountClient(context).verifyAndSave(cookies.toMutableMap()) - - suspend fun verifyMobileLogin( - context: Context, - token: ZhihuMobileLoginToken, - ): Boolean = accountClient(context).verifyMobileAndSave(token) - - fun delete(context: Context) { - accountClient(context).clear() - } - - suspend fun refreshProfile(context: Context) { - accountClient(context).refreshAndSaveProfile() - } - - internal fun identityClient(context: Context): ZhihuIdentityClient { - val client = accountClient(context) - return ZhihuIdentityClient( - currentClient = client::httpClient, - temporaryClient = client::temporaryHttpClient, - currentSession = client::load, - saveSession = client::save, - ) - } - - private fun accountClient(context: Context): ZhihuAccountClient { - accountClient?.let { return it } - val appContext = context.applicationContext - return ZhihuAccountClient( - repository = ZhihuAccountRepository( - AndroidAccountSessionStore(File(appContext.filesDir, "account.json")), - ), - createClient = { cookies, _, onCookieChanged, isTemporary -> - httpClientFactoryOverride?.invoke(appContext, if (isTemporary) cookies else null) - ?: createConfiguredHttpClient( - context = appContext, - cookies = cookies, - onCookieChanged = onCookieChanged, - ) - }, - onSessionChanged = { session -> - dataState.value = session.toAndroidData() - }, - ).also { - accountClient = it - } + androidZhihuAccountStore(context).save(data.toSession()) } private fun Data.toSession(): ZhihuAccountSession = ZhihuAccountSession( @@ -243,25 +110,6 @@ object AccountData { mobileTokenExpiresAt = mobileTokenExpiresAt, ) - private class AndroidAccountSessionStore( - private val file: File, - ) : ZhihuAccountSessionStore { - override fun readText(): String? = if (file.exists()) { - file.readText() - } else { - null - } - - override fun writeText(text: String) { - file.parentFile?.mkdirs() - file.writeText(text) - } - - override fun delete() { - file.delete() - } - } - /** * 将snake_case的JSON转换为camelCase并解析为对象 */ diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AndroidDataSupport.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AndroidDataSupport.kt index 095da6231..2f399a90a 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AndroidDataSupport.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/data/AndroidDataSupport.kt @@ -19,6 +19,7 @@ package com.github.zly2006.zhihu.data import android.content.Context import android.util.Log +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment import io.ktor.client.HttpClient @@ -27,7 +28,7 @@ fun Context.asApiEnvironment(): ZhihuApiEnvironment { AccountData.loadData(appContext) return object : ZhihuApiEnvironment { - override fun httpClient(): HttpClient = AccountData.httpClient(appContext) + override fun httpClient(): HttpClient = androidZhihuAccountStore(appContext).client.httpClient() override fun authenticatedCookies(): Map<String, String> = AccountData.data.cookies diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.android.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.android.kt index 78ff31922..602986db3 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.android.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.android.kt @@ -25,8 +25,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +actual val isImagePickerSupported: Boolean = true + @Composable -actual fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): (() -> Unit)? { +actual fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): ImagePickerLauncher { val context = LocalContext.current val contentResolver = context.contentResolver @@ -43,7 +45,9 @@ actual fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): (() -> ), ) } - return { launcher.launch("image/*") } + return object : ImagePickerLauncher { + override fun launch() = launcher.launch("image/*") + } } private fun queryDisplayName(cursor: Cursor?): String? { diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.android.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.android.kt deleted file mode 100644 index 4d8451594..000000000 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.android.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.editor - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.github.zly2006.zhihu.data.asApiEnvironment - -@Composable -actual fun rememberZhihuPinPublisher(): ZhihuPinPublisher { - val context = LocalContext.current.applicationContext - return remember(context) { - ZhihuApiPinPublisher(context.asApiEnvironment()) - } -} diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.android.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.android.kt index 72c42a96e..f8fdc9c3b 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.android.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.android.kt @@ -19,6 +19,7 @@ package com.github.zly2006.zhihu.markdown import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.data.AccountData import com.github.zly2006.zhihu.data.toCookieHeaderString import com.hrm.latex.renderer.font.MathFont @@ -26,7 +27,7 @@ import com.hrm.latex.renderer.font.MathFont @Composable actual fun rememberMarkdownMathFont(): MathFont? { val context = LocalContext.current - val httpClient = AccountData.httpClient(context) + val httpClient = androidZhihuAccountStore(context).client.httpClient() return rememberLatexFonts(context, httpClient).downloaded?.mathFont } diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/platform/AndroidPlatformCapabilities.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/platform/AndroidPlatformCapabilities.kt index 305d760f7..cf683726c 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/platform/AndroidPlatformCapabilities.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/platform/AndroidPlatformCapabilities.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.core.content.edit import androidx.core.net.toUri -import com.github.zly2006.zhihu.data.AccountData +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.ui.PREFERENCE_NAME import com.github.zly2006.zhihu.ui.components.OpenImageDialog import com.github.zly2006.zhihu.util.clipboardManager @@ -48,69 +48,113 @@ import kotlinx.io.files.Path private const val WEBVIEW_ACTIVITY_CLASS = "com.github.zly2006.zhihu.WebviewActivity" @Composable -actual fun rememberExternalUrlOpener(): (String) -> Unit { +actual fun rememberExternalUrlOpener(): ExternalUrlOpener { val context = LocalContext.current - return remember(context) { { url -> luoTianYiUrlLauncher(context, url.toUri()) } } + return remember(context) { + object : ExternalUrlOpener { + override fun invoke(url: String) = luoTianYiUrlLauncher(context, url.toUri()) + } + } } +internal actual val platformBottomBarItemLimit: Int? = 5 + +actual val platformName: String = "Android" + +actual val isJvm: Boolean = false + +actual val isNative: Boolean = false + +actual val isAigcVoteSupported: Boolean = true + +actual val isBlocklistNlpSupported: Boolean = true + +actual val isSentenceSimilaritySupported: Boolean = true + +actual val isArticleHtmlExportSupported: Boolean = true + +actual val isArticleImageExportSupported: Boolean = true + @Composable -actual fun rememberSystemUrlOpener(): (String) -> Unit { +actual fun rememberSystemUrlOpener(): SystemUrlOpener { val context = LocalContext.current - return remember(context) { { url -> context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } } + return remember(context) { + object : SystemUrlOpener { + override fun invoke(url: String) = context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } + } } @Composable -actual fun rememberZhihuWebUrlOpener(): (String) -> Unit { +actual fun rememberZhihuWebUrlOpener(): ZhihuWebUrlOpener { val context = LocalContext.current - return remember(context) { { url -> context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).setClassName(context, WEBVIEW_ACTIVITY_CLASS)) } } + return remember(context) { + object : ZhihuWebUrlOpener { + override fun invoke(url: String) = context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).setClassName(context, WEBVIEW_ACTIVITY_CLASS)) + } + } } @Composable -actual fun rememberImagePreviewOpener(): (String) -> Unit { +actual fun rememberImagePreviewOpener(): ImagePreviewOpener { val openGallery = rememberImageGalleryOpener() - return remember(openGallery) { { url -> openGallery(listOf(url), 0) } } + return remember(openGallery) { + object : ImagePreviewOpener { + override fun invoke(url: String) = openGallery(listOf(url), 0) + } + } } @Composable -actual fun rememberImageGalleryOpener(): (List<String>, Int) -> Unit { +actual fun rememberImageGalleryOpener(): ImageGalleryOpener { val context = LocalContext.current return remember(context) { - { urls, initialIndex -> - OpenImageDialog(context, urls, initialIndex).show() + object : ImageGalleryOpener { + override fun invoke(urls: List<String>, initialIndex: Int) { + OpenImageDialog(context, urls, initialIndex).show() + } } } } @Composable -actual fun rememberImageSaver(): (String) -> Unit { +actual fun rememberImageSaver(): ImageSaver { val context = LocalContext.current val scope = rememberCoroutineScope() return remember(context, scope) { - { imageUrl -> - scope.launch { - saveImageToGallery(context, AccountData.httpClient(context), imageUrl) + object : ImageSaver { + override fun invoke(url: String) { + scope.launch { + saveImageToGallery(context, androidZhihuAccountStore(context).client.httpClient(), url) + } } } } } @Composable -actual fun rememberImageSharer(): (String) -> Unit { +actual fun rememberImageSharer(): ImageSharer { val context = LocalContext.current val scope = rememberCoroutineScope() return remember(context, scope) { - { imageUrl -> - scope.launch { - shareImage(context, AccountData.httpClient(context), imageUrl) + object : ImageSharer { + override fun invoke(url: String) { + scope.launch { + shareImage(context, androidZhihuAccountStore(context).client.httpClient(), url) + } } } } } @Composable -actual fun rememberPlainTextClipboard(): (label: String, text: String) -> Unit { +actual fun rememberPlainTextClipboard(): PlainTextClipboard { val context = LocalContext.current - return remember(context) { { label, text -> context.clipboardManager.setPrimaryClip(ClipData.newPlainText(label, text)) } } + return remember(context) { + object : PlainTextClipboard { + override fun invoke(label: String, text: String) = context.clipboardManager.setPrimaryClip(ClipData.newPlainText(label, text)) + } + } } @Composable @@ -129,34 +173,47 @@ actual fun rememberAppPrivateDirectory(): Path { fun androidSettingsStore(context: Context): SettingsStore { val preferences = context.applicationContext.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) - return SettingsStore( - getBoolean = { key, defaultValue -> preferences.getBoolean(key, defaultValue) }, - putBoolean = { key, value -> preferences.edit { putBoolean(key, value) } }, - getString = { key, defaultValue -> preferences.getString(key, defaultValue) ?: defaultValue }, - putString = { key, value -> preferences.edit { putString(key, value) } }, - getStringOrNull = { key -> preferences.getString(key, null) }, - putStringSet = { key, value -> preferences.edit { putStringSet(key, value) } }, - getStringSet = { key, defaultValue -> preferences.getStringSet(key, defaultValue)?.toSet() ?: defaultValue }, - getInt = { key, defaultValue -> preferences.getInt(key, defaultValue) }, - putInt = { key, value -> preferences.edit { putInt(key, value) } }, - getLong = { key, defaultValue -> preferences.getLong(key, defaultValue) }, - putLong = { key, value -> preferences.edit { putLong(key, value) } }, - getFloat = { key, defaultValue -> preferences.getFloat(key, defaultValue) }, - putFloat = { key, value -> preferences.edit { putFloat(key, value) } }, - remove = { key -> preferences.edit { remove(key) } }, - observeKeyChanges = { onChanged -> + return object : SettingsStore { + override fun getBoolean(key: String, defaultValue: Boolean) = preferences.getBoolean(key, defaultValue) + + override fun putBoolean(key: String, value: Boolean) = preferences.edit { putBoolean(key, value) } + + override fun getString(key: String, defaultValue: String) = preferences.getString(key, defaultValue) ?: defaultValue + + override fun putString(key: String, value: String) = preferences.edit { putString(key, value) } + + override fun getStringOrNull(key: String) = preferences.getString(key, null) + + override fun putStringSet(key: String, value: Set<String>) = preferences.edit { putStringSet(key, value) } + + override fun getStringSet(key: String, defaultValue: Set<String>) = preferences.getStringSet(key, defaultValue)?.toSet() ?: defaultValue + + override fun getInt(key: String, defaultValue: Int) = preferences.getInt(key, defaultValue) + + override fun putInt(key: String, value: Int) = preferences.edit { putInt(key, value) } + + override fun getLong(key: String, defaultValue: Long) = preferences.getLong(key, defaultValue) + + override fun putLong(key: String, value: Long) = preferences.edit { putLong(key, value) } + + override fun getFloat(key: String, defaultValue: Float) = preferences.getFloat(key, defaultValue) + + override fun putFloat(key: String, value: Float) = preferences.edit { putFloat(key, value) } + + override fun remove(key: String) = preferences.edit { remove(key) } + + override fun observeKeyChanges(onChanged: (String) -> Unit): AutoCloseable { val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key != null) { onChanged(key) } } preferences.registerOnSharedPreferenceChangeListener(listener) - val unregister = { + return AutoCloseable { preferences.unregisterOnSharedPreferenceChangeListener(listener) } - unregister - }, - ) + } + } } fun androidUserMessageSink(context: Context): UserMessageSink { @@ -176,14 +233,11 @@ fun androidUserMessageSink(context: Context): UserMessageSink { } } - return UserMessageSink( - showShortMessage = { message -> - showToast(message, Toast.LENGTH_SHORT) - }, - showLongMessage = { message -> - showToast(message, Toast.LENGTH_LONG) - }, - ) + return object : UserMessageSink { + override fun showShortMessage(message: String) = showToast(message, Toast.LENGTH_SHORT) + + override fun showLongMessage(message: String) = showToast(message, Toast.LENGTH_LONG) + } } @Composable diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/reading/AndroidReadingPlayer.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/reading/AndroidReadingPlayer.kt index 5f4039cd0..22c72d692 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/reading/AndroidReadingPlayer.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/reading/AndroidReadingPlayer.kt @@ -61,8 +61,6 @@ private class AndroidReadingPlayerController( private val context: Context, override val state: State<ReadingPlayerState>, ) : ReadingPlayerController { - override val isSupported: Boolean = true - override fun start(request: ReadingStartRequest) { val normalizedPreferences = request.preferences.normalized() val normalizedQueue = request.queue @@ -111,10 +109,12 @@ private class AndroidReadingPlayerController( } @Composable -actual fun rememberReadingPlayerController(): ReadingPlayerController { +internal actual fun rememberSupportedReadingPlayerController(): ReadingPlayerController { val context = LocalContext.current val state = AndroidReadingPlayerBridge.state.collectAsState() return remember(context, state) { AndroidReadingPlayerController(context, state) } } + +actual val isReadingPlayerSupported: Boolean = true diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/AndroidUiRuntimes.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/AndroidUiRuntimes.kt index b3235b22f..fa2e4544e 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/AndroidUiRuntimes.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/AndroidUiRuntimes.kt @@ -19,7 +19,6 @@ package com.github.zly2006.zhihu.ui import android.app.Activity import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager @@ -31,21 +30,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.content.FileProvider -import androidx.core.net.toUri import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import com.github.zly2006.zhihu.data.AccountData +import com.github.zly2006.zhihu.filter.ContentOpenEventSupport +import com.github.zly2006.zhihu.filter.ContentOpenFrom +import com.github.zly2006.zhihu.filter.TrackedContentIdentity import com.github.zly2006.zhihu.navigation.Article +import com.github.zly2006.zhihu.navigation.CommentHolder +import com.github.zly2006.zhihu.navigation.NavDestination import com.github.zly2006.zhihu.notification.NotificationSettingsStore import com.github.zly2006.zhihu.platform.UserMessageSink +import com.github.zly2006.zhihu.platform.androidSettingsStore import com.github.zly2006.zhihu.platform.rememberUserMessageSink +import com.github.zly2006.zhihu.reading.AndroidReadingPlayerBridge +import com.github.zly2006.zhihu.reading.ContentReadingService +import com.github.zly2006.zhihu.reading.ReadingContentType +import com.github.zly2006.zhihu.reading.ReadingPlaybackStatus +import com.github.zly2006.zhihu.reading.ReadingPreferences +import com.github.zly2006.zhihu.reading.ReadingQueueItem +import com.github.zly2006.zhihu.reading.ReadingStartRequest +import com.github.zly2006.zhihu.reading.ReadingTemplateField +import com.github.zly2006.zhihu.reading.loadReadingPlaybackSpeed import com.github.zly2006.zhihu.ui.article.prepareContentDocument import com.github.zly2006.zhihu.ui.components.WebviewComp import com.github.zly2006.zhihu.ui.components.setupUpWebviewClient @@ -65,54 +76,11 @@ import kotlinx.coroutines.withContext import org.jsoup.Jsoup import java.io.File -private const val QR_CODE_SCAN_ACTIVITY_CLASS = "com.github.zly2006.zhihu.QRCodeScanActivity" private const val WEBVIEW_ACTIVITY_CLASS = "com.github.zly2006.zhihu.WebviewActivity" -private const val QR_SCAN_RESULT_EXTRA = "scan_result" - -@Composable -actual fun rememberAccountSettingsAccountState(): androidx.compose.runtime.State<AccountSettingsAccountState> { - val accountDataState = AccountData.asState() - return remember(accountDataState.value) { - androidx.compose.runtime.derivedStateOf { - accountDataState.value.toAccountSettingsAccountState() - } - } -} - -@Composable -actual fun rememberAccountQrLoginRequester(): () -> Unit { - val context = LocalContext.current - val scanActivityLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) scan@{ result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - val scanResult = result.data?.getStringExtra(QR_SCAN_RESULT_EXTRA) ?: return@scan - context.startActivity( - Intent().apply { - setClassName(context.packageName, WEBVIEW_ACTIVITY_CLASS) - data = scanResult.toUri() - }, - ) - } - } - return remember(context, scanActivityLauncher) { - { scanActivityLauncher.launch(Intent().setClassName(context.packageName, QR_CODE_SCAN_ACTIVITY_CLASS)) } - } -} @Composable actual fun rememberAppVersionInfo(): String = LocalContext.current.zhihuVersionInfo() -fun AccountData.Data.toAccountSettingsAccountState(): AccountSettingsAccountState = AccountSettingsAccountState( - login = login, - hasRequiredCookie = cookies["d_c0"].isNullOrBlank().not(), - username = username, - avatarUrl = self?.avatarUrl, - id = self?.id ?: "", - urlToken = self?.urlToken, - identityManagementSupported = true, -) - private fun Context.zhihuVersionInfo(): String { val versionName = runCatching { packageManager.getPackageInfo(packageName, 0).versionName @@ -129,34 +97,59 @@ private fun Context.zhihuVersionInfo(): String { @Composable actual fun rememberArticleTtsState(): TtsState { - val articleHost = LocalContext.current.articleHost() - return articleHost?.articleTtsState ?: TtsState.Uninitialized + val state by AndroidReadingPlayerBridge.state.collectAsState() + return when (state.status) { + ReadingPlaybackStatus.Idle -> TtsState.Ready + ReadingPlaybackStatus.Initializing -> TtsState.Initializing + ReadingPlaybackStatus.Loading -> TtsState.LoadingText + ReadingPlaybackStatus.Playing -> TtsState.Speaking + ReadingPlaybackStatus.Paused -> TtsState.Paused + ReadingPlaybackStatus.Error -> TtsState.Error + } } @Composable -actual fun rememberArticleSpeechToggler(): (title: String, content: String) -> Unit { - val activityContext = LocalContext.current +actual fun rememberArticleSpeechToggler(): ArticleSpeechToggler { + val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val userMessages = rememberUserMessageSink() - val articleHost = activityContext.articleHost() - val ttsState = articleHost?.articleTtsState ?: TtsState.Uninitialized - return remember(coroutineScope, userMessages, articleHost, ttsState) { - { title, content -> - if (ttsState.isSpeaking) { - articleHost?.stopArticleSpeaking() - } else if (ttsState !in listOf(TtsState.Error, TtsState.Uninitialized, TtsState.Initializing)) { - coroutineScope.launch { - try { - withContext(Dispatchers.IO) { - val textToRead = articleSpeechText(title, content) - withContext(Dispatchers.Main) { - if (textToRead.isNotBlank()) { - articleHost?.speakArticleText(textToRead, title) + val ttsState = rememberArticleTtsState() + return remember(context, coroutineScope, userMessages, ttsState) { + object : ArticleSpeechToggler { + override fun invoke(title: String, content: String) { + if (ttsState.isSpeaking) { + context.startService(ContentReadingService.commandIntent(context, ContentReadingService.ACTION_STOP)) + } else if (ttsState !in listOf(TtsState.Error, TtsState.Uninitialized, TtsState.Initializing)) { + coroutineScope.launch { + try { + withContext(Dispatchers.IO) { + val textToRead = articleSpeechText(title, content) + withContext(Dispatchers.Main) { + if (textToRead.isNotBlank()) { + AndroidReadingPlayerBridge.start( + context, + ReadingStartRequest( + queue = listOf( + ReadingQueueItem( + contentType = ReadingContentType.Article, + id = title.hashCode().toLong() and 0xffffffffL, + title = title, + bodyHtml = textToRead, + ), + ), + preferences = ReadingPreferences( + fieldOrder = listOf(ReadingTemplateField.Body), + enabledFields = setOf(ReadingTemplateField.Body), + queueLimit = 1, + transitionText = "", + ), + playbackSpeed = loadReadingPlaybackSpeed(androidSettingsStore(context)), + ), + ) + } } } - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { + } catch (e: Exception) { userMessages.showMessage("朗读失败:${e.message}") } } @@ -167,22 +160,24 @@ actual fun rememberArticleSpeechToggler(): (title: String, content: String) -> U } @Composable -actual fun rememberArticleBrowserOpener(): (Article) -> Unit { +actual fun rememberArticleBrowserOpener(): ArticleBrowserOpener { val context = LocalContext.current.applicationContext val coroutineScope = rememberCoroutineScope() val userMessages = rememberUserMessageSink() return remember(context, coroutineScope, userMessages) { - { article -> - coroutineScope.launch { - OpenInBrowser.openUrlInBrowser(context, article) - userMessages.showMessage("已发送到浏览器") + object : ArticleBrowserOpener { + override fun invoke(article: Article) { + coroutineScope.launch { + OpenInBrowser.openUrlInBrowser(context, article) + userMessages.showMessage("已发送到浏览器") + } } } } } @Composable -actual fun rememberArticleHost(): ArticleHost? = LocalContext.current.articleHost() +actual fun consumePendingCommentId(content: com.github.zly2006.zhihu.navigation.NavDestination): String? = remember(content) { AndroidArticleNavigationHandoff.consumeCommentId(content) } @Composable actual fun ArticleWebViewContent( @@ -234,11 +229,12 @@ actual fun rememberHomeIsDebuggable(): Boolean { @Composable actual fun rememberBlocklistRuleImporter( userMessages: UserMessageSink, -): (((String) -> Unit) -> Unit) { + onImported: (String) -> Unit, +): BlocklistRuleImporter { val context = LocalContext.current val database = remember(context) { getContentFilterDatabase(context) } val coroutineScope = rememberCoroutineScope() - var importCallback by remember { mutableStateOf<((String) -> Unit)?>(null) } + val currentOnImported by rememberUpdatedState(onImported) val importLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), ) { uri -> @@ -259,7 +255,7 @@ actual fun rememberBlocklistRuleImporter( text = text, ) } - importCallback?.invoke(summary) + currentOnImported(summary) } catch (e: Exception) { Log.e("BlocklistSettings", "Failed to import blocklist", e) userMessages.showShortMessage("导入失败: ${e.message}") @@ -267,47 +263,48 @@ actual fun rememberBlocklistRuleImporter( } } } - return remember(context, database, userMessages, importLauncher) { - { onImported -> - importCallback = onImported - importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) + return remember(importLauncher) { + object : BlocklistRuleImporter { + override fun invoke() = importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) } } } @Composable -actual fun rememberBlocklistRuleExporter(): suspend () -> String { +actual fun rememberBlocklistRuleExporter(): BlocklistRuleExporter { val context = LocalContext.current val database = remember(context) { getContentFilterDatabase(context) } return remember(context, database) { - suspend { - val file = withContext(Dispatchers.IO) { - val dir = context.getExternalFilesDir(null) ?: context.filesDir - val file = File(dir, "zhihupp_blocklist.json") - file.writeText( - encodeBlocklistBackup( - keywordDao = database.blockedKeywordDao(), - userDao = database.blockedUserDao(), - questionAuthorDao = database.blockedQuestionAuthorDao(), - topicDao = database.blockedTopicDao(), - ), - ) - file - } - val intent = Intent().apply { - action = Intent.ACTION_VIEW - setDataAndType( - FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - file, - ), - "application/json", - ) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + object : BlocklistRuleExporter { + override suspend fun invoke(): String { + val file = withContext(Dispatchers.IO) { + val dir = context.getExternalFilesDir(null) ?: context.filesDir + val file = File(dir, "zhihupp_blocklist.json") + file.writeText( + encodeBlocklistBackup( + keywordDao = database.blockedKeywordDao(), + userDao = database.blockedUserDao(), + questionAuthorDao = database.blockedQuestionAuthorDao(), + topicDao = database.blockedTopicDao(), + ), + ) + file + } + val intent = Intent().apply { + action = Intent.ACTION_VIEW + setDataAndType( + FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file, + ), + "application/json", + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "查看屏蔽规则")) + return "已导出到 ${file.absolutePath}" } - context.startActivity(Intent.createChooser(intent, "查看屏蔽规则")) - "已导出到 ${file.absolutePath}" } } } @@ -324,7 +321,7 @@ actual fun ZhihuHtmlWebViewContent(html: String) { } } -actual fun supportsZhihuHtmlWebView(): Boolean = true +actual val isLegacyWebViewSupported: Boolean = true @Composable actual fun rememberCommentEmojiInlineContent(emojiKeys: Set<String>): Map<String, InlineTextContent> = @@ -360,8 +357,46 @@ actual fun rememberNotificationEnvironment( } } -fun Context.articleHost(): ArticleHost? = - (this as? ArticleHost) ?: (this as? ContextWrapper)?.baseContext?.takeIf { it !== this }?.articleHost() +object AndroidArticleNavigationHandoff { + private var pendingContentIdentity: TrackedContentIdentity? = null + private var pendingContentOpenFrom: String? = null + private var pendingComment: CommentHolder? = null + var clipboardDestination: NavDestination? = null + private set + + fun markClipboardDestination(destination: NavDestination) { + clipboardDestination = destination + } + + fun prepareComment(holder: CommentHolder) { + pendingComment = holder + } + + fun clearCommentUnless(destination: NavDestination) { + if (pendingComment?.article != destination) pendingComment = null + } + + fun consumeCommentId(destination: NavDestination): String? { + val holder = pendingComment?.takeIf { it.article == destination } ?: return null + pendingComment = null + return holder.commentId + } + + fun prepareContentOpen( + destination: NavDestination, + openFrom: String, + ) { + pendingContentIdentity = ContentOpenEventSupport.toTrackedContentIdentity(destination) + pendingContentOpenFrom = openFrom.takeIf { pendingContentIdentity != null } + } + + fun consumeContentOpenFrom(destination: NavDestination): String { + val identity = ContentOpenEventSupport.toTrackedContentIdentity(destination) ?: return ContentOpenFrom.UNKNOWN + if (identity != pendingContentIdentity) return ContentOpenFrom.UNKNOWN + pendingContentIdentity = null + return pendingContentOpenFrom.also { pendingContentOpenFrom = null } ?: ContentOpenFrom.UNKNOWN + } +} @Composable actual fun QuestionDetailWebViewContent( @@ -376,8 +411,6 @@ actual fun QuestionDetailWebViewContent( } } -actual fun supportsQuestionDetailWebView(): Boolean = true - actual fun Modifier.questionSelectionWorkaround(): Modifier = fuckHonorService() @Composable diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/AndroidComponents.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/AndroidComponents.kt index 8902cdfeb..9f88f008d 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/AndroidComponents.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/AndroidComponents.kt @@ -35,10 +35,11 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.core.graphics.drawable.toDrawable import androidx.core.net.toUri +import com.github.zly2006.zhihu.navigation.NavDestination import com.github.zly2006.zhihu.nlp.KeywordAnalyzerCore import com.github.zly2006.zhihu.nlp.KeywordWithWeight import com.github.zly2006.zhihu.platform.androidUserMessageSink -import com.github.zly2006.zhihu.ui.articleHost +import com.github.zly2006.zhihu.ui.AndroidArticleNavigationHandoff import com.github.zly2006.zhihu.util.clipboardManager import com.github.zly2006.zhihu.util.luoTianYiUrlLauncher import com.github.zly2006.zhihu.viewmodel.filter.androidKeywordWeightExtractor @@ -124,23 +125,25 @@ actual val feedKeywordExtractionAvailable: Boolean = true actual fun rememberShareActionExecutor(): ShareActionExecutor { val context = LocalContext.current return remember(context) { - { action, content, shareText -> - if (action == ShareAction.CopyLink) { - context.articleHost()?.clipboardDestination = content - context.clipboardManager.setPrimaryClip(ClipData.newPlainText("Link", shareText)) - androidUserMessageSink(context).showShortMessage("已复制链接") - } else { - val shareIntent = Intent().apply { - this.action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, shareText) - if (action == ShareAction.DirectShare) { - putExtra(Intent.EXTRA_TITLE, getShareTitle(content)) + object : ShareActionExecutor { + override fun invoke(action: ShareAction, content: NavDestination, shareText: String) { + if (action == ShareAction.CopyLink) { + AndroidArticleNavigationHandoff.markClipboardDestination(content) + context.clipboardManager.setPrimaryClip(ClipData.newPlainText("Link", shareText)) + androidUserMessageSink(context).showShortMessage("已复制链接") + } else { + val shareIntent = Intent().apply { + this.action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + if (action == ShareAction.DirectShare) { + putExtra(Intent.EXTRA_TITLE, getShareTitle(content)) + } } + context.startActivity( + Intent.createChooser(shareIntent, "分享到").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) } - context.startActivity( - Intent.createChooser(shareIntent, "分享到").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) } } } diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/WebviewComp.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/WebviewComp.kt index a0e7fbeef..7318a943a 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/WebviewComp.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/components/WebviewComp.kt @@ -62,6 +62,7 @@ import androidx.lifecycle.lifecycleScope import androidx.webkit.WebResourceErrorCompat import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewClientCompat +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.data.AccountData import com.github.zly2006.zhihu.data.fetchHighestQualityZhihuVideoUrl import com.github.zly2006.zhihu.navigation.NavDestination @@ -184,13 +185,13 @@ class CustomWebView : WebView { } menu.add("保存图片").setOnMenuItemClickListener { GlobalScope.launch(Dispatchers.Main) { - saveImageToGallery(context, AccountData.httpClient(context), url) + saveImageToGallery(context, androidZhihuAccountStore(context).client.httpClient(), url) } true } menu.add("分享图片").setOnMenuItemClickListener { GlobalScope.launch(Dispatchers.Main) { - shareImage(context, AccountData.httpClient(context), url) + shareImage(context, androidZhihuAccountStore(context).client.httpClient(), url) } true } diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AndroidSubscreenRuntimes.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AndroidSubscreenRuntimes.kt index 7ad4c4892..1ead5940c 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AndroidSubscreenRuntimes.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AndroidSubscreenRuntimes.kt @@ -41,25 +41,29 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.github.zly2006.zhihu.platform.androidUserMessageSink import com.github.zly2006.zhihu.platform.rememberIsLiteVariant +import com.github.zly2006.zhihu.reading.AndroidReadingPlayerBridge +import com.github.zly2006.zhihu.ui.rememberArticleTtsState import com.github.zly2006.zhihu.updater.UpdateManager import com.github.zly2006.zhihu.updater.UpdateManager.UpdateState +import com.github.zly2006.zhihu.util.ContinuousUsageReminderManager import com.github.zly2006.zhihu.util.PowerSaveModeCompat import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.util.withContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import java.io.File @Composable -actual fun rememberDeveloperRuntimeInfo(): DeveloperRuntimeInfo { +actual fun rememberDeveloperInfo(): DeveloperInfoSnapshot { val context = LocalContext.current - return produceState(initialValue = DeveloperRuntimeInfo(), context) { + val ttsState = rememberArticleTtsState() + return produceState(initialValue = DeveloperInfoSnapshot(), context, ttsState) { while (true) { - val runtimeInfo = (context as? DeveloperRuntimeInfoProvider)?.developerRuntimeInfo - ?: DeveloperRuntimeInfo() - value = runtimeInfo.copy( + val readingState = AndroidReadingPlayerBridge.state.value + value = DeveloperInfoSnapshot( networkStatus = context.networkStatusText(), powerSaveModeText = when (PowerSaveModeCompat.getPowerSaveMode(context)) { @@ -67,6 +71,10 @@ actual fun rememberDeveloperRuntimeInfo(): DeveloperRuntimeInfo { PowerSaveModeCompat.HUAWEI_POWER_SAVE -> "省电模式:华为傻逼模式已开启" else -> null }, + continuousUsageDurationMs = ContinuousUsageReminderManager.currentElapsedForegroundMs(), + ttsState = ttsState, + currentTtsEngineLabel = readingState.engineLabel.ifBlank { "按需初始化" }, + availableTtsEngineLabels = readingState.availableEngineLabels, ) delay(1_000L) } @@ -92,6 +100,8 @@ private fun Context.networkStatusText(): String { } } +actual val isWebViewCustomFontSupported: Boolean = true + @Composable actual fun WebViewCustomFontSettings( customFontName: String?, @@ -146,39 +156,75 @@ actual fun WebViewCustomFontSettings( } @Composable -actual fun rememberSystemUpdateRuntime(): SystemUpdateRuntime { - val context = LocalContext.current +actual fun rememberSystemUpdateState(): StateFlow<SystemUpdateState> { val scope = rememberCoroutineScope() - return remember(context, scope) { - SystemUpdateRuntime( - state = UpdateManager.updateState.map { it.toSystemUpdateState() }.stateIn( - scope, - SharingStarted.Eagerly, - UpdateManager.updateState.value.toSystemUpdateState(), - ), - checkForUpdate = { UpdateManager.checkForUpdate(context) }, - skipVersion = { version -> + return remember(scope) { + UpdateManager.updateState.map { it.toSystemUpdateState() }.stateIn( + scope, + SharingStarted.Eagerly, + UpdateManager.updateState.value.toSystemUpdateState(), + ) + } +} + +@Composable +actual fun rememberSystemUpdateChecker(): SystemUpdateChecker { + val context = LocalContext.current + return remember(context) { + object : SystemUpdateChecker { + override suspend fun check() = UpdateManager.checkForUpdate(context) + } + } +} + +@Composable +actual fun rememberSystemUpdateVersionSkipper(): SystemUpdateVersionSkipper { + val context = LocalContext.current + return remember(context) { + object : SystemUpdateVersionSkipper { + override fun skip(version: String) { UpdateManager.skipVersion(context, version) UpdateManager.updateState.value = UpdateState.Latest - }, - resetToNoUpdate = { - UpdateManager.updateState.value = UpdateState.NoUpdate - }, - downloadUpdate = { url -> UpdateManager.downloadUpdate(context, url) }, - installDownloadedUpdate = { + } + } + } +} + +@Composable +actual fun rememberSystemUpdateDownloader(): SystemUpdateDownloader { + val context = LocalContext.current + return remember(context) { + object : SystemUpdateDownloader { + override suspend fun download(url: String) = UpdateManager.downloadUpdate(context, url) + } + } +} + +@Composable +actual fun rememberDownloadedSystemUpdateInstaller(): DownloadedSystemUpdateInstaller { + val context = LocalContext.current + return remember(context) { + object : DownloadedSystemUpdateInstaller { + override suspend fun install() { val state = UpdateManager.updateState.value if (state is UpdateState.Downloaded) { UpdateManager.installUpdate(context, state.file) } - }, - setError = { message -> - UpdateManager.updateState.value = UpdateState.Error(message) - }, - supportsApkInstall = true, - ) + } + } } } +actual fun resetSystemUpdateState() { + UpdateManager.updateState.value = UpdateState.NoUpdate +} + +actual fun setSystemUpdateError(message: String) { + UpdateManager.updateState.value = UpdateState.Error(message) +} + +actual val isApkUpdateInstallSupported: Boolean = true + private fun UpdateState.toSystemUpdateState(): SystemUpdateState = when (this) { UpdateState.NoUpdate -> SystemUpdateState.NoUpdate UpdateState.Checking -> SystemUpdateState.Checking diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/updater/UpdateManager.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/updater/UpdateManager.kt index dea478146..8537188b7 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/updater/UpdateManager.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/updater/UpdateManager.kt @@ -21,7 +21,7 @@ import android.content.Context import android.content.Intent import android.util.Log import androidx.core.content.FileProvider -import com.github.zly2006.zhihu.data.AccountData +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.platform.androidSettingsStore import com.github.zly2006.zhihu.platform.isAndroidLiteVariantPackageName import com.github.zly2006.zhihu.updater.GithubAsset @@ -116,7 +116,7 @@ object UpdateManager { } suspend fun getLatestVersion(context: Context): GithubRelease { - val client = AccountData.httpClient(context) + val client = androidZhihuAccountStore(context).client.httpClient() return fetchLatestZhihuRelease(client, getGitHubToken(context)) } @@ -173,7 +173,7 @@ object UpdateManager { updateState.value = UpdateState.Checking androidSettingsStore(context).putLong(PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()) - val client = AccountData.httpClient(context) + val client = androidZhihuAccountStore(context).client.httpClient() val currentVersion = SchematicVersion.fromString(context.versionName()) val checkNightly = androidSettingsStore(context).getBoolean("checkNightlyUpdates", false) diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/ContinuousUsageReminderManager.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/ContinuousUsageReminderManager.kt index 202e0b751..a04b814ef 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/ContinuousUsageReminderManager.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/ContinuousUsageReminderManager.kt @@ -31,6 +31,10 @@ import kotlinx.coroutines.launch class ContinuousUsageReminderManager( private val activity: ComponentActivity, ) { + init { + current = this + } + private val settingsStore by lazy { androidSettingsStore(activity) } private var policy = ContinuousUsageReminderPolicy(loadIntervalMinutes()) @@ -73,6 +77,7 @@ class ContinuousUsageReminderManager( checkJob = null reminderDialog?.dismiss() reminderDialog = null + if (current === this) current = null } private fun ensureCheckLoop() { @@ -140,6 +145,10 @@ class ContinuousUsageReminderManager( } companion object { + private var current: ContinuousUsageReminderManager? = null + + fun currentElapsedForegroundMs(): Long = current?.currentElapsedForegroundMs() ?: 0L + const val KEY_CONTINUOUS_USAGE_REMINDER_INTERVAL_MINUTES = "continuousUsageReminderIntervalMinutes" private const val KEY_SESSION_ACCUMULATED_FOREGROUND_MS = "continuousUsageReminderSessionAccumulatedMs" private const val KEY_SESSION_LAST_BACKGROUND_WALL_CLOCK_MS = "continuousUsageReminderLastBackgroundWallClockMs" diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/CrashReporter.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/CrashReporter.kt new file mode 100644 index 000000000..d95177e1e --- /dev/null +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/CrashReporter.kt @@ -0,0 +1,216 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package com.github.zly2006.zhihu.util + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.FileProvider +import com.github.zly2006.zhihu.data.ZhihuJson.json +import com.github.zly2006.zhihu.platform.androidSettingsStore +import com.github.zly2006.zhihu.viewmodel.AIGC_VOTE_SERVER_URL_KEY +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import java.io.File +import java.security.MessageDigest +import java.util.UUID + +private const val CRASH_REPORT_INSTALL_ID_KEY = "crashReportInstallId" +private const val CRASH_REPORT_DIRECTORY = "crash-reports" +private const val DEFAULT_CRASH_REPORT_SERVER_URL = "https://aigc-vote.ai.fintechedu.cn" +private const val MAX_STACK_TRACE_LENGTH = 32_000 +private const val TAG = "CrashReporter" + +@Serializable +data class CrashReportPayload( + val client_hash: String, + val app_version: String, + val platform: String, + val device_model: String? = null, + val os_version: String? = null, + val build_code: Long? = null, + val occurred_at: Long, + val exception_type: String, + val exception_message: String? = null, + val thread_name: String? = null, + val stack_trace: String, +) + +/** + * 崩溃日志本地留存与 opt-in 匿名上报。 + * + * 崩溃日志总是先写入应用私有目录,用户开启 [CRASH_REPORT_ENABLED_PREFERENCE_KEY] 后才在下次启动时 + * 上报到 aigc-vote 服务端;上报内容不含账号、Cookie 或浏览内容。用户也可以手动导出本地日志。 + */ +object CrashReporter { + private var handlerInstalled = false + private var fallbackHandler: ((Thread, Throwable) -> Unit)? = null + private var previousHandler: Thread.UncaughtExceptionHandler? = null + + @Synchronized + fun install(context: Context, fallback: ((Thread, Throwable) -> Unit)? = null) { + if (fallback != null || fallbackHandler == null) { + fallbackHandler = fallback + } + if (handlerInstalled) return + handlerInstalled = true + previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + recordCrash(context.applicationContext, thread, throwable) + fallbackHandler?.invoke(thread, throwable) + ?: previousHandler?.uncaughtException(thread, throwable) + } + } + + fun isEnabled(context: Context): Boolean = + androidSettingsStore(context).getBoolean(CRASH_REPORT_ENABLED_PREFERENCE_KEY, false) + + fun pendingCrashReports(context: Context): List<File> = + crashReportsDir(context) + ?.listFiles { file -> file.isFile && file.extension == "json" } + ?.sortedBy { it.name } + ?: emptyList() + + fun crashReportsDir(context: Context): File? = runCatching { + File(context.applicationContext.filesDir, CRASH_REPORT_DIRECTORY).apply { mkdirs() } + }.getOrNull() + + fun recordCrash(context: Context, thread: Thread, throwable: Throwable) { + val dir = crashReportsDir(context) ?: return + val payload = buildPayload(context.applicationContext, thread, throwable) + val file = File(dir, "crash-${System.currentTimeMillis()}.json") + runCatching { file.writeText(json.encodeToString(payload)) } + } + + suspend fun uploadPendingCrashReports(context: Context): Int = withContext(Dispatchers.IO) { + if (!isEnabled(context)) return@withContext 0 + val files = pendingCrashReports(context) + if (files.isEmpty()) return@withContext 0 + val settings = androidSettingsStore(context) + val baseUrl = settings + .getString(AIGC_VOTE_SERVER_URL_KEY, DEFAULT_CRASH_REPORT_SERVER_URL) + .trimEnd('/') + .ifBlank { DEFAULT_CRASH_REPORT_SERVER_URL } + val versionName = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "unknown" + val client = HttpClient() + var uploaded = 0 + try { + for (file in files) { + val payload = runCatching { + json.decodeFromString<CrashReportPayload>(file.readText()) + }.getOrNull() ?: run { + file.delete() + continue + } + val response: HttpResponse = client.post("$baseUrl/v1/crash-reports") { + contentType(ContentType.Application.Json) + setBody(payload) + header(HttpHeaders.UserAgent, "Zhihu++/$versionName") + } + if (response.status.isSuccess()) { + file.delete() + uploaded += 1 + } else { + break + } + } + } catch (e: Exception) { + Log.w(TAG, "上传崩溃日志失败", e) + } finally { + client.close() + } + uploaded + } + + fun exportCrashReports(context: Context): Int { + val files = pendingCrashReports(context) + if (files.isEmpty()) return 0 + val appContext = context.applicationContext + val uris = files.map { + FileProvider.getUriForFile(appContext, "${appContext.packageName}.provider", it) + } + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "application/json" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val started = runCatching { + appContext.startActivity( + Intent.createChooser(intent, "导出崩溃日志").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + }.isSuccess + if (!started) { + Log.w(TAG, "导出崩溃日志失败") + } + return if (started) files.size else 0 + } + + private fun buildPayload( + context: Context, + thread: Thread, + throwable: Throwable, + ): CrashReportPayload { + val versionName = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "unknown" + val versionCode = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode + }.getOrNull() ?: 0L + return CrashReportPayload( + client_hash = installHash(context), + app_version = versionName, + platform = "android", + device_model = Build.MODEL, + os_version = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + build_code = versionCode.takeIf { it > 0 }, + occurred_at = System.currentTimeMillis() / 1000, + exception_type = throwable.javaClass.name, + exception_message = throwable.message, + thread_name = thread.name, + stack_trace = throwable.stackTraceToString().take(MAX_STACK_TRACE_LENGTH), + ) + } + + private fun installHash(context: Context): String { + val settings = androidSettingsStore(context) + val installId = settings.getStringOrNull(CRASH_REPORT_INSTALL_ID_KEY) ?: run { + val id = UUID.randomUUID().toString() + settings.putString(CRASH_REPORT_INSTALL_ID_KEY, id) + id + } + return MessageDigest + .getInstance("SHA-256") + .digest(installId.toByteArray()) + .joinToString("") { byte -> "%02x".format(byte) } + } +} diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/EdgeToEdgeCompat.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/EdgeToEdgeCompat.kt index ce87773ea..2c6573e7e 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/EdgeToEdgeCompat.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/EdgeToEdgeCompat.kt @@ -25,12 +25,11 @@ import androidx.activity.enableEdgeToEdge inline fun ComponentActivity.enableEdgeToEdgeCompat() { enableEdgeToEdge() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 16 can add a dark protection scrim over transparent status bars + // when contrast enforcement remains enabled, changing the app surface color. + window.isStatusBarContrastEnforced = false // Fix for three-button nav not properly going edge-to-edge. // TODO: https://issuetracker.google.com/issues/298296168 window.isNavigationBarContrastEnforced = false - // Keep the transparent status bar composited directly over the app surface. - // Without this explicit setting, API 35+ can apply a dark contrast scrim - // during activity recreation, producing pixels darker than the app background. - window.isStatusBarContrastEnforced = false } } diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/OpenInBrowser.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/OpenInBrowser.kt index f5abd6b14..9e536153d 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/OpenInBrowser.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/OpenInBrowser.kt @@ -18,6 +18,7 @@ package com.github.zly2006.zhihu.util import android.content.Context +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.data.AccountData import com.github.zly2006.zhihu.data.Collection import com.github.zly2006.zhihu.data.ZhihuJson @@ -64,8 +65,9 @@ object OpenInBrowser { } val url = "https://api.zhihu.com/collections/contents/$contentType/${destination.id}" val body = "add_collections=${collection.id}" - return AccountData - .httpClient(context) + return androidZhihuAccountStore(context) + .client + .httpClient() .put(url) { signFetchRequest() contentType(ContentType.Application.FormUrlEncoded) diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/Utils.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/Utils.kt index 08e5499e4..b203e05b4 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/Utils.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/util/Utils.kt @@ -22,6 +22,7 @@ import android.net.Uri import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.core.net.toUri +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.data.AccountData import com.github.zly2006.zhihu.platform.androidSettingsStore import com.github.zly2006.zhihu.util.signZhihuFetchRequest @@ -67,8 +68,9 @@ fun telemetry(context: Context, usage: String) { ?.let(this::update) }.digest(data.self!!.id.toByteArray()) .toHexString() - AccountData - .httpClient(context) + androidZhihuAccountStore(context) + .client + .httpClient() .post("https://redenmc.com/api/zhihu/usage?client_hash=$hash&usage=$usage") { contentType(ContentType.Application.Json) header( diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidPaginationEnvironment.android.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidPaginationEnvironment.android.kt index 99a1444e3..ffcd4a31b 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidPaginationEnvironment.android.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidPaginationEnvironment.android.kt @@ -23,7 +23,6 @@ import android.app.AlertDialog import android.content.ClipData import android.content.ContentValues import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Environment @@ -36,9 +35,9 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import com.github.zly2006.zhihu.account.androidZhihuAccountStore import com.github.zly2006.zhihu.data.AIGC_MARKING_ENABLED_PREFERENCE_KEY import com.github.zly2006.zhihu.data.AccountData -import com.github.zly2006.zhihu.data.AigcVoteClient import com.github.zly2006.zhihu.data.AigcVoteVoter import com.github.zly2006.zhihu.data.DataHolder import com.github.zly2006.zhihu.data.Feed @@ -46,15 +45,16 @@ import com.github.zly2006.zhihu.data.FeedDisplayItem import com.github.zly2006.zhihu.data.HistoryStorage import com.github.zly2006.zhihu.data.ZhihuCookieStorage import com.github.zly2006.zhihu.data.ZhihuJson.json +import com.github.zly2006.zhihu.data.asApiEnvironment import com.github.zly2006.zhihu.data.navDestination import com.github.zly2006.zhihu.data.target import com.github.zly2006.zhihu.filter.ContentOpenEventSupport import com.github.zly2006.zhihu.navigation.NavDestination +import com.github.zly2006.zhihu.navigation.requestLoginNavigation import com.github.zly2006.zhihu.notification.NotificationSettingsStore import com.github.zly2006.zhihu.platform.androidSettingsStore import com.github.zly2006.zhihu.platform.androidUserMessageSink -import com.github.zly2006.zhihu.ui.articleHost -import com.github.zly2006.zhihu.ui.homeFeedStartupCacheFileNames +import com.github.zly2006.zhihu.ui.AndroidArticleNavigationHandoff import com.github.zly2006.zhihu.util.HttpStatusException import com.github.zly2006.zhihu.util.ResolvedCollectionHtmlExportItem import com.github.zly2006.zhihu.util.buildArticleExportFileName @@ -76,6 +76,8 @@ import com.github.zly2006.zhihu.viewmodel.filter.androidKeywordSemanticMatcher import com.github.zly2006.zhihu.viewmodel.filter.contentFilterSettings import com.github.zly2006.zhihu.viewmodel.filter.getContentFilterDatabase import com.github.zly2006.zhihu.viewmodel.local.LocalRecommendationEngine +import com.github.zly2006.zhihu.viewmodel.local.buildLocalRecommendationEngine +import com.github.zly2006.zhihu.viewmodel.local.getLocalContentDatabase import io.ktor.client.HttpClient import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.api.createClientPlugin @@ -111,7 +113,7 @@ private val ZHIHU_PP_ANDROID_HEADERS = createClientPlugin("ZhihuPPAndroidHeaders } private const val AIGC_VOTE_CLIENT_ID_KEY = "aigcVoteClientId" -private const val AIGC_VOTE_SERVER_URL_KEY = "aigcVoteServerUrl" +internal const val AIGC_VOTE_SERVER_URL_KEY = "aigcVoteServerUrl" private const val DEFAULT_ANDROID_AIGC_VOTE_SERVER_URL = "https://aigc-vote.ai.fintechedu.cn" open class SharedAndroidPaginationEnvironment( @@ -119,7 +121,10 @@ open class SharedAndroidPaginationEnvironment( private val allowGuestAccess: Boolean, ) : AndroidContextPaginationEnvironment, CollectionContentEnvironment { - private val localRecommendationEngine by lazy { LocalRecommendationEngine(context) } + private val localRecommendationEngine by lazy { + val dao = getLocalContentDatabase(context).contentDao() + buildLocalRecommendationEngine(dao, context.asApiEnvironment()) + } private val settingsStore by lazy { androidSettingsStore(context) } private val userMessageSink by lazy { androidUserMessageSink(context) } private val aigcVoteHttpClient by lazy { @@ -129,58 +134,6 @@ open class SharedAndroidPaginationEnvironment( } } } - private val aigcVoteClient by lazy { - AigcVoteClient( - httpClient = aigcVoteHttpClient, - baseUrl = aigcVoteServerUrl(), - clientId = aigcVoteClientId(), - ) - } - - override suspend fun refreshAccountProfile() { - AccountData.refreshProfile(context) - } - - override fun requestLogin(): Boolean { - context.startLoginActivity() - return true - } - - override fun clearAccountSession() { - AccountData.delete(context) - } - - override fun currentAccountId(): String = AccountData.data.self - ?.id - .orEmpty() - - override fun identityClient() = AccountData.identityClient(context.applicationContext) - - override fun restartApplication() { - val activity = context as? Activity ?: return - val launchIntent = activity.packageManager - .getLaunchIntentForPackage(activity.packageName) - ?: error("无法获取应用启动入口") - launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - activity.startActivity(launchIntent) - } - - override suspend fun verifyLogin(cookies: Map<String, String>): Boolean = - AccountData.verifyLogin(context, cookies) - - override fun saveCookies(cookies: Map<String, String>) { - AccountData.saveData( - context, - AccountData.data.copy(cookies = cookies.toMutableMap(), login = true), - ) - } - - override fun logout() { - homeFeedStartupCacheFileNames().forEach { fileName -> - File(context.filesDir, fileName).delete() - } - clearAccountSession() - } override fun httpClient(): HttpClient { val loginForRecommendation = settingsStore.getBoolean("loginForRecommendation", true) @@ -195,7 +148,7 @@ open class SharedAndroidPaginationEnvironment( } } } - return AccountData.httpClient(context) + return androidZhihuAccountStore(context).client.httpClient() } override fun mobileHomeFeedHttpClient(): HttpClient { @@ -219,12 +172,14 @@ open class SharedAndroidPaginationEnvironment( } } - override fun aigcVoteClient(): AigcVoteClient? = - if (settingsStore.getBoolean(AIGC_MARKING_ENABLED_PREFERENCE_KEY, false)) { - aigcVoteClient - } else { - null - } + override fun isAigcVoteEnabled(): Boolean = + settingsStore.getBoolean(AIGC_MARKING_ENABLED_PREFERENCE_KEY, false) + + override fun aigcVoteHttpClient(): HttpClient = aigcVoteHttpClient + + override fun aigcVoteBaseUrl(): String = aigcVoteServerUrl() + + override fun aigcVoteClientId(): String = aigcVoteClientIdValue() override fun aigcVoteVoter(): AigcVoteVoter? = AccountData.data.self?.let { self -> @@ -250,7 +205,7 @@ open class SharedAndroidPaginationEnvironment( .getString(AIGC_VOTE_SERVER_URL_KEY, DEFAULT_ANDROID_AIGC_VOTE_SERVER_URL) .ifBlank { DEFAULT_ANDROID_AIGC_VOTE_SERVER_URL } - private fun aigcVoteClientId(): String { + private fun aigcVoteClientIdValue(): String { settingsStore.getStringOrNull(AIGC_VOTE_CLIENT_ID_KEY)?.takeIf { it.isNotBlank() }?.let { return it } @@ -271,16 +226,12 @@ open class SharedAndroidPaginationEnvironment( showDebugErrorDialog(error) } Log.e(tag, "Failed to fetch feeds", error) - context.mainExecutor.execute { - userMessageSink.showShortMessage("加载失败: ${error.message}") - } + userMessageSink.showShortMessage("加载失败: ${error.message}") } override suspend fun handleMobileHomeFeedFailure(error: Exception) { Log.e("AndroidHomeFeedViewModel", "Failed to fetch feeds", error) - context.mainExecutor.execute { - userMessageSink.showShortMessage("安卓端推荐加载失败: ${error.message}") - } + userMessageSink.showShortMessage("安卓端推荐加载失败: ${error.message}") } override fun feedDisplaySettings(): FeedDisplaySettings = FeedDisplaySettings( @@ -366,7 +317,7 @@ open class SharedAndroidPaginationEnvironment( openFrom: String, ) { val resolvedOpenFrom = openFrom.ifBlank { - context.articleHost()?.consumePendingContentOpenFrom(destination) ?: "" + AndroidArticleNavigationHandoff.consumeContentOpenFrom(destination) } ContentOpenEventSupport.recordOpenEvent( database = getContentFilterDatabase(context), @@ -412,19 +363,11 @@ open class SharedAndroidPaginationEnvironment( ), onNlpBlocked = { blockedThisRound -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - context.mainExecutor.execute { - userMessageSink.showShortMessage("NLP 已屏蔽 ${blockedThisRound.first().title.take(10)}... 等 ${blockedThisRound.size} 条内容") - } + userMessageSink.showShortMessage("NLP 已屏蔽 ${blockedThisRound.first().title.take(10)}... 等 ${blockedThisRound.size} 条内容") } }, ), blockedFeedRecordDao = filterDatabase.blockedFeedRecordDao(), - onDetailFetchFailed = { item -> - Log.w("ContentFilterExtensions", "Failed to fetch content details for item '${item.title}'. Using dummy content for filtering.") - }, - onDetailsKeywordFiltered = { item, keyword -> - Log.e("ContentFilterExtensions", "Filtered item '${item.title}' due to keyword '$keyword' in details: ${item.content}") - }, ).filter(items) } @@ -473,8 +416,6 @@ open class SharedAndroidPaginationEnvironment( } } - override fun articleAnswerSwitchState() = context.articleHost()?.articleAnswerSwitchState - override suspend fun exportCollectionItemsToHtmlZip( collectionTitle: String, items: List<CollectionItem>, @@ -527,9 +468,7 @@ open class SharedAndroidPaginationEnvironment( override suspend fun handleCollectionExportFailure(error: Exception) { Log.e("CollectionContentViewModel", "Failed to export collection HTML zip", error) - context.mainExecutor.execute { - userMessageSink.showShortMessage("导出失败: ${error.message}") - } + userMessageSink.showShortMessage("导出失败: ${error.message}") } private suspend fun resolveCollectionItemForHtmlExport( @@ -570,7 +509,8 @@ open class SharedAndroidPaginationEnvironment( .setTitle("登录已过期") .setMessage("请重新登录以继续使用完整功能。") .setPositiveButton("重新登录") { _, _ -> - requestRelogin() + androidZhihuAccountStore(context).clear() + requestLoginNavigation() }.setNegativeButton("取消", null) .show() } @@ -682,8 +622,8 @@ open class SharedAndroidPaginationEnvironment( return file.absolutePath } - override fun articleImageExportRenderer(loadAssetText: (String) -> String): ArticleImageExportRenderer = - AndroidArticleExportRenderer(context, loadAssetText) + override fun articleImageExportRenderer(): ArticleImageExportRenderer = + AndroidArticleExportRenderer(context) override fun hasImageExportPermission(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || @@ -744,11 +684,3 @@ private fun Context.canSafelyShowDialog(): Boolean { val lifecycleOwner = activity as? LifecycleOwner ?: return true return lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) } - -private fun Context.startLoginActivity() { - val intent = Intent().setClassName(packageName, "com.github.zly2006.zhihu.LoginActivity") - if (this !is Activity) { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(intent) -} diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidViewmodelSupport.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidViewmodelSupport.kt index 7a9b25078..17c01e849 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidViewmodelSupport.kt +++ b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/AndroidViewmodelSupport.kt @@ -43,7 +43,6 @@ private const val ARTICLE_EXPORT_DPI = 200f class AndroidArticleExportRenderer( private val context: Context, - private val loadAssetText: (String) -> String, ) : ArticleImageExportRenderer { override suspend fun prepareExportWebView( htmlContent: String, @@ -193,7 +192,10 @@ class AndroidArticleExportRenderer( } fun injectExportFootnoteScript(webView: WebView, onInjected: () -> Unit) { - val jsCode = loadAssetText("footnotes.js") + val jsCode = context.assets + .open("footnotes.js") + .bufferedReader() + .use { it.readText() } if (jsCode.isBlank()) { onInjected() return diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/AndroidLocalSupport.kt b/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/AndroidLocalSupport.kt deleted file mode 100644 index 798ce0ebc..000000000 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/AndroidLocalSupport.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.viewmodel.local - -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.util.Log -import com.github.zly2006.zhihu.data.asApiEnvironment -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.jsonArray -import kotlin.jvm.java - -fun LocalRecommendationEngine(context: Context): LocalRecommendationEngine { - val dao = getLocalContentDatabase(context).contentDao() - return buildLocalRecommendationEngine( - dao = dao, - fetchFeedArray = { url -> - context - .asApiEnvironment() - .fetchJson(url, "") - ?.get("data") - ?.jsonArray ?: JsonArray(emptyList()) - }, - isNetworkAvailable = { isLocalRecommendationNetworkAvailable(context) }, - logWarning = { message -> Log.w("LocalRecommendationEngine", message) }, - logError = { message, throwable -> Log.e("LocalRecommendationEngine", message, throwable) }, - ) -} - -private fun isLocalRecommendationNetworkAvailable(context: Context): Boolean = try { - val connectivityManager = context.getSystemService(ConnectivityManager::class.java) - val activeNetwork = connectivityManager.activeNetwork - val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) - networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true -} catch (_: Exception) { - false -} diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/LoginScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/LoginScreen.kt new file mode 100644 index 000000000..354d7fb93 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/LoginScreen.kt @@ -0,0 +1,246 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package com.github.zly2006.zhihu.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.github.zly2006.zhihu.platform.rememberExternalUrlOpener + +enum class LoginMethod( + val label: String, + val tag: String, +) { + Phone("手机号登录", "login_mode_phone"), + Qr("扫码登录", "login_mode_qr"), + Web("备用网页登录", "login_mode_web"), +} + +expect val supportedLoginMethods: List<LoginMethod> + +@Composable +expect fun QrLoginPane(onLoginSuccess: (String) -> Unit) + +@Composable +expect fun WebLoginPane(onLoginSuccess: (String) -> Unit) + +@Composable +fun LoginScreen( + onLoginComplete: () -> Unit, + onOpenTelemetrySettings: () -> Unit, +) { + val openExternalUrl = rememberExternalUrlOpener() + var noticeStep by rememberSaveable { + mutableIntStateOf(0) + } + var selectedMethod by rememberSaveable(supportedLoginMethods) { + mutableStateOf(supportedLoginMethods.first()) + } + var loggedInUsername by remember { mutableStateOf<String?>(null) } + val onLoginSuccess: (String) -> Unit = { username -> loggedInUsername = username } + + if (noticeStep < LOGIN_NOTICE_COUNT) { + val notice = loginNotices[noticeStep] + LoginNoticeScreen( + stepTag = "login_notice_step_${noticeStep + 1}", + message = notice.message, + secondaryButtonText = notice.secondaryButtonText, + onSecondaryAction = { + when (noticeStep) { + 0 -> openExternalUrl("https://www.zhihu.com/app/") + 1 -> openExternalUrl("https://www.zhihu.com/term/zhihu-terms") + else -> onOpenTelemetrySettings() + } + }, + onConfirm = { noticeStep++ }, + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + supportedLoginMethods.forEach { method -> + LoginMethodButton( + method = method, + selected = method == selectedMethod, + modifier = Modifier.weight(1f), + onClick = { selectedMethod = method }, + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + when (selectedMethod) { + LoginMethod.Phone -> PhoneLoginPane(onLoginSuccess) + LoginMethod.Qr -> QrLoginPane(onLoginSuccess) + LoginMethod.Web -> WebLoginPane(onLoginSuccess) + } + } + } + } + + loggedInUsername?.let { username -> + AlertDialog( + onDismissRequest = onLoginComplete, + title = { Text("登录成功") }, + text = { Text("欢迎回来,$username") }, + confirmButton = { + TextButton(onClick = onLoginComplete) { + Text("确定") + } + }, + ) + } +} + +@Composable +private fun LoginMethodButton( + method: LoginMethod, + selected: Boolean, + modifier: Modifier, + onClick: () -> Unit, +) { + if (selected) { + Button( + onClick = onClick, + modifier = modifier.testTag(method.tag), + ) { + Text(method.label) + } + } else { + OutlinedButton( + onClick = onClick, + modifier = modifier.testTag(method.tag), + ) { + Text(method.label) + } + } +} + +@Composable +private fun LoginNoticeScreen( + stepTag: String, + message: String, + secondaryButtonText: String, + onConfirm: () -> Unit, + onSecondaryAction: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(stepTag), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onSecondaryAction, + modifier = Modifier + .fillMaxWidth() + .testTag("login_notice_secondary_action"), + ) { + Text(secondaryButtonText) + } + Button( + onClick = onConfirm, + modifier = Modifier + .fillMaxWidth() + .testTag("login_notice_confirm"), + ) { + Text("确认") + } + } + } + } +} + +private data class LoginNotice( + val message: String, + val secondaryButtonText: String, +) + +private const val LOGIN_NOTICE_COUNT = 3 + +private val loginNotices = listOf( + LoginNotice( + message = "我清楚,本应用由开源社区开发和维护,不由知乎官方开发并运营,也不受到知乎官方的承认或支持,使用本应用的一切后果由我本人承担。我可以在 https://www.zhihu.com/app/ 下载官方应用。", + secondaryButtonText = "下载官方 App", + ), + LoginNotice( + message = "在使用本应用的过程中,我承诺遵守知乎使用协议 https://www.zhihu.com/term/zhihu-terms 。我保证在使用过程中不侵犯知乎及其他作者的著作权,使用本应用产生的一切输出仅用于个人浏览和备份,不会进行传播等其他影响作者著作权的行为。", + secondaryButtonText = "查看协议", + ), + LoginNotice( + message = "我知晓,本应用可能会收集部分匿名化的使用信息来确定使用人数,我可以在设置中随时关闭此项遥测。", + secondaryButtonText = "查看设置", + ), +) diff --git a/app/src/main/java/com/github/zly2006/zhihu/PhoneLoginPane.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/PhoneLoginPane.kt similarity index 81% rename from app/src/main/java/com/github/zly2006/zhihu/PhoneLoginPane.kt rename to shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/PhoneLoginPane.kt index 6bfc0b757..84de0d43b 100644 --- a/app/src/main/java/com/github/zly2006/zhihu/PhoneLoginPane.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/PhoneLoginPane.kt @@ -15,15 +15,8 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package com.github.zly2006.zhihu +package com.github.zly2006.zhihu.account -import android.content.Context -import android.content.pm.PackageManager -import android.graphics.BitmapFactory -import android.os.Build -import android.os.Environment -import android.os.StatFs -import android.util.Base64 import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -56,40 +49,28 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.core.app.NotificationManagerCompat -import com.github.zly2006.zhihu.account.ZhihuPhoneDigitsResult -import com.github.zly2006.zhihu.account.ZhihuPhoneLoginClient -import com.github.zly2006.zhihu.account.ZhihuPhoneLoginDeviceInfo -import com.github.zly2006.zhihu.data.AccountData +import io.ktor.client.HttpClient import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.TimeZone -import kotlin.time.Clock @Composable -internal fun PhoneLoginPane(activity: LoginActivity) { - val context = LocalContext.current - val cookies = remember { mutableMapOf<String, String>() } - val httpClient = remember { AccountData.httpClient(context, cookies) } - val deviceInfo = remember(context) { context.phoneLoginDeviceInfo() } - val loginClient = remember { - ZhihuPhoneLoginClient( - httpClient = httpClient, - cookies = cookies, - deviceInfo = deviceInfo, - nowEpochSeconds = { Clock.System.now().toEpochMilliseconds() / 1000 }, - ) - } - DisposableEffect(httpClient) { - onDispose(httpClient::close) +expect fun rememberLoginHttpClient(cookies: MutableMap<String, String>): HttpClient + +expect fun decodePhoneLoginCaptchaImage(content: String): ImageBitmap? + +@Composable +fun PhoneLoginPane(onLoginSuccess: (String) -> Unit) { + val accountStore = rememberZhihuAccountStore() + val loginClient = remember { ZhihuPhoneLoginClient() } + DisposableEffect(loginClient) { + onDispose(loginClient::close) } val scope = rememberCoroutineScope() @@ -204,12 +185,7 @@ internal fun PhoneLoginPane(activity: LoginActivity) { if (captchaRequired) { val captchaBitmap = remember(captchaImageBase64) { - runCatching { - val image = captchaImageBase64.orEmpty() - val encoded = image.substringAfter("base64,", image) - val bytes = Base64.decode(encoded, Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() - }.getOrNull() + captchaImageBase64?.let(::decodePhoneLoginCaptchaImage) } captchaBitmap?.let { image -> Image( @@ -352,7 +328,9 @@ internal fun PhoneLoginPane(activity: LoginActivity) { errorMessage = null try { val token = loginClient.signIn(phoneNumber, digits) - if (!activity.finalizeMobileLogin(token)) { + if (accountStore.login(token)) { + onLoginSuccess(accountStore.session.username) + } else { errorMessage = "登录凭证验证失败,请重试" } } catch (error: CancellationException) { @@ -394,27 +372,3 @@ internal fun PhoneLoginPane(activity: LoginActivity) { } } } - -private fun Context.phoneLoginDeviceInfo(): ZhihuPhoneLoginDeviceInfo { - val runtime = Runtime.getRuntime() - val storage = StatFs(Environment.getDataDirectory().path) - val installTime = runCatching { - packageManager.getPackageInfo(packageName, 0).firstInstallTime - }.getOrDefault(0L) - return ZhihuPhoneLoginDeviceInfo( - timezoneOffsetSeconds = TimeZone.getDefault().rawOffset / 1_000L, - appInstallTimeMillis = installTime, - notificationEnabled = NotificationManagerCompat.from(this).areNotificationsEnabled(), - bluetoothAvailable = packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH), - phoneBrand = Build.BRAND, - phoneModel = Build.MODEL, - androidRelease = Build.VERSION.RELEASE, - cpuType = Build.SUPPORTED_ABIS.firstOrNull().orEmpty(), - cpuCount = runtime.availableProcessors(), - cpuUsage = "0.0", - totalMemoryMegabytes = (runtime.totalMemory() / 1_048_576L).toInt(), - freeMemoryMegabytes = (runtime.freeMemory() / 1_048_576L).toInt(), - totalStorageMegabytes = (storage.totalBytes / 1_048_576L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), - freeStorageMegabytes = (storage.availableBytes / 1_048_576L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), - ) -} diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/QrLogin.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/QrLogin.kt index 2e6809752..c18f1b9a9 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/QrLogin.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/QrLogin.kt @@ -372,21 +372,12 @@ private fun <T> decodeZhihuLoginJsonTyped(serializer: KSerializer<T>, json: Json @Composable fun SharedQrLoginPane( - createClient: (MutableMap<String, String>) -> HttpClient, onLoginSuccess: suspend (Map<String, String>) -> Boolean, modifier: Modifier = Modifier, generateQrBitmap: (String) -> ImageBitmap = ::generateQrLoginBitmap, initialCookies: Map<String, String> = emptyMap(), qrReadyMessage: String = "请打开知乎++ App 扫一扫", onQrReady: () -> Unit = {}, - readRiskControlCookies: (String) -> Map<String, String> = { emptyMap() }, - riskControlContent: ( - @Composable ( - url: String, - cookies: Map<String, String>, - onCookiesChanged: (Map<String, String>) -> Unit, - ) -> Unit - )? = null, ) { var refreshKey by rememberSaveable { mutableIntStateOf(0) } var qrBitmap by remember { mutableStateOf<ImageBitmap?>(null) } @@ -395,10 +386,10 @@ fun SharedQrLoginPane( var riskControlUrl by remember { mutableStateOf<String?>(null) } var riskControlMessage by remember { mutableStateOf<String?>(null) } var isWorking by remember { mutableStateOf(true) } + val cookies = remember(refreshKey) { sessionCookies.toMutableMap() } + val client = rememberLoginHttpClient(cookies) LaunchedEffect(refreshKey) { - val cookies = sessionCookies.toMutableMap() - val client = createClient(cookies) qrBitmap = null statusText = "正在获取二维码" riskControlUrl = null @@ -453,8 +444,6 @@ fun SharedQrLoginPane( } catch (e: Exception) { statusText = e.message ?: "二维码获取失败,请重试" isWorking = false - } finally { - client.close() } } @@ -474,7 +463,6 @@ fun SharedQrLoginPane( ) OutlinedButton( onClick = { - sessionCookies = sessionCookies + readRiskControlCookies(currentRiskControlUrl) riskControlUrl = null riskControlMessage = null refreshKey += 1 @@ -490,18 +478,23 @@ fun SharedQrLoginPane( .weight(1f) .fillMaxWidth(), ) { - riskControlContent?.invoke( - currentRiskControlUrl, - sessionCookies, - ) { updatedCookies -> - sessionCookies = sessionCookies + updatedCookies - } ?: Text( - text = "当前被知乎风控,请过几个小时再试", - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - textAlign = TextAlign.Center, - ) + if (isLoginRiskControlSupported) { + LoginRiskControlPane( + url = currentRiskControlUrl, + cookies = sessionCookies, + onCookiesChanged = { updatedCookies -> + sessionCookies = sessionCookies + updatedCookies + }, + ) + } else { + Text( + text = "当前被知乎风控,请过几个小时再试", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + textAlign = TextAlign.Center, + ) + } } } return @@ -573,3 +566,12 @@ fun SharedQrLoginPane( } expect fun generateQrLoginBitmap(content: String): ImageBitmap + +expect val isLoginRiskControlSupported: Boolean + +@Composable +expect fun LoginRiskControlPane( + url: String, + cookies: Map<String, String>, + onCookiesChanged: (Map<String, String>) -> Unit, +) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClient.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClient.kt index b662fc2db..5dbfef7e5 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClient.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClient.kt @@ -1,146 +1,264 @@ /* * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. + * Licensed under the GNU Affero General Public License version 3. */ package com.github.zly2006.zhihu.account +import androidx.compose.runtime.Composable import com.github.zly2006.zhihu.data.fetchVerifiedZhihuSession +import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.HttpClientEngineFactory +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow -class ZhihuAccountClient( - private val repository: ZhihuAccountRepository, - private val createClient: ( - cookies: MutableMap<String, String>, - session: ZhihuAccountSession, - onCookieChanged: () -> Unit, - isTemporary: Boolean, - ) -> HttpClient, - private val onSessionChanged: (ZhihuAccountSession) -> Unit = {}, -) { - private var session: ZhihuAccountSession? = null - private var httpClient: HttpClient? = null - private var httpClientCookies: Map<String, String>? = null - private var httpClientUserAgent: String? = null - - fun load(): ZhihuAccountSession { - val cached = session - if (cached != null) return cached - return repository.load().also { - session = it - onSessionChanged(it) +class ZhihuAccountClient internal constructor( + private val accountState: ZhihuAccountState, +) : AutoCloseable { + private val cookies = accountState.value.session.cookies + .toMutableMap() + private val client = createAccountHttpClient(cookies, accountState.value.session.userAgent) { + accountState.save(load().copy(cookies = cookies.toMutableMap())) + } + + fun load(): ZhihuAccountSession = accountState.value.session + + fun save(session: ZhihuAccountSession) { + require(load().hasSameIdentityAs(session)) { + "ZhihuAccountClient 只能刷新同一账号的 session;切换身份必须替换 client" } + persistSession(session) + } + + private fun persistSession(session: ZhihuAccountSession) { + val updatedCookies = session.cookies.toMap() + cookies.clear() + cookies.putAll(updatedCookies) + accountState.save(session.copy(cookies = cookies.toMutableMap())) + } + + fun httpClient(): HttpClient = client + + fun temporaryHttpClient(cookies: MutableMap<String, String>): HttpClient = + createAccountHttpClient(cookies, load().userAgent) + + suspend fun refreshAndSaveProfile(): ZhihuAccountSession? { + val current = load() + val refreshed = fetchVerifiedZhihuSession(client, current.cookies, current.userAgent) ?: return null + return refreshed + .copy( + mobileAccessToken = current.mobileAccessToken, + mobileRefreshToken = current.mobileRefreshToken, + mobileTokenType = current.mobileTokenType, + mobileTokenExpiresAt = current.mobileTokenExpiresAt, + ).also(::persistSession) + } + + suspend fun <T> withAuthenticatedClient( + block: suspend (client: HttpClient, cookies: Map<String, String>) -> T, + ): T = block(client, cookies) + + override fun close() { + client.close() } +} + +class ZhihuAccountStore internal constructor( + repository: ZhihuAccountRepository, +) : AutoCloseable { + private val accountState = ZhihuAccountState(repository) + private var mutableClient = ZhihuAccountClient(accountState) + + val accountsState: StateFlow<ZhihuAccounts> = accountState.state.asStateFlow() + val session: ZhihuAccountSession + get() = accountState.value.session + val accounts: List<ZhihuSavedAccount> + get() = accountState.value.accounts + val client: ZhihuAccountClient + get() = mutableClient fun save(session: ZhihuAccountSession) { - val shouldInvalidateClient = httpClient != null && - (httpClientCookies != session.cookies || httpClientUserAgent != session.userAgent) - this.session = session - onSessionChanged(session) - if (shouldInvalidateClient) { - invalidateHttpClient() + if (this.session.hasSameIdentityAs(session)) { + mutableClient.save(session) + } else { + replaceSession(session) } - repository.save(session) + } + + fun replaceSession(session: ZhihuAccountSession) { + accountState.saveCurrent(session) + replaceClient() } fun clear() { - val emptySession = ZhihuAccountSession() - session = emptySession - onSessionChanged(emptySession) - invalidateHttpClient() - repository.clear() - } - - fun httpClient(): HttpClient { - httpClient?.let { return it } - val currentSession = load() - return createClient(currentSession.cookies, currentSession, { persistCurrentSession() }, false).also { - httpClient = it - httpClientCookies = currentSession.cookies.toMap() - httpClientUserAgent = currentSession.userAgent - } + accountState.removeCurrent() + replaceClient() } - fun temporaryHttpClient(cookies: MutableMap<String, String>): HttpClient = - createClient(cookies, load(), {}, true) + suspend fun switchAccount(id: String): Boolean { + if (id == accountState.value.activeAccountId) return true + val savedSession = accountState.value.accounts + .firstOrNull { it.id == id } + ?.session ?: return false + val verified = verifyCandidateSession(savedSession.cookies.toMutableMap(), savedSession.userAgent) + ?.copy( + mobileAccessToken = savedSession.mobileAccessToken, + mobileRefreshToken = savedSession.mobileRefreshToken, + mobileTokenType = savedSession.mobileTokenType, + mobileTokenExpiresAt = savedSession.mobileTokenExpiresAt, + ) ?: return false + accountState.select(id, verified) + replaceClient() + return true + } + + fun removeAccount(id: String) { + val activeAccountId = accountState.value.activeAccountId + accountState.remove(id) + if (id == activeAccountId) replaceClient() + } + + suspend fun login(cookies: MutableMap<String, String>): Boolean { + val session = verifyCandidateSession(cookies) ?: return false + selectVerifiedSession(session) + return true + } - fun invalidateHttpClient() { - httpClient?.close() - httpClient = null - httpClientCookies = null - httpClientUserAgent = null + suspend fun login(token: ZhihuMobileLoginToken): Boolean { + val session = verifyCandidateSession(token.cookies.toMutableMap())?.copy( + mobileAccessToken = token.accessToken, + mobileRefreshToken = token.refreshToken, + mobileTokenType = token.tokenType, + mobileTokenExpiresAt = token.expiresAt, + ) ?: return false + selectVerifiedSession(session) + return true } - suspend fun verifyAndSave(cookies: MutableMap<String, String>): Boolean { - val temporaryClient = temporaryHttpClient(cookies) - try { - val verified = fetchVerifiedZhihuSession(temporaryClient, cookies, load().userAgent) ?: return false - save(verified) - return true + private fun selectVerifiedSession(session: ZhihuAccountSession) { + accountState.selectOrAdd(session) + replaceClient() + } + + private suspend fun verifyCandidateSession( + cookies: MutableMap<String, String>, + userAgent: String = session.userAgent, + ): ZhihuAccountSession? { + val client = createAccountHttpClient(cookies, userAgent) + return try { + fetchVerifiedZhihuSession(client, cookies, userAgent) } finally { - temporaryClient.close() + client.close() } } - suspend fun verifyMobileAndSave(token: ZhihuMobileLoginToken): Boolean { - val cookies = token.cookies.toMutableMap() - val temporaryClient = temporaryHttpClient(cookies) - try { - val verified = fetchVerifiedZhihuSession(temporaryClient, cookies, load().userAgent) ?: return false - save( - verified.copy( - mobileAccessToken = token.accessToken, - mobileRefreshToken = token.refreshToken, - mobileTokenType = token.tokenType, - mobileTokenExpiresAt = token.expiresAt, + private fun replaceClient() { + mutableClient.close() + mutableClient = ZhihuAccountClient(accountState) + } + + override fun close() = mutableClient.close() +} + +internal class ZhihuAccountState( + private val repository: ZhihuAccountRepository, +) { + val state = MutableStateFlow(repository.loadAccounts()) + val value: ZhihuAccounts + get() = state.value + + fun save(session: ZhihuAccountSession) { + saveCurrent(session) + } + + fun saveCurrent(session: ZhihuAccountSession) { + val current = value + val activeId = current.activeAccountId + val updated = if (activeId == null) { + if (!session.login) { + ZhihuAccounts() + } else { + val id = session.accountSlotId() + ZhihuAccounts(id, listOf(ZhihuSavedAccount(id, session))) + } + } else { + current.copy( + accounts = current.accounts.map { account -> + if (account.id == activeId) account.copy(session = session) else account + }, + ) + } + persist(updated) + } + + fun selectOrAdd(session: ZhihuAccountSession) { + val existing = value.accounts.firstOrNull { it.session.hasSameIdentityAs(session) } + if (existing != null) { + persist( + value.copy( + activeAccountId = existing.id, + accounts = value.accounts.map { if (it.id == existing.id) it.copy(session = session) else it }, ), ) - return true - } finally { - temporaryClient.close() + } else { + val id = session.accountSlotId() + persist(ZhihuAccounts(id, value.accounts + ZhihuSavedAccount(id, session))) } } - suspend fun refreshAndSaveProfile(): ZhihuAccountSession? { - val currentSession = load() - val refreshed = fetchVerifiedZhihuSession(httpClient(), currentSession.cookies, currentSession.userAgent) - ?: return null - val updated = refreshed.copy( - mobileAccessToken = currentSession.mobileAccessToken, - mobileRefreshToken = currentSession.mobileRefreshToken, - mobileTokenType = currentSession.mobileTokenType, - mobileTokenExpiresAt = currentSession.mobileTokenExpiresAt, + fun select(id: String, session: ZhihuAccountSession) { + require(value.accounts.any { it.id == id }) { "找不到要切换的登录账号" } + persist( + value.copy( + activeAccountId = id, + accounts = value.accounts.map { if (it.id == id) it.copy(session = session) else it }, + ), ) - save(updated) - return updated } - suspend fun <T> withAuthenticatedClient( - block: suspend (client: HttpClient, cookies: Map<String, String>) -> T, - ): T { - val currentSession = load() - return block(httpClient(), currentSession.cookies) + fun remove(id: String) { + val remaining = value.accounts.filterNot { it.id == id } + val activeId = if (value.activeAccountId == id) remaining.firstOrNull()?.id else value.activeAccountId + persist(ZhihuAccounts(activeId, remaining)) + } + + fun removeCurrent() { + value.activeAccountId?.let(::remove) ?: repository.clear() } - private fun persistCurrentSession() { - val currentSession = load() - val updated = currentSession.copy(cookies = currentSession.cookies.toMutableMap()) - session = updated - httpClientCookies = updated.cookies.toMap() - httpClientUserAgent = updated.userAgent - onSessionChanged(updated) - repository.save(updated) + private fun persist(accounts: ZhihuAccounts) { + if (accounts.accounts.isEmpty()) repository.clear() else repository.saveAccounts(accounts) + state.value = accounts } } + +internal fun createAccountHttpClient( + cookies: MutableMap<String, String>, + userAgent: String, + onCookieChanged: () -> Unit = {}, +): HttpClient { + val configure: io.ktor.client.HttpClientConfig<*>.() -> Unit = { + installZhihuCommonClientConfig(cookies, userAgent, onCookieChanged) + } + return accountHttpClientEngineForTesting?.let { HttpClient(it, configure) } + ?: HttpClient(accountHttpClientEngineFactory, configure) +} + +private fun ZhihuAccountSession.hasSameIdentityAs(other: ZhihuAccountSession): Boolean { + if (login != other.login) return false + if (!login) return true + val identity = accountIdentityKey() + val otherIdentity = other.accountIdentityKey() + return identity != null && identity == otherIdentity +} + +internal expect val accountHttpClientEngineFactory: HttpClientEngineFactory<*> + +var accountHttpClientEngineForTesting: HttpClientEngine? = null + +@Composable +expect fun rememberZhihuAccountStore(): ZhihuAccountStore diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepository.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepository.kt index 3cdbaf65b..4875f9a9a 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepository.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepository.kt @@ -21,6 +21,7 @@ import com.github.zly2006.zhihu.data.ZhihuJson import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject const val DEFAULT_ZHIHU_USER_AGENT = "Mozilla/5.0 (X11; U; Linux x86_64; en-US) AppleWebKit/540.0 (KHTML, like Gecko) Ubuntu/10.10 Chrome/9.1.0.0 Safari/540.0" @@ -48,6 +49,23 @@ data class ZhihuAccountSession( val mobileTokenExpiresAt: Long? = null, ) +@Serializable +data class ZhihuSavedAccount( + val id: String, + val session: ZhihuAccountSession, +) + +@Serializable +data class ZhihuAccounts( + val activeAccountId: String? = null, + val accounts: List<ZhihuSavedAccount> = emptyList(), +) { + val session: ZhihuAccountSession + get() = accounts.firstOrNull { it.id == activeAccountId }?.session ?: guestZhihuAccountSession +} + +private val guestZhihuAccountSession = ZhihuAccountSession() + interface ZhihuAccountSessionStore { fun readText(): String? @@ -60,19 +78,44 @@ class ZhihuAccountRepository( private val store: ZhihuAccountSessionStore, private val json: Json = ZhihuJson.json, ) { - fun load(): ZhihuAccountSession = runCatching { - store - .readText() - ?.takeIf { it.isNotBlank() } - ?.let { json.decodeFromString<ZhihuAccountSession>(it) } - ?: ZhihuAccountSession() - }.getOrDefault(ZhihuAccountSession()) + fun loadAccounts(): ZhihuAccounts = runCatching { + val text = store.readText()?.takeIf { it.isNotBlank() } ?: return@runCatching ZhihuAccounts() + val element = json.parseToJsonElement(text) + if (element is JsonObject && "accounts" in element) { + json.decodeFromString<ZhihuAccounts>(text) + } else { + val session = json.decodeFromString<ZhihuAccountSession>(text) + if (session.login) { + val id = session.accountSlotId() + ZhihuAccounts(id, listOf(ZhihuSavedAccount(id, session))) + } else { + ZhihuAccounts() + } + } + }.getOrDefault(ZhihuAccounts()) + + fun load(): ZhihuAccountSession = loadAccounts().session fun save(session: ZhihuAccountSession) { - store.writeText(json.encodeToString(session)) + if (session.login) { + val id = session.accountSlotId() + saveAccounts(ZhihuAccounts(id, listOf(ZhihuSavedAccount(id, session)))) + } else { + clear() + } + } + + fun saveAccounts(accounts: ZhihuAccounts) { + store.writeText(json.encodeToString(accounts)) } fun clear() { store.delete() } } + +internal fun ZhihuAccountSession.accountIdentityKey(): String? = profile?.id?.takeIf(String::isNotBlank) + ?: profile?.urlToken?.takeIf(String::isNotBlank) + ?: username.takeIf(String::isNotBlank) + +internal fun ZhihuAccountSession.accountSlotId(): String = accountIdentityKey() ?: "account" diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityClient.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityClient.kt deleted file mode 100644 index 27b9f1352..000000000 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityClient.kt +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.account - -import com.github.zly2006.zhihu.data.ZhihuJson -import io.ktor.client.HttpClient -import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - -private const val ZHIHU_API_BASE_URL = "https://api.zhihu.com" -private const val IDENTITY_ACCOUNT_LIST_PATH = "/people/account/list" -private const val CREATE_SUB_ACCOUNT_PATH = "/account/sub/register" -private const val SWITCH_ACCOUNT_PATH = "/account/switch" -private const val CURRENT_ACCOUNT_PATH = "/people/self" - -const val ZHIHU_ANDROID_IDENTITY_USER_AGENT = - "com.zhihu.android/Futureve/11.2.0 Mozilla/5.0 (Linux; Android 12; sdk_gphone64_arm64 " + - "Build/SE1A.220630.001.A1; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " + - "Chrome/57.0.1000.10 Mobile Safari/537.36" - -@Serializable -data class ZhihuIdentityAccount( - val id: String, - val urlToken: String? = null, - val name: String, - val avatarUrl: String? = null, - val isActive: Boolean = false, - val canCreateSubAccount: Boolean = false, - val accountType: Int = 0, - val subAccountControlStatus: Int = 0, -) - -data class ZhihuIdentityChangeResult( - val account: ZhihuIdentityAccount, - val session: ZhihuAccountSession, -) - -class ZhihuIdentityApiException( - message: String, - val statusCode: Int, -) : IllegalStateException(message) - -/** - * 知乎移动端“身份管理”协议客户端。 - * - * `/account/sub/register` 与 `/account/switch` 都会直接签发目标身份的新 token 和 cookie。收到响应后必须先用新凭证 - * 请求 `/people/self`,再一次性保存完整会话;仅替换用户 ID 会让后续推荐和互动请求继续落在旧身份。 - */ -class ZhihuIdentityClient( - private val currentClient: () -> HttpClient, - private val temporaryClient: (MutableMap<String, String>) -> HttpClient, - private val currentSession: () -> ZhihuAccountSession, - private val saveSession: (ZhihuAccountSession) -> Unit, -) { - suspend fun listAccounts(): List<ZhihuIdentityAccount> { - val response = currentClient().get("$ZHIHU_API_BASE_URL$IDENTITY_ACCOUNT_LIST_PATH") { - applyIdentityHeaders(currentSession()) - } - val body = response.successBody("获取身份列表") - return ZhihuJson.decodeJson<ZhihuIdentityAccountListResponse>(ZhihuJson.json.parseToJsonElement(body)).data - } - - suspend fun createSubAccount(): ZhihuIdentityChangeResult { - val response = currentClient().post("$ZHIHU_API_BASE_URL$CREATE_SUB_ACCOUNT_PATH") { - applyIdentityHeaders(currentSession()) - } - return applyIssuedToken(response.successBody("创建新账号")) - } - - suspend fun switchAccount(targetUserId: String): ZhihuIdentityChangeResult { - require(targetUserId.isNotBlank()) { "目标账号不能为空" } - val response = currentClient().post("$ZHIHU_API_BASE_URL$SWITCH_ACCOUNT_PATH") { - applyIdentityHeaders(currentSession()) - contentType(ContentType.Application.Json) - setBody(SwitchAccountRequest(targetUserId)) - } - return applyIssuedToken( - body = response.successBody("切换账号"), - expectedAccountId = targetUserId, - ) - } - - private suspend fun applyIssuedToken( - body: String, - expectedAccountId: String? = null, - ): ZhihuIdentityChangeResult { - val token = ZhihuJson.decodeJson<ZhihuIdentityToken>(ZhihuJson.json.parseToJsonElement(body)) - check(token.accessToken.isNotBlank()) { "服务器未返回新账号凭证" } - check(token.cookie["z_c0"].isNullOrBlank().not()) { "服务器未返回新账号 Cookie" } - - val oldSession = currentSession() - val newCookies = oldSession.cookies.toMutableMap().apply { - putAll(token.cookie) - } - val client = temporaryClient(newCookies) - try { - val response = client.get("$ZHIHU_API_BASE_URL$CURRENT_ACCOUNT_PATH") { - applyIdentityHeaders( - oldSession.copy( - mobileAccessToken = token.accessToken, - mobileTokenType = token.tokenType, - ), - ) - } - val profileBody = response.successBody("初始化新账号") - val rawProfile = ZhihuJson.json.parseToJsonElement(profileBody).jsonObject - val convertedProfile = ZhihuJson.snakeCaseToCamelCase(rawProfile) - val profile = ZhihuJson.decodeJson<ZhihuIdentityProfile>(rawProfile) - check(profile.id.isNotBlank() && profile.name.isNotBlank()) { "服务器返回的账号资料不完整" } - check(expectedAccountId == null || profile.id == expectedAccountId) { - "服务器返回的账号与目标账号不一致" - } - - val nextSession = oldSession.copy( - login = true, - username = profile.name, - cookies = newCookies, - profile = ZhihuAccountProfileSnapshot( - id = profile.id, - name = profile.name, - urlToken = profile.urlToken, - userType = profile.userType, - avatarUrl = profile.avatarUrl, - ), - self = convertedProfile, - mobileAccessToken = token.accessToken, - mobileRefreshToken = token.refreshToken, - mobileTokenType = token.tokenType, - mobileTokenExpiresAt = token.expiresAt, - ) - saveSession(nextSession) - return ZhihuIdentityChangeResult( - account = ZhihuIdentityAccount( - id = profile.id, - urlToken = profile.urlToken, - name = profile.name, - avatarUrl = profile.avatarUrl, - isActive = true, - canCreateSubAccount = profile.canCreateSubAccount, - accountType = profile.accountType, - subAccountControlStatus = profile.subAccountControlStatus, - ), - session = nextSession, - ) - } finally { - client.close() - } - } -} - -private fun io.ktor.client.request.HttpRequestBuilder.applyIdentityHeaders(session: ZhihuAccountSession) { - accept(ContentType.Application.Json) - header(HttpHeaders.UserAgent, ZHIHU_ANDROID_IDENTITY_USER_AGENT) - header("x-api-version", "3.0.93") - header("x-app-version", "11.2.0") - header("x-app-build", "release") - header("x-app-bundleid", "com.zhihu.android") - header("x-app-flavor", "zhihuwap64") - header( - "x-app-za", - "OS=Android&Release=12&Model=sdk_gphone64_arm64&VersionName=11.2.0&VersionCode=40210&" + - "Product=com.zhihu.android&Width=1440&Height=2952&Installer=Market&DeviceType=AndroidPhone&Brand=google", - ) - header("x-network-type", "WiFi") - header("x-zse-93", "101_1_1.0") - session.mobileAccessToken - ?.takeIf { it.isNotBlank() } - ?.let { accessToken -> - val tokenType = session.mobileTokenType?.takeIf { it.isNotBlank() } ?: "bearer" - header(HttpHeaders.Authorization, "$tokenType $accessToken") - } -} - -private suspend fun HttpResponse.successBody(operation: String): String { - val body = bodyAsText() - if (status == HttpStatusCode.OK) return body - - val serverMessage = runCatching { - val json = ZhihuJson.json.parseToJsonElement(body).jsonObject - json["error"] - ?.jsonObject - ?.get("message") - ?.jsonPrimitive - ?.content - ?: json["message"]?.jsonPrimitive?.content - }.getOrNull() - throw ZhihuIdentityApiException( - message = serverMessage?.let { "$operation 失败:$it" } ?: "$operation 失败(HTTP ${status.value})", - statusCode = status.value, - ) -} - -@Serializable -private data class ZhihuIdentityAccountListResponse( - val data: List<ZhihuIdentityAccount> = emptyList(), -) - -@Serializable -private data class SwitchAccountRequest( - @SerialName("target_user_id") - val targetUserId: String, -) - -@Serializable -private data class ZhihuIdentityToken( - val uid: String = "", - val userId: Long? = null, - val tokenType: String = "bearer", - val accessToken: String, - val refreshToken: String? = null, - val expiresIn: Long? = null, - val cookie: Map<String, String> = emptyMap(), - val expiresAt: Long? = null, -) - -@Serializable -private data class ZhihuIdentityProfile( - val id: String = "", - val name: String = "", - val urlToken: String? = null, - val userType: String = "", - val avatarUrl: String? = null, - val canCreateSubAccount: Boolean = false, - val accountType: Int = 0, - val subAccountControlStatus: Int = 0, -) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityProtocol.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityProtocol.kt new file mode 100644 index 000000000..9d139fb05 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityProtocol.kt @@ -0,0 +1,135 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package com.github.zly2006.zhihu.account + +import com.github.zly2006.zhihu.data.ZhihuJson +import io.ktor.client.request.accept +import io.ktor.client.request.header +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +const val ZHIHU_ANDROID_IDENTITY_USER_AGENT = + "com.zhihu.android/Futureve/11.2.0 Mozilla/5.0 (Linux; Android 12; sdk_gphone64_arm64 " + + "Build/SE1A.220630.001.A1; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " + + "Chrome/57.0.1000.10 Mobile Safari/537.36" + +@Serializable +data class ZhihuIdentityAccount( + val id: String, + val urlToken: String? = null, + val name: String, + val avatarUrl: String? = null, + val isActive: Boolean = false, + val canCreateSubAccount: Boolean = false, + val accountType: Int = 0, + val subAccountControlStatus: Int = 0, +) + +data class ZhihuIdentityChangeResult( + val account: ZhihuIdentityAccount, + val session: ZhihuAccountSession, +) + +class ZhihuIdentityApiException( + message: String, + val statusCode: Int, +) : IllegalStateException(message) + +internal fun io.ktor.client.request.HttpRequestBuilder.applyIdentityHeaders(session: ZhihuAccountSession) { + accept(ContentType.Application.Json) + header(HttpHeaders.UserAgent, ZHIHU_ANDROID_IDENTITY_USER_AGENT) + header("x-api-version", "3.0.93") + header("x-app-version", "11.2.0") + header("x-app-build", "release") + header("x-app-bundleid", "com.zhihu.android") + header("x-app-flavor", "zhihuwap64") + header( + "x-app-za", + "OS=Android&Release=12&Model=sdk_gphone64_arm64&VersionName=11.2.0&VersionCode=40210&" + + "Product=com.zhihu.android&Width=1440&Height=2952&Installer=Market&DeviceType=AndroidPhone&Brand=google", + ) + header("x-network-type", "WiFi") + header("x-zse-93", "101_1_1.0") + session.mobileAccessToken + ?.takeIf { it.isNotBlank() } + ?.let { accessToken -> + val tokenType = session.mobileTokenType?.takeIf { it.isNotBlank() } ?: "bearer" + header(HttpHeaders.Authorization, "$tokenType $accessToken") + } +} + +internal suspend fun HttpResponse.identitySuccessBody(operation: String): String { + val body = bodyAsText() + if (status == HttpStatusCode.OK) return body + + val serverMessage = runCatching { + val json = ZhihuJson.json.parseToJsonElement(body).jsonObject + json["error"] + ?.jsonObject + ?.get("message") + ?.jsonPrimitive + ?.content + ?: json["message"]?.jsonPrimitive?.content + }.getOrNull() + throw ZhihuIdentityApiException( + message = serverMessage?.let { "$operation 失败:$it" } ?: "$operation 失败(HTTP ${status.value})", + statusCode = status.value, + ) +} + +@Serializable +internal data class ZhihuIdentityAccountListResponse( + val data: List<ZhihuIdentityAccount> = emptyList(), +) + +@Serializable +internal data class SwitchAccountRequest( + @SerialName("target_user_id") + val targetUserId: String, +) + +@Serializable +internal data class ZhihuIdentityToken( + val uid: String = "", + val userId: Long? = null, + val tokenType: String = "bearer", + val accessToken: String, + val refreshToken: String? = null, + val expiresIn: Long? = null, + val cookie: Map<String, String> = emptyMap(), + val expiresAt: Long? = null, +) + +@Serializable +internal data class ZhihuIdentityProfile( + val id: String = "", + val name: String = "", + val urlToken: String? = null, + val userType: String = "", + val avatarUrl: String? = null, + val canCreateSubAccount: Boolean = false, + val accountType: Int = 0, + val subAccountControlStatus: Int = 0, +) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClient.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClient.kt index b44f69a4d..f5d2b320e 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClient.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClient.kt @@ -21,7 +21,6 @@ import com.github.zly2006.zhihu.data.ZhihuJson import com.github.zly2006.zhihu.data.toCookieHeaderString import com.github.zly2006.zhihu.util.ZhihuMessageBodyEncryptor import com.github.zly2006.zhihu.util.hmacSha1Hex -import io.ktor.client.HttpClient import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.accept import io.ktor.client.request.get @@ -39,6 +38,7 @@ import io.ktor.http.isSuccess import kotlinx.serialization.Serializable import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlin.time.Clock private const val PHONE_LOGIN_API_BASE_URL = "https://api.zhihu.com" private const val DEVICE_GUEST_INIT_PATH = "/api/account/prod/init/udid_guest" @@ -98,12 +98,10 @@ sealed interface ZhihuPhoneDigitsResult { * Captcha 是服务端条件分支:正常情况下服务端会直接允许发送短信,但风控要求验证时必须先完成 `/captcha`, * 不能把当前抓包中没有出现验证码误解成可以永久绕过验证码。 */ -class ZhihuPhoneLoginClient( - private val httpClient: HttpClient, - private val cookies: MutableMap<String, String>, - private val deviceInfo: ZhihuPhoneLoginDeviceInfo, - private val nowEpochSeconds: () -> Long, -) { +class ZhihuPhoneLoginClient : AutoCloseable { + private val cookies = phoneLoginCookiesForTesting ?: mutableMapOf() + private val httpClient = createAccountHttpClient(cookies, ZHIHU_ANDROID_PHONE_LOGIN_USER_AGENT) + private val deviceInfo = phoneLoginDeviceInfoForTesting ?: phoneLoginDeviceInfo private var authorization = "oauth $MOBILE_CLIENT_ID" private var deviceId: String? = null private var webDeviceCookie = cookies.remove("d_c0")?.takeIf(String::isNotBlank) @@ -193,7 +191,7 @@ class ZhihuPhoneLoginClient( require(digits.isNotBlank()) { "短信验证码不能为空" } ensureGuestToken() - val timestamp = nowEpochSeconds() + val timestamp = currentEpochSeconds() val signature = hmacSha1Hex( MOBILE_CLIENT_SECRET, "$DIGITS_GRANT_TYPE$MOBILE_CLIENT_ID$MOBILE_SOURCE$timestamp", @@ -255,7 +253,7 @@ class ZhihuPhoneLoginClient( webDeviceCookie = checkNotNull(fetchedDeviceCookie) { "服务器未返回必要的 Cookie d_c0" } } - val timestamp = nowEpochSeconds().toString() + val timestamp = currentEpochSeconds().toString() val form = Parameters .build { append("app_build", "40408") @@ -356,8 +354,20 @@ class ZhihuPhoneLoginClient( }.formUrlEncode() setBody(ZhihuMessageBodyEncryptor.encrypt(form)) } + + private fun currentEpochSeconds(): Long = + phoneLoginNowEpochSecondsForTesting?.invoke() + ?: Clock.System.now().toEpochMilliseconds() / 1_000 + + override fun close() = httpClient.close() } +internal expect val phoneLoginDeviceInfo: ZhihuPhoneLoginDeviceInfo + +var phoneLoginCookiesForTesting: MutableMap<String, String>? = null +var phoneLoginDeviceInfoForTesting: ZhihuPhoneLoginDeviceInfo? = null +var phoneLoginNowEpochSecondsForTesting: (() -> Long)? = null + private fun normalizePhoneNumber(phoneNumber: String): String { val compact = phoneNumber .trim() diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/AigcVoteClient.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/AigcVoteModels.kt similarity index 75% rename from shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/AigcVoteClient.kt rename to shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/AigcVoteModels.kt index fdfe6f140..bcc096207 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/AigcVoteClient.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/AigcVoteModels.kt @@ -17,14 +17,6 @@ package com.github.zly2006.zhihu.data -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -32,60 +24,6 @@ private const val MIN_CREDIT_DURATION_MS = 15_000L private const val MIN_CREDIT_SCROLL_RATIO = 0.25 const val AIGC_MARKING_ENABLED_PREFERENCE_KEY = "enableAigcMarking" -class AigcVoteClient( - private val httpClient: HttpClient, - baseUrl: String, - private val clientId: String, -) { - private val normalizedBaseUrl = baseUrl.trimEnd('/') - - suspend fun syncReadEvent(event: AigcVoteReadEvent): AigcVoteReadEventsResponse = - httpClient - .post("$normalizedBaseUrl/v1/read-events:batch") { - contentType(ContentType.Application.Json) - setBody( - AigcVoteReadEventsRequest( - clientId = clientId, - events = listOf(event.toRequestEvent()), - ), - ) - }.body() - - suspend fun submitFlag(submission: AigcVoteFlagSubmission): AigcVoteFlagResponse = - httpClient - .post("$normalizedBaseUrl/v1/contents/${submission.contentType}/${submission.contentId}/aigc-flag") { - contentType(ContentType.Application.Json) - setBody( - AigcVoteFlagRequest( - clientId = clientId, - voter = submission.voter, - title = submission.title, - authorHash = submission.authorHash, - contentHtml = submission.contentHtml, - contentUpdatedAt = submission.contentUpdatedAt, - evidence = submission.evidence.toFlagEvidence(), - ), - ) - }.body() - - suspend fun getFlagStatus( - contentType: String, - contentId: String, - voter: AigcVoteVoter? = null, - ): AigcVoteFlagStatusResponse = - httpClient - .get("$normalizedBaseUrl/v1/contents/$contentType/$contentId/aigc-flag") { - parameter("client_id", clientId) - if (voter != null) { - parameter("voter_id", voter.id) - parameter("voter_name", voter.name) - if (!voter.urlToken.isNullOrBlank()) { - parameter("voter_url_token", voter.urlToken) - } - } - }.body() -} - data class AigcVoteReadEvidence( val foregroundDurationMs: Long, val maxScrollRatio: Double, @@ -147,7 +85,7 @@ data class AigcVoteVoter( ) @Serializable -private data class AigcVoteReadEventsRequest( +internal data class AigcVoteReadEventsRequest( @SerialName("client_id") val clientId: String, val events: List<AigcVoteReadEventRequest>, @@ -175,7 +113,7 @@ data class AigcVoteReadEventRequest( ) @Serializable -private data class AigcVoteFlagRequest( +internal data class AigcVoteFlagRequest( @SerialName("client_id") val clientId: String, val voter: AigcVoteVoter, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuApiClients.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuApiClients.kt index a8313a3f2..e0120f41a 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuApiClients.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuApiClients.kt @@ -88,7 +88,7 @@ suspend fun fetchZhihuAuthenticatedJson( class ZhihuCookieStorage( private val cookies: MutableMap<String, String>, - private val onCookieChanged: (() -> Unit)? = null, + private val onCookieChanged: () -> Unit = {}, ) : CookiesStorage { override suspend fun addCookie(requestUrl: Url, cookie: Cookie) { // https://github.com/zly2006/zhihu-plus-plus/issues/25#issuecomment-3311926550 @@ -97,7 +97,7 @@ class ZhihuCookieStorage( } if (cookie.domain?.endsWith("zhihu.com") != false) { cookies[cookie.name] = cookie.value - onCookieChanged?.invoke() + onCookieChanged() } } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuDataTypes.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuDataTypes.kt index a666ff451..8aceb52cd 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuDataTypes.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/data/ZhihuDataTypes.kt @@ -73,7 +73,7 @@ data class ZhihuAccountProfile( fun <T : HttpClientEngineConfig> HttpClientConfig<T>.installZhihuCommonClientConfig( cookies: MutableMap<String, String>, userAgent: String, - onCookieChanged: (() -> Unit)? = null, + onCookieChanged: () -> Unit = {}, enableHttpCache: Boolean = false, ) { if (enableHttpCache) { diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.kt index b5de6427b..20b6ebce7 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.kt @@ -25,11 +25,11 @@ data class PickedImage( val fileName: String?, ) -/** - * 返回值为“触发选择图片”的函数。 - * - * - Android:会弹出系统选择器,选取图片后回调 onPicked。 - * - 其他平台:返回 null(UI 可以隐藏“插入图片”入口)。 - */ +interface ImagePickerLauncher { + fun launch() +} + +expect val isImagePickerSupported: Boolean + @Composable -expect fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): (() -> Unit)? +expect fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): ImagePickerLauncher diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.kt index bd9e8f4c0..a9c20ff7e 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.kt @@ -17,208 +17,12 @@ package com.github.zly2006.zhihu.editor -import androidx.compose.runtime.Composable -import com.github.zly2006.zhihu.data.DataHolder -import com.github.zly2006.zhihu.data.ZhihuJson -import com.github.zly2006.zhihu.navigation.Article -import com.github.zly2006.zhihu.navigation.ArticleType -import com.github.zly2006.zhihu.util.raiseForStatus -import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment -import com.github.zly2006.zhihu.viewmodel.fetchContentDetail -import com.github.zly2006.zhihu.viewmodel.postSigned -import io.ktor.client.call.body -import io.ktor.client.request.header -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.contentType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -/** - * 将“发布/编辑知乎回答”封装为平台能力: - * - Android:用 app 的登录态 Cookie + XSRF + zse96(v2) 签名请求知乎网页 API。 - * - Desktop:当前不实现(避免桌面端误触发导致困惑/风控),但保持编译通过。 - */ -interface ZhihuAnswerPublisher { - /** - * 探测当前登录账号在该问题下是否已发布过回答。 - * - * 返回值: - * - null:未找到(视为“新回答”) - * - answerId:找到(发布时会走“更新回答”的 payload) - */ - suspend fun findMyAnswerId(questionId: Long): Long? - - /** - * 获取“我已发布的回答”的可编辑内容,用于回填编辑框。 - * - * 说明: - * - zhihu_obsidian 的“更新回答”逻辑依赖 answerId;本 app 也用相同策略。 - * - 优先返回 editable_content(知乎用于编辑器的 HTML),否则退回 content。 - */ - suspend fun fetchAnswerForEditing(answerId: Long): ExistingAnswerForEditing? - - /** - * 写入“问题草稿”(对应 zhihu_obsidian 的 patchDraft)。 - * - * 说明: - * - 草稿接口和发布接口是两步(知乎网页版也是类似链路)。 - * - 对应端点:POST /api/v4/questions/{questionId}/draft - */ - suspend fun patchDraft( - questionId: Long, - answerId: Long?, - html: String, - tocEnabled: Boolean, - ) - - /** - * 将草稿发布为正式回答(或更新已有回答)。 - * - * 返回值:发布成功后的 answerId - * - * 对应端点:POST /api/v4/content/publish - */ - suspend fun publishAnswer( - questionId: Long, - answerId: Long?, - html: String, - tocEnabled: Boolean, - ): Long -} - -data class ExistingAnswerForEditing( - val answerId: Long, - val html: String, - val tocEnabled: Boolean, -) - -@Composable -expect fun rememberZhihuAnswerPublisher(): ZhihuAnswerPublisher - -private const val QUESTION_RELATIONSHIP_INCLUDE = "relationship,relationship.my_answer" - -internal class ZhihuApiAnswerPublisher( - private val environment: ZhihuApiEnvironment, -) : ZhihuAnswerPublisher { - override suspend fun findMyAnswerId(questionId: Long): Long? { - if (environment.authenticatedCookies()["d_c0"].isNullOrBlank()) return null - - val element = runCatching { - environment.fetchJson("https://api.zhihu.com/questions/$questionId", QUESTION_RELATIONSHIP_INCLUDE) - }.getOrNull() ?: return null - - val response = ZhihuJson.decodeJson(DataHolder.QuestionRelationshipApiResponse.serializer(), element) - val myAnswer = response.relationship?.myAnswer ?: return null - - if (myAnswer.isDeleted == true) { - return null - } - - return myAnswer.answerId?.toLongOrNull() - } - - override suspend fun fetchAnswerForEditing(answerId: Long): ExistingAnswerForEditing? { - val destination = Article(type = ArticleType.Answer, id = answerId) - val answer = environment.fetchContentDetail(destination) as? DataHolder.Answer ?: return null - val html = answer.editableContent ?: answer.content - val tocEnabled = answer.settings?.tableOfContents?.enabled ?: false - - return ExistingAnswerForEditing( - answerId = answerId, - html = html, - tocEnabled = tocEnabled, - ) - } - - override suspend fun patchDraft( - questionId: Long, - answerId: Long?, - html: String, - tocEnabled: Boolean, - ) { - val xsrf = environment.authenticatedCookies()["_xsrf"] - ?: throw IllegalStateException("缺少 _xsrf Cookie,无法写入草稿;请先确保已登录。") - - val body = PatchDraftRequest( - content = html, - settings = PatchDraftSettings( - tableOfContentsEnabled = tocEnabled, - ), - ) - - environment - .postSigned("https://www.zhihu.com/api/v4/questions/$questionId/draft") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Referrer, "https://www.zhihu.com/question/$questionId/answer/${answerId ?: ""}") - header("x-xsrftoken", xsrf) - setBody(body) - }.raiseForStatus(dumpRequest = true) - } - - override suspend fun publishAnswer( - questionId: Long, - answerId: Long?, - html: String, - tocEnabled: Boolean, - ): Long { - val xsrf = environment.authenticatedCookies()["_xsrf"] - ?: throw IllegalStateException("缺少 _xsrf Cookie,无法发布回答;请先确保已登录。") - - val isPublished = answerId != null - val requestBody = PublishAnswerRequest( - data = PublishAnswerData( - publish = PublishTrace(traceId = newPublishTraceId()), - draft = PublishDraft( - isPublished = isPublished, - contentId = answerId?.toString(), - ), - extraInfo = PublishExtraInfo( - questionId = questionId.toString(), - pcBusinessParams = buildPcBusinessParams(tocEnabled), - ), - hybrid = PublishHybrid( - html = html, - ), - contentsTables = PublishContentsTables( - tableOfContentsEnabled = tocEnabled, - ), - ), - ) - - val responseElement = environment - .postSigned("https://www.zhihu.com/api/v4/content/publish") { - contentType(ContentType.Application.Json) - header("x-xsrftoken", xsrf) - setBody(requestBody) - }.raiseForStatus(dumpRequest = true) - .body<JsonElement>() - - val response = ZhihuJson.decodeJson(DataHolder.ContentPublishResponse.serializer(), responseElement) - if (response.message == "success") { - val resultText = response.data?.result - ?: throw IllegalStateException("发布成功但返回缺少 data.result: $responseElement") - - return parsePublishContentId(resultText) - ?: throw IllegalStateException("发布成功但无法解析 publish.id") - } - - val code = response.code - if (code == 103003) { - throw IllegalStateException(response.message ?: "已回答过该问题,创建回答失败") - } - - throw IllegalStateException( - "发布失败: ${response.message ?: "unknown"}\n$responseElement", - ) - } -} - @Serializable data class PatchDraftRequest( val content: String, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.kt index 612b5d486..f9406903f 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.kt @@ -17,208 +17,56 @@ package com.github.zly2006.zhihu.editor -import androidx.compose.runtime.Composable import com.fleeksoft.ksoup.Ksoup -import com.github.zly2006.zhihu.data.DataHolder -import com.github.zly2006.zhihu.data.ZhihuJson -import com.github.zly2006.zhihu.util.raiseForStatus -import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment -import com.github.zly2006.zhihu.viewmodel.postSigned -import io.ktor.client.call.body -import io.ktor.client.request.header -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.contentType -import io.ktor.http.encodeURLParameter import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -/** - * 将“发布知乎想法”封装为平台能力。 - * - * 想法的标题、正文和图片 payload 与回答发布完全不同,不能与回答编辑流程共用一个发布器。 - */ -interface ZhihuPinPublisher { - suspend fun recommendTopics( - query: String, - title: String, - contentHtml: String, - ): List<PinTopicSuggestion> - - /** - * 保存想法草稿。 - * - * 对应端点:POST https://api.zhihu.com/content/drafts,action=pin。 - */ - suspend fun savePinDraft( - title: String, - html: String, - textLength: Int, - images: List<UploadedZhihuImage>, - topics: List<PinContentTopicItem> = emptyList(), - ) - - /** - * 发布一条新的想法。 - * - * 返回值:发布成功后的 pinId。 - * - * 对应端点:POST /api/v4/content/publish,action=pin。 - */ - suspend fun publishPin( - title: String, - html: String, - textLength: Int, - images: List<UploadedZhihuImage>, - topics: List<PinContentTopicItem> = emptyList(), - ): Long -} - -@Composable -expect fun rememberZhihuPinPublisher(): ZhihuPinPublisher - -internal class ZhihuApiPinPublisher( - private val environment: ZhihuApiEnvironment, -) : ZhihuPinPublisher { - override suspend fun recommendTopics( - query: String, - title: String, - contentHtml: String, - ): List<PinTopicSuggestion> { - if (query.isBlank()) return emptyList() - val responseElement = environment - .postSigned( - "https://api.zhihu.com/content/publish/topics/recommend?" + - "recommend_type=pin&key_word=${query.encodeURLParameter(spaceToPlus = true)}", - ) { - contentType(ContentType.Application.Json) - setBody(PinTopicSuggestionRequest(title = title, content = contentHtml)) - }.raiseForStatus(dumpRequest = true) - .body<JsonElement>() - return ZhihuJson.decodeJson<PinTopicSuggestionResponse>(responseElement).data.list - } - - override suspend fun savePinDraft( - title: String, - html: String, - textLength: Int, - images: List<UploadedZhihuImage>, - topics: List<PinContentTopicItem>, - ) { - val xsrf = environment.authenticatedCookies()["_xsrf"] - ?: throw IllegalStateException("缺少 _xsrf Cookie,无法保存想法草稿;请先确保已登录。") - - environment - .postSigned("https://api.zhihu.com/content/drafts") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Referrer, "https://www.zhihu.com/") - header("x-xsrftoken", xsrf) - setBody( - SavePinDraftRequest( - data = buildPinContentPayload( - title = title, - html = html, - textLength = textLength, - images = images, - topics = topics, - ), - ), +internal fun buildPinContentPayload( + title: String, + html: String, + textLength: Int, + images: List<UploadedZhihuImage>, + topics: List<PinContentTopicItem>, +): PinContentPayload = + PinContentPayload( + publish = PublishTrace(traceId = newPublishTraceId()), + title = title + .takeIf { it.isNotBlank() } + ?.let { PinContentTitle(title = it) }, + hybrid = html + .takeIf { it.isNotBlank() } + ?.let { + PinContentHybrid( + html = it, + textLength = textLength, ) - }.raiseForStatus(dumpRequest = true) - } - - override suspend fun publishPin( - title: String, - html: String, - textLength: Int, - images: List<UploadedZhihuImage>, - topics: List<PinContentTopicItem>, - ): Long { - val xsrf = environment.authenticatedCookies()["_xsrf"] - ?: throw IllegalStateException("缺少 _xsrf Cookie,无法发布想法;请先确保已登录。") - - val responseElement = environment - .postSigned("https://www.zhihu.com/api/v4/content/publish") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Referrer, "https://www.zhihu.com/") - header("x-xsrftoken", xsrf) - setBody( - PublishPinRequest( - data = buildPinContentPayload( - title = title, - html = html, - textLength = textLength, - images = images, - topics = topics, - ), - ), + }, + media = images + .takeIf { it.isNotEmpty() } + ?.let { uploadedImages -> + PinContentMedia( + medias = uploadedImages.map { image -> + PinContentMediaItem( + image = PinContentImage( + height = image.rawHeight, + width = image.rawWidth, + url = image.url, + originalUrl = image.originalUrl, + watermark = image.watermarkMode + ?: image.watermark?.let { if (it) "watermark" else "original" }, + watermarkUrl = image.watermarkUrl, + ), + ) + }, ) - }.raiseForStatus(dumpRequest = true) - .body<JsonElement>() - - val response = ZhihuJson.decodeJson(DataHolder.ContentPublishResponse.serializer(), responseElement) - if (response.message == "success") { - val resultText = response.data?.result - ?: throw IllegalStateException("发布成功但返回缺少 data.result: $responseElement") - - return parsePublishContentId(resultText) - ?: throw IllegalStateException("发布成功但无法解析 publish.id") - } - - throw IllegalStateException( - "发布失败: ${response.message ?: "unknown"}\n$responseElement", - ) - } - - private fun buildPinContentPayload( - title: String, - html: String, - textLength: Int, - images: List<UploadedZhihuImage>, - topics: List<PinContentTopicItem>, - ): PinContentPayload = - PinContentPayload( - publish = PublishTrace(traceId = newPublishTraceId()), - title = title - .takeIf { it.isNotBlank() } - ?.let { PinContentTitle(title = it) }, - hybrid = html - .takeIf { it.isNotBlank() } - ?.let { - PinContentHybrid( - html = it, - textLength = textLength, - ) - }, - media = images - .takeIf { it.isNotEmpty() } - ?.let { uploadedImages -> - PinContentMedia( - medias = uploadedImages.map { image -> - PinContentMediaItem( - image = PinContentImage( - height = image.rawHeight, - width = image.rawWidth, - url = image.url, - originalUrl = image.originalUrl, - watermark = image.watermarkMode - ?: image.watermark?.let { if (it) "watermark" else "original" }, - watermarkUrl = image.watermarkUrl, - ), - ) - }, - ) - }, - topic = topics - .takeIf { it.isNotEmpty() } - ?.map { it.copy(topicName = "#${it.displayName}#") } - ?.let(::PinContentTopic), - ) -} + }, + topic = topics + .takeIf { it.isNotEmpty() } + ?.map { it.copy(topicName = "#${it.displayName}#") } + ?.let(::PinContentTopic), + ) internal fun calculatePinHtmlTextLength(html: String): Int = Ksoup diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/AnswerNavigator.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/AnswerNavigator.kt index f9c25e4c6..cc884a29b 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/AnswerNavigator.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/AnswerNavigator.kt @@ -240,15 +240,6 @@ class QuestionAnswerNavigator( initialPreviousAnswers: List<Article> = emptyList(), initialNextUrl: String = "", private val order: String? = null, - private val getAlreadyOpenedAnswerIds: suspend (List<Long>) -> Set<Long> = { answerIds -> - ContentOpenEventSupport - .getAlreadyOpenedContentIds( - database = getContentFilterDatabase(), - content = answerIds.map { ContentType.ANSWER to it.toString() }, - ).mapNotNull { key -> - key.substringAfter(':', "").toLongOrNull() - }.toSet() - }, environment: ZhihuApiEnvironment, ) : AnswerNavigator("此问题", environment) { private val pendingInitialNextAnswers = ArrayDeque<Article>().also { deque -> @@ -367,7 +358,14 @@ class QuestionAnswerNavigator( id !in knownOpenedIds }.toList() if (idsToLookup.isNotEmpty()) { - knownOpenedIds += getAlreadyOpenedAnswerIds(idsToLookup) + knownOpenedIds += openedAnswerIdsReaderForTesting?.invoke(idsToLookup) + ?: ContentOpenEventSupport + .getAlreadyOpenedContentIds( + database = getContentFilterDatabase(), + content = idsToLookup.map { ContentType.ANSWER to it.toString() }, + ).mapNotNull { key -> + key.substringAfter(':', "").toLongOrNull() + }.toSet() } val partition = ContentOpenEventSupport.partitionQuestionAnswerCandidates( candidates = candidates, @@ -453,6 +451,8 @@ class QuestionAnswerNavigator( } } +var openedAnswerIdsReaderForTesting: (suspend (List<Long>) -> Set<Long>)? = null + /** * 从收藏夹中导航回答。 * diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.native.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/LoginNavigation.kt similarity index 63% rename from shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.native.kt rename to shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/LoginNavigation.kt index 94c689062..1ac703a3b 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.native.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/LoginNavigation.kt @@ -15,15 +15,15 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package com.github.zly2006.zhihu.editor +package com.github.zly2006.zhihu.navigation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow -@Composable -actual fun rememberZhihuAnswerPublisher(): ZhihuAnswerPublisher { - val environment = rememberZhihuPublisherEnvironment() - return remember(environment) { - ZhihuApiAnswerPublisher(environment) - } +private val loginNavigationRequests = Channel<Unit>(Channel.UNLIMITED) + +internal val loginNavigationRequestFlow = loginNavigationRequests.receiveAsFlow() + +fun requestLoginNavigation() { + check(loginNavigationRequests.trySend(Unit).isSuccess) { "Login navigation is unavailable" } } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/NavDestination.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/NavDestination.kt index d918870de..1a4fe51df 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/NavDestination.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/navigation/NavDestination.kt @@ -44,6 +44,9 @@ interface TopLevelDestination { @Serializable data object MainTabs : NavDestination +@Serializable +data object Login : NavDestination + /** * 主 pager 的历史顶层 tab 目标。 * diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/platform/PlatformCapabilities.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/platform/PlatformCapabilities.kt index 98bf1c839..9a9bcd8cf 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/platform/PlatformCapabilities.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/platform/PlatformCapabilities.kt @@ -21,15 +21,34 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import kotlinx.io.files.Path +internal expect val platformBottomBarItemLimit: Int? + +expect val platformName: String + +expect val isJvm: Boolean + +expect val isNative: Boolean + +expect val isAigcVoteSupported: Boolean + +expect val isBlocklistNlpSupported: Boolean + +expect val isSentenceSimilaritySupported: Boolean + +expect val isArticleHtmlExportSupported: Boolean + +expect val isArticleImageExportSupported: Boolean + enum class UserMessageDuration { Short, Long, } -data class UserMessageSink( - val showShortMessage: (String) -> Unit, - val showLongMessage: (String) -> Unit = showShortMessage, -) { +interface UserMessageSink { + fun showShortMessage(message: String) + + fun showLongMessage(message: String) = showShortMessage(message) + fun showMessage( message: String, duration: UserMessageDuration = UserMessageDuration.Short, @@ -44,23 +63,70 @@ data class UserMessageSink( @Composable expect fun rememberUserMessageSink(): UserMessageSink -data class SettingsStore( - val getBoolean: (String, Boolean) -> Boolean, - val putBoolean: (String, Boolean) -> Unit, - val getString: (String, String) -> String, - val putString: (String, String) -> Unit, - val getStringOrNull: (String) -> String?, - val putStringSet: (String, Set<String>) -> Unit, - val getStringSet: (String, Set<String>) -> Set<String>, - val getInt: (String, Int) -> Int, - val putInt: (String, Int) -> Unit, - val getLong: (String, Long) -> Long, - val putLong: (String, Long) -> Unit, - val getFloat: (String, Float) -> Float, - val putFloat: (String, Float) -> Unit, - val remove: (String) -> Unit, - val observeKeyChanges: (onChanged: (String) -> Unit) -> () -> Unit = { {} }, -) +interface SettingsStore { + fun getBoolean(key: String, defaultValue: Boolean): Boolean + + fun putBoolean(key: String, value: Boolean) + + fun getString(key: String, defaultValue: String): String + + fun putString(key: String, value: String) + + fun getStringOrNull(key: String): String? + + fun putStringSet(key: String, value: Set<String>) + + fun getStringSet(key: String, defaultValue: Set<String>): Set<String> + + fun getInt(key: String, defaultValue: Int): Int + + fun putInt(key: String, value: Int) + + fun getLong(key: String, defaultValue: Long): Long + + fun putLong(key: String, value: Long) + + fun getFloat(key: String, defaultValue: Float): Float + + fun putFloat(key: String, value: Float) + + fun remove(key: String) + + fun observeKeyChanges(onChanged: (String) -> Unit): AutoCloseable = AutoCloseable { } +} + +interface SystemUrlOpener { + operator fun invoke(url: String) +} + +interface ZhihuWebUrlOpener { + operator fun invoke(url: String) +} + +interface ImagePreviewOpener { + operator fun invoke(url: String) +} + +interface ExternalUrlOpener : + SystemUrlOpener, + ZhihuWebUrlOpener, + ImagePreviewOpener + +interface ImageGalleryOpener { + operator fun invoke(urls: List<String>, initialIndex: Int) +} + +interface ImageSaver { + operator fun invoke(url: String) +} + +interface ImageSharer { + operator fun invoke(url: String) +} + +interface PlainTextClipboard { + operator fun invoke(label: String, text: String) +} @Composable expect fun rememberSettingsStore(): SettingsStore @@ -71,28 +137,28 @@ expect fun Modifier.exportTestTagsForUiAutomation(): Modifier expect fun rememberAppPrivateDirectory(): Path @Composable -expect fun rememberExternalUrlOpener(): (String) -> Unit +expect fun rememberExternalUrlOpener(): ExternalUrlOpener @Composable -expect fun rememberSystemUrlOpener(): (String) -> Unit +expect fun rememberSystemUrlOpener(): SystemUrlOpener @Composable -expect fun rememberZhihuWebUrlOpener(): (String) -> Unit +expect fun rememberZhihuWebUrlOpener(): ZhihuWebUrlOpener @Composable -expect fun rememberImagePreviewOpener(): (String) -> Unit +expect fun rememberImagePreviewOpener(): ImagePreviewOpener @Composable -expect fun rememberImageGalleryOpener(): (List<String>, Int) -> Unit +expect fun rememberImageGalleryOpener(): ImageGalleryOpener @Composable -expect fun rememberImageSaver(): (String) -> Unit +expect fun rememberImageSaver(): ImageSaver @Composable -expect fun rememberImageSharer(): (String) -> Unit +expect fun rememberImageSharer(): ImageSharer @Composable -expect fun rememberPlainTextClipboard(): (label: String, text: String) -> Unit +expect fun rememberPlainTextClipboard(): PlainTextClipboard @Composable expect fun PlatformBackHandler( diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayer.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayer.kt index b9a682b61..38f6233fe 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayer.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayer.kt @@ -271,7 +271,6 @@ data class ReadingStartRequest( interface ReadingPlayerController { val state: State<ReadingPlayerState> - val isSupported: Boolean fun start(request: ReadingStartRequest) @@ -290,7 +289,6 @@ interface ReadingPlayerController { internal object UnsupportedReadingPlayerController : ReadingPlayerController { override val state: State<ReadingPlayerState> = mutableStateOf(ReadingPlayerState()) - override val isSupported: Boolean = false override fun start(request: ReadingStartRequest) = Unit @@ -308,7 +306,13 @@ internal object UnsupportedReadingPlayerController : ReadingPlayerController { } @Composable -expect fun rememberReadingPlayerController(): ReadingPlayerController +fun rememberReadingPlayerController(): ReadingPlayerController = + if (isReadingPlayerSupported) rememberSupportedReadingPlayerController() else UnsupportedReadingPlayerController + +expect val isReadingPlayerSupported: Boolean + +@Composable +internal expect fun rememberSupportedReadingPlayerController(): ReadingPlayerController private val readingPreferencesJson = Json { ignoreUnknownKeys = true diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/AccountSettingScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/AccountSettingScreen.kt index 3083eb82e..545f244a1 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/AccountSettingScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/AccountSettingScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwitchAccount @@ -80,27 +79,29 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.github.zly2006.zhihu.account.rememberZhihuAccountStore import com.github.zly2006.zhihu.navigation.Account import com.github.zly2006.zhihu.navigation.Collections import com.github.zly2006.zhihu.navigation.LocalNavigator import com.github.zly2006.zhihu.navigation.Notification import com.github.zly2006.zhihu.navigation.OnlineHistory import com.github.zly2006.zhihu.navigation.Person +import com.github.zly2006.zhihu.navigation.requestLoginNavigation +import com.github.zly2006.zhihu.platform.platformBottomBarItemLimit import com.github.zly2006.zhihu.platform.rememberPlainTextClipboard import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.platform.rememberSystemUrlOpener import com.github.zly2006.zhihu.platform.rememberUserMessageSink -import com.github.zly2006.zhihu.reading.rememberReadingPlayerController +import com.github.zly2006.zhihu.reading.isReadingPlayerSupported import com.github.zly2006.zhihu.ui.components.SettingItem import com.github.zly2006.zhihu.ui.components.SettingItemGroup import com.github.zly2006.zhihu.ui.subscreens.BOTTOM_BAR_ITEMS_PREFERENCE_KEY import com.github.zly2006.zhihu.ui.subscreens.SystemUpdateState import com.github.zly2006.zhihu.ui.subscreens.defaultBottomBarSelectionKeys import com.github.zly2006.zhihu.ui.subscreens.normalizeBottomBarSelection -import com.github.zly2006.zhihu.ui.subscreens.rememberSystemUpdateRuntime +import com.github.zly2006.zhihu.ui.subscreens.rememberSystemUpdateState import com.github.zly2006.zhihu.ui.subscreens.shouldShowAccountHistoryShortcut import com.github.zly2006.zhihu.util.Log -import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment import kotlinx.coroutines.CancellationException import org.jetbrains.compose.resources.painterResource import zhihu.shared.generated.resources.Res @@ -128,7 +129,7 @@ const val ACCOUNT_SETTINGS_IDENTITY_MANAGEMENT_TAG = "accountSettings.identityMa /** * 账号与设置入口页。 * - * 已登录时顶部展示头像、昵称、扫码登录和退出登录,Duo3 账号入口迁移开启后会额外展示收藏夹、关注订阅、通知和历史等快捷块; + * 已登录时顶部展示头像、昵称、重新登录和退出登录,Duo3 账号入口迁移开启后会额外展示收藏夹、关注订阅、通知和历史等快捷块; * 未登录时只展示登录入口。下方设置区是外观、推荐过滤、系统更新、开发者选项和开源许可的统一入口,其中开发者选项通过连续点击版本号开启。 * * 这个页面既可以作为底部栏 tab 展示,也可以作为主页头像弹出的账号面板内容使用,所以 [innerPadding]、[onDismissRequest] 和 @@ -142,29 +143,29 @@ fun AccountSettingScreen( showUnreadBadge: Boolean = true, onDismissRequest: () -> Unit = {}, refreshAccountProfileOnEnter: Boolean = true, - testAccountData: AccountSettingsAccountState? = null, ) { val navigator = LocalNavigator.current - val environment = rememberPaginationEnvironment(allowGuestAccess = false) + val accountStore = rememberZhihuAccountStore() val accountState = rememberAccountSettingsAccountState() - val requestQrLoginScan = rememberAccountQrLoginRequester() + val requestLogin = ::requestLoginNavigation val settings = rememberSettingsStore() val copyPlainText = rememberPlainTextClipboard() val openSystemUrl = rememberSystemUrlOpener() val userMessages = rememberUserMessageSink() - val updateRuntime = rememberSystemUpdateRuntime() + val systemUpdateState = rememberSystemUpdateState() val versionInfo = rememberAppVersionInfo() - val readingPlayerSupported = rememberReadingPlayerController().isSupported + val readingPlayerSupported = isReadingPlayerSupported val useDuo3HomeAccount = remember { settings.getBoolean("duo3_home_account", false) } val selectedBottomBarItemKeys = remember { normalizeBottomBarSelection( settings.getStringSet( BOTTOM_BAR_ITEMS_PREFERENCE_KEY, - defaultBottomBarSelectionKeys(useDuo3HomeAccount), + defaultBottomBarSelectionKeys(useDuo3HomeAccount, platformBottomBarItemLimit), ), useDuo3HomeAccount, enforceMinimumSelection = true, + maximumSelection = platformBottomBarItemLimit, ) } var isDeveloper by remember { mutableStateOf(settings.getBoolean("developer", false)) } @@ -174,7 +175,7 @@ fun AccountSettingScreen( settings.putBoolean("developer", isDeveloper) } val liveData by accountState - val data = testAccountData ?: liveData + val data = liveData Scaffold( modifier = Modifier.fillMaxSize(), @@ -191,7 +192,7 @@ fun AccountSettingScreen( LaunchedEffect(data.login, refreshAccountProfileOnEnter) { if (refreshAccountProfileOnEnter && data.login) { try { - environment.refreshAccountProfile() + accountStore.client.refreshAndSaveProfile() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -232,19 +233,6 @@ fun AccountSettingScreen( modifier = Modifier.testTag(ACCOUNT_SETTINGS_PROFILE_NAME_TAG), ) Spacer(Modifier.weight(1f)) - FilledTonalIconButton( - onClick = { - requestQrLoginScan() - }, - modifier = Modifier.size(40.dp), - ) { - Icon( - imageVector = Icons.Default.QrCodeScanner, - contentDescription = "扫码登录", - modifier = Modifier.size(24.dp), - ) - } - Spacer(Modifier.width(16.dp)) FilledTonalIconButton( onClick = { showLogoutDialog = true @@ -269,9 +257,7 @@ fun AccountSettingScreen( icon = { Icon(Icons.AutoMirrored.Filled.Login, null) }, modifier = Modifier.testTag(ACCOUNT_SETTINGS_LOGIN_ITEM_TAG), onClick = { - if (!environment.requestLogin()) { - userMessages.showShortMessage("当前平台暂不支持登录") - } + requestLogin() }, ) } @@ -469,7 +455,7 @@ fun AccountSettingScreen( } SettingItemGroup { - if (data.login && data.identityManagementSupported) { + if (data.login) { SettingItem( title = { Text("身份管理") }, description = { Text("创建马甲号或切换当前账号") }, @@ -523,7 +509,7 @@ fun AccountSettingScreen( } } - val updateState by updateRuntime.state.collectAsState() + val updateState by systemUpdateState.collectAsState() LaunchedEffect(updateState) { if (updateState is SystemUpdateState.UpdateAvailable) { val state = updateState as SystemUpdateState.UpdateAvailable @@ -613,11 +599,19 @@ fun AccountSettingScreen( AlertDialog( onDismissRequest = { showLogoutDialog = false }, title = { Text("退出登录") }, - text = { Text("确定要退出登录吗?") }, + text = { + Text( + if (accountStore.accounts.size > 1) { + "确定退出并移除当前登录账号吗?退出后会自动切换到另一个已保存账号。" + } else { + "确定要退出登录吗?" + }, + ) + }, confirmButton = { TextButton( onClick = { - environment.logout() + accountStore.clear() showLogoutDialog = false }, ) { diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ArticleScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ArticleScreen.kt index 9f09e2f72..d88cc69c7 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ArticleScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ArticleScreen.kt @@ -112,6 +112,9 @@ import com.github.zly2006.zhihu.navigation.LocalNavigator import com.github.zly2006.zhihu.navigation.Question import com.github.zly2006.zhihu.navigation.Topic import com.github.zly2006.zhihu.platform.PlatformBackHandler +import com.github.zly2006.zhihu.platform.isArticleHtmlExportSupported +import com.github.zly2006.zhihu.platform.isArticleImageExportSupported +import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.platform.rememberUserMessageSink import com.github.zly2006.zhihu.ui.AnswerDoubleTapAction import com.github.zly2006.zhihu.ui.article.AigcFlagSheet @@ -127,24 +130,29 @@ import com.github.zly2006.zhihu.ui.article.voteUpActiveButtonColors import com.github.zly2006.zhihu.ui.article.voteUpNeutralButtonColors import com.github.zly2006.zhihu.ui.article.voteUpNeutralContent import com.github.zly2006.zhihu.ui.article.voteUpNeutralContentDuo3 +import com.github.zly2006.zhihu.ui.components.ANSWER_SWITCH_SENSITIVITY_PREFERENCE_KEY import com.github.zly2006.zhihu.ui.components.AnswerHorizontalOverscroll import com.github.zly2006.zhihu.ui.components.AnswerVerticalOverscroll import com.github.zly2006.zhihu.ui.components.AuthorBadge import com.github.zly2006.zhihu.ui.components.CollectionDialogComponent import com.github.zly2006.zhihu.ui.components.CommentScreenComponent +import com.github.zly2006.zhihu.ui.components.DEFAULT_ANSWER_SWITCH_SENSITIVITY import com.github.zly2006.zhihu.ui.components.DraggableRefreshButton import com.github.zly2006.zhihu.ui.components.ExportDialogComponent import com.github.zly2006.zhihu.ui.components.MyModalBottomSheet import com.github.zly2006.zhihu.ui.components.VerticalReadingProgressBar import com.github.zly2006.zhihu.ui.components.VotersSheet import com.github.zly2006.zhihu.ui.components.ZhihuTwoRowsTopAppBar +import com.github.zly2006.zhihu.ui.components.normalizedAnswerSwitchSensitivity import com.github.zly2006.zhihu.ui.components.rememberPreferCollapsedExitUntilCollapsedScrollBehavior +import com.github.zly2006.zhihu.ui.subscreens.DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY import com.github.zly2006.zhihu.util.formatCompactCount import com.github.zly2006.zhihu.util.smoothGradient import com.github.zly2006.zhihu.viewmodel.ArticleViewModel import com.github.zly2006.zhihu.viewmodel.addReadHistory import com.github.zly2006.zhihu.viewmodel.formatArticleDateTime import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState import com.materialkolor.ktx.harmonize import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -180,12 +188,46 @@ fun ArticleScreen( val readingPlayerOverlayPadding = LocalReadingPlayerOverlayPadding.current val readingPlayerOverlayOffsetState = LocalReadingPlayerOverlayOffsetState.current val environment = rememberPaginationEnvironment(allowGuestAccess = false) - val articleHost = rememberArticleHost() - val backStackEntry by articleHost?.articleNavController?.currentBackStackEntryAsState() + val articleNavController = LocalArticleNavController.current + val backStackEntry by articleNavController?.currentBackStackEntryAsState() ?: remember { mutableStateOf(null) } val scrollState = rememberScrollState() - val articleSettings = rememberArticleScreenSettingsState() + val settings = rememberSettingsStore() + val isTitleAutoHide by rememberObservedSetting(settings, "titleAutoHide") { getBoolean("titleAutoHide", false) } + val autoHideArticleBottomBar by rememberObservedSetting(settings, "autoHideArticleBottomBar") { + getBoolean("autoHideArticleBottomBar", false) + } + val answerSwitchMode by rememberObservedSetting(settings, "answerSwitchMode") { + getString("answerSwitchMode", "vertical") + } + val answerSwitchSensitivity by rememberObservedSetting(settings, ANSWER_SWITCH_SENSITIVITY_PREFERENCE_KEY) { + normalizedAnswerSwitchSensitivity(getFloat(ANSWER_SWITCH_SENSITIVITY_PREFERENCE_KEY, DEFAULT_ANSWER_SWITCH_SENSITIVITY)) + } + val pinAnswerDate by rememberObservedSetting(settings, "pinAnswerDate") { getBoolean("pinAnswerDate", false) } + val useDuo3ArticleActions by rememberObservedSetting(settings, "duo3_article_actions") { + getBoolean("duo3_article_actions", false) + } + val buttonSkipAnswer by rememberObservedSetting(settings, "buttonSkipAnswer") { getBoolean("buttonSkipAnswer", true) } + val autoHideSkipAnswerButton by rememberObservedSetting(settings, "autoHideSkipAnswerButton") { + getBoolean("autoHideSkipAnswerButton", true) + } + var answerDoubleTapAction by rememberObservedSetting(settings, ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY) { + AnswerDoubleTapAction.fromPreference( + getString(ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY, AnswerDoubleTapAction.Ask.preferenceValue), + ) + } + val useWebView by rememberObservedSetting(settings, ARTICLE_USE_WEBVIEW_PREFERENCE_KEY) { + getBoolean(ARTICLE_USE_WEBVIEW_PREFERENCE_KEY, false) + } + val useTiqianMarkdown by rememberObservedSetting(settings, DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY) { + getBoolean(DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY, false) + } + + fun saveAnswerDoubleTapAction(action: AnswerDoubleTapAction) { + answerDoubleTapAction = action + settings.putString(ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY, action.preferenceValue) + } val userMessages = rememberUserMessageSink() val density = LocalDensity.current val readingPlayerOverlayPaddingPx = with(density) { readingPlayerOverlayPadding.roundToPx() } @@ -209,13 +251,13 @@ fun ArticleScreen( var showVoters by rememberSaveable(article.type, article.id) { mutableStateOf(false) } val topBarState = rememberArticleTopBarState( scrollState = scrollState, - autoHide = articleSettings.isTitleAutoHide, + autoHide = isTitleAutoHide, ) val bottomBarState = rememberArticleBottomBarState( scrollState = scrollState, - autoHide = articleSettings.autoHideArticleBottomBar, + autoHide = autoHideArticleBottomBar, scrollDeltaThreshold = with(density) { ScrollThresholdDp.toPx() }, - showSlot = backStackEntry?.hasRoute(Article::class) == true || articleHost == null, + showSlot = backStackEntry?.hasRoute(Article::class) == true || articleNavController == null, navigationBarHeightPx = density.run { WindowInsets.navigationBars .asPaddingValues() @@ -224,11 +266,7 @@ fun ArticleScreen( .coerceAtLeast(0f) }, ) - val sharedData = if (article.type == ArticleType.Answer) { - environment.articleAnswerSwitchState() - } else { - null - } + val sharedData = sharedArticleAnswerSwitchState.takeIf { article.type == ArticleType.Answer } var isImmersiveMode by remember(sharedData) { mutableStateOf(sharedData?.isImmersiveMode ?: false) } @@ -236,26 +274,28 @@ fun ArticleScreen( switchState = sharedData, viewModel = viewModel, navigator = navigator, - navController = articleHost?.articleNavController, - answerSwitchMode = articleSettings.answerSwitchMode, + navController = articleNavController, + answerSwitchMode = answerSwitchMode, readingQueueSourceId = article.readingQueueSourceId, ) val hapticFeedback = LocalHapticFeedback.current - val readingPlayerOverlayOwner = remember(article.type, article.id) { Any() } - val usesVerticalAnswerSwitch = article.type == ArticleType.Answer && articleSettings.answerSwitchMode == "vertical" - DisposableEffect(readingPlayerOverlayOffsetState, readingPlayerOverlayOwner, usesVerticalAnswerSwitch) { + val readingPlayerOverlayRouteId = articleNavController?.currentBackStackEntry?.id + ?: article.readingQueueSourceId + ?: "${article.type}:${article.id}" + val usesVerticalAnswerSwitch = article.type == ArticleType.Answer && answerSwitchMode == "vertical" + DisposableEffect(readingPlayerOverlayOffsetState, readingPlayerOverlayRouteId, usesVerticalAnswerSwitch) { if (usesVerticalAnswerSwitch) { - readingPlayerOverlayOffsetState?.activate(readingPlayerOverlayOwner) + readingPlayerOverlayOffsetState?.beginRoute(readingPlayerOverlayRouteId) } onDispose { if (usesVerticalAnswerSwitch) { - readingPlayerOverlayOffsetState?.deactivate(readingPlayerOverlayOwner) + readingPlayerOverlayOffsetState?.endRoute(readingPlayerOverlayRouteId) } } } - val updateReadingPlayerOverlayOffset = remember(readingPlayerOverlayOffsetState, readingPlayerOverlayOwner) { + val updateReadingPlayerOverlayOffset = remember(readingPlayerOverlayOffsetState, readingPlayerOverlayRouteId) { { offsetPx: Float -> - readingPlayerOverlayOffsetState?.update(readingPlayerOverlayOwner, offsetPx) + readingPlayerOverlayOffsetState?.update(readingPlayerOverlayRouteId, offsetPx) Unit } } @@ -296,14 +336,14 @@ fun ArticleScreen( fun handleAnswerDoubleTap() { if (article.type != ArticleType.Answer) return - performAnswerDoubleTapAction(articleSettings.answerDoubleTapAction) + performAnswerDoubleTapAction(answerDoubleTapAction) } val answerDoubleTapModifier = if ( article.type == ArticleType.Answer && - articleSettings.answerDoubleTapAction != AnswerDoubleTapAction.None + answerDoubleTapAction != AnswerDoubleTapAction.None ) { - Modifier.pointerInput(articleSettings.answerDoubleTapAction) { + Modifier.pointerInput(answerDoubleTapAction) { detectTapGestures( onDoubleTap = { handleAnswerDoubleTap() }, ) @@ -386,8 +426,8 @@ fun ArticleScreen( navigationIcon = { IconButton( onClick = { - if (articleHost != null) { - articleHost.articleNavController.popBackStack() + if (articleNavController != null) { + articleNavController.popBackStack() } else { navigator.onNavigateBack() } @@ -401,7 +441,7 @@ fun ArticleScreen( } }, actions = { - if (articleSettings.useDuo3ArticleActions) { + if (useDuo3ArticleActions) { IconButton( onClick = { showActionsMenu = true }, ) { @@ -516,7 +556,7 @@ fun ArticleScreen( // 操作栏内容的共享组合,按 useDuo3ArticleActions 切换两套视觉。 @Composable fun ActionBarContent() { - if (!articleSettings.useDuo3ArticleActions) { + if (!useDuo3ArticleActions) { // ── 主视觉:按钮式投票与操作区 ──────────────────────── Row( modifier = Modifier @@ -755,14 +795,15 @@ fun ArticleScreen( ) } - val ttsState = articleHost?.articleTtsState - AnimatedVisibility(visible = ttsState?.isSpeaking == true) { + val ttsState = rememberArticleTtsState() + val toggleArticleSpeech = rememberArticleSpeechToggler() + AnimatedVisibility(visible = ttsState.isSpeaking) { IconButton( onClick = { - articleHost?.stopArticleSpeaking() + toggleArticleSpeech("", "") userMessages.showMessage("已停止朗读") }, - enabled = ttsState !in listOf(TtsState.Error, TtsState.Uninitialized, TtsState.Initializing, null), + enabled = ttsState !in listOf(TtsState.Error, TtsState.Uninitialized, TtsState.Initializing), colors = IconButtonDefaults.iconButtonColors( containerColor = Color(0xFF4CAF50).harmonize(MaterialTheme.colorScheme.primary), contentColor = Color.White, @@ -938,7 +979,7 @@ fun ArticleScreen( } Spacer(Modifier.height(12.dp)) } - val hasPinnedDate = articleSettings.pinAnswerDate + val hasPinnedDate = pinAnswerDate val hasSocialCredit = viewModel.votersTotal > 0 || viewModel.aigcSupportVoterCount > 0 val endorsements = viewModel.endorsements val hasEndorsements = endorsements.isNotEmpty() @@ -967,7 +1008,7 @@ fun ArticleScreen( } Spacer(modifier = Modifier.height(16.dp)) } - if (articleSettings.useWebView) { + if (useWebView && isLegacyWebViewSupported) { // WebView 正文渲染已经废弃,只保留为紧急回退路径;正文外 UI 不再为它单独分支。 ArticleWebViewContent( article = article, @@ -984,7 +1025,7 @@ fun ArticleScreen( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, ) { - if (!articleSettings.pinAnswerDate) { + if (!pinAnswerDate) { DateTexts() } if (viewModel.ipInfo != null) { @@ -1005,7 +1046,7 @@ fun ArticleScreen( scrollState = scrollState, selectable = true, enableScroll = false, - useTiqianRenderer = articleSettings.useTiqianMarkdown, + useTiqianRenderer = useTiqianMarkdown, header = {}, footer = { ArticleVideoAttachmentContent(viewModel.attachment) @@ -1013,7 +1054,7 @@ fun ArticleScreen( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, ) { - if (!articleSettings.pinAnswerDate) { + if (!pinAnswerDate) { DateTexts() } if (viewModel.ipInfo != null) { @@ -1057,7 +1098,7 @@ fun ArticleScreen( .then(answerDoubleTapModifier), ) { // 根据模式渲染 - if (article.type == ArticleType.Answer && articleSettings.answerSwitchMode == "vertical") { + if (article.type == ArticleType.Answer && answerSwitchMode == "vertical") { AnswerVerticalOverscroll( previousAnswer = nav?.previousAnswer, nextAnswer = nav?.nextAnswer, @@ -1066,24 +1107,24 @@ fun ArticleScreen( isAtTop = { scrollState.value == 0 }, isAtBottom = { scrollState.value >= effectiveScrollMaxValue }, scrollState = scrollState, - answerSwitchSensitivity = articleSettings.answerSwitchSensitivity, + answerSwitchSensitivity = answerSwitchSensitivity, onOverscrollOffsetChange = updateReadingPlayerOverlayOffset, ) { MainContent() } - } else if (article.type == ArticleType.Answer && articleSettings.answerSwitchMode == "horizontal") { + } else if (article.type == ArticleType.Answer && answerSwitchMode == "horizontal") { AnswerHorizontalOverscroll( canGoPrevious = nav?.previousAnswer != null, canGoNext = nav?.nextAnswer != null, onNavigatePrevious = answerNavigationState::navigateToPrevious, onNavigateNext = answerNavigationState::navigateToNext, previousContent = nav?.previousAnswer?.let { cached -> - { CachedAnswerPreview(cached, articleSettings.useTiqianMarkdown) } + { CachedAnswerPreview(cached, useTiqianMarkdown) } }, nextContent = nav?.nextAnswer?.let { cached -> - { CachedAnswerPreview(cached, articleSettings.useTiqianMarkdown) } + { CachedAnswerPreview(cached, useTiqianMarkdown) } }, - answerSwitchSensitivity = articleSettings.answerSwitchSensitivity, + answerSwitchSensitivity = answerSwitchSensitivity, ) { MainContent() } @@ -1103,11 +1144,11 @@ fun ArticleScreen( ) // 跳转按钮需要压在问题区和回答区之上。 - if (article.type == ArticleType.Answer && articleSettings.buttonSkipAnswer && !isImmersiveMode) { + if (article.type == ArticleType.Answer && buttonSkipAnswer && !isImmersiveMode) { val isAtTop by remember(scrollState) { derivedStateOf { scrollState.value == 0 } } - val showSkipButton = !articleSettings.autoHideSkipAnswerButton || bottomBarState.isScrollingUp || isAtTop + val showSkipButton = !autoHideSkipAnswerButton || bottomBarState.isScrollingUp || isAtTop val skipButtonAlpha by animateFloatAsState( targetValue = if (showSkipButton) 1f else 0f, animationSpec = tween(200), @@ -1256,7 +1297,7 @@ fun ArticleScreen( Button( onClick = { showDoubleTapActionDialog = false - articleSettings.saveAnswerDoubleTapAction(AnswerDoubleTapAction.None) + saveAnswerDoubleTapAction(AnswerDoubleTapAction.None) userMessages.showMessage("已将双击回答动作设为:${AnswerDoubleTapAction.None.label}") }, modifier = Modifier.fillMaxWidth(), @@ -1266,7 +1307,7 @@ fun ArticleScreen( Button( onClick = { showDoubleTapActionDialog = false - articleSettings.saveAnswerDoubleTapAction(AnswerDoubleTapAction.VoteUp) + saveAnswerDoubleTapAction(AnswerDoubleTapAction.VoteUp) upVoteFromDoubleTap() userMessages.showMessage("已将双击回答动作设为:${AnswerDoubleTapAction.VoteUp.label}") }, @@ -1277,7 +1318,7 @@ fun ArticleScreen( Button( onClick = { showDoubleTapActionDialog = false - articleSettings.saveAnswerDoubleTapAction(AnswerDoubleTapAction.OpenComments) + saveAnswerDoubleTapAction(AnswerDoubleTapAction.OpenComments) showComments = true userMessages.showMessage("已将双击回答动作设为:${AnswerDoubleTapAction.OpenComments.label}") }, @@ -1288,7 +1329,7 @@ fun ArticleScreen( Button( onClick = { showDoubleTapActionDialog = false - articleSettings.saveAnswerDoubleTapAction(AnswerDoubleTapAction.ToggleImmersive) + saveAnswerDoubleTapAction(AnswerDoubleTapAction.ToggleImmersive) isImmersiveMode = !isImmersiveMode userMessages.showMessage("已将双击回答动作设为:${AnswerDoubleTapAction.ToggleImmersive.label}") }, @@ -1302,6 +1343,8 @@ fun ArticleScreen( // 导出对话框 ExportDialogComponent( showDialog = showExportDialog, + isHtmlExportSupported = isArticleHtmlExportSupported, + isImageExportSupported = isArticleImageExportSupported, onDismiss = { showExportDialog = false }, onExportHtml = { includeAppAttribution, onComplete -> viewModel.exportToHtml(environment, includeAppAttribution, onComplete) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/BlocklistSettingsScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/BlocklistSettingsScreen.kt index 6d87e3957..551e0d91f 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/BlocklistSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/BlocklistSettingsScreen.kt @@ -69,6 +69,9 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.github.zly2006.zhihu.navigation.LocalNavigator import com.github.zly2006.zhihu.navigation.Person +import com.github.zly2006.zhihu.platform.isBlocklistNlpSupported +import com.github.zly2006.zhihu.platform.platformName +import com.github.zly2006.zhihu.platform.rememberIsLiteVariant import com.github.zly2006.zhihu.platform.rememberUserMessageSink import com.github.zly2006.zhihu.util.Log import com.github.zly2006.zhihu.viewmodel.filter.BlockedKeyword @@ -80,8 +83,6 @@ import com.github.zly2006.zhihu.viewmodel.filter.KeywordType import com.github.zly2006.zhihu.viewmodel.filter.getContentFilterDatabase import kotlinx.coroutines.launch -typealias BlocklistSettingsNlpContent = @Composable (onNavigateBack: () -> Unit) -> Unit - object BlocklistSettingsTestTags { const val ROOT = "blocklistSettings:root" const val STATS_CARD = "blocklistSettings:statsCard" @@ -114,49 +115,37 @@ object BlocklistSettingsTestTags { const val TOPIC_DIALOG_DISMISS = "blocklistSettings:topicDialog:dismiss" } -data class BlocklistSettingsTestConfig( - val blockedKeywords: List<BlockedKeyword> = emptyList(), - val blockedUsers: List<BlockedUser> = emptyList(), - val blockedQuestionAuthors: List<BlockedQuestionAuthor> = emptyList(), - val blockedTopics: List<BlockedTopic> = emptyList(), - val stats: BlocklistStats? = null, - val onImportRequested: (() -> Unit)? = null, - val onExportRequested: (() -> Unit)? = null, - val onAddKeyword: ((String, Boolean, Boolean) -> Unit)? = null, - val onDeleteKeyword: ((BlockedKeyword) -> Unit)? = null, - val onClearKeywords: (() -> Unit)? = null, - val onAddUser: ((String, String) -> Unit)? = null, - val onDeleteUser: ((BlockedUser) -> Unit)? = null, - val onClearUsers: (() -> Unit)? = null, - val onAddQuestionAuthor: ((String, String) -> Unit)? = null, - val onDeleteQuestionAuthor: ((BlockedQuestionAuthor) -> Unit)? = null, - val onClearQuestionAuthors: (() -> Unit)? = null, - val onAddTopic: ((String, String) -> Unit)? = null, - val onDeleteTopic: ((BlockedTopic) -> Unit)? = null, - val onClearTopics: (() -> Unit)? = null, - val nlpContent: BlocklistSettingsNlpContent? = null, -) - /** * 屏蔽列表管理页。 * - * 页面用 tab 管理关键词、NLP 智能屏蔽短语、用户和主题四类规则,并展示统计、添加、删除和清空操作。Lite variant 可能没有 - * NLP 内容区,因此 [nlpContent] 需要作为可空插槽传入;新增屏蔽类型时要同步数据管理、设置页入口和 Feed 卡片菜单。 + * 页面用 tab 管理关键词、NLP 智能屏蔽短语、用户和主题四类规则,并展示统计、添加、删除和清空操作。新增屏蔽类型时要同步 + * 数据管理、设置页入口和 Feed 卡片菜单。 */ @Composable fun BlocklistSettingsScreen( - nlpContent: BlocklistSettingsNlpContent? = null, - testConfig: BlocklistSettingsTestConfig? = null, + nlpContent: @Composable (onNavigateBack: () -> Unit) -> Unit = { + error("$platformName 暂不支持 NLP 智能屏蔽设置") + }, ) { val navigator = LocalNavigator.current val userMessages = rememberUserMessageSink() - val requestImport = rememberBlocklistRuleImporter(userMessages) val exportRules = rememberBlocklistRuleExporter() val database = remember { getContentFilterDatabase() } val coroutineScope = rememberCoroutineScope() + val nlpSupported = isBlocklistNlpSupported && !rememberIsLiteVariant() var selectedTab by remember { mutableIntStateOf(0) } - val tabs = listOf("屏蔽关键词", "NLP智能屏蔽", "屏蔽用户", "屏蔽提问者", "屏蔽主题") + val tabs = buildList { + add("屏蔽关键词") + if (nlpSupported) add("NLP智能屏蔽") + add("屏蔽用户") + add("屏蔽提问者") + add("屏蔽主题") + } + val nlpTab = if (nlpSupported) 1 else -1 + val blockedUsersTab = if (nlpSupported) 2 else 1 + val blockedQuestionAuthorsTab = blockedUsersTab + 1 + val blockedTopicsTab = blockedQuestionAuthorsTab + 1 var loadedBlockedKeywords by remember { mutableStateOf<List<BlockedKeyword>>(emptyList()) } var loadedBlockedUsers by remember { mutableStateOf<List<BlockedUser>>(emptyList()) } @@ -164,11 +153,11 @@ fun BlocklistSettingsScreen( var loadedBlockedTopics by remember { mutableStateOf<List<BlockedTopic>>(emptyList()) } var loadedStats by remember { mutableStateOf<BlocklistStats?>(null) } - val blockedKeywords = testConfig?.blockedKeywords ?: loadedBlockedKeywords - val blockedUsers = testConfig?.blockedUsers ?: loadedBlockedUsers - val blockedQuestionAuthors = testConfig?.blockedQuestionAuthors ?: loadedBlockedQuestionAuthors - val blockedTopics = testConfig?.blockedTopics ?: loadedBlockedTopics - val stats = testConfig?.stats ?: loadedStats + val blockedKeywords = loadedBlockedKeywords + val blockedUsers = loadedBlockedUsers + val blockedQuestionAuthors = loadedBlockedQuestionAuthors + val blockedTopics = loadedBlockedTopics + val stats = loadedStats var showAddKeywordDialog by remember { mutableStateOf(false) } var showAddUserDialog by remember { mutableStateOf(false) } @@ -200,25 +189,33 @@ fun BlocklistSettingsScreen( } } - LaunchedEffect(testConfig) { - if (testConfig == null) { - loadData() - } + val requestImport = rememberBlocklistRuleImporter(userMessages) { summary -> + userMessages.showLongMessage("导入成功:$summary") + loadData() + } + + LaunchedEffect(Unit) { + loadData() } Scaffold( modifier = Modifier.fillMaxSize(), floatingActionButton = { // 只在传统关键词、用户屏蔽、提问者屏蔽和主题屏蔽标签页显示添加按钮 - if (selectedTab == 0 || selectedTab == 2 || selectedTab == 3 || selectedTab == 4) { + if ( + selectedTab == 0 || + selectedTab == blockedUsersTab || + selectedTab == blockedQuestionAuthorsTab || + selectedTab == blockedTopicsTab + ) { FloatingActionButton( modifier = Modifier.testTag(BlocklistSettingsTestTags.FAB), onClick = { when (selectedTab) { 0 -> showAddKeywordDialog = true - 2 -> showAddUserDialog = true - 3 -> showAddQuestionAuthorDialog = true - 4 -> showAddTopicDialog = true + blockedUsersTab -> showAddUserDialog = true + blockedQuestionAuthorsTab -> showAddQuestionAuthorDialog = true + blockedTopicsTab -> showAddTopicDialog = true } }, ) { @@ -315,34 +312,19 @@ fun BlocklistSettingsScreen( ) { TextButton( modifier = Modifier.testTag(BlocklistSettingsTestTags.IMPORT_BUTTON), - onClick = { - val importAction = testConfig?.onImportRequested - if (importAction != null) { - importAction() - } else { - requestImport { summary -> - userMessages.showLongMessage("导入成功:$summary") - loadData() - } - } - }, + onClick = { requestImport() }, ) { Text("导入规则") } TextButton( modifier = Modifier.testTag(BlocklistSettingsTestTags.EXPORT_BUTTON), onClick = { - val exportAction = testConfig?.onExportRequested - if (exportAction != null) { - exportAction() - } else { - coroutineScope.launch { - try { - userMessages.showLongMessage(exportRules()) - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("导出失败: ${e.message}") - } + coroutineScope.launch { + try { + userMessages.showLongMessage(exportRules()) + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("导出失败: ${e.message}") } } }, @@ -371,49 +353,36 @@ fun BlocklistSettingsScreen( 0 -> BlockedKeywordsList( keywords = blockedKeywords, onDeleteKeyword = { keyword -> - val onDeleteKeyword = testConfig?.onDeleteKeyword - if (onDeleteKeyword != null) { - onDeleteKeyword(keyword) - } else { - coroutineScope.launch { - try { - database.blockedKeywordDao().deleteKeywordById(keyword.id) - userMessages.showShortMessage("已删除关键词") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("删除失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedKeywordDao().deleteKeywordById(keyword.id) + userMessages.showShortMessage("已删除关键词") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("删除失败: ${e.message}") } } }, onClearAll = { - val onClearKeywords = testConfig?.onClearKeywords - if (onClearKeywords != null) { - onClearKeywords() - } else { - coroutineScope.launch { - try { - database.blockedKeywordDao().clearAllKeywords() - userMessages.showShortMessage("已清空所有关键词") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("清空失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedKeywordDao().clearAllKeywords() + userMessages.showShortMessage("已清空所有关键词") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("清空失败: ${e.message}") } } }, ) - 1 -> { - val actualNlpContent = testConfig?.nlpContent ?: nlpContent - if (actualNlpContent != null) { - actualNlpContent(navigator.onNavigateBack) - } else { - Text("AI features are not available on this platform.") - } + nlpTab -> if (nlpSupported) { + nlpContent(navigator.onNavigateBack) + } else { + error("$platformName 暂不支持 NLP 智能屏蔽设置") } - 2 -> BlockedPeopleList( + blockedUsersTab -> BlockedPeopleList( users = blockedUsers, category = "users", emptyText = "暂无屏蔽用户", @@ -421,36 +390,26 @@ fun BlocklistSettingsScreen( userName = { it.userName }, avatarUrl = { it.avatarUrl }, onDelete = { user -> - val onDeleteUser = testConfig?.onDeleteUser - if (onDeleteUser != null) { - onDeleteUser(user) - } else { - coroutineScope.launch { - try { - database.blockedUserDao().deleteUserById(user.userId) - userMessages.showShortMessage("已删除用户") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("删除失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedUserDao().deleteUserById(user.userId) + userMessages.showShortMessage("已删除用户") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("删除失败: ${e.message}") } } }, onClearAll = { - val onClearUsers = testConfig?.onClearUsers - if (onClearUsers != null) { - onClearUsers() - } else { - coroutineScope.launch { - try { - database.blockedUserDao().clearAllUsers() - userMessages.showShortMessage("已清空所有用户") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("清空失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedUserDao().clearAllUsers() + userMessages.showShortMessage("已清空所有用户") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("清空失败: ${e.message}") } } }, @@ -464,7 +423,7 @@ fun BlocklistSettingsScreen( ) }, ) - 3 -> BlockedPeopleList( + blockedQuestionAuthorsTab -> BlockedPeopleList( users = blockedQuestionAuthors, category = "questionAuthors", emptyText = "暂无屏蔽提问者", @@ -472,36 +431,26 @@ fun BlocklistSettingsScreen( userName = { it.userName }, avatarUrl = { it.avatarUrl }, onDelete = { user -> - val onDeleteUser = testConfig?.onDeleteQuestionAuthor - if (onDeleteUser != null) { - onDeleteUser(user) - } else { - coroutineScope.launch { - try { - database.blockedQuestionAuthorDao().deleteUserById(user.userId) - userMessages.showShortMessage("已删除提问者") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("删除失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedQuestionAuthorDao().deleteUserById(user.userId) + userMessages.showShortMessage("已删除提问者") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("删除失败: ${e.message}") } } }, onClearAll = { - val onClearUsers = testConfig?.onClearQuestionAuthors - if (onClearUsers != null) { - onClearUsers() - } else { - coroutineScope.launch { - try { - database.blockedQuestionAuthorDao().clearAllUsers() - userMessages.showShortMessage("已清空所有屏蔽提问者") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("清空失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedQuestionAuthorDao().clearAllUsers() + userMessages.showShortMessage("已清空所有屏蔽提问者") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("清空失败: ${e.message}") } } }, @@ -515,39 +464,29 @@ fun BlocklistSettingsScreen( ) }, ) - 4 -> BlockedTopicsList( + blockedTopicsTab -> BlockedTopicsList( topics = blockedTopics, onDeleteTopic = { topic -> - val onDeleteTopic = testConfig?.onDeleteTopic - if (onDeleteTopic != null) { - onDeleteTopic(topic) - } else { - coroutineScope.launch { - try { - database.blockedTopicDao().deleteTopicById(topic.topicId) - userMessages.showShortMessage("已删除主题") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("删除失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedTopicDao().deleteTopicById(topic.topicId) + userMessages.showShortMessage("已删除主题") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("删除失败: ${e.message}") } } }, onClearAll = { - val onClearTopics = testConfig?.onClearTopics - if (onClearTopics != null) { - onClearTopics() - } else { - coroutineScope.launch { - try { - database.blockedTopicDao().clearAllTopics() - userMessages.showShortMessage("已清空所有主题") - loadData() - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("清空失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedTopicDao().clearAllTopics() + userMessages.showShortMessage("已清空所有主题") + loadData() + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("清空失败: ${e.message}") } } }, @@ -561,28 +500,22 @@ fun BlocklistSettingsScreen( AddKeywordDialog( onDismiss = { showAddKeywordDialog = false }, onConfirm = { keyword, caseSensitive, isRegex -> - val onAddKeyword = testConfig?.onAddKeyword - if (onAddKeyword != null) { - onAddKeyword(keyword, caseSensitive, isRegex) - showAddKeywordDialog = false - } else { - coroutineScope.launch { - try { - database.blockedKeywordDao().insertKeyword( - BlockedKeyword( - keyword = keyword.trim(), - keywordType = KeywordType.EXACT_MATCH.name, - caseSensitive = caseSensitive, - isRegex = isRegex, - ), - ) - userMessages.showShortMessage("已添加关键词") - loadData() - showAddKeywordDialog = false - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("添加失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedKeywordDao().insertKeyword( + BlockedKeyword( + keyword = keyword.trim(), + keywordType = KeywordType.EXACT_MATCH.name, + caseSensitive = caseSensitive, + isRegex = isRegex, + ), + ) + userMessages.showShortMessage("已添加关键词") + loadData() + showAddKeywordDialog = false + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("添加失败: ${e.message}") } } }, @@ -594,21 +527,15 @@ fun BlocklistSettingsScreen( AddTopicDialog( onDismiss = { showAddTopicDialog = false }, onConfirm = { topicId, topicName -> - val onAddTopic = testConfig?.onAddTopic - if (onAddTopic != null) { - onAddTopic(topicId, topicName) - showAddTopicDialog = false - } else { - coroutineScope.launch { - try { - database.blockedTopicDao().insertTopic(BlockedTopic(topicId = topicId, topicName = topicName)) - userMessages.showShortMessage("已添加主题") - loadData() - showAddTopicDialog = false - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("添加失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedTopicDao().insertTopic(BlockedTopic(topicId = topicId, topicName = topicName)) + userMessages.showShortMessage("已添加主题") + loadData() + showAddTopicDialog = false + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("添加失败: ${e.message}") } } }, @@ -620,21 +547,15 @@ fun BlocklistSettingsScreen( AddUserDialog( onDismiss = { showAddUserDialog = false }, onConfirm = { userId, userName -> - val onAddUser = testConfig?.onAddUser - if (onAddUser != null) { - onAddUser(userId, userName) - showAddUserDialog = false - } else { - coroutineScope.launch { - try { - database.blockedUserDao().insertUser(BlockedUser(userId = userId, userName = userName)) - userMessages.showShortMessage("已添加用户") - loadData() - showAddUserDialog = false - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("添加失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedUserDao().insertUser(BlockedUser(userId = userId, userName = userName)) + userMessages.showShortMessage("已添加用户") + loadData() + showAddUserDialog = false + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("添加失败: ${e.message}") } } }, @@ -647,23 +568,17 @@ fun BlocklistSettingsScreen( hint = "添加后会屏蔽该用户提出的问题,不影响该用户回答别人问题的内容", onDismiss = { showAddQuestionAuthorDialog = false }, onConfirm = { userId, userName -> - val onAddUser = testConfig?.onAddQuestionAuthor - if (onAddUser != null) { - onAddUser(userId, userName) - showAddQuestionAuthorDialog = false - } else { - coroutineScope.launch { - try { - database.blockedQuestionAuthorDao().insertUser( - BlockedQuestionAuthor(userId = userId, userName = userName), - ) - userMessages.showShortMessage("已添加屏蔽提问者") - loadData() - showAddQuestionAuthorDialog = false - } catch (e: Exception) { - Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) - userMessages.showShortMessage("添加失败: ${e.message}") - } + coroutineScope.launch { + try { + database.blockedQuestionAuthorDao().insertUser( + BlockedQuestionAuthor(userId = userId, userName = userName), + ) + userMessages.showShortMessage("已添加屏蔽提问者") + loadData() + showAddQuestionAuthorDialog = false + } catch (e: Exception) { + Log.e("BlocklistSettingsScreen", "Blocklist settings action failed", e) + userMessages.showShortMessage("添加失败: ${e.message}") } } }, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseScreen.kt index 70eb1eda4..769963133 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseScreen.kt @@ -81,7 +81,6 @@ import kotlin.random.Random * * 默认选中策略见 [pickDefaultCollectionId](优先默认收藏夹,否则第一个)。 * - * @param testCollections 测试注入的收藏夹列表;非 null 时跳过收藏夹列表的网络拉取,便于仪器测试。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -91,16 +90,15 @@ fun CollectionBrowseScreen( showBackButton: Boolean = true, scrollToTopTrigger: Int = 0, isActive: Boolean = true, - testCollections: List<Collection>? = null, ) { val navigator = LocalNavigator.current val environment = rememberPaginationEnvironment(allowGuestAccess = false) val contentEnvironment = environment as CollectionContentEnvironment - val useTestCollections = testCollections != null || urlToken == null + val useLocalCollections = urlToken == null val collectionsViewModel: CollectionsViewModel = viewModel(key = urlToken) { CollectionsViewModel(urlToken.orEmpty()) } - val collections = testCollections ?: collectionsViewModel.allData + val collections = collectionsViewModel.allData val userMessages = rememberUserMessageSink() val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -116,7 +114,7 @@ fun CollectionBrowseScreen( if ( shouldRefreshCollectionDataOnActivation( isActive = isActive, - useTestCollections = useTestCollections, + useLocalCollections = useLocalCollections, refreshOnNextActivation = refreshCollectionsOnNextActivation, ) ) { @@ -127,7 +125,7 @@ fun CollectionBrowseScreen( LaunchedEffect(collections.map { it.id }, collectionsViewModel.isLoading) { if (collections.isNotEmpty() && collections.none { it.id == selectedCollectionId }) { selectedCollectionId = pickDefaultCollectionId(collections) - } else if (collections.isEmpty() && (useTestCollections || collectionsViewModel.isEnd)) { + } else if (collections.isEmpty() && (useLocalCollections || collectionsViewModel.isEnd)) { selectedCollectionId = null } } @@ -160,7 +158,7 @@ fun CollectionBrowseScreen( items } LaunchedEffect(contentViewModel, isActive, randomMode, randomSeed, selectedCollectionItemCount) { - if (isActive && !useTestCollections && contentViewModel != null) { + if (isActive && !useLocalCollections && contentViewModel != null) { if (randomMode) { contentViewModel.refreshRandom( environment = contentEnvironment, @@ -180,7 +178,7 @@ fun CollectionBrowseScreen( ) ) { TopLevelReselectAction.Refresh -> { - if (!useTestCollections) { + if (!useLocalCollections) { collectionsViewModel.refresh(environment) if (randomMode) { randomSeed = Random.nextInt() @@ -301,7 +299,7 @@ fun CollectionBrowseScreen( }, ) { innerPadding -> when { - collections.isEmpty() && (useTestCollections || collectionsViewModel.isEnd) -> { + collections.isEmpty() && (useLocalCollections || collectionsViewModel.isEnd) -> { Box( modifier = Modifier .fillMaxSize() @@ -325,7 +323,7 @@ fun CollectionBrowseScreen( PullToRefreshBox( isRefreshing = collectionsViewModel.isLoading || contentViewModel.isLoading, onRefresh = { - if (!useTestCollections) { + if (!useLocalCollections) { collectionsViewModel.refresh(environment) if (randomMode) { randomSeed = Random.nextInt() @@ -442,9 +440,9 @@ internal fun pickDefaultCollectionId(collections: List<Collection>): String? = internal fun shouldRefreshCollectionDataOnActivation( isActive: Boolean, - useTestCollections: Boolean, + useLocalCollections: Boolean, refreshOnNextActivation: Boolean = true, -): Boolean = isActive && !useTestCollections && refreshOnNextActivation +): Boolean = isActive && !useLocalCollections && refreshOnNextActivation internal fun orderCollectionItems( items: List<FeedDisplayItem>, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionContentScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionContentScreen.kt index 94eaa9c92..8bf895a00 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionContentScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CollectionContentScreen.kt @@ -71,6 +71,7 @@ import com.github.zly2006.zhihu.viewmodel.CollectionContentViewModel import com.github.zly2006.zhihu.viewmodel.CollectionHtmlExportDialogState import com.github.zly2006.zhihu.viewmodel.formatArticleDateTime import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -187,7 +188,7 @@ internal fun CollectionContentBody( displayItems: List<FeedDisplayItem> = viewModel.displayItems, ) { val navigator = LocalNavigator.current - val sharedData = environment.articleAnswerSwitchState() + val sharedData = sharedArticleAnswerSwitchState val readingQueueSourceId = "collection:$collectionId:contents" RegisterReadingQueueSource( sourceId = readingQueueSourceId, @@ -232,7 +233,7 @@ internal fun CollectionContentBody( .padding(vertical = 8.dp) .testTag("${tagPrefix}_item_${item.stableKey}"), ) { _, destination -> - if (destination is Article && destination.type == ArticleType.Answer && sharedData != null) { + if (destination is Article && destination.type == ArticleType.Answer) { val index = displayItems.indexOf(item) val nextItems = if (index >= 0) visibleCollectionItems.drop(index + 1) else emptyList() val previousItems = if (index > 0) visibleCollectionItems.take(index).reversed() else emptyList() diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CommentScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CommentScreen.kt index 823f376d0..48822e922 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CommentScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/CommentScreen.kt @@ -201,13 +201,6 @@ enum class CommentImageMenuAction { Share, } -data class CommentScreenTestOverrides( - val viewModel: BaseCommentViewModel? = null, - val onArchiveComment: ((CommentModel) -> Unit)? = null, - val onImageMenuAction: ((CommentImageMenuAction, String) -> Unit)? = null, - val commentEmojis: List<CommentEmoji>? = null, -) - @Composable fun SwipeToReplyContainer( modifier: Modifier = Modifier, @@ -447,7 +440,6 @@ fun CommentScreen( commentInput: String, onCommentInputChange: (String) -> Unit, listState: LazyListState = rememberLazyListState(), - testOverrides: CommentScreenTestOverrides? = null, initialComment: DataHolder.Comment? = null, onInitialChildCommentResolved: (CommentModel, DataHolder.Comment) -> Unit = { _, _ -> }, ) { @@ -476,8 +468,7 @@ fun CommentScreen( ), ) } - val availableCommentEmojis = rememberCommentEmojis() - val commentEmojis = testOverrides?.commentEmojis ?: availableCommentEmojis + val commentEmojis = rememberCommentEmojis() val emojiInlineContent = rememberCommentEmojiInlineContent( remember(commentEmojis) { commentEmojis.mapTo(mutableSetOf(), CommentEmoji::inlineKey) }, ) @@ -496,7 +487,7 @@ fun CommentScreen( } // 根据内容类型选择合适的ViewModel - val viewModel: BaseCommentViewModel = testOverrides?.viewModel ?: when (resolvedContent) { + val viewModel: BaseCommentViewModel = when (resolvedContent) { is CommentHolder -> remember(viewModelKey) { // 子评论不进行状态保存 ChildCommentViewModel(resolvedContent, initialComment) @@ -737,7 +728,6 @@ fun CommentScreen( } }, onChildCommentClick = onChildCommentClick, - onImageMenuAction = testOverrides?.onImageMenuAction, onDelete = if (allowDelete && commentItem.item.canDelete) { { commentPendingDeletion = commentItem @@ -781,7 +771,6 @@ fun CommentScreen( } }, onChildCommentClick = onChildCommentClick, - onImageMenuAction = testOverrides?.onImageMenuAction, onDelete = if (childComment.canDelete) { { commentPendingDeletion = childCommentItem @@ -941,11 +930,6 @@ fun CommentScreen( val commentItem = viewModel.createCommentItem(dto, article = rootContent) SwipeToReplyContainer( modifier = Modifier.testTag("comment_row_${dto.id}"), - onArchive = testOverrides?.onArchiveComment?.let { onArchive -> - { - onArchive(commentItem) - } - }, onReply = { if (activeCommentItem == null) { if (commentItem.clickTarget != null) { @@ -1343,7 +1327,7 @@ private fun CommentItem( dfsSimple( node = stripped, onNavigate = navigator.onNavigate, - openExternalUrl = openExternalUrl, + openExternalUrl = openExternalUrl::invoke, componentUsed = emojisUsed, ) } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/FollowScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/FollowScreen.kt index 111f8837d..5533d6821 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/FollowScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/FollowScreen.kt @@ -119,49 +119,20 @@ const val FOLLOW_DYNAMIC_REFRESH_BUTTON_TAG = "follow_dynamic_refresh_button" */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun FollowScreen( - scrollToTopTrigger: Int, - innerPadding: PaddingValues, - parentPagerState: PagerState, -): Unit = FollowScreenContent( - scrollToTopTrigger = scrollToTopTrigger, - innerPadding = innerPadding, - parentPagerState = parentPagerState, - onTestRecommendRefreshClick = null, - onTestRecommendLoadMore = null, - onTestDynamicRefreshClick = null, - onTestDynamicLoadMore = null, -) - -/** - * 关注页的测试入口。 - * - * 与生产入口使用同一套 UI 内容,但允许测试注入刷新和加载更多回调,避免 instrumentation 测试依赖真实网络或分页状态。 - */ -@Composable fun FollowScreen( scrollToTopTrigger: Int = 0, innerPadding: PaddingValues, parentPagerState: PagerState, - onTestRecommendRefreshClick: (() -> Unit)?, - onTestRecommendLoadMore: (() -> Unit)?, - onTestDynamicRefreshClick: (() -> Unit)?, - onTestDynamicLoadMore: (() -> Unit)?, ): Unit = FollowScreenContent( scrollToTopTrigger = scrollToTopTrigger, innerPadding = innerPadding, parentPagerState = parentPagerState, - onTestRecommendRefreshClick = onTestRecommendRefreshClick, - onTestRecommendLoadMore = onTestRecommendLoadMore, - onTestDynamicRefreshClick = onTestDynamicRefreshClick, - onTestDynamicLoadMore = onTestDynamicLoadMore, ) /** * 关注页的实际布局实现。 * * 页面内部用横向 pager 承载“推荐”和“动态”两个 tab,并把 tab 切换、列表刷新、加载更多和主壳的回到顶部触发集中在这里。 - * 因为它同时被生产入口和测试入口复用,新增 UI 状态时要保持默认参数可测试。 */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -169,10 +140,6 @@ private fun FollowScreenContent( scrollToTopTrigger: Int = 0, innerPadding: PaddingValues = PaddingValues(0.dp), parentPagerState: PagerState, - onTestRecommendRefreshClick: (() -> Unit)? = null, - onTestRecommendLoadMore: (() -> Unit)? = null, - onTestDynamicRefreshClick: (() -> Unit)? = null, - onTestDynamicLoadMore: (() -> Unit)? = null, ) { val viewModel = viewModel { FollowScreenData() } val titles = listOf("推荐", "动态") @@ -218,15 +185,11 @@ private fun FollowScreenContent( 0 -> FollowRecommendScreen( scrollToTopTrigger = scrollToTopTrigger, isActive = pagerState.currentPage == 0, - onTestRefreshClick = onTestRecommendRefreshClick, - onTestLoadMore = onTestRecommendLoadMore, ) 1 -> FollowDynamicScreen( scrollToTopTrigger = scrollToTopTrigger, isActive = pagerState.currentPage == 1, - onTestRefreshClick = onTestDynamicRefreshClick, - onTestLoadMore = onTestDynamicLoadMore, ) } } @@ -371,8 +334,6 @@ fun FollowingUsersRow() { fun FollowRecommendScreen( scrollToTopTrigger: Int = 0, isActive: Boolean = true, - onTestRefreshClick: (() -> Unit)? = null, - onTestLoadMore: (() -> Unit)? = null, ) { val viewModel: FollowRecommendViewModel = viewModel { FollowRecommendViewModel() } val readingQueueSourceId = "follow:recommend" @@ -429,7 +390,7 @@ fun FollowRecommendScreen( FollowingUsersRow() } }, - onLoadMore = { onTestLoadMore?.invoke() ?: viewModel.loadMore(environment) }, + onLoadMore = { viewModel.loadMore(environment) }, footer = ProgressIndicatorFooter, ) { item -> FeedCard( @@ -493,7 +454,7 @@ fun FollowRecommendScreen( DraggableRefreshButton( modifier = Modifier.testTag(FOLLOW_RECOMMEND_REFRESH_BUTTON_TAG), onClick = { - onTestRefreshClick?.invoke() ?: viewModel.refresh(environment) + viewModel.refresh(environment) }, ) { if (viewModel.isLoading) { @@ -521,8 +482,6 @@ fun FollowRecommendScreen( fun FollowDynamicScreen( scrollToTopTrigger: Int = 0, isActive: Boolean = true, - onTestRefreshClick: (() -> Unit)? = null, - onTestLoadMore: (() -> Unit)? = null, ) { val viewModel: FollowViewModel = viewModel { FollowViewModel() } val readingQueueSourceId = "follow:dynamic" @@ -574,7 +533,7 @@ fun FollowDynamicScreen( items = viewModel.displayItems, listState = listState, modifier = Modifier.testTag(FOLLOW_DYNAMIC_LIST_TAG), - onLoadMore = { onTestLoadMore?.invoke() ?: viewModel.loadMore(environment) }, + onLoadMore = { viewModel.loadMore(environment) }, topContent = { item { Spacer(modifier = Modifier.height(8.dp)) @@ -644,7 +603,7 @@ fun FollowDynamicScreen( DraggableRefreshButton( modifier = Modifier.testTag(FOLLOW_DYNAMIC_REFRESH_BUTTON_TAG), onClick = { - onTestRefreshClick?.invoke() ?: viewModel.refresh(environment) + viewModel.refresh(environment) }, ) { if (viewModel.isLoading) { diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HomeScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HomeScreen.kt index 9b4867a24..1a698068e 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HomeScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HomeScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars @@ -112,6 +111,7 @@ import com.github.zly2006.zhihu.navigation.Notification import com.github.zly2006.zhihu.navigation.Pin import com.github.zly2006.zhihu.navigation.Search import com.github.zly2006.zhihu.navigation.WritePin +import com.github.zly2006.zhihu.navigation.requestLoginNavigation import com.github.zly2006.zhihu.notification.HOME_NOTIFICATION_ACTION_OPEN_ANSWER import com.github.zly2006.zhihu.notification.HOME_NOTIFICATION_ACTION_OPEN_ARTICLE import com.github.zly2006.zhihu.notification.HOME_NOTIFICATION_ACTION_OPEN_PIN @@ -145,7 +145,7 @@ import com.github.zly2006.zhihu.ui.components.feedKeywordExtractionAvailable import com.github.zly2006.zhihu.ui.subscreens.DEFAULT_FAB_OPACITY import com.github.zly2006.zhihu.ui.subscreens.PREF_FAB_OPACITY import com.github.zly2006.zhihu.ui.subscreens.SystemUpdateState -import com.github.zly2006.zhihu.ui.subscreens.rememberSystemUpdateRuntime +import com.github.zly2006.zhihu.ui.subscreens.rememberSystemUpdateState import com.github.zly2006.zhihu.ui.topLevelReselectAction import com.github.zly2006.zhihu.util.Log import com.github.zly2006.zhihu.viewmodel.QUALITY_FILTER_MODE_PREFERENCE_KEY @@ -183,6 +183,7 @@ const val HOME_WRITE_QUESTION_BUTTON_TAG = "home_write_question_button" const val HOME_WRITE_ANSWER_BUTTON_TAG = "home_write_answer_button" const val HOME_WRITE_PIN_BUTTON_TAG = "home_write_pin_button" const val HOME_NOTIFICATION_BUTTON_TAG = "home_notification_button" +const val HOME_NOTIFICATION_BUTTON_CONTENT_TAG = "home_notification_button_content" const val HOME_NOTIFICATION_BADGE_TAG = "home_notification_badge" const val HOME_ACCOUNT_BUTTON_TAG = "home_account_button" const val HOME_FEED_LIST_TAG = "home_feed_list" @@ -209,6 +210,7 @@ fun homePinAnnouncementReadKey(pinId: Long): String = "readHomePinAnnouncement_$ fun HomeScreen( scrollToTopTrigger: Int, innerPadding: PaddingValues, + showTopActions: Boolean = true, ) { val readingPlayerOverlayPadding = LocalReadingPlayerOverlayPadding.current val navigator = LocalNavigator.current @@ -248,13 +250,13 @@ fun HomeScreen( title = { Text("Cookie 不完整") }, text = { Text("当前登录信息缺少必要的 Cookie d_c0,请重新登录。") }, confirmButton = { - TextButton(onClick = { paginationEnvironment.requestLogin() }) { + TextButton(onClick = ::requestLoginNavigation) { Text("重新登录") } }, ) } - val updateState by rememberSystemUpdateRuntime().state.collectAsState() + val updateState by rememberSystemUpdateState().collectAsState() val updateAnnouncement = updateState as? SystemUpdateState.UpdateAvailable val versionName = rememberAppVersionInfo().substringBefore(' ').takeIf { it.firstOrNull()?.isDigit() == true } val onlineNotificationRepository = remember(settings) { @@ -338,9 +340,7 @@ fun HomeScreen( if (!account.login && settings.getBoolean("loginForRecommendation", true) ) { - if (!paginationEnvironment.requestLogin()) { - userMessages.showShortMessage("当前平台暂不支持登录") - } + requestLoginNavigation() } else if (viewModel.displayItems.isEmpty()) { val cachedItems = if (autoRefreshOnStartup) { emptyList() @@ -433,6 +433,9 @@ fun HomeScreen( .blur(createMenuBlurRadius) }, topBar = { + if (!showTopActions) { + return@Scaffold + } if (duo3HomeAccount) { Box { Surface( @@ -575,23 +578,27 @@ fun HomeScreen( }, contentAlignment = Alignment.Center, ) { - // IconButton 的圆形 Surface 会裁掉越过圆形边界的 badge;点击盒与内容盒必须分离。 + // 点击盒与内容盒分离,避免 IconButton 的裁剪边界截掉 BadgedBox 的 badge。 Box( - modifier = Modifier.size(40.dp), + modifier = Modifier + .size(40.dp) + .testTag(HOME_NOTIFICATION_BUTTON_CONTENT_TAG), contentAlignment = Alignment.Center, ) { - Icon( - Icons.Default.Notifications, - contentDescription = "通知", - tint = MaterialTheme.colorScheme.onSurface, - ) - if (showUnreadBadge && unreadCount > 0) { - Badge( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp) - .testTag(HOME_NOTIFICATION_BADGE_TAG), - ) { Text("$unreadCount") } + BadgedBox( + badge = { + if (showUnreadBadge && unreadCount > 0) { + Badge(modifier = Modifier.testTag(HOME_NOTIFICATION_BADGE_TAG)) { + Text("$unreadCount") + } + } + }, + ) { + Icon( + Icons.Default.Notifications, + contentDescription = "通知", + tint = MaterialTheme.colorScheme.onSurface, + ) } } } @@ -670,7 +677,7 @@ fun HomeScreen( accept.value ?.jsonPrimitive ?.contentOrNull - ?.let(openExternalUrl) + ?.let(openExternalUrl::invoke) } HOME_NOTIFICATION_ACTION_OPEN_UPDATE_SETTINGS -> { navigator.onNavigate(Account.SystemAndUpdateSettings()) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HotListScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HotListScreen.kt index fbfd58806..b84b1c036 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HotListScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/HotListScreen.kt @@ -56,8 +56,7 @@ const val HOT_LIST_REFRESH_BUTTON_TAG = "hot_list_refresh_button" /** * 热榜页面。 * - * 页面主体是知乎热榜分页列表,支持下拉刷新、加载更多和刷新 FAB。它既可以作为主 tab 页使用, - * 也可以在测试中通过 [onTestRefreshClick]、[onTestLoadMore] 控制分页行为,因此新增交互时要保留测试注入路径。 + * 页面主体是知乎热榜分页列表,支持下拉刷新、加载更多和刷新 FAB。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -65,8 +64,6 @@ fun HotListScreen( innerPadding: PaddingValues = PaddingValues(0.dp), scrollToTopTrigger: Int = 0, isActive: Boolean = true, - onTestRefreshClick: (() -> Unit)? = null, - onTestLoadMore: (() -> Unit)? = null, ) { val viewModel: HotListViewModel = viewModel { HotListViewModel() } val readingQueueSourceId = "hot-list:total" @@ -95,7 +92,7 @@ fun HotListScreen( ) if (isActive) { when (action) { - TopLevelReselectAction.Refresh -> onTestRefreshClick?.invoke() ?: viewModel.refresh(environment) + TopLevelReselectAction.Refresh -> viewModel.refresh(environment) TopLevelReselectAction.ScrollToTop -> listState.animateScrollToItem(0) null -> {} } @@ -114,7 +111,7 @@ fun HotListScreen( PaginatedList( items = viewModel.displayItems, listState = listState, - onLoadMore = { onTestLoadMore?.invoke() ?: viewModel.loadMore(environment) }, + onLoadMore = { viewModel.loadMore(environment) }, modifier = Modifier .padding(innerPadding) .testTag(HOT_LIST_LIST_TAG), @@ -133,7 +130,7 @@ fun HotListScreen( DraggableRefreshButton( modifier = Modifier.testTag(HOT_LIST_REFRESH_BUTTON_TAG), onClick = { - onTestRefreshClick?.invoke() ?: viewModel.refresh(environment) + viewModel.refresh(environment) }, ) { if (viewModel.isLoading) { diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt index 6d0ef499e..c5c5c4ba8 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt @@ -475,7 +475,7 @@ fun NotificationItemView( dfsSimple( node = document.body(), onNavigate = navigator.onNavigate, - openExternalUrl = openExternalUrl, + openExternalUrl = openExternalUrl::invoke, componentUsed = emojisUsed, ) } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PeopleScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PeopleScreen.kt index c9b6e361b..6a2b6b793 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PeopleScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PeopleScreen.kt @@ -20,17 +20,24 @@ package com.github.zly2006.zhihu.ui import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -48,7 +55,6 @@ import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -61,11 +67,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.lifecycle.ViewModel import coil3.compose.AsyncImage import com.fleeksoft.ksoup.Ksoup @@ -704,6 +717,12 @@ fun PeopleScreen( } val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val density = LocalDensity.current + LaunchedEffect(density) { + scrollBehavior.state.heightOffsetLimit = -with(density) { 240.dp.toPx() } + } + val collapsedFraction = scrollBehavior.state.collapsedFraction + val headerHeight = lerp(240.dp, 0.dp, collapsedFraction) Scaffold( modifier = Modifier @@ -711,14 +730,17 @@ fun PeopleScreen( .nestedScroll(scrollBehavior.nestedScrollConnection) .fillMaxSize(), topBar = { - Box { - TopAppBar( - title = { + Box( + modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), + ) { + Column { + Box(modifier = Modifier.height(headerHeight)) { UserInfoHeader( viewModel = viewModel, pagerState = pagerState, modifier = Modifier .padding(horizontal = 8.dp) + .fillMaxSize() .testTag(PEOPLE_SCREEN_HEADER_TAG), onFollowToggle = { coroutineScope.launch { @@ -759,12 +781,52 @@ fun PeopleScreen( } }, ) - }, - colors = TopAppBarDefaults.topAppBarColors().copy( - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - scrollBehavior = scrollBehavior, - expandedHeight = 240.dp, + } + PrimaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + modifier = Modifier.testTag(PEOPLE_SCREEN_TAB_ROW_TAG), + ) { + PEOPLE_SCREEN_TITLES.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + modifier = Modifier.testTag("people_screen_tab_$index"), + ) { + Text( + text = title, + modifier = Modifier.padding(16.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(y = lerp(240.dp, 0.dp, collapsedFraction)) + .padding(end = 0.dp) + .size(width = 96.dp, height = 56.dp) + .alpha(collapsedFraction) + .background( + Brush.horizontalGradient( + colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surface), + ), + ).pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + while (awaitPointerEvent(PointerEventPass.Initial).changes.any { it.pressed }) { + awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } + } + }, ) if (viewModel.memberHashId.isNotBlank() && viewModel.memberHashId != Person.EMPTY_ID) { IconButton( @@ -779,7 +841,7 @@ fun PeopleScreen( }, modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 32.dp, end = 8.dp) + .padding(top = lerp(32.dp, 4.dp, collapsedFraction), end = 8.dp) .testTag(PEOPLE_SCREEN_SEARCH_BUTTON_TAG), ) { Icon(Icons.Default.Search, contentDescription = "搜索 TA 的创作") @@ -793,29 +855,6 @@ fun PeopleScreen( .padding(innerPadding) .padding(horizontal = 8.dp), ) { - PrimaryScrollableTabRow( - selectedTabIndex = pagerState.currentPage, - modifier = Modifier.testTag(PEOPLE_SCREEN_TAB_ROW_TAG), - ) { - PEOPLE_SCREEN_TITLES.forEachIndexed { index, title -> - Tab( - selected = pagerState.currentPage == index, - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - }, - modifier = Modifier.testTag("people_screen_tab_$index"), - ) { - Text( - text = title, - modifier = Modifier.padding(16.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } HorizontalPager( state = pagerState, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PinScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PinScreen.kt index 40c821be6..946c044f0 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PinScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/PinScreen.kt @@ -92,6 +92,7 @@ import com.github.zly2006.zhihu.reading.ReadingPlaybackStatus import com.github.zly2006.zhihu.reading.ReadingQueueSourceRegistry import com.github.zly2006.zhihu.reading.ReadingStartRequest import com.github.zly2006.zhihu.reading.hasReadableFields +import com.github.zly2006.zhihu.reading.isReadingPlayerSupported import com.github.zly2006.zhihu.reading.loadReadingPlaybackSpeed import com.github.zly2006.zhihu.reading.loadReadingPreferences import com.github.zly2006.zhihu.reading.rememberReadingPlayerController @@ -323,7 +324,7 @@ fun PinScreen( } } }, - enabled = readingPlayer.isSupported && readingItem?.hasReadableFields(readingPreferences) == true, + enabled = isReadingPlayerSupported && readingItem?.hasReadableFields(readingPreferences) == true, modifier = Modifier.testTag(PIN_SCREEN_READING_BUTTON_TAG), ) { when { diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/QuestionScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/QuestionScreen.kt index 71efc20cd..b96884bb8 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/QuestionScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/QuestionScreen.kt @@ -122,6 +122,7 @@ import com.github.zly2006.zhihu.viewmodel.ContentLoadEnvironment import com.github.zly2006.zhihu.viewmodel.addReadHistory import com.github.zly2006.zhihu.viewmodel.feed.QuestionFeedViewModel import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -184,7 +185,7 @@ fun QuestionScreen( items = viewModel.displayItems, ) val paginationEnvironment = rememberPaginationEnvironment(allowGuestAccess = false) - val answerSwitchState = paginationEnvironment.articleAnswerSwitchState() + val answerSwitchState = sharedArticleAnswerSwitchState val listState = rememberLazyListState() var questionContent by remember(question.questionId) { mutableStateOf("") } var answerCount by remember(question.questionId) { mutableIntStateOf(0) } @@ -331,7 +332,7 @@ fun QuestionScreen( readingQueueSourceId = answerReadingQueueSourceId, modifier = Modifier.testTag("question_feed_item_${item.stableKey}"), ) { _, destination -> - answerSwitchState?.pendingNavigator = viewModel.createAnswerNavigatorFor(item, paginationEnvironment) + answerSwitchState.pendingNavigator = viewModel.createAnswerNavigatorFor(item, paginationEnvironment) destination?.let(navigator.onNavigate) } } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetState.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetState.kt index 757533db6..720555b64 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetState.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetState.kt @@ -25,25 +25,25 @@ import androidx.compose.runtime.staticCompositionLocalOf @Stable internal class ReadingPlayerOverlayOffsetState { - private var activeOwner: Any? = null + private var activeRouteId: String? = null var verticalOffsetPx by mutableFloatStateOf(0f) private set - fun activate(owner: Any) { - activeOwner = owner + fun beginRoute(routeId: String) { + activeRouteId = routeId verticalOffsetPx = 0f } - fun update(owner: Any, offsetPx: Float) { - if (activeOwner === owner) { + fun update(routeId: String, offsetPx: Float) { + if (activeRouteId == routeId) { verticalOffsetPx = offsetPx.coerceAtMost(0f) } } - fun deactivate(owner: Any) { - if (activeOwner === owner) { - activeOwner = null + fun endRoute(routeId: String) { + if (activeRouteId == routeId) { + activeRouteId = null verticalOffsetPx = 0f } } @@ -52,8 +52,8 @@ internal class ReadingPlayerOverlayOffsetState { verticalOffsetPx = 0f } - fun revokeOwner() { - activeOwner = null + fun clearRoute() { + activeRouteId = null verticalOffsetPx = 0f } } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/SearchScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/SearchScreen.kt index aef7422a6..80728a826 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/SearchScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/SearchScreen.kt @@ -155,8 +155,6 @@ private fun saveSearchHistory( @Composable fun SearchScreen( search: Search, - testHotSearchQueries: List<String>? = null, - onTestHotSearchRefresh: (() -> Unit)? = null, ) { val navigator = LocalNavigator.current val userMessages = rememberUserMessageSink() @@ -194,16 +192,11 @@ fun SearchScreen( val shouldAutoFocusSearchInput = search.query.isBlank() val showHotSearch = remember { mutableStateOf(!isMemberSearch && settings.getBoolean("showSearchHotSearch", true)) } - val hotSearchItems = remember(testHotSearchQueries) { - mutableStateListOf<HotSearchItem>().apply { - addAll(testHotSearchQueries.orEmpty().map { query -> HotSearchItem(query = query) }) - } - } + val hotSearchItems = remember { mutableStateListOf<HotSearchItem>() } var hotSearchMoreMenuExpanded by remember { mutableStateOf(false) } var historyMoreMenuExpanded by remember { mutableStateOf(false) } var filterMenuExpanded by remember { mutableStateOf(false) } var feedAuthorBlockRequest by remember { mutableStateOf<FeedAuthorBlockRequest?>(null) } - val useTestHotSearchQueries = testHotSearchQueries != null val showSearchHistory = remember { mutableStateOf(!isMemberSearch && settings.getBoolean("showSearchHistory", true)) } val searchHistoryItems = remember { mutableStateListOf<String>().apply { @@ -283,8 +276,8 @@ fun SearchScreen( } } - LaunchedEffect(showHotSearch.value, useTestHotSearchQueries, isMemberSearch) { - if (!isMemberSearch && showHotSearch.value && !useTestHotSearchQueries) { + LaunchedEffect(showHotSearch.value, isMemberSearch) { + if (!isMemberSearch && showHotSearch.value) { runCatching { fetchHotSearch() } } } @@ -494,11 +487,7 @@ fun SearchScreen( Row(verticalAlignment = Alignment.CenterVertically) { IconButton( onClick = { - if (useTestHotSearchQueries) { - onTestHotSearchRefresh?.invoke() - } else { - coroutineScope.launch { runCatching { fetchHotSearch() } } - } + coroutineScope.launch { runCatching { fetchHotSearch() } } }, modifier = Modifier .size(40.dp) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/UiSupportFiles.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/UiSupportFiles.kt index 6b3627325..f39fb5229 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/UiSupportFiles.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/UiSupportFiles.kt @@ -23,18 +23,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import com.fleeksoft.ksoup.Ksoup +import com.github.zly2006.zhihu.account.rememberZhihuAccountStore import com.github.zly2006.zhihu.data.DataHolder -import com.github.zly2006.zhihu.filter.ContentOpenFrom import com.github.zly2006.zhihu.markdown.RenderMarkdown import com.github.zly2006.zhihu.navigation.AnswerNavigator import com.github.zly2006.zhihu.navigation.Article @@ -46,11 +47,6 @@ import com.github.zly2006.zhihu.navigation.TopLevelDestination import com.github.zly2006.zhihu.platform.SettingsStore import com.github.zly2006.zhihu.platform.UserMessageSink import com.github.zly2006.zhihu.platform.rememberSettingsStore -import com.github.zly2006.zhihu.ui.ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY -import com.github.zly2006.zhihu.ui.AnswerDoubleTapAction -import com.github.zly2006.zhihu.ui.components.ANSWER_SWITCH_SENSITIVITY_PREFERENCE_KEY -import com.github.zly2006.zhihu.ui.components.DEFAULT_ANSWER_SWITCH_SENSITIVITY -import com.github.zly2006.zhihu.ui.components.normalizedAnswerSwitchSensitivity import com.github.zly2006.zhihu.ui.subscreens.DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY import com.github.zly2006.zhihu.viewmodel.ArticleViewModel.CachedAnswerContent import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment @@ -120,7 +116,7 @@ internal fun JsonObject?.booleanCompat(vararg keys: String): Boolean { fun PinHtmlContent(html: String) { val settings = rememberSettingsStore() if (settings.getBoolean(ARTICLE_USE_WEBVIEW_PREFERENCE_KEY, false) && - supportsZhihuHtmlWebView() + isLegacyWebViewSupported ) { ZhihuHtmlWebViewContent(html) } else { @@ -135,131 +131,29 @@ fun PinHtmlContent(html: String) { } } -expect fun supportsZhihuHtmlWebView(): Boolean +expect val isLegacyWebViewSupported: Boolean @Composable expect fun ZhihuHtmlWebViewContent(html: String) -/** - * 文章页 Compose UI 使用的运行时设置视图。 - * - * 这些值保存在可变 state 中,是因为大多数阅读设置都应该在用户已经打开文章时即时生效:标题/底栏自动隐藏、回答切换、 - * WebView 渲染和双击正文动作都不应要求重建页面。持久化仍由 [SettingsStore] 负责;这个类只镜像会影响当前 UI 的值, - * 并暴露文章页内弹窗会用到的显式保存入口。 - */ -class ArticleScreenSettingsState( - isTitleAutoHide: Boolean, - autoHideArticleBottomBar: Boolean, - answerSwitchMode: String, - answerSwitchSensitivity: Float, - pinAnswerDate: Boolean, - useDuo3ArticleActions: Boolean, - buttonSkipAnswer: Boolean, - autoHideSkipAnswerButton: Boolean, - answerDoubleTapAction: AnswerDoubleTapAction, - useWebView: Boolean, - useTiqianMarkdown: Boolean, - private val saveAnswerDoubleTapActionPreference: (AnswerDoubleTapAction) -> Unit, -) { - var isTitleAutoHide by mutableStateOf(isTitleAutoHide) - var autoHideArticleBottomBar by mutableStateOf(autoHideArticleBottomBar) - var answerSwitchMode by mutableStateOf(answerSwitchMode) - var answerSwitchSensitivity by mutableFloatStateOf(answerSwitchSensitivity) - var pinAnswerDate by mutableStateOf(pinAnswerDate) - var useDuo3ArticleActions by mutableStateOf(useDuo3ArticleActions) - var buttonSkipAnswer by mutableStateOf(buttonSkipAnswer) - var autoHideSkipAnswerButton by mutableStateOf(autoHideSkipAnswerButton) - var answerDoubleTapAction by mutableStateOf(answerDoubleTapAction) - var useWebView by mutableStateOf(useWebView) - var useTiqianMarkdown by mutableStateOf(useTiqianMarkdown) - - fun saveAnswerDoubleTapAction(action: AnswerDoubleTapAction) { - answerDoubleTapAction = action - saveAnswerDoubleTapActionPreference(action) - } -} - -/** - * 订阅会改变文章页可见阅读行为的设置项。 - * - * 设置页和文章页内弹窗可能修改同一批 key。这里通过监听这些 key 并原地更新 [ArticleScreenSettingsState], - * 让文章 UI 在保留滚动位置、已加载内容和 ViewModel 状态的同时应用新设置。 - */ @Composable -fun rememberArticleScreenSettingsState(): ArticleScreenSettingsState { - val settings = rememberSettingsStore() - val state = remember(settings) { - ArticleScreenSettingsState( - isTitleAutoHide = settings.getBoolean("titleAutoHide", false), - autoHideArticleBottomBar = settings.getBoolean("autoHideArticleBottomBar", false), - answerSwitchMode = settings.getString("answerSwitchMode", "vertical"), - answerSwitchSensitivity = normalizedAnswerSwitchSensitivity( - settings.getFloat( - ANSWER_SWITCH_SENSITIVITY_PREFERENCE_KEY, - DEFAULT_ANSWER_SWITCH_SENSITIVITY, - ), - ), - pinAnswerDate = settings.getBoolean("pinAnswerDate", false), - useDuo3ArticleActions = settings.getBoolean("duo3_article_actions", false), - buttonSkipAnswer = settings.getBoolean("buttonSkipAnswer", true), - autoHideSkipAnswerButton = settings.getBoolean("autoHideSkipAnswerButton", true), - answerDoubleTapAction = settings.answerDoubleTapAction(), - useWebView = settings.getBoolean(ARTICLE_USE_WEBVIEW_PREFERENCE_KEY, false), - useTiqianMarkdown = settings.getBoolean(DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY, false), - saveAnswerDoubleTapActionPreference = { action -> - settings.putString( - ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY, - action.preferenceValue, - ) - }, - ) - } - - DisposableEffect(settings, state) { - val unregister = settings.observeKeyChanges { key -> - when (key) { - "titleAutoHide" -> state.isTitleAutoHide = settings.getBoolean(key, false) - "autoHideArticleBottomBar" -> { - state.autoHideArticleBottomBar = settings.getBoolean(key, false) - } - - "buttonSkipAnswer" -> state.buttonSkipAnswer = settings.getBoolean(key, true) - "autoHideSkipAnswerButton" -> state.autoHideSkipAnswerButton = settings.getBoolean(key, true) - "answerSwitchMode" -> { - state.answerSwitchMode = settings.getString(key, "vertical") - } - - ANSWER_SWITCH_SENSITIVITY_PREFERENCE_KEY -> { - state.answerSwitchSensitivity = normalizedAnswerSwitchSensitivity( - settings.getFloat(key, DEFAULT_ANSWER_SWITCH_SENSITIVITY), - ) - } - - "pinAnswerDate" -> state.pinAnswerDate = settings.getBoolean(key, false) - "duo3_article_actions" -> state.useDuo3ArticleActions = settings.getBoolean(key, false) - ARTICLE_USE_WEBVIEW_PREFERENCE_KEY -> state.useWebView = settings.getBoolean(key, false) - DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY -> state.useTiqianMarkdown = settings.getBoolean(key, false) - ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY -> { - state.answerDoubleTapAction = settings.answerDoubleTapAction() - } - } +internal fun <T> rememberObservedSetting( + settings: SettingsStore, + key: String, + read: SettingsStore.() -> T, +): MutableState<T> { + val state = remember(settings, key) { mutableStateOf(settings.read()) } + DisposableEffect(settings, key, state) { + val subscription = settings.observeKeyChanges { changedKey -> + if (changedKey == key) state.value = settings.read() } - onDispose(unregister) + onDispose(subscription::close) } - return state } -private fun SettingsStore.answerDoubleTapAction(): AnswerDoubleTapAction = - AnswerDoubleTapAction.fromPreference( - getString( - ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY, - AnswerDoubleTapAction.Ask.preferenceValue, - ), - ) - @Composable -expect fun rememberArticleHost(): ArticleHost? +expect fun consumePendingCommentId(content: NavDestination): String? @Composable expect fun ArticleWebViewContent( @@ -289,7 +183,7 @@ fun QuestionDetailContent( ) { val settings = rememberSettingsStore() if (settings.getBoolean(ARTICLE_USE_WEBVIEW_PREFERENCE_KEY, false) && - supportsQuestionDetailWebView() + isLegacyWebViewSupported ) { QuestionDetailWebViewContent( questionId = questionId, @@ -306,8 +200,6 @@ fun QuestionDetailContent( } } -expect fun supportsQuestionDetailWebView(): Boolean - @Composable expect fun QuestionDetailWebViewContent( questionId: Long, @@ -317,11 +209,19 @@ expect fun QuestionDetailWebViewContent( @Composable expect fun rememberArticleTtsState(): TtsState +interface ArticleSpeechToggler { + operator fun invoke(title: String, content: String) +} + @Composable -expect fun rememberArticleSpeechToggler(): (title: String, content: String) -> Unit +expect fun rememberArticleSpeechToggler(): ArticleSpeechToggler + +interface ArticleBrowserOpener { + operator fun invoke(article: Article) +} @Composable -expect fun rememberArticleBrowserOpener(): (Article) -> Unit +expect fun rememberArticleBrowserOpener(): ArticleBrowserOpener fun articleActionText( article: Article, @@ -363,32 +263,6 @@ fun articleSpeechText( } } -/** - * 文章页需要从外围应用获取的宿主级服务。 - * - * 文章会参与历史记录、回答间导航、内容打开来源归因、TTS、剪贴板和 deep link 交接。这个接口刻意比 Activity 窄, - * 让 common 文章 UI 能同时运行在 Android、Desktop 和测试环境里,而不依赖平台类。 - */ -interface ArticleHost { - val articleNavController: NavHostController - val articleAnswerSwitchState: ArticleAnswerSwitchState - val articleTtsState: TtsState - var clipboardDestination: NavDestination? - - fun postHistoryDestination(destination: NavDestination) - - fun consumePendingContentOpenFrom(destination: NavDestination): String = ContentOpenFrom.UNKNOWN - - fun consumePendingCommentId(destination: NavDestination): String? = null - - fun speakArticleText( - text: String, - title: String, - ) - - fun stopArticleSpeaking() -} - /** * 同一问题下不同回答之间导航时使用的共享状态。 * @@ -480,34 +354,63 @@ data class AccountSettingsAccountState( val avatarUrl: String? = null, val id: String = "", val urlToken: String? = null, - val identityManagementSupported: Boolean = false, ) @Composable -expect fun rememberAccountSettingsAccountState(): State<AccountSettingsAccountState> - -@Composable -expect fun rememberAccountQrLoginRequester(): () -> Unit +fun rememberAccountSettingsAccountState(): State<AccountSettingsAccountState> { + val accountStore = rememberZhihuAccountStore() + val accounts = accountStore.accountsState.collectAsState() + return remember(accounts) { + derivedStateOf { + val session = accounts.value.session + AccountSettingsAccountState( + login = session.login, + hasRequiredCookie = session.cookies["d_c0"] + .isNullOrBlank() + .not(), + username = session.username, + avatarUrl = session.profile?.avatarUrl, + id = session.profile + ?.id + .orEmpty(), + urlToken = session.profile?.urlToken, + ) + } + } +} @Composable expect fun rememberAppVersionInfo(): String -fun noopSettingsStore(): SettingsStore = SettingsStore( - getBoolean = { _, defaultValue -> defaultValue }, - putBoolean = { _, _ -> }, - getString = { _, defaultValue -> defaultValue }, - putString = { _, _ -> }, - getStringOrNull = { _ -> null }, - putStringSet = { _, _ -> }, - getStringSet = { _, defaultValue -> defaultValue }, - getInt = { _, defaultValue -> defaultValue }, - putInt = { _, _ -> }, - getLong = { _, defaultValue -> defaultValue }, - putLong = { _, _ -> }, - getFloat = { _, defaultValue -> defaultValue }, - putFloat = { _, _ -> }, - remove = { _ -> }, -) +fun noopSettingsStore(): SettingsStore = object : SettingsStore { + override fun getBoolean(key: String, defaultValue: Boolean) = defaultValue + + override fun putBoolean(key: String, value: Boolean) = Unit + + override fun getString(key: String, defaultValue: String) = defaultValue + + override fun putString(key: String, value: String) = Unit + + override fun getStringOrNull(key: String): String? = null + + override fun putStringSet(key: String, value: Set<String>) = Unit + + override fun getStringSet(key: String, defaultValue: Set<String>) = defaultValue + + override fun getInt(key: String, defaultValue: Int) = defaultValue + + override fun putInt(key: String, value: Int) = Unit + + override fun getLong(key: String, defaultValue: Long) = defaultValue + + override fun putLong(key: String, value: Long) = Unit + + override fun getFloat(key: String, defaultValue: Float) = defaultValue + + override fun putFloat(key: String, value: Float) = Unit + + override fun remove(key: String) = Unit +} internal const val PEOPLE_PROFILE_INCLUDE_PATH = "allow_message,is_followed,is_following,is_org,is_blocking,badge_v2,answer_count,follower_count,following_count,articles_count,question_count,pins_count" @@ -533,10 +436,19 @@ expect fun Modifier.commentSelectionWorkaround(): Modifier @Composable expect fun rememberBlocklistRuleImporter( userMessages: UserMessageSink, -): (((String) -> Unit) -> Unit) + onImported: (String) -> Unit, +): BlocklistRuleImporter + +interface BlocklistRuleImporter { + operator fun invoke() +} + +interface BlocklistRuleExporter { + suspend operator fun invoke(): String +} @Composable -expect fun rememberBlocklistRuleExporter(): suspend () -> String +expect fun rememberBlocklistRuleExporter(): BlocklistRuleExporter /** * 沉浸式阅读时控制系统栏(状态栏/导航栏)的显隐。 diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WriteAnswerScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WriteAnswerScreen.kt index 22e756bf9..6561888c6 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WriteAnswerScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WriteAnswerScreen.kt @@ -57,11 +57,25 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.github.zly2006.zhihu.data.DataHolder +import com.github.zly2006.zhihu.data.ZhihuJson +import com.github.zly2006.zhihu.editor.PatchDraftRequest +import com.github.zly2006.zhihu.editor.PatchDraftSettings +import com.github.zly2006.zhihu.editor.PublishAnswerData +import com.github.zly2006.zhihu.editor.PublishAnswerRequest +import com.github.zly2006.zhihu.editor.PublishContentsTables +import com.github.zly2006.zhihu.editor.PublishDraft +import com.github.zly2006.zhihu.editor.PublishExtraInfo +import com.github.zly2006.zhihu.editor.PublishHybrid +import com.github.zly2006.zhihu.editor.PublishTrace import com.github.zly2006.zhihu.editor.UnknownImageFormatException import com.github.zly2006.zhihu.editor.ZhihuImageUploadSource +import com.github.zly2006.zhihu.editor.buildPcBusinessParams import com.github.zly2006.zhihu.editor.compileMdToZhihuHtml +import com.github.zly2006.zhihu.editor.isImagePickerSupported +import com.github.zly2006.zhihu.editor.newPublishTraceId +import com.github.zly2006.zhihu.editor.parsePublishContentId import com.github.zly2006.zhihu.editor.rememberImagePickerLauncher -import com.github.zly2006.zhihu.editor.rememberZhihuAnswerPublisher import com.github.zly2006.zhihu.editor.uploadZhihuImage import com.github.zly2006.zhihu.markdown.zhihuHtmlToMarkdown import com.github.zly2006.zhihu.navigation.Article @@ -77,9 +91,19 @@ import com.github.zly2006.zhihu.ui.components.WriteContentFabColumn import com.github.zly2006.zhihu.ui.components.WriteContentMarkdownEditor import com.github.zly2006.zhihu.ui.components.WriteContentPreviewSheet import com.github.zly2006.zhihu.ui.components.replaceSelection +import com.github.zly2006.zhihu.util.raiseForStatus +import com.github.zly2006.zhihu.viewmodel.fetchContentDetail +import com.github.zly2006.zhihu.viewmodel.postSigned import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment +import io.ktor.client.call.body +import io.ktor.client.request.header +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType import kotlinx.coroutines.launch import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonElement const val WRITE_ANSWER_CONTENT_TAG = "WriteAnswerContent" const val WRITE_ANSWER_FAB_PREVIEW_TAG = "WriteAnswerFabPreview" @@ -93,7 +117,7 @@ fun WriteAnswerScreen( ) { val navigator = LocalNavigator.current val userMessages = rememberUserMessageSink() - val publisher = rememberZhihuAnswerPublisher() + val environment = rememberPaginationEnvironment(false) val coroutineScope = rememberCoroutineScope() val copyToClipboard = rememberPlainTextClipboard() val settings = rememberSettingsStore() @@ -123,7 +147,19 @@ fun WriteAnswerScreen( suspend fun ensureAnswerId(): Long? { val cached = existingAnswerId if (cached != null) return cached - val answerId = publisher.findMyAnswerId(destination.questionId) + if (environment.authenticatedCookies()["d_c0"].isNullOrBlank()) return null + val element = runCatching { + environment.fetchJson( + "https://api.zhihu.com/questions/${destination.questionId}", + "relationship,relationship.my_answer", + ) + }.getOrNull() ?: return null + val relationship = ZhihuJson.decodeJson(DataHolder.QuestionRelationshipApiResponse.serializer(), element) + val answerId = relationship.relationship + ?.myAnswer + ?.takeUnless { it.isDeleted == true } + ?.answerId + ?.toLongOrNull() existingAnswerId = answerId return answerId } @@ -168,19 +204,59 @@ fun WriteAnswerScreen( runCatching { val html = compileMdToZhihuHtml(markdown = content.text) val answerId = ensureAnswerId() - publisher.patchDraft( - questionId = destination.questionId, - answerId = answerId, - html = html, - tocEnabled = tocEnabled, - ) + val xsrf = environment.authenticatedCookies()["_xsrf"] + ?: error("缺少 _xsrf Cookie,无法写入回答草稿;请先确保已登录。") + environment + .postSigned("https://www.zhihu.com/api/v4/questions/${destination.questionId}/draft") { + contentType(ContentType.Application.Json) + header( + HttpHeaders.Referrer, + "https://www.zhihu.com/question/${destination.questionId}/answer/${answerId ?: ""}", + ) + header("x-xsrftoken", xsrf) + setBody( + PatchDraftRequest( + content = html, + settings = PatchDraftSettings(tableOfContentsEnabled = tocEnabled), + ), + ) + }.raiseForStatus(dumpRequest = true) if (publish) { - publisher.publishAnswer( - questionId = destination.questionId, - answerId = answerId, - html = html, - tocEnabled = tocEnabled, + val responseElement = environment + .postSigned("https://www.zhihu.com/api/v4/content/publish") { + contentType(ContentType.Application.Json) + header("x-xsrftoken", xsrf) + setBody( + PublishAnswerRequest( + data = PublishAnswerData( + publish = PublishTrace(traceId = newPublishTraceId()), + draft = PublishDraft( + isPublished = answerId != null, + contentId = answerId?.toString(), + ), + extraInfo = PublishExtraInfo( + questionId = destination.questionId.toString(), + pcBusinessParams = buildPcBusinessParams(tocEnabled), + ), + hybrid = PublishHybrid(html), + contentsTables = PublishContentsTables(tocEnabled), + ), + ), + ) + }.raiseForStatus(dumpRequest = true) + .body<JsonElement>() + val response = ZhihuJson.decodeJson( + DataHolder.ContentPublishResponse.serializer(), + responseElement, ) + if (response.message != "success") { + if (response.code == 103003) { + error(response.message ?: "已回答过该问题,创建回答失败") + } + error("发布失败: ${response.message ?: "unknown"}\n$responseElement") + } + parsePublishContentId(response.data?.result ?: error("发布成功但返回缺少 data.result")) + ?: error("发布成功但无法解析 publish.id") } else { null } @@ -201,50 +277,53 @@ fun WriteAnswerScreen( } } - val environment = rememberPaginationEnvironment(false) - val launchImagePicker = rememberImagePickerLauncher { picked -> - if (isSubmitting || isUploadingImage) return@rememberImagePickerLauncher - isUploadingImage = true - coroutineScope.launch { - runCatching { - uploadZhihuImage( - environment, - picked.bytes, - picked.mimeType, - picked.fileName, - ZhihuImageUploadSource.Article, - ) - }.onSuccess { uploaded -> - val title = buildString { - append("zhimg:w=").append(uploaded.rawWidth) - append(";h=").append(uploaded.rawHeight) - uploaded.watermark?.let { append(";wm=").append(if (it) 1 else 0) } - if (uploaded.watermark == true) { - uploaded.watermarkUrl?.let { append(";wmsrc=").append(it) } + val launchImagePicker = if (isImagePickerSupported) { + rememberImagePickerLauncher { picked -> + if (isSubmitting || isUploadingImage) return@rememberImagePickerLauncher + isUploadingImage = true + coroutineScope.launch { + runCatching { + uploadZhihuImage( + environment, + picked.bytes, + picked.mimeType, + picked.fileName, + ZhihuImageUploadSource.Article, + ) + }.onSuccess { uploaded -> + val title = buildString { + append("zhimg:w=").append(uploaded.rawWidth) + append(";h=").append(uploaded.rawHeight) + uploaded.watermark?.let { append(";wm=").append(if (it) 1 else 0) } + if (uploaded.watermark == true) { + uploaded.watermarkUrl?.let { append(";wmsrc=").append(it) } + } + } + val alt = picked.fileName + ?.substringBeforeLast('.') + ?.takeIf { it.isNotBlank() } + .orEmpty() + val snippet = "![$alt](${uploaded.url} \"$title\")" + content = content.replaceSelection(snippet, cursorOffsetInInsert = snippet.length) + userMessages.showShortMessage("图片已插入") + }.onFailure { e -> + if (e is UnknownImageFormatException) { + userMessages.showShortMessage(e.message ?: "无法识别图片格式,已取消上传") + } else { + errorDialogMessage = buildWriteOperationErrorMessage("插入图片失败", e) } } - val alt = picked.fileName - ?.substringBeforeLast('.') - ?.takeIf { it.isNotBlank() } - .orEmpty() - val snippet = "![$alt](${uploaded.url} \"$title\")" - content = content.replaceSelection(snippet, cursorOffsetInInsert = snippet.length) - userMessages.showShortMessage("图片已插入") - }.onFailure { e -> - if (e is UnknownImageFormatException) { - userMessages.showShortMessage(e.message ?: "无法识别图片格式,已取消上传") - } else { - errorDialogMessage = buildWriteOperationErrorMessage("插入图片失败", e) - } + isUploadingImage = false } - isUploadingImage = false } + } else { + null } LaunchedEffect(destination.questionId) { isDetecting = true existingAnswerId = runCatching { - publisher.findMyAnswerId(destination.questionId) + ensureAnswerId() }.onFailure { e -> errorDialogMessage = buildWriteOperationErrorMessage("检测已有回答失败", e) }.getOrNull() @@ -254,11 +333,13 @@ fun WriteAnswerScreen( if (content.text.isNotBlank()) return@LaunchedEffect isLoadingExistingAnswer = true runCatching { - publisher.fetchAnswerForEditing(answerId) - }.onSuccess { editing -> - if (editing != null && content.text.isBlank()) { - tocEnabled = editing.tocEnabled - content = TextFieldValue(zhihuHtmlToMarkdown(editing.html)) + environment.fetchContentDetail( + Article(type = ArticleType.Answer, id = answerId), + ) as? DataHolder.Answer + }.onSuccess { answer -> + if (answer != null && content.text.isBlank()) { + tocEnabled = answer.settings?.tableOfContents?.enabled ?: false + content = TextFieldValue(zhihuHtmlToMarkdown(answer.editableContent ?: answer.content)) } }.onFailure { e -> errorDialogMessage = buildWriteOperationErrorMessage("加载已有回答失败", e) @@ -315,15 +396,15 @@ fun WriteAnswerScreen( ) { WriteContentFabColumn( previewEnabled = !isSubmitting && content.text.isNotBlank(), - imageEnabled = launchImagePicker != null && !isSubmitting && !isUploadingImage, + imageEnabled = isImagePickerSupported && !isSubmitting && !isUploadingImage, saveEnabled = !isSubmitting, - showImageButton = launchImagePicker != null, + showImageButton = isImagePickerSupported, isUploadingImage = isUploadingImage, previewTag = WRITE_ANSWER_FAB_PREVIEW_TAG, imageTag = WRITE_ANSWER_FAB_IMAGE_TAG, saveTag = WRITE_ANSWER_FAB_SAVE_TAG, onPreview = ::showPreview, - onImage = { launchImagePicker?.invoke() }, + onImage = { launchImagePicker?.launch() }, onSave = { submitAnswer(publish = false) }, ) } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WritePinScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WritePinScreen.kt index e8d3afa21..2dddfcecf 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WritePinScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/WritePinScreen.kt @@ -68,16 +68,24 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.github.zly2006.zhihu.data.DataHolder +import com.github.zly2006.zhihu.data.ZhihuJson import com.github.zly2006.zhihu.editor.PinContentTopicItem import com.github.zly2006.zhihu.editor.PinContentTopicMarker import com.github.zly2006.zhihu.editor.PinTopicSuggestion +import com.github.zly2006.zhihu.editor.PinTopicSuggestionRequest +import com.github.zly2006.zhihu.editor.PinTopicSuggestionResponse +import com.github.zly2006.zhihu.editor.PublishPinRequest +import com.github.zly2006.zhihu.editor.SavePinDraftRequest import com.github.zly2006.zhihu.editor.UnknownImageFormatException import com.github.zly2006.zhihu.editor.UploadedZhihuImage import com.github.zly2006.zhihu.editor.ZhihuImageUploadSource +import com.github.zly2006.zhihu.editor.buildPinContentPayload import com.github.zly2006.zhihu.editor.calculatePinHtmlTextLength import com.github.zly2006.zhihu.editor.compilePinMarkdownToZhihuHtml +import com.github.zly2006.zhihu.editor.isImagePickerSupported +import com.github.zly2006.zhihu.editor.parsePublishContentId import com.github.zly2006.zhihu.editor.rememberImagePickerLauncher -import com.github.zly2006.zhihu.editor.rememberZhihuPinPublisher import com.github.zly2006.zhihu.editor.uploadZhihuImage import com.github.zly2006.zhihu.markdown.rememberMarkdownImageModel import com.github.zly2006.zhihu.navigation.LocalNavigator @@ -90,12 +98,22 @@ import com.github.zly2006.zhihu.ui.components.MarkdownShortcut import com.github.zly2006.zhihu.ui.components.WriteContentFabColumn import com.github.zly2006.zhihu.ui.components.WriteContentMarkdownEditor import com.github.zly2006.zhihu.ui.components.WriteContentPreviewSheet +import com.github.zly2006.zhihu.util.raiseForStatus +import com.github.zly2006.zhihu.viewmodel.postSigned import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment +import io.ktor.client.call.body +import io.ktor.client.request.header +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.encodeURLParameter import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonElement const val WRITE_PIN_TITLE_TAG = "WritePinTitle" const val WRITE_PIN_CONTENT_TAG = "WritePinContent" @@ -192,7 +210,6 @@ private class PinTopicVisualTransformation( fun WritePinScreen(destination: WritePin = WritePin()) { val navigator = LocalNavigator.current val userMessages = rememberUserMessageSink() - val publisher = rememberZhihuPinPublisher() val coroutineScope = rememberCoroutineScope() val copyToClipboard = rememberPlainTextClipboard() val settings = rememberSettingsStore() @@ -249,7 +266,16 @@ fun WritePinScreen(destination: WritePin = WritePin()) { val contentHtml = compilePinMarkdownToZhihuHtml(newValue.text, selectedTopics) topicSuggestionError = null try { - val suggestions = publisher.recommendTopics(query.query, title.text, contentHtml) + val responseElement = environment + .postSigned( + "https://api.zhihu.com/content/publish/topics/recommend?" + + "recommend_type=pin&key_word=${query.query.encodeURLParameter(spaceToPlus = true)}", + ) { + contentType(ContentType.Application.Json) + setBody(PinTopicSuggestionRequest(title = title.text, content = contentHtml)) + }.raiseForStatus(dumpRequest = true) + .body<JsonElement>() + val suggestions = ZhihuJson.decodeJson<PinTopicSuggestionResponse>(responseElement).data.list if (activePinTopicQuery(content, selectedTopics) == query) { topicSuggestions = suggestions } @@ -307,22 +333,42 @@ fun WritePinScreen(destination: WritePin = WritePin()) { runCatching { val html = compilePinMarkdownToZhihuHtml(markdownSnapshot, topicsSnapshot) val textLength = calculatePinHtmlTextLength(html) + val topics = topicsSnapshot.map(PinContentTopicMarker::topic).distinctBy(PinContentTopicItem::topicId) + val payload = buildPinContentPayload( + title = title.text.trim(), + html = html, + textLength = textLength, + images = imagesSnapshot, + topics = topics, + ) + val xsrf = environment.authenticatedCookies()["_xsrf"] + ?: error("缺少 _xsrf Cookie,无法${if (publish) "发布" else "保存"}想法;请先确保已登录。") if (publish) { - publisher.publishPin( - title = title.text.trim(), - html = html, - textLength = textLength, - images = imagesSnapshot, - topics = topicsSnapshot.map(PinContentTopicMarker::topic).distinctBy(PinContentTopicItem::topicId), + val responseElement = environment + .postSigned("https://www.zhihu.com/api/v4/content/publish") { + contentType(ContentType.Application.Json) + header(HttpHeaders.Referrer, "https://www.zhihu.com/") + header("x-xsrftoken", xsrf) + setBody(PublishPinRequest(data = payload)) + }.raiseForStatus(dumpRequest = true) + .body<JsonElement>() + val response = ZhihuJson.decodeJson( + DataHolder.ContentPublishResponse.serializer(), + responseElement, ) + if (response.message != "success") { + error("发布失败: ${response.message ?: "unknown"}\n$responseElement") + } + parsePublishContentId(response.data?.result ?: error("发布成功但返回缺少 data.result")) + ?: error("发布成功但无法解析 publish.id") } else { - publisher.savePinDraft( - title = title.text.trim(), - html = html, - textLength = textLength, - images = imagesSnapshot, - topics = topicsSnapshot.map(PinContentTopicMarker::topic).distinctBy(PinContentTopicItem::topicId), - ) + environment + .postSigned("https://api.zhihu.com/content/drafts") { + contentType(ContentType.Application.Json) + header(HttpHeaders.Referrer, "https://www.zhihu.com/") + header("x-xsrftoken", xsrf) + setBody(SavePinDraftRequest(data = payload)) + }.raiseForStatus(dumpRequest = true) null } }.onSuccess { pinId -> @@ -342,34 +388,38 @@ fun WritePinScreen(destination: WritePin = WritePin()) { } } - val launchImagePicker = rememberImagePickerLauncher { picked -> - if (isSubmitting || isUploadingImage) return@rememberImagePickerLauncher - if (images.size >= PIN_IMAGE_LIMIT) { - userMessages.showShortMessage("图片最多添加 $PIN_IMAGE_LIMIT 张") - return@rememberImagePickerLauncher - } - isUploadingImage = true - coroutineScope.launch { - runCatching { - uploadZhihuImage( - environment, - picked.bytes, - picked.mimeType, - picked.fileName, - ZhihuImageUploadSource.Pin, - ) - }.onSuccess { uploaded -> - images = images + uploaded - userMessages.showShortMessage("图片已添加") - }.onFailure { e -> - if (e is UnknownImageFormatException) { - userMessages.showShortMessage(e.message ?: "无法识别图片格式,已取消上传") - } else { - errorDialogMessage = buildWriteOperationErrorMessage("插入图片失败", e) + val launchImagePicker = if (isImagePickerSupported) { + rememberImagePickerLauncher { picked -> + if (isSubmitting || isUploadingImage) return@rememberImagePickerLauncher + if (images.size >= PIN_IMAGE_LIMIT) { + userMessages.showShortMessage("图片最多添加 $PIN_IMAGE_LIMIT 张") + return@rememberImagePickerLauncher + } + isUploadingImage = true + coroutineScope.launch { + runCatching { + uploadZhihuImage( + environment, + picked.bytes, + picked.mimeType, + picked.fileName, + ZhihuImageUploadSource.Pin, + ) + }.onSuccess { uploaded -> + images = images + uploaded + userMessages.showShortMessage("图片已添加") + }.onFailure { e -> + if (e is UnknownImageFormatException) { + userMessages.showShortMessage(e.message ?: "无法识别图片格式,已取消上传") + } else { + errorDialogMessage = buildWriteOperationErrorMessage("插入图片失败", e) + } } + isUploadingImage = false } - isUploadingImage = false } + } else { + null } Scaffold( @@ -403,15 +453,15 @@ fun WritePinScreen(destination: WritePin = WritePin()) { floatingActionButton = { WriteContentFabColumn( previewEnabled = !isSubmitting && content.text.isNotBlank(), - imageEnabled = launchImagePicker != null && !isSubmitting && !isUploadingImage, + imageEnabled = isImagePickerSupported && !isSubmitting && !isUploadingImage, saveEnabled = !isSubmitting, - showImageButton = launchImagePicker != null, + showImageButton = isImagePickerSupported, isUploadingImage = isUploadingImage, previewTag = WRITE_PIN_FAB_PREVIEW_TAG, imageTag = WRITE_PIN_FAB_IMAGE_TAG, saveTag = WRITE_PIN_FAB_SAVE_TAG, onPreview = ::showPreview, - onImage = { launchImagePicker?.invoke() }, + onImage = { launchImagePicker?.launch() }, onSave = { submitPin(publish = false) }, ) }, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ZhihuMain.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ZhihuMain.kt index 28324f160..fb7d0c4ba 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ZhihuMain.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/ZhihuMain.kt @@ -92,6 +92,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.toRoute +import com.github.zly2006.zhihu.account.LoginScreen import com.github.zly2006.zhihu.filter.ContentOpenFrom import com.github.zly2006.zhihu.navigation.Account import com.github.zly2006.zhihu.navigation.Article @@ -105,6 +106,7 @@ import com.github.zly2006.zhihu.navigation.History import com.github.zly2006.zhihu.navigation.Home import com.github.zly2006.zhihu.navigation.HotList import com.github.zly2006.zhihu.navigation.LocalNavigator +import com.github.zly2006.zhihu.navigation.Login import com.github.zly2006.zhihu.navigation.MainTabs import com.github.zly2006.zhihu.navigation.MyCollections import com.github.zly2006.zhihu.navigation.NavDestination @@ -120,7 +122,9 @@ import com.github.zly2006.zhihu.navigation.TopLevelDestination import com.github.zly2006.zhihu.navigation.Topic import com.github.zly2006.zhihu.navigation.WriteAnswer import com.github.zly2006.zhihu.navigation.WritePin +import com.github.zly2006.zhihu.navigation.loginNavigationRequestFlow import com.github.zly2006.zhihu.platform.PlatformBackHandler +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.reading.rememberReadingPlayerController import com.github.zly2006.zhihu.reading.saveReadingPlaybackSpeed @@ -163,6 +167,7 @@ private sealed class MainTabPage( } internal val LocalReadingPlayerOverlayPadding = staticCompositionLocalOf { 0.dp } +internal val LocalArticleNavController = staticCompositionLocalOf<NavHostController?> { null } /** * Zhihu++ 的共享应用主壳。 @@ -187,10 +192,15 @@ fun ZhihuMain( preferenceState: ZhihuMainPreferenceState, isDarkTheme: Boolean, articleContent: @Composable (Article, NavBackStackEntry) -> Unit, + showMainNavigationBar: Boolean = true, + showHomeTopActions: Boolean = true, + onCurrentMainTabDestinationChange: (TopLevelDestination) -> Unit = {}, sentenceSimilarityContent: @Composable () -> Unit = { - Text("Sentence similarity test is not available on this platform.") + error("$platformName 暂不支持句子相似度测试") + }, + blocklistSettingsNlpContent: @Composable (onNavigateBack: () -> Unit) -> Unit = { + error("$platformName 暂不支持 NLP 智能屏蔽设置") }, - blocklistSettingsNlpContent: BlocklistSettingsNlpContent? = null, articleEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = null, articleExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = null, ) { @@ -210,6 +220,7 @@ fun ZhihuMain( var readingPlayerHeightPx by remember { mutableIntStateOf(0) } val readingPlayerOverlayOffsetState = remember { ReadingPlayerOverlayOffsetState() } val density = LocalDensity.current + val currentOnMainTabDestinationChange by rememberUpdatedState(onCurrentMainTabDestinationChange) val navEntry by navController.currentBackStackEntryAsState() val showMainNavigation = navEntry?.destination?.hasRoute<MainTabs>() == true @@ -269,7 +280,7 @@ fun ZhihuMain( val isOnArticle = navEntry?.destination?.hasRoute<Article>() == true LaunchedEffect(navEntry) { isReadingPlayerExpandedByUser = false - if (!isOnArticle) readingPlayerOverlayOffsetState.revokeOwner() + if (!isOnArticle) readingPlayerOverlayOffsetState.clearRoute() } var wasOnArticle by remember { mutableStateOf(false) } if (!isOnArticle && wasOnArticle) { @@ -345,10 +356,19 @@ fun ZhihuMain( } } + LaunchedEffect(navController) { + loginNavigationRequestFlow.collect { + navController.navigate(Login) { + launchSingleTop = true + } + } + } + LaunchedEffect(mainPagerState.currentPage, mainTabPages) { mainTabPages.getOrNull(mainPagerState.currentPage)?.bottomDestination?.let { destination -> currentMainTabDestination = destination setCurrentMainTabOpenFrom(destination.openFrom) + currentOnMainTabDestinationChange(destination) } } @@ -419,7 +439,7 @@ fun ZhihuMain( }, floatingActionButtonPosition = FabPosition.Center, bottomBar = { - if (navEntry != null) { + if (showMainNavigationBar && navEntry != null) { // 页面切换时重置底部导航栏可见状态 LaunchedEffect(navEntry) { isBottomBarVisible = true } val currentBottomDestination = mainTabPages @@ -481,6 +501,7 @@ fun ZhihuMain( }, ) { innerPadding -> CompositionLocalProvider( + LocalArticleNavController provides navController, LocalNavigator provides Navigator( onNavigate = { destination -> navigate(destination) @@ -535,6 +556,15 @@ fun ZhihuMain( scrollToTopTrigger = scrollToTopTrigger, innerPadding = innerPadding, collectionDirectBrowseEnabled = collectionDirectBrowseEnabled, + showHomeTopActions = showHomeTopActions, + ) + } + composable<Login> { + LoginScreen( + onLoginComplete = { navController.popBackStack() }, + onOpenTelemetrySettings = { + navController.navigate(Account.SystemAndUpdateSettings("allowTelemetry")) + }, ) } composable<Question> { navEntry -> @@ -765,6 +795,7 @@ private fun MainTabsPager( scrollToTopTrigger: Int, innerPadding: PaddingValues, collectionDirectBrowseEnabled: Boolean, + showHomeTopActions: Boolean, ) { HorizontalPager( state = pagerState, @@ -776,6 +807,7 @@ private fun MainTabsPager( MainTabPage.HomePage -> HomeScreen( scrollToTopTrigger = scrollToTopTrigger, innerPadding = innerPadding, + showTopActions = showHomeTopActions, ) MainTabPage.FollowPage -> FollowScreen( scrollToTopTrigger = scrollToTopTrigger, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/article/ArticleActionsMenu.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/article/ArticleActionsMenu.kt index 1dfb41dc5..cca6f36c9 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/article/ArticleActionsMenu.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/article/ArticleActionsMenu.kt @@ -61,6 +61,7 @@ import com.github.zly2006.zhihu.reading.ReadingQueueItem import com.github.zly2006.zhihu.reading.ReadingQueueSourceRegistry import com.github.zly2006.zhihu.reading.ReadingStartRequest import com.github.zly2006.zhihu.reading.hasReadableFields +import com.github.zly2006.zhihu.reading.isReadingPlayerSupported import com.github.zly2006.zhihu.reading.loadReadingPlaybackSpeed import com.github.zly2006.zhihu.reading.loadReadingPreferences import com.github.zly2006.zhihu.reading.rememberReadingPlayerController @@ -212,7 +213,7 @@ fun ArticleActionsMenu( fun Content() { MenuActionButton( icon = { - if (readingPlayer.isSupported && hasReadingSession) { + if (isReadingPlayerSupported && hasReadingSession) { Icon( Icons.AutoMirrored.Filled.VolumeOff, contentDescription = null, @@ -226,7 +227,7 @@ fun ArticleActionsMenu( ) else -> Icon( - if (!readingPlayer.isSupported && ttsState.isSpeaking) { + if (!isReadingPlayerSupported && ttsState.isSpeaking) { Icons.AutoMirrored.Filled.VolumeOff } else { Icons.AutoMirrored.Filled.VolumeUp @@ -237,21 +238,21 @@ fun ArticleActionsMenu( } } }, - text = if (readingPlayer.isSupported) { + text = if (isReadingPlayerSupported) { if (hasReadingSession) "停止朗读" else "开始连续朗读" } else if (ttsState.isSpeaking) { "停止朗读" } else { "开始朗读" }, - enabled = if (readingPlayer.isSupported) { + enabled = if (isReadingPlayerSupported) { hasReadingSession || readingItem.hasReadableFields(readingPreferences) } else { ttsState !in listOf(TtsState.Error, TtsState.Uninitialized, TtsState.Initializing) }, onClick = { onDismissRequest() - if (readingPlayer.isSupported) { + if (isReadingPlayerSupported) { if (hasReadingSession) { readingPlayer.stop() } else { diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/CommentScreenComponent.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/CommentScreenComponent.kt index 5abb1506c..8f43be653 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/CommentScreenComponent.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/CommentScreenComponent.kt @@ -52,7 +52,7 @@ import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.theme.Typography import com.github.zly2006.zhihu.ui.CommentScreen import com.github.zly2006.zhihu.ui.commentThreadKey -import com.github.zly2006.zhihu.ui.rememberArticleHost +import com.github.zly2006.zhihu.ui.consumePendingCommentId import com.github.zly2006.zhihu.viewmodel.CommentItem import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -69,9 +69,9 @@ fun CommentScreenComponent( isZhPlusAuthorContent: Boolean = false, ) { val settings = rememberSettingsStore() - val articleHost = rememberArticleHost() + val initialPendingCommentId = consumePendingCommentId(content) var pendingCommentId by remember(content) { - mutableStateOf(articleHost?.consumePendingCommentId(content)) + mutableStateOf(initialPendingCommentId) } val commentsVisible = showComments || pendingCommentId != null var authorCommentPolicyAcknowledged by remember { diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ExportDialogComponent.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ExportDialogComponent.kt index f5b7b3754..5baccd05a 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ExportDialogComponent.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ExportDialogComponent.kt @@ -62,6 +62,8 @@ import kotlin.math.roundToInt @Composable fun ExportDialogComponent( showDialog: Boolean, + isHtmlExportSupported: Boolean, + isImageExportSupported: Boolean, onDismiss: () -> Unit, onExportHtml: suspend (includeAppAttribution: Boolean, onComplete: (Boolean) -> Unit) -> Unit, onExportImage: suspend (includeAppAttribution: Boolean, onComplete: (Boolean) -> Unit) -> Unit, @@ -155,7 +157,7 @@ fun ExportDialogComponent( }, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.secondaryContainer, - enabled = !isExporting, + enabled = isHtmlExportSupported && !isExporting, ) { Row( modifier = Modifier @@ -205,7 +207,7 @@ fun ExportDialogComponent( }, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.secondaryContainer, - enabled = !isExporting, + enabled = isImageExportSupported && !isExporting, ) { Row( modifier = Modifier @@ -312,7 +314,7 @@ fun ExportDialogComponent( }, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.tertiaryContainer, - enabled = !isExporting, + enabled = isImageExportSupported && !isExporting, ) { Row( modifier = Modifier @@ -344,7 +346,7 @@ fun ExportDialogComponent( Checkbox( checked = includeAppAttribution, onCheckedChange = { includeAppAttribution = it }, - enabled = !isExporting, + enabled = (isHtmlExportSupported || isImageExportSupported) && !isExporting, ) Spacer(modifier = Modifier.width(8.dp)) Text( diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ShareDialogComponent.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ShareDialogComponent.kt index 0e12af300..76f5f5cbd 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ShareDialogComponent.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/ShareDialogComponent.kt @@ -60,6 +60,7 @@ import com.github.zly2006.zhihu.navigation.NavDestination import com.github.zly2006.zhihu.navigation.Pin import com.github.zly2006.zhihu.navigation.Question import com.github.zly2006.zhihu.navigation.Topic +import com.github.zly2006.zhihu.platform.PlainTextClipboard import com.github.zly2006.zhihu.platform.SettingsStore import com.github.zly2006.zhihu.platform.UserMessageSink @@ -204,21 +205,29 @@ enum class ShareAction { CopyLink, } -typealias ShareActionExecutor = (ShareAction, NavDestination, String) -> Unit +interface ShareActionExecutor { + operator fun invoke( + action: ShareAction, + content: NavDestination, + shareText: String, + ) +} @Composable expect fun rememberShareActionExecutor(): ShareActionExecutor internal fun clipboardShareActionExecutor( - copyPlainText: (label: String, text: String) -> Unit, + copyPlainText: PlainTextClipboard, userMessages: UserMessageSink, -): ShareActionExecutor = { action, _, shareText -> - if (action == ShareAction.CopyLink) { - copyPlainText("Link", shareText) - userMessages.showMessage("已复制链接") - } else { - copyPlainText("Share", shareText) - userMessages.showMessage("已复制分享文本") +): ShareActionExecutor = object : ShareActionExecutor { + override fun invoke(action: ShareAction, content: NavDestination, shareText: String) { + if (action == ShareAction.CopyLink) { + copyPlainText("Link", shareText) + userMessages.showMessage("已复制链接") + } else { + copyPlainText("Share", shareText) + userMessages.showMessage("已复制分享文本") + } } } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/WriteContentPreviewSheet.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/WriteContentPreviewSheet.kt index c322ba612..ec114a9f3 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/WriteContentPreviewSheet.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/components/WriteContentPreviewSheet.kt @@ -42,8 +42,8 @@ import androidx.compose.ui.unit.dp import com.github.zly2006.zhihu.markdown.RenderMarkdown import com.github.zly2006.zhihu.markdown.RenderMarkdownText import com.github.zly2006.zhihu.ui.ZhihuHtmlWebViewContent +import com.github.zly2006.zhihu.ui.isLegacyWebViewSupported import com.github.zly2006.zhihu.ui.questionSelectionWorkaround -import com.github.zly2006.zhihu.ui.supportsZhihuHtmlWebView @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -89,7 +89,7 @@ fun WriteContentPreviewSheet( CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } - html != null && supportsZhihuHtmlWebView() -> { + html != null && isLegacyWebViewSupported -> { val scrollState = rememberScrollState() Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { ZhihuHtmlWebViewContent(html = html) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AppearanceSettingsScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AppearanceSettingsScreen.kt index 4f3e49f4a..6019882c3 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AppearanceSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/AppearanceSettingsScreen.kt @@ -95,6 +95,7 @@ import com.github.zly2006.zhihu.navigation.LocalNavigator import com.github.zly2006.zhihu.navigation.MyCollections import com.github.zly2006.zhihu.navigation.OnlineHistory import com.github.zly2006.zhihu.navigation.TopLevelDestination +import com.github.zly2006.zhihu.platform.platformBottomBarItemLimit import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.platform.rememberUserMessageSink import com.github.zly2006.zhihu.theme.ThemeManager @@ -113,6 +114,7 @@ import com.github.zly2006.zhihu.ui.components.SettingItemGroup import com.github.zly2006.zhihu.ui.components.SettingItemOverall import com.github.zly2006.zhihu.ui.components.SettingItemWithSwitch import com.github.zly2006.zhihu.ui.components.normalizedAnswerSwitchSensitivity +import com.github.zly2006.zhihu.ui.isLegacyWebViewSupported import kotlinx.coroutines.delay import kotlin.math.abs import kotlin.math.roundToInt @@ -171,7 +173,16 @@ internal fun resolveValidStartDestinationKey( else -> Home.name } -internal fun defaultBottomBarSelectionKeys(duo3HomeAccount: Boolean): Set<String> = if (duo3HomeAccount) { +internal fun defaultBottomBarSelectionKeys( + duo3HomeAccount: Boolean, + maximumSelection: Int? = 5, +): Set<String> = if (maximumSelection == null) { + if (duo3HomeAccount) { + linkedSetOf(Home.name, Follow.name, HotList.name, Daily.name, OnlineHistory.name, MyCollections.name) + } else { + linkedSetOf(Home.name, Follow.name, HotList.name, Daily.name, OnlineHistory.name, MyCollections.name, Account.name) + } +} else if (duo3HomeAccount) { linkedSetOf(Home.name, Follow.name, Daily.name) } else { linkedSetOf(Home.name, Follow.name, Daily.name, OnlineHistory.name, Account.name) @@ -181,11 +192,12 @@ internal fun normalizeBottomBarSelection( selectedKeys: Collection<String>, duo3HomeAccount: Boolean, enforceMinimumSelection: Boolean = false, + maximumSelection: Int? = 5, ): Set<String> { val allowedKeys = topLevelDestinationsInOrder.map { it.first }.toSet() val normalized = selectedKeys .filterTo(linkedSetOf()) { it in allowedKeys } - .ifEmpty { defaultBottomBarSelectionKeys(duo3HomeAccount).toMutableSet() } + .ifEmpty { defaultBottomBarSelectionKeys(duo3HomeAccount, maximumSelection).toMutableSet() } if (duo3HomeAccount) { if (Home.name in normalized) { @@ -195,7 +207,7 @@ internal fun normalizeBottomBarSelection( } } else { normalized.add(Account.name) - while (normalized.size > 5) { + while (maximumSelection != null && normalized.size > maximumSelection) { val removableKey = listOf( HotList.name, MyCollections.name, @@ -301,10 +313,11 @@ fun AppearanceSettingsScreen( val normalizedSelection = normalizeBottomBarSelection( settings.getStringSet( BOTTOM_BAR_ITEMS_PREFERENCE_KEY, - defaultBottomBarSelectionKeys(duo3HomeAccount.value), + defaultBottomBarSelectionKeys(duo3HomeAccount.value, platformBottomBarItemLimit), ), duo3HomeAccount.value, enforceMinimumSelection = true, + maximumSelection = platformBottomBarItemLimit, ) mutableStateOf( bottomBarItemOrderFromPreference( @@ -313,7 +326,6 @@ fun AppearanceSettingsScreen( ), ) } - DisposableEffect(Unit) { onDispose { onExit() @@ -758,6 +770,7 @@ fun AppearanceSettingsScreen( title = { Text("使用 WebView 显示文章") }, description = { Text("关闭后使用 Compose 渲染,支持代码高亮等高级功能。警告:这个渲染模式不再推荐,非专业人士请不要开启!") }, checked = articleUseWebview.value, + enabled = isLegacyWebViewSupported, onCheckedChange = { articleUseWebview.value = it settings.putBoolean(ARTICLE_USE_WEBVIEW_PREFERENCE_KEY, it) @@ -767,7 +780,7 @@ fun AppearanceSettingsScreen( bringIntoViewRequester = requesterFor(ARTICLE_USE_WEBVIEW_PREFERENCE_KEY), ) - if (articleUseWebview.value) { + if (articleUseWebview.value && isLegacyWebViewSupported) { var customFontName by remember { mutableStateOf(settings.getStringOrNull("webviewCustomFontName")) } @@ -775,29 +788,31 @@ fun AppearanceSettingsScreen( modifier = Modifier.testTag(APPEARANCE_SETTINGS_WEBVIEW_OPTIONS_TAG), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - SettingItem( - modifier = Modifier.testTag(APPEARANCE_SETTINGS_WEBVIEW_FONT_TAG), - title = { - Text( - "WebView 自定义字体", - modifier = Modifier.testTag(APPEARANCE_SETTINGS_WEBVIEW_FONT_TAG), - ) - }, - description = { Text(customFontName ?: "未设置") }, - bottomAction = { - WebViewCustomFontSettings( - customFontName = customFontName, - onCustomFontNameChange = { name -> - if (name == null) { - settings.remove("webviewCustomFontName") - } else { - settings.putString("webviewCustomFontName", name) - } - customFontName = name - }, - ) - }, - ) + if (isWebViewCustomFontSupported) { + SettingItem( + modifier = Modifier.testTag(APPEARANCE_SETTINGS_WEBVIEW_FONT_TAG), + title = { + Text( + "WebView 自定义字体", + modifier = Modifier.testTag(APPEARANCE_SETTINGS_WEBVIEW_FONT_TAG), + ) + }, + description = { Text(customFontName ?: "未设置") }, + bottomAction = { + WebViewCustomFontSettings( + customFontName = customFontName, + onCustomFontNameChange = { name -> + if (name == null) { + settings.remove("webviewCustomFontName") + } else { + settings.putString("webviewCustomFontName", name) + } + customFontName = name + }, + ) + }, + ) + } val useHardwareAcceleration = remember { mutableStateOf(settings.getBoolean("webviewHardwareAcceleration", true)) } SettingItemWithSwitch( @@ -1056,6 +1071,7 @@ fun AppearanceSettingsScreen( currentOrderKeys, duo3HomeAccountEnabled, enforceMinimumSelection = true, + maximumSelection = platformBottomBarItemLimit, ) val normalizedOrderKeys = normalizeBottomBarItemOrder(currentOrderKeys, normalizedSet) val availableKeys = normalizedOrderKeys @@ -1187,8 +1203,13 @@ fun AppearanceSettingsScreen( userMessages.showShortMessage("至少保留3项") } - !isChecked && selectedBottomBarItemKeys.value.size >= 5 -> { - userMessages.showShortMessage("最多选择5项") + !isChecked -> { + platformBottomBarItemLimit + ?.takeIf { selectedBottomBarItemKeys.value.size >= it } + ?.let { maximum -> + userMessages.showShortMessage("最多选择${maximum}项") + } + ?: persistBottomBarSelection(candidateOrderKeys) } else -> persistBottomBarSelection(candidateOrderKeys) diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/DeveloperSettingsScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/DeveloperSettingsScreen.kt index 442d7d129..f4b93d0c4 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/DeveloperSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/DeveloperSettingsScreen.kt @@ -61,10 +61,13 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import com.github.zly2006.zhihu.account.rememberZhihuAccountStore import com.github.zly2006.zhihu.data.ZHIHU_ME_URL import com.github.zly2006.zhihu.navigation.Account import com.github.zly2006.zhihu.navigation.LocalNavigator import com.github.zly2006.zhihu.navigation.SentenceSimilarityTest +import com.github.zly2006.zhihu.platform.isSentenceSimilaritySupported +import com.github.zly2006.zhihu.platform.rememberIsLiteVariant import com.github.zly2006.zhihu.platform.rememberPlainTextClipboard import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.platform.rememberUserMessageSink @@ -89,7 +92,8 @@ const val DEVELOPER_SETTINGS_COLOR_SCHEME_TAG = "developerSettings/colorScheme" fun DeveloperSettingsScreen() { val navigator = LocalNavigator.current val environment = rememberPaginationEnvironment(allowGuestAccess = false) - val runtimeInfo = rememberDeveloperRuntimeInfo() + val accountStore = rememberZhihuAccountStore() + val runtimeInfo = rememberDeveloperInfo() val copyPlainText = rememberPlainTextClipboard() val userMessages = rememberUserMessageSink() val coroutineScope = rememberCoroutineScope() @@ -159,7 +163,7 @@ fun DeveloperSettingsScreen() { FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { coroutineScope.launch { - if (environment.verifyLogin(environment.authenticatedCookies())) { + if (accountStore.client.refreshAndSaveProfile() != null) { userMessages.showShortMessage("登录成功") } else { userMessages.showShortMessage("登录失败") @@ -178,12 +182,14 @@ fun DeveloperSettingsScreen() { Button(onClick = { showSignedRequestDialog = true }) { Text("签名请求") } - Button( - modifier = Modifier.testTag(DEVELOPER_SETTINGS_SENTENCE_SIMILARITY_TAG), - onClick = { - navigator.onNavigate(SentenceSimilarityTest) - }, - ) { Text("句子相似度") } + if (isSentenceSimilaritySupported && !rememberIsLiteVariant()) { + Button( + modifier = Modifier.testTag(DEVELOPER_SETTINGS_SENTENCE_SIMILARITY_TAG), + onClick = { + navigator.onNavigate(SentenceSimilarityTest) + }, + ) { Text("句子相似度") } + } Button( modifier = Modifier.testTag(DEVELOPER_SETTINGS_COLOR_SCHEME_TAG), @@ -330,12 +336,9 @@ fun DeveloperSettingsScreen() { } if (cookies.isNotEmpty()) { - environment.saveCookies(cookies) - - // 验证登录状态 coroutineScope.launch { try { - if (environment.verifyLogin(cookies)) { + if (accountStore.login(cookies)) { userMessages.showShortMessage("Cookie设置成功并验证登录状态") } else { userMessages.showShortMessage("Cookie设置成功,但验证登录失败,请检查Cookie是否有效") diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/IdentityManagementScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/IdentityManagementScreen.kt index cb3cb7993..304776e43 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/IdentityManagementScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/IdentityManagementScreen.kt @@ -32,7 +32,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SwitchAccount @@ -51,6 +53,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -63,13 +66,32 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.github.zly2006.zhihu.account.SwitchAccountRequest +import com.github.zly2006.zhihu.account.ZhihuAccountProfileSnapshot +import com.github.zly2006.zhihu.account.ZhihuAccountStore import com.github.zly2006.zhihu.account.ZhihuIdentityAccount +import com.github.zly2006.zhihu.account.ZhihuIdentityAccountListResponse +import com.github.zly2006.zhihu.account.ZhihuIdentityChangeResult +import com.github.zly2006.zhihu.account.ZhihuIdentityProfile +import com.github.zly2006.zhihu.account.ZhihuIdentityToken +import com.github.zly2006.zhihu.account.ZhihuSavedAccount +import com.github.zly2006.zhihu.account.applyIdentityHeaders +import com.github.zly2006.zhihu.account.identitySuccessBody +import com.github.zly2006.zhihu.account.rememberZhihuAccountStore +import com.github.zly2006.zhihu.data.ZhihuJson import com.github.zly2006.zhihu.navigation.LocalNavigator +import com.github.zly2006.zhihu.navigation.requestLoginNavigation import com.github.zly2006.zhihu.platform.rememberUserMessageSink import com.github.zly2006.zhihu.ui.components.SettingItem import com.github.zly2006.zhihu.ui.components.SettingItemGroup -import com.github.zly2006.zhihu.viewmodel.rememberPaginationEnvironment +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import kotlinx.serialization.json.jsonObject const val IDENTITY_MANAGEMENT_SCREEN_TAG = "identityManagement.screen" const val IDENTITY_MANAGEMENT_CREATE_TAG = "identityManagement.create" @@ -77,7 +99,6 @@ const val IDENTITY_MANAGEMENT_RETRY_TAG = "identityManagement.retry" const val IDENTITY_MANAGEMENT_CREATE_CONFIRM_TAG = "identityManagement.createConfirm" data class IdentityManagementState( - val supported: Boolean = true, val accounts: List<ZhihuIdentityAccount> = emptyList(), val currentAccountId: String = "", val loading: Boolean = false, @@ -96,28 +117,90 @@ data class IdentityManagementState( } } +private suspend fun completeIdentityChange( + accountStore: ZhihuAccountStore, + body: String, + expectedAccountId: String? = null, +): ZhihuIdentityChangeResult { + val token = ZhihuJson.decodeJson<ZhihuIdentityToken>(ZhihuJson.json.parseToJsonElement(body)) + check(token.accessToken.isNotBlank()) { "服务器未返回新账号凭证" } + check(token.cookie["z_c0"].isNullOrBlank().not()) { "服务器未返回新账号 Cookie" } + val oldSession = accountStore.session + val newCookies = oldSession.cookies.toMutableMap().apply { putAll(token.cookie) } + val client = accountStore.client.temporaryHttpClient(newCookies) + return try { + val response = client.get("https://api.zhihu.com/people/self") { + applyIdentityHeaders( + oldSession.copy( + mobileAccessToken = token.accessToken, + mobileTokenType = token.tokenType, + ), + ) + } + val rawProfile = ZhihuJson.json + .parseToJsonElement( + response.identitySuccessBody("初始化新账号"), + ).jsonObject + val profile = ZhihuJson.decodeJson<ZhihuIdentityProfile>(rawProfile) + check(profile.id.isNotBlank() && profile.name.isNotBlank()) { "服务器返回的账号资料不完整" } + check(expectedAccountId == null || profile.id == expectedAccountId) { "服务器返回的账号与目标账号不一致" } + val nextSession = oldSession.copy( + login = true, + username = profile.name, + cookies = newCookies, + profile = ZhihuAccountProfileSnapshot( + id = profile.id, + name = profile.name, + urlToken = profile.urlToken, + userType = profile.userType, + avatarUrl = profile.avatarUrl, + ), + self = ZhihuJson.snakeCaseToCamelCase(rawProfile), + mobileAccessToken = token.accessToken, + mobileRefreshToken = token.refreshToken, + mobileTokenType = token.tokenType, + mobileTokenExpiresAt = token.expiresAt, + ) + accountStore.replaceSession(nextSession) + ZhihuIdentityChangeResult( + account = ZhihuIdentityAccount( + id = profile.id, + urlToken = profile.urlToken, + name = profile.name, + avatarUrl = profile.avatarUrl, + isActive = true, + canCreateSubAccount = profile.canCreateSubAccount, + accountType = profile.accountType, + subAccountControlStatus = profile.subAccountControlStatus, + ), + session = nextSession, + ) + } finally { + client.close() + } +} + /** * “身份管理”页面。 * - * 账号列表来自 `/people/account/list`。切换和创建都会签发一套新的会话凭证,保存凭证后重建主壳,确保推荐流、 - * 历史、通知和个人资料不会继续复用旧账号缓存。 + * 账号列表来自 `/people/account/list`。切换和创建都会签发一套新的会话凭证,并由账户 store 原子替换当前会话和 + * 与它绑定的客户端。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun IdentityManagementScreen() { val navigator = LocalNavigator.current - val environment = rememberPaginationEnvironment(allowGuestAccess = false) - val identityClient = remember(environment) { environment.identityClient() } + val accountStore = rememberZhihuAccountStore() val userMessages = rememberUserMessageSink() val coroutineScope = rememberCoroutineScope() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - var state by remember(identityClient) { + var state by remember(accountStore) { mutableStateOf( IdentityManagementState( - supported = identityClient != null, - currentAccountId = environment.currentAccountId(), - loading = identityClient != null, - errorMessage = if (identityClient == null) "请在 Android 客户端使用身份管理" else null, + currentAccountId = accountStore.session.profile + ?.id + .orEmpty(), + loading = true, ), ) } @@ -125,15 +208,28 @@ fun IdentityManagementScreen() { var switchTarget by remember { mutableStateOf<ZhihuIdentityAccount?>(null) } var showCreateDialog by remember { mutableStateOf(false) } var acceptedCreateRules by remember { mutableStateOf(false) } + var switchLoginAccount by remember { mutableStateOf<ZhihuSavedAccount?>(null) } + var removeLoginAccount by remember { mutableStateOf<ZhihuSavedAccount?>(null) } + val savedAccounts by accountStore.accountsState.collectAsState() suspend fun refresh() { - val client = identityClient ?: return if (state.switchingToAccountId != null || state.creating) return state = state.copy(loading = true, errorMessage = null) state = try { state.copy( - accounts = client.listAccounts(), - currentAccountId = environment.currentAccountId(), + accounts = ZhihuJson + .decodeJson<ZhihuIdentityAccountListResponse>( + ZhihuJson.json.parseToJsonElement( + accountStore.client + .httpClient() + .get("https://api.zhihu.com/people/account/list") { + applyIdentityHeaders(accountStore.session) + }.identitySuccessBody("获取身份列表"), + ), + ).data, + currentAccountId = accountStore.session.profile + ?.id + .orEmpty(), loading = false, ) } catch (e: Exception) { @@ -141,7 +237,7 @@ fun IdentityManagementScreen() { } } - LaunchedEffect(identityClient) { + LaunchedEffect(accountStore) { refresh() } @@ -180,17 +276,6 @@ fun IdentityManagementScreen() { .padding(innerPadding) .padding(vertical = 16.dp), ) { - if (!state.supported) { - SettingItemGroup { - SettingItem( - title = { Text("当前平台暂不支持身份管理") }, - description = { Text(state.errorMessage ?: "请在 Android 客户端使用此功能") }, - icon = { Icon(Icons.Default.ErrorOutline, null) }, - ) - } - return@Column - } - if (state.loading && state.accounts.isEmpty()) { Box( modifier = Modifier @@ -223,9 +308,6 @@ fun IdentityManagementScreen() { if (state.accounts.isNotEmpty()) { SettingItemGroup( title = "当前手机号下的账号", - footer = { - Text("两个账号共用当前手机号,但昵称、主页、内容、推荐和互动数据相互独立。") - }, ) { state.accounts.forEachIndexed { index, account -> val isCurrent = account.id == state.currentAccountId @@ -282,6 +364,30 @@ fun IdentityManagementScreen() { } if (state.accounts.isNotEmpty()) { + val otherAccounts = savedAccounts.accounts.filterNot { it.id == savedAccounts.activeAccountId } + SettingItemGroup( + title = "其他登录账号", + ) { + otherAccounts.forEach { account -> + SettingItem( + title = { Text(account.session.profile?.name ?: account.session.username) }, + description = { Text("切换到这个登录账号") }, + icon = { Icon(Icons.Default.SwitchAccount, null) }, + endAction = { + IconButton(onClick = { removeLoginAccount = account }) { + Icon(Icons.Default.DeleteOutline, contentDescription = "移除登录账号") + } + }, + onClick = { switchLoginAccount = account }, + ) + } + SettingItem( + title = { Text("添加其他手机号登录账号") }, + icon = { Icon(Icons.AutoMirrored.Filled.Login, null) }, + onClick = ::requestLoginNavigation, + ) + } + SettingItemGroup( title = "新账号", footer = { @@ -298,7 +404,6 @@ fun IdentityManagementScreen() { ) { SettingItem( title = { Text("创建新账号") }, - description = { Text("共用当前手机号,数据相互独立") }, icon = { Icon(Icons.Default.Add, null) }, modifier = Modifier.testTag(IDENTITY_MANAGEMENT_CREATE_TAG), enabled = state.canCreateSubAccount && !state.busy, @@ -324,7 +429,7 @@ fun IdentityManagementScreen() { }, title = { Text("切换账号") }, text = { - Text("将切换到“${account.name}”并重新加载应用。之后的推荐、内容和互动行为都属于该账号。") + Text("将切换到“${account.name}”。之后的推荐、内容和互动行为都属于该账号。") }, confirmButton = { TextButton( @@ -333,11 +438,22 @@ fun IdentityManagementScreen() { switchTarget = null coroutineScope.launch { runCatching { - val client = checkNotNull(identityClient) check(!state.busy) { "另一个账号操作正在进行" } state = state.copy(switchingToAccountId = account.id, errorMessage = null) try { - val result = client.switchAccount(account.id) + require(account.id.isNotBlank()) { "目标账号不能为空" } + val response = accountStore.client.httpClient().post( + "https://api.zhihu.com/account/switch", + ) { + applyIdentityHeaders(accountStore.session) + contentType(ContentType.Application.Json) + setBody(SwitchAccountRequest(account.id)) + } + val result = completeIdentityChange( + accountStore, + response.identitySuccessBody("切换账号"), + account.id, + ) state = state.copy( currentAccountId = result.account.id, switchingToAccountId = null, @@ -351,7 +467,7 @@ fun IdentityManagementScreen() { } }.onSuccess { userMessages.showShortMessage("已切换到 ${account.name}") - environment.restartApplication() + navigator.onNavigateBack() }.onFailure { userMessages.showLongMessage(it.message ?: "切换账号失败") } @@ -410,12 +526,19 @@ fun IdentityManagementScreen() { showCreateDialog = false coroutineScope.launch { runCatching { - val client = checkNotNull(identityClient) check(!state.busy) { "另一个账号操作正在进行" } check(state.canCreateSubAccount) { "当前账号暂不能创建新账号" } state = state.copy(creating = true, errorMessage = null) try { - val result = client.createSubAccount() + val response = accountStore.client.httpClient().post( + "https://api.zhihu.com/account/sub/register", + ) { + applyIdentityHeaders(accountStore.session) + } + val result = completeIdentityChange( + accountStore, + response.identitySuccessBody("创建新账号"), + ) state = state.copy( accounts = state.accounts + result.account, currentAccountId = result.account.id, @@ -430,7 +553,7 @@ fun IdentityManagementScreen() { } }.onSuccess { userMessages.showLongMessage("新账号已创建并初始化,正在重新加载") - environment.restartApplication() + navigator.onNavigateBack() }.onFailure { userMessages.showLongMessage(it.message ?: "创建新账号失败") } @@ -450,4 +573,60 @@ fun IdentityManagementScreen() { }, ) } + + switchLoginAccount?.let { account -> + AlertDialog( + onDismissRequest = { switchLoginAccount = null }, + title = { Text("切换登录账号") }, + text = { Text("将切换到“${account.session.profile?.name ?: account.session.username}”。") }, + confirmButton = { + TextButton( + onClick = { + switchLoginAccount = null + coroutineScope.launch { + try { + if (!accountStore.switchAccount(account.id)) { + userMessages.showLongMessage("登录凭据已失效,请重新添加这个账号") + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + userMessages.showLongMessage(e.message ?: "切换登录账号失败") + } + } + }, + ) { + Text("切换") + } + }, + dismissButton = { + TextButton(onClick = { switchLoginAccount = null }) { + Text("取消") + } + }, + ) + } + + removeLoginAccount?.let { account -> + AlertDialog( + onDismissRequest = { removeLoginAccount = null }, + title = { Text("移除登录账号") }, + text = { Text("将从本机删除“${account.session.profile?.name ?: account.session.username}”的登录凭据,不会注销知乎账号。") }, + confirmButton = { + TextButton( + onClick = { + accountStore.removeAccount(account.id) + removeLoginAccount = null + }, + ) { + Text("移除") + } + }, + dismissButton = { + TextButton(onClick = { removeLoginAccount = null }) { + Text("取消") + } + }, + ) + } } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SettingsSearchScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SettingsSearchScreen.kt index 444f3c460..e2fbea610 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SettingsSearchScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SettingsSearchScreen.kt @@ -54,6 +54,7 @@ import com.github.zly2006.zhihu.navigation.LocalNavigator import com.github.zly2006.zhihu.navigation.NavDestination import com.github.zly2006.zhihu.navigation.Notification import com.github.zly2006.zhihu.notification.NotificationType +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.ui.ANSWER_DOUBLE_TAP_ACTION_PREFERENCE_KEY import com.github.zly2006.zhihu.ui.ARTICLE_USE_WEBVIEW_PREFERENCE_KEY @@ -242,6 +243,17 @@ private val settingsSearchEntries = buildList { add(systemEntry("system.allowTelemetry", "允许发送遥测统计数据", "控制匿名使用统计。", "allowTelemetry", listOf("统计", "隐私", "数据收集", "使用数据"))) add(systemEntry("system.aigcMarking", "启用 AIGC 标记", "开启后可查看其他用户对内容是否疑似 AIGC 的标记。", AIGC_MARKING_ENABLED_PREFERENCE_KEY, listOf("AI", "AIGC"))) add(systemEntry("system.reminder", "防沉迷提醒", "设置连续使用提醒的间隔。", CONTINUOUS_USAGE_REMINDER_INTERVAL_MINUTES_KEY, listOf("连续使用", "休息提醒"))) + if (platformName == "macOS") { + add( + systemEntry( + "system.macosQuitOnWindowClose", + "关闭窗口时退出应用", + "关闭最后一个窗口时同时退出 macOS 应用;默认关闭。", + MACOS_QUIT_ON_WINDOW_CLOSE_PREFERENCE_KEY, + listOf("macOS", "退出应用", "关闭窗口", "退出程序"), + ), + ) + } add(notificationEntry("notification.autoMarkAsRead", "打开通知自动已读", "进入通知页后自动标记当前批次为已读。", "autoMarkAsRead", listOf("已读", "标记已读"))) add(notificationEntry("notification.unreadBadge", "显示未读红点", "控制首页和账号入口的未读角标。", "unreadBadge", listOf("角标", "红点", "未读数"))) @@ -286,12 +298,12 @@ fun SettingsSearchScreen() { mutableStateOf(settings.getBoolean("developer", false)) } DisposableEffect(settings) { - val unregister = settings.observeKeyChanges { key -> + val subscription = settings.observeKeyChanges { key -> if (key == "developer") { developerModeEnabled = settings.getBoolean("developer", false) } } - onDispose(unregister) + onDispose(subscription::close) } val results = remember(query, developerModeEnabled) { settingsSearchEntries diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SubscreenRuntimes.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SubscreenRuntimes.kt index dc43f66e4..4c533d9b5 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SubscreenRuntimes.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SubscreenRuntimes.kt @@ -21,23 +21,14 @@ import androidx.compose.runtime.Composable import com.github.zly2006.zhihu.ui.TtsState import kotlinx.coroutines.flow.StateFlow +expect val isWebViewCustomFontSupported: Boolean + @Composable expect fun WebViewCustomFontSettings( customFontName: String?, onCustomFontNameChange: (String?) -> Unit, ) -data class SystemUpdateRuntime( - val state: StateFlow<SystemUpdateState>, - val checkForUpdate: suspend () -> Unit, - val skipVersion: (String) -> Unit, - val resetToNoUpdate: () -> Unit, - val downloadUpdate: suspend (String) -> Unit, - val installDownloadedUpdate: suspend () -> Unit, - val setError: (String) -> Unit, - val supportsApkInstall: Boolean, -) - sealed interface SystemUpdateState { data object NoUpdate : SystemUpdateState @@ -63,9 +54,43 @@ sealed interface SystemUpdateState { } @Composable -expect fun rememberSystemUpdateRuntime(): SystemUpdateRuntime +expect fun rememberSystemUpdateState(): StateFlow<SystemUpdateState> + +interface SystemUpdateChecker { + suspend fun check() +} + +interface SystemUpdateVersionSkipper { + fun skip(version: String) +} + +interface SystemUpdateDownloader { + suspend fun download(url: String) +} + +interface DownloadedSystemUpdateInstaller { + suspend fun install() +} + +@Composable +expect fun rememberSystemUpdateChecker(): SystemUpdateChecker + +@Composable +expect fun rememberSystemUpdateVersionSkipper(): SystemUpdateVersionSkipper + +@Composable +expect fun rememberSystemUpdateDownloader(): SystemUpdateDownloader + +@Composable +expect fun rememberDownloadedSystemUpdateInstaller(): DownloadedSystemUpdateInstaller + +expect fun resetSystemUpdateState() + +expect fun setSystemUpdateError(message: String) -data class DeveloperRuntimeInfo( +expect val isApkUpdateInstallSupported: Boolean + +data class DeveloperInfoSnapshot( val networkStatus: String = "网络状态:未知", val powerSaveModeText: String? = null, val continuousUsageDurationMs: Long = 0L, @@ -74,9 +99,5 @@ data class DeveloperRuntimeInfo( val availableTtsEngineLabels: List<String> = emptyList(), ) -interface DeveloperRuntimeInfoProvider { - val developerRuntimeInfo: DeveloperRuntimeInfo -} - @Composable -expect fun rememberDeveloperRuntimeInfo(): DeveloperRuntimeInfo +expect fun rememberDeveloperInfo(): DeveloperInfoSnapshot diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SystemAndUpdateSettingsScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SystemAndUpdateSettingsScreen.kt index 7e6c49e53..758c0b5ef 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SystemAndUpdateSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/SystemAndUpdateSettingsScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.github.zly2006.zhihu.data.AIGC_MARKING_ENABLED_PREFERENCE_KEY import com.github.zly2006.zhihu.navigation.LocalNavigator +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberExternalUrlOpener import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.ui.components.SettingItem @@ -89,13 +90,14 @@ import zhihu.shared.generated.resources.ic_github_24dp import zhihu.shared.generated.resources.ic_telegram_24dp internal const val CONTINUOUS_USAGE_REMINDER_INTERVAL_MINUTES_KEY = "continuousUsageReminderIntervalMinutes" +internal const val MACOS_QUIT_ON_WINDOW_CLOSE_PREFERENCE_KEY = "macosQuitOnWindowClose" const val SYSTEM_SETTINGS_AIGC_MARKING_TAG = "system_settings_aigc_marking" /** * 系统、更新和外部服务设置页。 * * 页面展示更新横幅、下载/安装/跳过版本操作、GitHub Token、Nightly、遥测、防沉迷提醒和社区链接。 - * 更新相关状态由平台 [SystemUpdateRuntime] 提供,防沉迷间隔写入 [CONTINUOUS_USAGE_REMINDER_INTERVAL_MINUTES_KEY], + * 更新相关状态与动作由细粒度平台能力提供,防沉迷间隔写入 [CONTINUOUS_USAGE_REMINDER_INTERVAL_MINUTES_KEY], * 改动时要同时考虑 Android 更新管理器和 Desktop 运行时。 */ @OptIn(ExperimentalMaterial3Api::class) @@ -104,7 +106,11 @@ fun SystemAndUpdateSettingsScreen( setting: String? = null, ) { val settings = rememberSettingsStore() - val updates = rememberSystemUpdateRuntime() + val updateStateFlow = rememberSystemUpdateState() + val checkForUpdate = rememberSystemUpdateChecker() + val skipUpdateVersion = rememberSystemUpdateVersionSkipper() + val downloadUpdate = rememberSystemUpdateDownloader() + val installDownloadedUpdate = rememberDownloadedSystemUpdateInstaller() val openExternalUrl = rememberExternalUrlOpener() val navigator = LocalNavigator.current val highlightedSetting = setting.orEmpty() @@ -144,7 +150,7 @@ fun SystemAndUpdateSettingsScreen( .padding(innerPadding) .padding(vertical = 16.dp), ) { - val updateState by updates.state.collectAsState() + val updateState by updateStateFlow.collectAsState() val coroutineScope = rememberCoroutineScope() val showUpdateBanner = updateState is SystemUpdateState.UpdateAvailable || updateState is SystemUpdateState.Downloading || @@ -242,7 +248,7 @@ fun SystemAndUpdateSettingsScreen( runCatching { openExternalUrl(cnDownloadUrl) }.onFailure { - updates.setError(it.message ?: "无法打开浏览器") + setSystemUpdateError(it.message ?: "无法打开浏览器") } }, modifier = Modifier.fillMaxWidth(), @@ -264,10 +270,11 @@ fun SystemAndUpdateSettingsScreen( onClick = { val state = updateState if (state is SystemUpdateState.UpdateAvailable) { - updates.skipVersion(state.version) + skipUpdateVersion.skip(state.version) } }, modifier = Modifier.weight(1f), + enabled = updateState !is SystemUpdateState.Downloaded || isApkUpdateInstallSupported, ) { Text("跳过此版本", Modifier.padding(0.dp, 4.dp)) } @@ -276,8 +283,8 @@ fun SystemAndUpdateSettingsScreen( onClick = { coroutineScope.launch { when (val state = updateState) { - is SystemUpdateState.UpdateAvailable -> updates.downloadUpdate(state.downloadUrl) - is SystemUpdateState.Downloaded -> updates.installDownloadedUpdate() + is SystemUpdateState.UpdateAvailable -> downloadUpdate.download(state.downloadUrl) + is SystemUpdateState.Downloaded -> installDownloadedUpdate.install() else -> {} } } @@ -288,7 +295,11 @@ fun SystemAndUpdateSettingsScreen( when (updateState) { is SystemUpdateState.UpdateAvailable -> "下载更新" is SystemUpdateState.Downloading -> "下载中..." - is SystemUpdateState.Downloaded -> "安装更新" + is SystemUpdateState.Downloaded -> if (isApkUpdateInstallSupported) { + "安装更新" + } else { + "暂不支持安装 APK 更新" + } else -> "下载更新" }, Modifier.padding(0.dp, 4.dp), @@ -335,6 +346,23 @@ fun SystemAndUpdateSettingsScreen( }, ) + if (platformName == "macOS") { + var quitOnWindowClose by remember { + mutableStateOf(settings.getBoolean(MACOS_QUIT_ON_WINDOW_CLOSE_PREFERENCE_KEY, false)) + } + SettingItemWithSwitch( + title = { Text("关闭窗口时退出应用") }, + description = { Text("关闭最后一个窗口时同时退出 macOS 应用;默认关闭") }, + checked = quitOnWindowClose, + onCheckedChange = { + quitOnWindowClose = it + settings.putBoolean(MACOS_QUIT_ON_WINDOW_CLOSE_PREFERENCE_KEY, it) + }, + settingKey = MACOS_QUIT_ON_WINDOW_CLOSE_PREFERENCE_KEY, + highlightedKey = highlightedSetting, + ) + } + var checkNightlyUpdates by remember { mutableStateOf(settings.getBoolean("checkNightlyUpdates", false)) } SettingItemWithSwitch( title = { Text("检查 Nightly 版本更新") }, @@ -388,13 +416,13 @@ fun SystemAndUpdateSettingsScreen( coroutineScope.launch { when (updateState) { is SystemUpdateState.NoUpdate, is SystemUpdateState.Error -> { - updates.checkForUpdate() - if (updates.state.value is SystemUpdateState.UpdateAvailable) { + checkForUpdate.check() + if (updateStateFlow.value is SystemUpdateState.UpdateAvailable) { scrollState.animateScrollTo(0) } } SystemUpdateState.Latest -> { - updates.resetToNoUpdate() + resetSystemUpdateState() } else -> { /* NOOP */ } } diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.native.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/util/CrashReporting.kt similarity index 68% rename from shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.native.kt rename to shared/src/commonMain/kotlin/com/github/zly2006/zhihu/util/CrashReporting.kt index a0f9acfd7..f8fe7baa6 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.native.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/util/CrashReporting.kt @@ -15,15 +15,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package com.github.zly2006.zhihu.editor +package com.github.zly2006.zhihu.util -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember - -@Composable -actual fun rememberZhihuPinPublisher(): ZhihuPinPublisher { - val environment = rememberZhihuPublisherEnvironment() - return remember(environment) { - ZhihuApiPinPublisher(environment) - } -} +/** 设置项 key:是否允许匿名上报崩溃日志(opt-in,默认关闭)。 */ +const val CRASH_REPORT_ENABLED_PREFERENCE_KEY = "allowCrashReport" diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/ArticleViewModel.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/ArticleViewModel.kt index 76738bee2..ccb15eacc 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/ArticleViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/ArticleViewModel.kt @@ -26,9 +26,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.zly2006.zhihu.data.AigcVoteFlagRequest +import com.github.zly2006.zhihu.data.AigcVoteFlagResponse +import com.github.zly2006.zhihu.data.AigcVoteFlagStatusResponse import com.github.zly2006.zhihu.data.AigcVoteFlagSubmission import com.github.zly2006.zhihu.data.AigcVoteNamedVoter import com.github.zly2006.zhihu.data.AigcVoteReadEvent +import com.github.zly2006.zhihu.data.AigcVoteReadEventsRequest +import com.github.zly2006.zhihu.data.AigcVoteReadEventsResponse import com.github.zly2006.zhihu.data.AigcVoteReadEvidence import com.github.zly2006.zhihu.data.Collection import com.github.zly2006.zhihu.data.CollectionResponse @@ -46,6 +51,7 @@ import com.github.zly2006.zhihu.navigation.CollectionAnswerNavigator import com.github.zly2006.zhihu.navigation.PaginationInfoNavigator import com.github.zly2006.zhihu.navigation.QuestionAnswerNavigator import com.github.zly2006.zhihu.platform.UserMessageSink +import com.github.zly2006.zhihu.platform.isAigcVoteSupported import com.github.zly2006.zhihu.util.ArticleExportComment import com.github.zly2006.zhihu.util.Log import com.github.zly2006.zhihu.util.ZhidaSummarySsePayload @@ -66,6 +72,8 @@ import io.ktor.client.call.body import io.ktor.client.request.accept import io.ktor.client.request.get import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsChannel @@ -96,7 +104,9 @@ import kotlin.time.Clock class ArticleViewModel( private val article: Article, val httpClient: HttpClient?, - private val userMessages: UserMessageSink = UserMessageSink(showShortMessage = {}), + private val userMessages: UserMessageSink = object : UserMessageSink { + override fun showShortMessage(message: String) = Unit + }, registerOnPause: (((() -> Unit) -> Unit))? = null, ) : ViewModel() { var permissionRequestCount by mutableIntStateOf(0) @@ -248,7 +258,7 @@ class ArticleViewModel( withContext(Dispatchers.Default) { try { if (article.type == ArticleType.Answer) { - val sharedData = environment.articleAnswerSwitchState() + val sharedData = sharedArticleAnswerSwitchState val answer = environment.fetchContentDetail(article) as? DataHolder.Answer if (answer != null) { exportSourceContent = answer @@ -293,21 +303,21 @@ class ArticleViewModel( environment.recordOpenEvent(article, answer.question.id) withContext(Dispatchers.Main.immediate) { // 设置问题回答导航器(如果当前不是收藏夹导航器) - if (sharedData?.navigator !is CollectionAnswerNavigator) { - val existingNav = sharedData?.navigator + if (sharedData.navigator !is CollectionAnswerNavigator) { + val existingNav = sharedData.navigator val isSameQuestion = when (existingNav) { is QuestionAnswerNavigator -> existingNav.questionId == questionId is PaginationInfoNavigator -> existingNav.questionId == questionId else -> false } if (!isSameQuestion) { - sharedData?.navigator = QuestionAnswerNavigator( + sharedData.navigator = QuestionAnswerNavigator( questionId = questionId, environment = environment, ) } } - sharedData?.navigator?.pushAnswer( + sharedData.navigator?.pushAnswer( toCachedContent(sourceLabel = sharedData.navigator?.sourceName ?: "此问题"), ) } @@ -316,7 +326,7 @@ class ArticleViewModel( // 仅在无前向历史时预取下一个回答 withContext(Dispatchers.Main.immediate) { - sharedData?.navigator?.let { nav -> + sharedData.navigator?.let { nav -> if (nav.currentAnswerIndex >= nav.answerHistory.size - 1) { nav.prefetchNext(article.id) } @@ -646,21 +656,28 @@ class ArticleViewModel( fun isAigcFlagEvidenceReady(): Boolean = currentAigcReadEvidence().isEligibleForCredit() fun loadAigcFlagStatus(environment: AigcVoteEnvironment) { - val client = environment.aigcVoteClient() - aigcVoteAvailable = client != null + aigcVoteAvailable = isAigcVoteSupported && environment.isAigcVoteEnabled() val voter = environment.aigcVoteVoter() aigcVoterName = voter?.name.orEmpty() - if (client == null) return + if (!aigcVoteAvailable) return + val client = environment.aigcVoteHttpClient() viewModelScope.launch { aigcVoteLoading = true aigcVoteError = null try { - val response = client.getFlagStatus( - contentType = aigcContentType(), - contentId = article.id.toString(), - voter = voter, - ) + val response = client + .get( + "${environment.aigcVoteBaseUrl().trimEnd('/')}/v1/contents/" + + "${aigcContentType()}/${article.id}/aigc-flag", + ) { + parameter("client_id", environment.aigcVoteClientId()) + voter?.let { + parameter("voter_id", it.id) + parameter("voter_name", it.name) + it.urlToken?.takeIf(String::isNotBlank)?.let { token -> parameter("voter_url_token", token) } + } + }.body<AigcVoteFlagStatusResponse>() aigcFlagged = response.myFlagged aigcVoteCredit = response.credit aigcVoteProgress = response.progress @@ -681,9 +698,9 @@ class ArticleViewModel( } fun syncAigcReadEventIfEligible(environment: AigcVoteEnvironment) { - val client = environment.aigcVoteClient() - aigcVoteAvailable = client != null - if (client == null || aigcReadSyncStarted || content.isBlank()) return + aigcVoteAvailable = isAigcVoteSupported && environment.isAigcVoteEnabled() + if (!aigcVoteAvailable || aigcReadSyncStarted || content.isBlank()) return + val client = environment.aigcVoteHttpClient() val evidence = currentAigcReadEvidence() val contentUpdatedAt = currentContentUpdatedAt() @@ -692,17 +709,20 @@ class ArticleViewModel( viewModelScope.launch { try { - val response = client.syncReadEvent( - AigcVoteReadEvent( - contentType = aigcContentType(), - contentId = article.id.toString(), - title = title, - authorHash = currentAuthorHash(), - contentHtml = content, - contentUpdatedAt = contentUpdatedAt, - evidence = evidence, - ), + val event = AigcVoteReadEvent( + contentType = aigcContentType(), + contentId = article.id.toString(), + title = title, + authorHash = currentAuthorHash(), + contentHtml = content, + contentUpdatedAt = contentUpdatedAt, + evidence = evidence, ) + val response = client + .post("${environment.aigcVoteBaseUrl().trimEnd('/')}/v1/read-events:batch") { + contentType(ContentType.Application.Json) + setBody(AigcVoteReadEventsRequest(environment.aigcVoteClientId(), listOf(event.toRequestEvent()))) + }.body<AigcVoteReadEventsResponse>() aigcVoteCredit = response.credit aigcVoteProgress = response.progress aigcVoteCap = response.cap @@ -718,13 +738,13 @@ class ArticleViewModel( } fun submitAigcFlag(environment: AigcVoteEnvironment) { - val client = environment.aigcVoteClient() - aigcVoteAvailable = client != null - if (client == null) { + aigcVoteAvailable = isAigcVoteSupported && environment.isAigcVoteEnabled() + if (!aigcVoteAvailable) { aigcVoteError = "未配置 AIGC 投票服务" userMessages.showShortMessage(aigcVoteError!!) return } + val client = environment.aigcVoteHttpClient() if (content.isBlank()) { aigcVoteError = "正文尚未加载完成" userMessages.showShortMessage(aigcVoteError!!) @@ -752,18 +772,34 @@ class ArticleViewModel( aigcVoteLoading = true aigcVoteError = null try { - val response = client.submitFlag( - AigcVoteFlagSubmission( - contentType = aigcContentType(), - contentId = article.id.toString(), - voter = voter, - title = title, - authorHash = currentAuthorHash(), - contentHtml = content, - contentUpdatedAt = currentContentUpdatedAt(), - evidence = currentAigcReadEvidence(), - ), + val submission = AigcVoteFlagSubmission( + contentType = aigcContentType(), + contentId = article.id.toString(), + voter = voter, + title = title, + authorHash = currentAuthorHash(), + contentHtml = content, + contentUpdatedAt = currentContentUpdatedAt(), + evidence = currentAigcReadEvidence(), ) + val response = client + .post( + "${environment.aigcVoteBaseUrl().trimEnd('/')}/v1/contents/" + + "${submission.contentType}/${submission.contentId}/aigc-flag", + ) { + contentType(ContentType.Application.Json) + setBody( + AigcVoteFlagRequest( + clientId = environment.aigcVoteClientId(), + voter = submission.voter, + title = submission.title, + authorHash = submission.authorHash, + contentHtml = submission.contentHtml, + contentUpdatedAt = submission.contentUpdatedAt, + evidence = submission.evidence.toFlagEvidence(), + ), + ) + }.body<AigcVoteFlagResponse>() aigcFlagged = response.myFlagged aigcVoteCredit = response.credit aigcCreditBypassAvailable = response.creditBypassAvailable @@ -967,16 +1003,7 @@ class ArticleViewModel( var preparedWebView: PreparedArticleExportContent? = null var bitmap: Any? = null - val renderer = environment.articleImageExportRenderer { fileName -> - try { - environment.loadExportAssetText(fileName) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e("ArticleViewModel", "Failed to load export asset: $fileName", e) - "" - } - }!! + val renderer = environment.articleImageExportRenderer() try { preparedWebView = renderer.prepareExportWebView( htmlContent = createHtmlContent( diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationViewModel.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationViewModel.kt index bba6f3511..1fdc3a79e 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationViewModel.kt @@ -24,8 +24,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.zly2006.zhihu.account.ZhihuIdentityClient -import com.github.zly2006.zhihu.data.AigcVoteClient import com.github.zly2006.zhihu.data.AigcVoteVoter import com.github.zly2006.zhihu.data.ContentDetailCache import com.github.zly2006.zhihu.data.DataHolder @@ -41,6 +39,7 @@ import com.github.zly2006.zhihu.data.getOrFetchContentDetail import com.github.zly2006.zhihu.navigation.AnswerNavigator import com.github.zly2006.zhihu.navigation.Article import com.github.zly2006.zhihu.navigation.NavDestination +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.ui.ArticleAnswerSwitchState import com.github.zly2006.zhihu.ui.ArticleAnswerTransitionDirection import com.github.zly2006.zhihu.util.Log @@ -223,6 +222,8 @@ open class ArticleAnswerSwitchData : override fun promoteForNavigation(direction: ArticleAnswerTransitionDirection) = Unit } +val sharedArticleAnswerSwitchState = ArticleAnswerSwitchData() + interface PreparedArticleExportContent interface ArticleImageExportRenderer { @@ -291,31 +292,6 @@ interface ZhihuApiEnvironment { } } -interface AccountEnvironment { - suspend fun refreshAccountProfile() = Unit - - fun requestLogin(): Boolean = false - - fun clearAccountSession() = Unit - - fun currentAccountId(): String = "" - - fun identityClient(): ZhihuIdentityClient? = null - - fun restartApplication() = Unit - - suspend fun verifyLogin(cookies: Map<String, String>): Boolean = false - - fun saveCookies(cookies: Map<String, String>) = Unit - - fun logout() = clearAccountSession() - - fun requestRelogin(): Boolean { - clearAccountSession() - return requestLogin() - } -} - suspend fun ZhihuApiEnvironment.fetchContentDetail(destination: NavDestination): DataHolder.Content? = runCatching { fetchZhihuContentDetail(destination) { url, include -> @@ -441,7 +417,13 @@ interface ContentOpenEnvironment { } interface AigcVoteEnvironment { - fun aigcVoteClient(): AigcVoteClient? = null + fun isAigcVoteEnabled(): Boolean = false + + fun aigcVoteHttpClient(): HttpClient = error("AIGC 内容标记客户端不可用") + + fun aigcVoteBaseUrl(): String = "" + + fun aigcVoteClientId(): String = "" fun aigcVoteVoter(): AigcVoteVoter? = null } @@ -520,17 +502,14 @@ interface ArticleExportEnvironment { bitmap: Any, ) = Unit - fun articleImageExportRenderer(loadAssetText: (String) -> String): ArticleImageExportRenderer? = null + fun articleImageExportRenderer(): ArticleImageExportRenderer = + error("$platformName 暂不支持文章图片导出") } interface ArticleExportContentEnvironment : ArticleExportEnvironment, ZhihuApiEnvironment -interface ArticleNavigationEnvironment { - fun articleAnswerSwitchState(): ArticleAnswerSwitchState? = null -} - interface ContentLoadEnvironment : ZhihuApiEnvironment, HistoryEnvironment, @@ -543,12 +522,10 @@ interface ProfileLoadEnvironment : interface ArticleLoadEnvironment : ZhihuApiEnvironment, - ContentLoadEnvironment, - ArticleNavigationEnvironment + ContentLoadEnvironment interface PaginationEnvironment : ZhihuApiEnvironment, - AccountEnvironment, MobileHomeFeedEnvironment, FeedDisplayEnvironment, ContentInteractionEnvironment, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/filter/ContentFilterExtensions.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/filter/ContentFilterExtensions.kt index e9e0d86d2..dfa84b1a2 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/filter/ContentFilterExtensions.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/filter/ContentFilterExtensions.kt @@ -29,6 +29,7 @@ import com.github.zly2006.zhihu.navigation.Article import com.github.zly2006.zhihu.navigation.NavDestination import com.github.zly2006.zhihu.navigation.Pin import com.github.zly2006.zhihu.platform.SettingsStore +import com.github.zly2006.zhihu.util.Log import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -95,7 +96,6 @@ class FeedContentFilterPipeline( private val blockedQuestionAuthorDao: BlockedQuestionAuthorDao, private val blockedTopicDao: BlockedTopicDao, private val blockedKeywordService: BlockedKeywordService, - private val htmlToText: (String) -> String = { html -> Ksoup.parse(html).text() }, private val onNlpBlocked: suspend (List<FilterableContent>) -> Unit = {}, ) { suspend fun filter(contents: List<FilterableContent>): FeedContentFilterResult { @@ -132,14 +132,14 @@ class FeedContentFilterPipeline( } if (settings.enableNlpBlocking) { - val blockedThisRound = mutableListOf<FilterableContent>() val finalFilteredContents = mutableListOf<FilterableContent>() + val blockedThisRound = mutableListOf<FilterableContent>() for (content in filteredContents) { val (shouldBlock, matchedKeywords) = blockedKeywordService.checkNLPBlockingWithWeight( title = content.title, excerpt = content.summary, - content = content.content?.let(htmlToText), + content = content.content?.let { Ksoup.parse(it).text() }, threshold = settings.nlpSimilarityThreshold, ) @@ -223,8 +223,6 @@ class FeedDisplayFilterPipeline( private val contentDetailProvider: ContentDetailProvider, private val contentFilterPipeline: FeedContentFilterPipeline, private val blockedFeedRecordDao: BlockedFeedRecordDao, - private val onDetailFetchFailed: (FeedDisplayItem) -> Unit = {}, - private val onDetailsKeywordFiltered: (FeedDisplayItem, String) -> Unit = { _, _ -> }, ) { suspend fun filter(items: List<FeedDisplayItem>): List<FeedDisplayItem> { val (followedUserItems, otherItems) = if (!settings.filterFollowedUserContent) { @@ -246,7 +244,7 @@ class FeedDisplayFilterPipeline( val rawContent = resolveRawContent(item) if (rawContent is DataHolder.DummyContent) { - onDetailFetchFailed(item) + Log.w("ContentFilterExtensions", "Failed to fetch content details for item '${item.title}'. Using dummy content for filtering.") } item to item.toFilterableContent(identity, rawContent) @@ -309,7 +307,7 @@ class FeedDisplayFilterPipeline( detailsPostFilterKeywords.none { keyword -> val shouldFilter = item.details.contains(keyword) if (shouldFilter) { - onDetailsKeywordFiltered(item, keyword) + Log.e("ContentFilterExtensions", "Filtered item '${item.title}' due to keyword '$keyword' in details: ${item.content}") } shouldFilter } diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/CrawlingExecutor.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/CrawlingExecutor.kt index 6cfecfa74..7c9c51be9 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/CrawlingExecutor.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/CrawlingExecutor.kt @@ -20,9 +20,11 @@ package com.github.zly2006.zhihu.viewmodel.local import com.github.zly2006.zhihu.data.Feed import com.github.zly2006.zhihu.data.ZhihuJson import com.github.zly2006.zhihu.navigation.zhihuQuestionFeedsUrl +import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonArray import kotlin.time.Clock /** @@ -30,8 +32,7 @@ import kotlin.time.Clock */ class CrawlingExecutor( private val dao: LocalContentDao, - private val fetchFeedArray: suspend (String) -> JsonArray, - private val nowMillis: () -> Long = { Clock.System.now().toEpochMilliseconds() }, + private val environment: ZhihuApiEnvironment, ) { /** * 执行爬虫任务 @@ -43,7 +44,8 @@ class CrawlingExecutor( dao.updateTask( task.copy( status = CrawlingStatus.InProgress, - executedAt = nowMillis(), + executedAt = crawlingNowMillisForTesting?.invoke() + ?: Clock.System.now().toEpochMilliseconds(), ), ) @@ -139,7 +141,9 @@ class CrawlingExecutor( private suspend fun executeCollaborativeFilteringTask(task: CrawlingTask): List<CrawlingResult> { // 基于用户行为的协同过滤推荐 // 获取用户最近点赞的内容,用于发现相似用户 - val recentLikes = dao.getBehaviorsByActionSince("like", nowMillis() - 7 * 24 * 60 * 60 * 1000) + val nowMillis = crawlingNowMillisForTesting?.invoke() + ?: Clock.System.now().toEpochMilliseconds() + val recentLikes = dao.getBehaviorsByActionSince("like", nowMillis - 7 * 24 * 60 * 60 * 1000) if (recentLikes.isEmpty()) { // 如果没有点赞行为,回退到推荐内容 @@ -158,4 +162,12 @@ class CrawlingExecutor( } } } + + private suspend fun fetchFeedArray(url: String): JsonArray = + crawlingFeedArrayForTesting?.invoke(url) + ?: environment.fetchJson(url, "")?.get("data")?.jsonArray + ?: JsonArray(emptyList()) } + +var crawlingFeedArrayForTesting: (suspend (String) -> JsonArray)? = null +var crawlingNowMillisForTesting: (() -> Long)? = null diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalContentInitializer.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalContentInitializer.kt index ad54ac9cc..aa7c46aaa 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalContentInitializer.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalContentInitializer.kt @@ -24,7 +24,6 @@ import kotlin.time.Clock class LocalContentInitializer( private val dao: LocalContentDao, - private val nowMillis: () -> Long = { Clock.System.now().toEpochMilliseconds() }, ) { suspend fun initializeIfNeeded() { withContext(Dispatchers.Default) { @@ -72,7 +71,10 @@ class LocalContentInitializer( ) } - val recentLikes = dao.getBehaviorsByActionSince("like", nowMillis() - 30 * 24 * 60 * 60 * 1000L) + val recentLikes = dao.getBehaviorsByActionSince( + "like", + Clock.System.now().toEpochMilliseconds() - 30 * 24 * 60 * 60 * 1000L, + ) val questionIds = recentLikes .mapNotNull { behavior -> extractQuestionIdFromContentId(behavior.contentId) } .distinct() diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationEngine.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationEngine.kt index c454da979..38aeb378c 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationEngine.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationEngine.kt @@ -21,6 +21,8 @@ import com.github.zly2006.zhihu.data.CommonFeed import com.github.zly2006.zhihu.data.Feed import com.github.zly2006.zhihu.data.target import com.github.zly2006.zhihu.navigation.NavDestination +import com.github.zly2006.zhihu.util.Log +import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment import com.github.zly2006.zhihu.viewmodel.local.LocalReasonPreference import com.github.zly2006.zhihu.viewmodel.local.applyReasonDiversity import com.github.zly2006.zhihu.viewmodel.local.buildLocalRecommendationReason @@ -30,21 +32,17 @@ import com.github.zly2006.zhihu.viewmodel.local.toLocalContentIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -import kotlinx.serialization.json.JsonArray import kotlin.time.Clock class LocalRecommendationEngine( private val dao: LocalContentDao, - private val feedGenerator: FeedGenerator, - private val userBehaviorAnalyzer: UserBehaviorAnalyzer, - private val initializeContentIfNeeded: suspend () -> Unit, - private val startScheduling: () -> Unit, - private val stopScheduling: () -> Unit, - private val executeTask: suspend (CrawlingTask) -> Unit, - private val isNetworkAvailable: () -> Boolean = { true }, - private val logWarning: (String) -> Unit = {}, - private val logError: (String, Throwable) -> Unit = { _, _ -> }, + private val crawlingExecutor: CrawlingExecutor, ) { + private val feedGenerator = FeedGenerator(dao) + private val userBehaviorAnalyzer = UserBehaviorAnalyzer(dao) + private val contentInitializer = LocalContentInitializer(dao) + private val taskScheduler = TaskScheduler(dao, crawlingExecutor) + @kotlin.concurrent.Volatile private var initialized = false @@ -57,8 +55,10 @@ class LocalRecommendationEngine( if (initialized) { return@withContext } - initializeContentIfNeeded() - startScheduling() + localRecommendationEngineForTesting?.initializeContent?.invoke() + ?: contentInitializer.initializeIfNeeded() + localRecommendationEngineForTesting?.startScheduling?.invoke() + ?: taskScheduler.startScheduling() initialized = true } } @@ -119,21 +119,17 @@ class LocalRecommendationEngine( } private suspend fun executeHighPriorityTasks() { - if (!isNetworkAvailable()) { - logWarning("No network connection, skipping task execution") - return - } - dao .getTasksByStatus(CrawlingStatus.NotStarted) .sortedByDescending { it.priority } .take(3) .forEach { task -> try { - executeTask(task) + localRecommendationEngineForTesting?.executeTask?.invoke(task) + ?: crawlingExecutor.executeTask(task) delay(1_000L) } catch (e: Exception) { - logError("Task execution failed: ${e.message}", e) + Log.e("LocalRecommendationEngine", "Task execution failed: ${e.message}", e) } } } @@ -141,33 +137,16 @@ class LocalRecommendationEngine( fun buildLocalRecommendationEngine( dao: LocalContentDao, - fetchFeedArray: suspend (String) -> JsonArray, - isNetworkAvailable: () -> Boolean = { true }, - logWarning: (String) -> Unit = {}, - logError: (String, Throwable) -> Unit = { _, _ -> }, -): LocalRecommendationEngine { - val crawlingExecutor = CrawlingExecutor( - dao = dao, - fetchFeedArray = fetchFeedArray, - ) - val taskScheduler = TaskScheduler( - dao = dao, - executeTask = { task -> crawlingExecutor.executeTask(task) }, - ) - val contentInitializer = LocalContentInitializer(dao) - return LocalRecommendationEngine( - dao = dao, - feedGenerator = FeedGenerator(dao), - userBehaviorAnalyzer = UserBehaviorAnalyzer(dao), - initializeContentIfNeeded = { contentInitializer.initializeIfNeeded() }, - startScheduling = { taskScheduler.startScheduling() }, - stopScheduling = { taskScheduler.stopScheduling() }, - executeTask = { task -> crawlingExecutor.executeTask(task) }, - isNetworkAvailable = isNetworkAvailable, - logWarning = logWarning, - logError = logError, - ) -} + environment: ZhihuApiEnvironment, +): LocalRecommendationEngine = LocalRecommendationEngine(dao, CrawlingExecutor(dao, environment)) + +data class LocalRecommendationEngineTestOverrides( + val initializeContent: suspend () -> Unit = {}, + val startScheduling: () -> Unit = {}, + val executeTask: suspend (CrawlingTask) -> Unit = {}, +) + +var localRecommendationEngineForTesting: LocalRecommendationEngineTestOverrides? = null data class RankedLocalResult( val result: CrawlingResult, diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/TaskScheduler.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/TaskScheduler.kt index 8cb2eff45..2e6d1f9de 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/TaskScheduler.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/viewmodel/local/TaskScheduler.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch */ class TaskScheduler( private val dao: LocalContentDao, - private val executeTask: suspend (CrawlingTask) -> Unit, + private val crawlingExecutor: CrawlingExecutor, ) { private var schedulerJob: Job? = null @@ -52,14 +52,6 @@ class TaskScheduler( } } - /** - * 停止调度 - */ - fun stopScheduling() { - schedulerJob?.cancel() - schedulerJob = null - } - /** * 执行待处理的任务 */ @@ -69,7 +61,7 @@ class TaskScheduler( // 限制并发执行的任务数量 pendingTasks.take(3).forEach { task -> try { - executeTask(task) + crawlingExecutor.executeTask(task) } catch (e: Exception) { // 忽略单个任务的执行错误 } diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClientTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClientTest.kt index 941f57909..006eb233f 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClientTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountClientTest.kt @@ -5,22 +5,14 @@ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. */ package com.github.zly2006.zhihu.account -import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig -import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.client.request.get import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf @@ -32,67 +24,36 @@ import kotlin.test.assertSame class ZhihuAccountClientTest { @Test - fun reusesCachedHttpClientUntilSessionChanges() { - val store = ClientInMemoryAccountSessionStore() - val createdClients = mutableListOf<HttpClient>() - val accountClient = ZhihuAccountClient( - repository = ZhihuAccountRepository(store), - createClient = { cookies, session, onCookieChanged, _ -> - testHttpClient(cookies, session, onCookieChanged).also(createdClients::add) - }, - ) - - val firstClient = accountClient.httpClient() - - assertSame(firstClient, accountClient.httpClient()) - - accountClient.save( - ZhihuAccountSession( + fun sameIdentitySessionRefreshKeepsTheBoundClient() = runTest { + withAccountEngine(emptyMockEngine()) { + val repository = ZhihuAccountRepository(ClientInMemoryAccountSessionStore()).apply { + save( + ZhihuAccountSession( + login = true, + username = "alice", + cookies = mutableMapOf("d_c0" to "old-dc0"), + ), + ) + } + val accountData = ZhihuAccountStore(repository) + val firstClient = accountData.client + val nextSession = ZhihuAccountSession( login = true, username = "alice", cookies = mutableMapOf("d_c0" to "new-dc0"), - ), - ) - val secondClient = accountClient.httpClient() - - assertNotSame(firstClient, secondClient) - assertEquals(2, createdClients.size) - secondClient.close() - } - - @Test - fun cookieChangePersistsSessionWithoutInvalidatingClient() { - val store = ClientInMemoryAccountSessionStore() - val repository = ZhihuAccountRepository(store) - repository.save( - ZhihuAccountSession( - login = true, - username = "alice", - cookies = mutableMapOf("z_c0" to "old-token"), - ), - ) - var factoryCookies: MutableMap<String, String>? = null - var factoryOnCookieChanged: (() -> Unit)? = null - val accountClient = ZhihuAccountClient( - repository = repository, - createClient = { cookies, session, onCookieChanged, _ -> - factoryCookies = cookies - factoryOnCookieChanged = onCookieChanged - testHttpClient(cookies, session, onCookieChanged) - }, - ) - val client = accountClient.httpClient() + ) - factoryCookies?.set("z_c0", "new-token") - factoryOnCookieChanged?.invoke() + accountData.save(nextSession) - assertEquals("new-token", repository.load().cookies["z_c0"]) - assertSame(client, accountClient.httpClient()) - client.close() + assertEquals(nextSession.username, accountData.session.username) + assertEquals(nextSession.username, accountData.client.load().username) + assertSame(firstClient, accountData.client) + accountData.close() + } } @Test - fun inPlaceCookieMutationInvalidatesCachedHttpClientOnSave() { + fun cookieChangesPersistTheBoundSessionWithoutCreatingAnotherState() = runTest { val store = ClientInMemoryAccountSessionStore() val repository = ZhihuAccountRepository(store) repository.save( @@ -102,25 +63,31 @@ class ZhihuAccountClientTest { cookies = mutableMapOf("z_c0" to "old-token"), ), ) - val accountClient = ZhihuAccountClient( - repository = repository, - createClient = { cookies, session, onCookieChanged, _ -> - testHttpClient(cookies, session, onCookieChanged) + withAccountEngine( + MockEngine { + respond( + content = "{}", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.SetCookie, "z_c0=new-token; Path=/"), + ) }, - ) - val firstClient = accountClient.httpClient() - val session = accountClient.load() - session.cookies["z_c0"] = "new-token" + ) { + val accountData = ZhihuAccountStore(repository) + val session = accountData.session + val client = accountData.client - accountClient.save(session) + client.httpClient().get("https://www.zhihu.com/") - val secondClient = accountClient.httpClient() - assertNotSame(firstClient, secondClient) - secondClient.close() + assertNotSame(session, accountData.session) + assertSame(client, accountData.client) + assertEquals("new-token", accountData.session.cookies["z_c0"]) + assertEquals("new-token", repository.load().cookies["z_c0"]) + accountData.close() + } } @Test - fun profileRefreshPreservesMobileTokens() = runTest { + fun profileRefreshKeepsClientAndPreservesMobileTokens() = runTest { val repository = ZhihuAccountRepository(ClientInMemoryAccountSessionStore()) repository.save( ZhihuAccountSession( @@ -132,97 +99,254 @@ class ZhihuAccountClientTest { mobileTokenExpiresAt = 1234, ), ) - val accountClient = ZhihuAccountClient( - repository = repository, - createClient = { cookies, session, onCookieChanged, _ -> - HttpClient( - MockEngine { - respond( - content = """{"id":"1","name":"alice","url_token":"alice","user_type":"people"}""", - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - }, - ) { - installZhihuCommonClientConfig(cookies, session.userAgent, onCookieChanged) - } + withAccountEngine( + MockEngine { + respond( + content = """{"id":"1","name":"alice","url_token":"alice","user_type":"people"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) }, - ) + ) { + val accountData = ZhihuAccountStore(repository) + val oldClient = accountData.client - val refreshed = accountClient.refreshAndSaveProfile() + val refreshed = accountData.client.refreshAndSaveProfile() - assertEquals("access", refreshed?.mobileAccessToken) - assertEquals("refresh", repository.load().mobileRefreshToken) - assertEquals("bearer", repository.load().mobileTokenType) - assertEquals(1234, repository.load().mobileTokenExpiresAt) + assertEquals("access", refreshed?.mobileAccessToken) + assertEquals("refresh", accountData.session.mobileRefreshToken) + assertSame(oldClient, accountData.client) + accountData.close() + } } @Test - fun mobileLoginVerifiesProfileAndSavesCookiesAndTokensTogether() = runTest { + fun mobileLoginVerifiesProfileAndAtomicallyReplacesSessionAndClient() = runTest { val store = ClientInMemoryAccountSessionStore() val repository = ZhihuAccountRepository(store) - val accountClient = ZhihuAccountClient( - repository = repository, - createClient = { cookies, session, onCookieChanged, _ -> - HttpClient( - MockEngine { request -> - assertEquals("/api/v4/me", request.url.encodedPath) - respond( - content = """{"id":"1","name":"alice","url_token":"alice","user_type":"people"}""", - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - }, - ) { - installZhihuCommonClientConfig(cookies, session.userAgent, onCookieChanged) - } + withAccountEngine( + MockEngine { request -> + assertEquals("/api/v4/me", request.url.encodedPath) + respond( + content = """{"id":"1","name":"alice","url_token":"alice","user_type":"people"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) }, - ) + ) { + val accountData = ZhihuAccountStore(repository) + val oldClient = accountData.client - val verified = accountClient.verifyMobileAndSave( - ZhihuMobileLoginToken( - accessToken = "mobile-access", - refreshToken = "mobile-refresh", - tokenType = "bearer", - expiresAt = 1_700_003_600L, - cookies = mapOf( - "q_c0" to "q-cookie", - "z_c0" to "z-cookie", - "d_c0" to "device-cookie", + val verified = accountData.login( + ZhihuMobileLoginToken( + accessToken = "mobile-access", + refreshToken = "mobile-refresh", + tokenType = "bearer", + expiresAt = 1_700_003_600L, + cookies = mapOf( + "q_c0" to "q-cookie", + "z_c0" to "z-cookie", + "d_c0" to "device-cookie", + ), ), - ), + ) + + assertEquals(true, verified) + assertEquals(1, store.writeCount) + assertEquals("alice", accountData.session.username) + assertEquals("z-cookie", accountData.session.cookies["z_c0"]) + assertEquals("device-cookie", accountData.session.cookies["d_c0"]) + assertEquals("mobile-access", accountData.session.mobileAccessToken) + assertEquals("mobile-refresh", accountData.session.mobileRefreshToken) + assertNotSame(oldClient, accountData.client) + assertSame(accountData.session, accountData.client.load()) + accountData.close() + } + } + + @Test + fun logoutDestroysTheBoundClientAndPublishesOneGuestSession() = runTest { + withAccountEngine( + MockEngine { + respond( + content = """{"id":"bob","name":"Bob","url_token":"bob","user_type":"people"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + val repository = ZhihuAccountRepository(ClientInMemoryAccountSessionStore()).apply { + save( + ZhihuAccountSession( + login = true, + username = "alice", + cookies = mutableMapOf("z_c0" to "token"), + ), + ) + } + val accountData = ZhihuAccountStore(repository) + val signedInClient = accountData.client + + accountData.clear() + + assertEquals(false, accountData.session.login) + assertSame(accountData.session, accountData.client.load()) + assertNotSame(signedInClient, accountData.client) + accountData.close() + } + } + + @Test + fun switchingSavedAccountsReplacesClientAndPreservesBothSessions() = runTest { + val alice = ZhihuAccountSession(login = true, username = "alice") + val bob = ZhihuAccountSession(login = true, username = "bob") + val repository = ZhihuAccountRepository(ClientInMemoryAccountSessionStore()).apply { + saveAccounts( + ZhihuAccounts( + activeAccountId = "alice", + accounts = listOf(ZhihuSavedAccount("alice", alice), ZhihuSavedAccount("bob", bob)), + ), + ) + } + withAccountEngine( + MockEngine { + respond( + content = """{"id":"bob","name":"Bob","url_token":"bob","user_type":"people"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + val accountStore = ZhihuAccountStore(repository) + val aliceClient = accountStore.client + + accountStore.switchAccount("bob") + + assertEquals("bob", accountStore.session.profile?.id) + assertEquals(alice, accountStore.accounts.first().session) + assertEquals( + "bob", + accountStore.accounts + .last() + .session.profile + ?.id, + ) + assertNotSame(aliceClient, accountStore.client) + assertSame(accountStore.session, accountStore.client.load()) + accountStore.close() + } + } + + @Test + fun expiredSavedAccountDoesNotReplaceTheCurrentSessionOrClient() = runTest { + val alice = ZhihuAccountSession(login = true, username = "alice") + val bob = ZhihuAccountSession(login = true, username = "bob") + val repository = ZhihuAccountRepository(ClientInMemoryAccountSessionStore()).apply { + saveAccounts( + ZhihuAccounts( + activeAccountId = "alice", + accounts = listOf(ZhihuSavedAccount("alice", alice), ZhihuSavedAccount("bob", bob)), + ), + ) + } + withAccountEngine( + MockEngine { + respond( + content = "unauthorized", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + }, + ) { + val accountStore = ZhihuAccountStore(repository) + val client = accountStore.client + + assertEquals(false, accountStore.switchAccount("bob")) + assertEquals(alice, accountStore.session) + assertSame(client, accountStore.client) + accountStore.close() + } + } + + @Test + fun logoutCurrentSavedAccountSelectsTheNextOne() = runTest { + val alice = ZhihuAccountSession(login = true, username = "alice") + val bob = ZhihuAccountSession(login = true, username = "bob") + val repository = ZhihuAccountRepository(ClientInMemoryAccountSessionStore()).apply { + saveAccounts( + ZhihuAccounts( + activeAccountId = "alice", + accounts = listOf(ZhihuSavedAccount("alice", alice), ZhihuSavedAccount("bob", bob)), + ), + ) + } + withAccountEngine(emptyMockEngine()) { + val accountStore = ZhihuAccountStore(repository) + + accountStore.clear() + + assertEquals(bob, accountStore.session) + assertEquals(listOf("bob"), accountStore.accounts.map { it.id }) + accountStore.close() + } + } + + @Test + fun loginAddsAndSelectsAnIndependentAccountWithoutOverwritingCurrent() = runTest { + val alice = ZhihuAccountSession( + login = true, + username = "alice", + profile = ZhihuAccountProfileSnapshot(id = "alice", name = "Alice"), ) + val repository = ZhihuAccountRepository(ClientInMemoryAccountSessionStore()).apply { save(alice) } + withAccountEngine( + MockEngine { + respond( + content = """{"id":"bob","name":"Bob","url_token":"bob","user_type":"people"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + val accountStore = ZhihuAccountStore(repository) + + val loggedIn = accountStore.login(mutableMapOf("z_c0" to "bob-token")) - val saved = repository.load() - assertEquals(true, verified) - assertEquals(1, store.writeCount) - assertEquals("alice", saved.username) - assertEquals("z-cookie", saved.cookies["z_c0"]) - assertEquals("device-cookie", saved.cookies["d_c0"]) - assertEquals("mobile-access", saved.mobileAccessToken) - assertEquals("mobile-refresh", saved.mobileRefreshToken) - assertEquals("bearer", saved.mobileTokenType) - assertEquals(1_700_003_600L, saved.mobileTokenExpiresAt) + assertEquals(true, loggedIn) + assertEquals("bob", accountStore.session.profile?.id) + assertEquals(listOf("alice", "bob"), accountStore.accounts.map { it.id }) + assertEquals( + "alice", + accountStore.accounts + .first() + .session.profile + ?.id, + ) + accountStore.close() + } } - private fun testHttpClient( - cookies: MutableMap<String, String>, - session: ZhihuAccountSession, - onCookieChanged: () -> Unit, - ): HttpClient = HttpClient( + private fun emptyMockEngine(): HttpClientEngine = MockEngine { respond( content = "{}", status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) - }, - ) { - installZhihuCommonClientConfig( - cookies = cookies, - userAgent = session.userAgent, - onCookieChanged = onCookieChanged, - ) + } +} + +private suspend fun <T> withAccountEngine( + engine: HttpClientEngine, + block: suspend () -> T, +): T { + check(accountHttpClientEngineForTesting == null) + accountHttpClientEngineForTesting = engine + return try { + block() + } finally { + accountHttpClientEngineForTesting = null + engine.close() } } diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepositoryTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepositoryTest.kt index acea5d33f..a9c75cb04 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuAccountRepositoryTest.kt @@ -17,6 +17,7 @@ package com.github.zly2006.zhihu.account +import com.github.zly2006.zhihu.data.ZhihuJson import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlin.test.Test @@ -95,6 +96,40 @@ class ZhihuAccountRepositoryTest { assertNull(store.text) assertFalse(repository.load().login) } + + @Test + fun legacySessionMigratesIntoOneSavedAccount() { + val legacy = ZhihuAccountSession( + login = true, + username = "alice", + profile = ZhihuAccountProfileSnapshot(id = "alice-id", name = "Alice"), + ) + val store = InMemoryAccountSessionStore(ZhihuJson.json.encodeToString(legacy)) + + val accounts = ZhihuAccountRepository(store).loadAccounts() + + assertEquals("alice-id", accounts.activeAccountId) + assertEquals(listOf(legacy), accounts.accounts.map { it.session }) + } + + @Test + fun savedAccountsRoundTripWithoutLosingInactiveSession() { + val repository = ZhihuAccountRepository(InMemoryAccountSessionStore()) + val alice = ZhihuAccountSession(login = true, username = "alice") + val bob = ZhihuAccountSession(login = true, username = "bob") + + repository.saveAccounts( + ZhihuAccounts( + activeAccountId = "bob", + accounts = listOf(ZhihuSavedAccount("alice", alice), ZhihuSavedAccount("bob", bob)), + ), + ) + + val restored = repository.loadAccounts() + assertEquals("bob", restored.activeAccountId) + assertEquals(listOf("alice", "bob"), restored.accounts.map { it.id }) + assertEquals(bob, restored.session) + } } private class InMemoryAccountSessionStore( diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityClientTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityClientTest.kt deleted file mode 100644 index e8fb4bc7a..000000000 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuIdentityClientTest.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.account - -import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.http.content.TextContent -import io.ktor.http.headersOf -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ZhihuIdentityClientTest { - @Test - fun decodesIdentityAccountListWithMobileHeaders() = runTest { - val session = ZhihuAccountSession( - login = true, - username = "current", - cookies = mutableMapOf("z_c0" to "current-cookie"), - mobileAccessToken = "current-access", - mobileTokenType = "bearer", - ) - val currentClient = identityTestClient( - MockEngine { request -> - assertEquals(HttpMethod.Get, request.method) - assertEquals("/people/account/list", request.url.encodedPath) - assertEquals("bearer current-access", request.headers[HttpHeaders.Authorization]) - assertEquals("11.2.0", request.headers["x-app-version"]) - respondJson( - """ - { - "data": [ - { - "id": "parent-id", - "url_token": "parent-token", - "name": "主账号", - "avatar_url": "https://example.com/parent.png", - "is_active": true, - "can_create_sub_account": true, - "account_type": 1, - "sub_account_control_status": 0 - }, - { - "id": "child-id", - "url_token": "child-token", - "name": "马甲号", - "avatar_url": "https://example.com/child.png", - "is_active": true, - "can_create_sub_account": true, - "account_type": 2, - "sub_account_control_status": 0 - } - ] - } - """.trimIndent(), - ) - }, - session.cookies, - ) - val client = ZhihuIdentityClient( - currentClient = { currentClient }, - temporaryClient = { error("temporary client should not be created") }, - currentSession = { session }, - saveSession = { error("list should not save a session") }, - ) - - val accounts = client.listAccounts() - - assertEquals(listOf(1, 2), accounts.map { it.accountType }) - assertEquals(listOf("parent-id", "child-id"), accounts.map { it.id }) - assertTrue(accounts.first().canCreateSubAccount) - currentClient.close() - } - - @Test - fun switchSendsTargetUserIdAndAtomicallySavesIssuedSession() = runTest { - var savedSession = ZhihuAccountSession( - login = true, - username = "主账号", - cookies = mutableMapOf( - "d_c0" to "device-cookie", - "z_c0" to "old-account-cookie", - ), - mobileAccessToken = "old-access", - mobileRefreshToken = "old-refresh", - mobileTokenType = "bearer", - ) - val currentClient = identityTestClient( - MockEngine { request -> - assertEquals(HttpMethod.Post, request.method) - assertEquals("/account/switch", request.url.encodedPath) - assertEquals( - """{"target_user_id":"child-id"}""", - (request.body as TextContent).text, - ) - respondJson(issuedTokenJson()) - }, - savedSession.cookies, - ) - val temporaryEngine = MockEngine { request -> - assertEquals(HttpMethod.Get, request.method) - assertEquals("/people/self", request.url.encodedPath) - assertEquals("bearer new-access", request.headers[HttpHeaders.Authorization]) - assertTrue(request.headers[HttpHeaders.Cookie].orEmpty().contains("z_c0=new-account-cookie")) - respondJson(childProfileJson()) - } - val client = ZhihuIdentityClient( - currentClient = { currentClient }, - temporaryClient = { cookies -> identityTestClient(temporaryEngine, cookies) }, - currentSession = { savedSession }, - saveSession = { savedSession = it }, - ) - - val result = client.switchAccount("child-id") - - assertEquals("child-id", result.account.id) - assertEquals(2, result.account.accountType) - assertEquals("马甲号", savedSession.username) - assertEquals("device-cookie", savedSession.cookies["d_c0"]) - assertEquals("new-account-cookie", savedSession.cookies["z_c0"]) - assertEquals("new-q-cookie", savedSession.cookies["q_c0"]) - assertEquals("new-access", savedSession.mobileAccessToken) - assertEquals("new-refresh", savedSession.mobileRefreshToken) - assertEquals(123456789L, savedSession.mobileTokenExpiresAt) - assertEquals("child-id", savedSession.profile?.id) - assertTrue(savedSession.login) - currentClient.close() - } - - @Test - fun createSubAccountPostsWithoutRenameOrProfileMutationBody() = runTest { - var savedSession = ZhihuAccountSession( - login = true, - username = "主账号", - cookies = mutableMapOf("z_c0" to "old-account-cookie"), - ) - val currentClient = identityTestClient( - MockEngine { request -> - assertEquals(HttpMethod.Post, request.method) - assertEquals("/account/sub/register", request.url.encodedPath) - assertFalse(request.headers.contains(HttpHeaders.ContentType)) - assertFalse(request.body is TextContent) - respondJson(issuedTokenJson()) - }, - savedSession.cookies, - ) - val client = ZhihuIdentityClient( - currentClient = { currentClient }, - temporaryClient = { cookies -> - identityTestClient( - MockEngine { request -> - assertEquals("/people/self", request.url.encodedPath) - respondJson(childProfileJson()) - }, - cookies, - ) - }, - currentSession = { savedSession }, - saveSession = { savedSession = it }, - ) - - val result = client.createSubAccount() - - assertEquals("child-id", result.account.id) - assertEquals("马甲号", savedSession.username) - currentClient.close() - } - - private fun identityTestClient( - engine: MockEngine, - cookies: MutableMap<String, String>, - ): HttpClient = HttpClient(engine) { - installZhihuCommonClientConfig( - cookies = cookies, - userAgent = "test-agent", - ) - } - - private fun io.ktor.client.engine.mock.MockRequestHandleScope.respondJson(body: String) = respond( - content = body, - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), - ) - - private fun issuedTokenJson(): String = - """ - { - "uid": "child-id", - "user_id": 42, - "token_type": "bearer", - "access_token": "new-access", - "refresh_token": "new-refresh", - "expires_in": 2591999, - "cookie": { - "q_c0": "new-q-cookie", - "z_c0": "new-account-cookie" - }, - "expires_at": 123456789 - } - """.trimIndent() - - private fun childProfileJson(): String = - """ - { - "id": "child-id", - "url_token": "child-token", - "name": "马甲号", - "user_type": "people", - "avatar_url": "https://example.com/child.png", - "can_create_sub_account": true, - "account_type": 2, - "sub_account_control_status": 0 - } - """.trimIndent() -} diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClientTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClientTest.kt index 8aee3ba1d..58d127972 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClientTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/account/ZhihuPhoneLoginClientTest.kt @@ -17,9 +17,7 @@ package com.github.zly2006.zhihu.account -import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig import com.github.zly2006.zhihu.util.ZhihuMessageBodyEncryptor -import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.MockRequestHandleScope import io.ktor.client.engine.mock.respond @@ -30,6 +28,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.content.TextContent import io.ktor.http.headersOf import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -37,11 +36,19 @@ import kotlin.test.assertIs import kotlin.test.assertTrue class ZhihuPhoneLoginClientTest { + @AfterTest + fun clearTestOverrides() { + accountHttpClientEngineForTesting = null + phoneLoginCookiesForTesting = null + phoneLoginDeviceInfoForTesting = null + phoneLoginNowEpochSecondsForTesting = null + } + @Test fun guestCaptchaCheckAndDigitsRequestMatchOfficialProtocol() = runTest { val cookies = mutableMapOf<String, String>() var requestIndex = 0 - val httpClient = phoneLoginTestClient( + val client = phoneLoginTestClient( MockEngine { request -> requestIndex++ when (requestIndex) { @@ -124,27 +131,20 @@ class ZhihuPhoneLoginClientTest { }, cookies, ) - val client = ZhihuPhoneLoginClient( - httpClient = httpClient, - cookies = cookies, - deviceInfo = DEVICE_INFO, - nowEpochSeconds = { 1_700_000_000L }, - ) - val result = client.requestDigits("138 0013 8000") assertIs<ZhihuPhoneDigitsResult.Sent>(result) assertTrue("d_c0" !in cookies) assertEquals("guest-cookie", cookies["q_c0"]) assertEquals(4, requestIndex) - httpClient.close() + client.close() } @Test fun captchaBranchLoadsImageAndVerifiesUserInputBeforeSendingDigits() = runTest { val cookies = mutableMapOf("d_c0" to "device-cookie") var requestIndex = 0 - val httpClient = phoneLoginTestClient( + val client = phoneLoginTestClient( MockEngine { request -> requestIndex++ when (requestIndex) { @@ -174,26 +174,19 @@ class ZhihuPhoneLoginClientTest { }, cookies, ) - val client = ZhihuPhoneLoginClient( - httpClient = httpClient, - cookies = cookies, - deviceInfo = DEVICE_INFO, - nowEpochSeconds = { 1_700_000_000L }, - ) - val result = client.requestDigits("13800138000") assertEquals("image-data", assertIs<ZhihuPhoneDigitsResult.CaptchaRequired>(result).imageBase64) assertTrue(client.verifyCaptcha(" a7Bc ")) assertEquals(4, requestIndex) - httpClient.close() + client.close() } @Test fun completePhoneLoginKeepsPreinitializedDeviceCookieInReturnedSession() = runTest { val cookies = mutableMapOf<String, String>() var requestIndex = 0 - val httpClient = phoneLoginTestClient( + val client = phoneLoginTestClient( MockEngine { request -> requestIndex++ when (requestIndex) { @@ -257,13 +250,6 @@ class ZhihuPhoneLoginClientTest { }, cookies, ) - val client = ZhihuPhoneLoginClient( - httpClient = httpClient, - cookies = cookies, - deviceInfo = DEVICE_INFO, - nowEpochSeconds = { 1_700_000_000L }, - ) - assertIs<ZhihuPhoneDigitsResult.Sent>(client.requestDigits("13800138000")) val token = client.signIn("+8613800138000", "123456") @@ -276,14 +262,14 @@ class ZhihuPhoneLoginClientTest { assertEquals("account-z-cookie", token.cookies["z_c0"]) assertEquals("account-q-cookie", token.cookies["q_c0"]) assertEquals(5, requestIndex) - httpClient.close() + client.close() } @Test fun loginStopsBeforeSmsWhenUdidEndpointDoesNotSetDeviceCookie() = runTest { val cookies = mutableMapOf<String, String>() var requestIndex = 0 - val httpClient = phoneLoginTestClient( + val client = phoneLoginTestClient( MockEngine { request -> requestIndex++ assertEquals(HttpMethod.Post, request.method) @@ -292,27 +278,20 @@ class ZhihuPhoneLoginClientTest { }, cookies, ) - val client = ZhihuPhoneLoginClient( - httpClient = httpClient, - cookies = cookies, - deviceInfo = DEVICE_INFO, - nowEpochSeconds = { 1_700_000_000L }, - ) - val error = assertFailsWith<IllegalStateException> { client.requestDigits("13800138000") } assertEquals("服务器未返回必要的 Cookie d_c0", error.message) assertEquals(1, requestIndex) - httpClient.close() + client.close() } @Test fun exposesServerMessageForInvalidPhoneNumber() = runTest { val cookies = mutableMapOf("d_c0" to "device-cookie") var requestIndex = 0 - val httpClient = phoneLoginTestClient( + val client = phoneLoginTestClient( MockEngine { request -> requestIndex++ when (requestIndex) { @@ -337,26 +316,19 @@ class ZhihuPhoneLoginClientTest { }, cookies, ) - val client = ZhihuPhoneLoginClient( - httpClient = httpClient, - cookies = cookies, - deviceInfo = DEVICE_INFO, - nowEpochSeconds = { 1_700_000_000L }, - ) - val error = assertFailsWith<IllegalStateException> { client.requestDigits("13800138000") } assertTrue(error.message.orEmpty().contains("手机号格式错误")) - httpClient.close() + client.close() } @Test fun ticketNeededErrorRechecksCaptchaAndRetriesDigitsOnce() = runTest { val cookies = mutableMapOf("d_c0" to "device-cookie") var requestIndex = 0 - val httpClient = phoneLoginTestClient( + val client = phoneLoginTestClient( MockEngine { request -> requestIndex++ when (requestIndex) { @@ -383,26 +355,20 @@ class ZhihuPhoneLoginClientTest { }, cookies, ) - val client = ZhihuPhoneLoginClient( - httpClient = httpClient, - cookies = cookies, - deviceInfo = DEVICE_INFO, - nowEpochSeconds = { 1_700_000_000L }, - ) - assertIs<ZhihuPhoneDigitsResult.Sent>(client.requestDigits("13800138000")) assertEquals(5, requestIndex) - httpClient.close() + client.close() } private fun phoneLoginTestClient( engine: MockEngine, cookies: MutableMap<String, String>, - ): HttpClient = HttpClient(engine) { - installZhihuCommonClientConfig( - cookies = cookies, - userAgent = "test-agent", - ) + ): ZhihuPhoneLoginClient { + accountHttpClientEngineForTesting = engine + phoneLoginCookiesForTesting = cookies + phoneLoginDeviceInfoForTesting = DEVICE_INFO + phoneLoginNowEpochSecondsForTesting = { 1_700_000_000L } + return ZhihuPhoneLoginClient() } private fun MockRequestHandleScope.respondJson( diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/data/AigcVoteClientTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/data/AigcVoteClientTest.kt deleted file mode 100644 index cbd9a18f4..000000000 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/data/AigcVoteClientTest.kt +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.data - -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.http.content.TextContent -import io.ktor.http.headersOf -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class AigcVoteClientTest { - @Test - fun readEvidenceRequiresRealForegroundReadingSignals() { - assertFalse( - AigcVoteReadEvidence( - foregroundDurationMs = 2_000, - maxScrollRatio = 0.9, - openedAtEpochSeconds = 1_781_020_123, - ).isEligibleForCredit(), - ) - assertFalse( - AigcVoteReadEvidence( - foregroundDurationMs = 20_000, - maxScrollRatio = 0.05, - openedAtEpochSeconds = 1_781_020_123, - ).isEligibleForCredit(), - ) - assertTrue( - AigcVoteReadEvidence( - foregroundDurationMs = 20_000, - maxScrollRatio = 0.35, - openedAtEpochSeconds = 1_781_020_123, - ).isEligibleForCredit(), - ) - } - - @Test - fun syncReadEventPostsContentVersionAndEvidence() = runTest { - val client = AigcVoteClient( - httpClient = mockClient { requestBody -> - assertEquals("/v1/read-events:batch", requestBody.path) - assertEquals( - "answer", - requestBody.json["events"]!! - .jsonArraySingle()["content_type"]!! - .jsonPrimitive.content, - ) - assertEquals( - "42", - requestBody.json["events"]!! - .jsonArraySingle()["content_id"]!! - .jsonPrimitive.content, - ) - assertEquals( - "测试回答", - requestBody.json["events"]!! - .jsonArraySingle()["title"]!! - .jsonPrimitive.content, - ) - assertEquals( - "author-md5", - requestBody.json["events"]!! - .jsonArraySingle()["author_hash"]!! - .jsonPrimitive.content, - ) - assertEquals( - "<p>正文</p>", - requestBody.json["events"]!! - .jsonArraySingle()["content_html"]!! - .jsonPrimitive.content, - ) - assertEquals( - "1781020000", - requestBody.json["events"]!! - .jsonArraySingle()["content_updated_at"]!! - .jsonPrimitive.content, - ) - assertEquals( - "45000", - requestBody.json["events"]!! - .jsonArraySingle()["foreground_duration_ms"]!! - .jsonPrimitive.content, - ) - }, - baseUrl = "http://127.0.0.1:8787", - clientId = "client-a", - ) - - val response = client.syncReadEvent( - AigcVoteReadEvent( - contentType = "answer", - contentId = "42", - title = "测试回答", - authorHash = "author-md5", - contentHtml = "<p>正文</p>", - contentUpdatedAt = 1_781_020_000, - evidence = AigcVoteReadEvidence( - foregroundDurationMs = 45_000, - maxScrollRatio = 0.82, - openedAtEpochSeconds = 1_781_020_123, - ), - ), - ) - - assertEquals(3, response.credit) - assertEquals(27, response.progress) - assertEquals(5, response.cap) - } - - @Test - fun submitFlagPostsHtmlAndContentUpdatedAt() = runTest { - val client = AigcVoteClient( - httpClient = mockClient { requestBody -> - assertEquals("/v1/contents/article/99/aigc-flag", requestBody.path) - assertEquals( - "voter-a", - requestBody.json["voter"]!! - .jsonObject["id"]!! - .jsonPrimitive.content, - ) - assertEquals( - "投票人 A", - requestBody.json["voter"]!! - .jsonObject["name"]!! - .jsonPrimitive.content, - ) - assertEquals("疑似 AIGC 文章", requestBody.json["title"]!!.jsonPrimitive.content) - assertEquals("<p>完整 HTML</p>", requestBody.json["content_html"]!!.jsonPrimitive.content) - assertEquals("1781020000", requestBody.json["content_updated_at"]!!.jsonPrimitive.content) - assertEquals( - "45000", - requestBody.json["evidence"]!! - .jsonObject["client_view_duration_ms"]!! - .jsonPrimitive.content, - ) - }, - baseUrl = "http://127.0.0.1:8787/", - clientId = "client-a", - ) - - val response = client.submitFlag( - AigcVoteFlagSubmission( - contentType = "article", - contentId = "99", - voter = AigcVoteVoter( - id = "voter-a", - name = "投票人 A", - urlToken = "voter-a-token", - avatarUrl = "https://pic.example/avatar.jpg", - ), - title = "疑似 AIGC 文章", - authorHash = "author-hash", - contentHtml = "<p>完整 HTML</p>", - contentUpdatedAt = 1_781_020_000, - evidence = AigcVoteReadEvidence( - foregroundDurationMs = 45_000, - maxScrollRatio = 0.82, - openedAtEpochSeconds = 1_781_020_123, - ), - ), - ) - - assertTrue(response.myFlagged) - assertEquals(2, response.credit) - assertFalse(response.creditBypassAvailable) - assertEquals(8, response.effectiveFlagCount) - assertEquals(5, response.currentVersionFlagCount) - assertEquals("medium", response.confidence) - assertEquals("投票人 A", response.voters.single().voterName) - } - - @Test - fun getFlagStatusSendsNamedVoterIdentity() = runTest { - val client = AigcVoteClient( - httpClient = HttpClient( - MockEngine { request -> - assertEquals(HttpMethod.Get, request.method) - assertEquals("/v1/contents/answer/42/aigc-flag", request.url.encodedPath) - assertEquals("client-a", request.url.parameters["client_id"]) - assertEquals("voter-a", request.url.parameters["voter_id"]) - assertEquals("投票人 A", request.url.parameters["voter_name"]) - assertEquals("voter-a-token", request.url.parameters["voter_url_token"]) - respond( - content = - """ - { - "my_flagged": false, - "credit": 0, - "progress": 0, - "cap": 5, - "credit_bypass_available": true, - "effective_flag_count": 0, - "raw_flag_count": 0, - "confidence": "low", - "voters": [] - } - """.trimIndent(), - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - }, - ) { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - }, - baseUrl = "http://127.0.0.1:8787", - clientId = "client-a", - ) - - val response = client.getFlagStatus( - contentType = "answer", - contentId = "42", - voter = AigcVoteVoter( - id = "voter-a", - name = "投票人 A", - urlToken = "voter-a-token", - ), - ) - - assertTrue(response.creditBypassAvailable) - assertFalse(response.myFlagged) - } - - private fun mockClient(assertBody: (CapturedRequestBody) -> Unit): HttpClient = HttpClient( - MockEngine { request -> - assertEquals(HttpMethod.Post, request.method) - val bodyText = (request.body as TextContent).text - val json = Json.parseToJsonElement(bodyText).jsonObject - assertEquals("client-a", json["client_id"]!!.jsonPrimitive.content) - val path = request.url.encodedPath - assertBody(CapturedRequestBody(path, json)) - respond( - content = if (path == "/v1/read-events:batch") { - """ - { - "credit": 3, - "progress": 27, - "cap": 5, - "accepted_events": 1, - "rejected_events": 0 - } - """.trimIndent() - } else { - """ - { - "credit": 2, - "my_flagged": true, - "effective_flag_count": 8, - "raw_flag_count": 9, - "current_version_flag_count": 5, - "content_hash": "abc", - "content_updated_at": 1781020000, - "confidence": "medium", - "credit_bypass_available": false, - "voters": [ - { - "voter_id": "voter-a", - "voter_name": "投票人 A", - "voter_url_token": "voter-a-token", - "voter_avatar_url": "https://pic.example/avatar.jpg", - "created_at": 1781020100, - "credit_bypassed": false - } - ] - } - """.trimIndent() - }, - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - }, - ) { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } -} - -private data class CapturedRequestBody( - val path: String, - val json: kotlinx.serialization.json.JsonObject, -) - -private fun kotlinx.serialization.json.JsonElement.jsonArraySingle(): kotlinx.serialization.json.JsonObject = - jsonArray.single().jsonObject diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisherTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisherTest.kt deleted file mode 100644 index 513eaeb8c..000000000 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisherTest.kt +++ /dev/null @@ -1,255 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.editor - -import com.github.zly2006.zhihu.data.ZhihuJson -import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig -import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.content.OutgoingContent -import io.ktor.http.headersOf -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse - -class ZhihuPinPublisherTest { - @Test - fun decodesObservedTopicRecommendationFields() { - val response = ZhihuJson.decodeJson<PinTopicSuggestionResponse>( - ZhihuJson.json.parseToJsonElement( - """{"data":{"is_show_create":true,"list":[{"id":"787460807","name":"AI","topic_id":3858106,"discuss_count":"31.8 万讨论"}]},"code":0}""", - ), - ) - - assertEquals( - PinTopicSuggestion("787460807", "AI", 3858106, "31.8 万讨论"), - response.data.list.single(), - ) - } - - @Test - fun serializesPinDraftPayloadWithObservedPublishShape() { - val request = SavePinDraftRequest( - data = PinContentPayload( - publish = PublishTrace(traceId = "trace-id"), - title = PinContentTitle(title = "标题"), - hybrid = PinContentHybrid( - html = "<p>正文</p>", - textLength = 2, - ), - ), - ) - - val root = ZhihuJson.json.encodeToJsonElement(SavePinDraftRequest.serializer(), request).jsonObject - val data = root.getValue("data").jsonObject - val commentsPermission = data.getValue("commentsPermission").jsonObject - val extraInfo = data.getValue("extra_info").jsonObject - val hybrid = data.getValue("hybrid").jsonObject - - assertEquals("pin", root.getValue("action").jsonPrimitive.content) - assertEquals("all", commentsPermission.getValue("comment_permission").jsonPrimitive.content) - assertEquals("all", extraInfo.getValue("view_permission").jsonPrimitive.content) - assertEquals("pc", extraInfo.getValue("publisher").jsonPrimitive.content) - assertEquals( - "标题", - data - .getValue("title") - .jsonObject - .getValue("title") - .jsonPrimitive.content, - ) - assertEquals("<p>正文</p>", hybrid.getValue("html").jsonPrimitive.content) - assertEquals(2, hybrid.getValue("textLength").jsonPrimitive.int) - assertFalse("attachment" in hybrid) - assertFalse("meta" in hybrid) - assertFalse("media" in data) - } - - @Test - fun serializesPinImagePayloadWithObservedMediaShape() { - val request = PublishPinRequest( - data = PinContentPayload( - publish = PublishTrace(traceId = "trace-id"), - hybrid = PinContentHybrid( - html = "<p>正文</p>", - textLength = 2, - ), - media = PinContentMedia( - medias = listOf( - PinContentMediaItem( - image = PinContentImage( - height = 64, - width = 64, - url = "https://picx.zhimg.com/v2-test.png", - originalUrl = "https://picx.zhimg.com/v2-test.png", - watermark = "watermark", - watermarkUrl = null, - ), - ), - ), - ), - ), - ) - - val root = ZhihuJson.json.encodeToJsonElement(PublishPinRequest.serializer(), request).jsonObject - val data = root.getValue("data").jsonObject - val media = data.getValue("media").jsonObject - val image = media - .getValue("medias") - .jsonArray - .single() - .jsonObject - .getValue("image") - .jsonObject - - assertEquals("pin", root.getValue("action").jsonPrimitive.content) - assertEquals(64, image.getValue("height").jsonPrimitive.int) - assertEquals(64, image.getValue("width").jsonPrimitive.int) - assertEquals("https://picx.zhimg.com/v2-test.png", image.getValue("url").jsonPrimitive.content) - assertEquals("https://picx.zhimg.com/v2-test.png", image.getValue("originalUrl").jsonPrimitive.content) - } - - @Test - fun serializesSelectedTopicsAndOmitsEmptyTopicPayload() { - val selected = PublishPinRequest( - data = PinContentPayload( - publish = PublishTrace(traceId = "trace-id"), - topic = PinContentTopic(listOf(PinContentTopicItem("19550517", "#互联网#"))), - ), - ) - val selectedData = ZhihuJson.json - .encodeToJsonElement(PublishPinRequest.serializer(), selected) - .jsonObject - .getValue("data") - .jsonObject - val topic = selectedData - .getValue("topic") - .jsonObject - .getValue("topics") - .jsonArray - .single() - .jsonObject - assertEquals("19550517", topic.getValue("topic_id").jsonPrimitive.content) - assertEquals("#互联网#", topic.getValue("topic_name").jsonPrimitive.content) - - val empty = PublishPinRequest(data = PinContentPayload(publish = PublishTrace(traceId = "trace-id"))) - val emptyData = ZhihuJson.json - .encodeToJsonElement(PublishPinRequest.serializer(), empty) - .jsonObject - .getValue("data") - .jsonObject - assertFalse("topic" in emptyData) - } - - @Test - fun compilesSelectedTopicAsInlineNodeAndUsesInternalTopicId() { - val html = compilePinMarkdownToZhihuHtml( - "正文 #编程 后文", - listOf(PinContentTopicMarker(PinContentTopicItem("1354", "编程"), 3, 6)), - ) - assertEquals( - "<p>正文 <a class=\"hash_tag\" data-topic-name=\"#编程#\" data-topic-id=\"1354\">#编程#</a> 后文</p>", - html, - ) - } - - @Test - fun compilesTopicNamesWithMarkdownAndHtmlCharacters() { - val topic = PinContentTopicItem("9&10", "C++ [A&B]") - val markdown = "正文 ${topic.inlineMarker} 后文" - - assertEquals( - "<p>正文 <a class=\"hash_tag\" data-topic-name=\"#C++ [A&B]#\" data-topic-id=\"9&10\">#C++ [A&B]#</a> 后文</p>", - compilePinMarkdownToZhihuHtml( - markdown, - listOf(PinContentTopicMarker(topic, 3, 3 + topic.inlineMarker.length)), - ), - ) - } - - @Test - fun capturesInlineTopicsInFinalDraftRequest() = runTest { - var requestBody = "" - val client = HttpClient( - MockEngine { request -> - requestBody = (request.body as OutgoingContent.ByteArrayContent).bytes().decodeToString() - respond( - "{}", - HttpStatusCode.OK, - headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), - ) - }, - ) { - installZhihuCommonClientConfig(mutableMapOf(), "test-agent") - } - val publisher = ZhihuApiPinPublisher( - object : ZhihuApiEnvironment { - override fun httpClient() = client - - override fun authenticatedCookies() = mapOf("_xsrf" to "xsrf", "d_c0" to "device") - - override suspend fun handleFetchFailure(tag: String?, error: Exception) = Unit - }, - ) - val topic = PinContentTopicItem("1354", "编程") - val html = compilePinMarkdownToZhihuHtml("正文 #编程 ", listOf(PinContentTopicMarker(topic, 3, 6))) - - publisher.savePinDraft("", html, calculatePinHtmlTextLength(html), emptyList(), listOf(topic)) - - val data = ZhihuJson.json - .parseToJsonElement(requestBody) - .jsonObject - .getValue("data") - .jsonObject - val payloadTopic = data - .getValue("topic") - .jsonObject - .getValue("topics") - .jsonArray - .single() - .jsonObject - assertEquals("1354", payloadTopic.getValue("topic_id").jsonPrimitive.content) - assertEquals("#编程#", payloadTopic.getValue("topic_name").jsonPrimitive.content) - assertEquals( - "<p>正文 <a class=\"hash_tag\" data-topic-name=\"#编程#\" data-topic-id=\"1354\">#编程#</a></p>", - data - .getValue("hybrid") - .jsonObject - .getValue("html") - .jsonPrimitive.content, - ) - } - - @Test - fun calculatesPinTextLengthFromHtmlText() { - assertEquals( - 7, - calculatePinHtmlTextLength("<p>正文 & <b>更多</b></p>"), - ) - } -} diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/navigation/QuestionAnswerNavigatorTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/navigation/QuestionAnswerNavigatorTest.kt index 9f01083bc..f0af342d6 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/navigation/QuestionAnswerNavigatorTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/navigation/QuestionAnswerNavigatorTest.kt @@ -28,6 +28,8 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -35,6 +37,16 @@ import kotlin.test.assertFalse import kotlin.test.assertNull class QuestionAnswerNavigatorTest { + @BeforeTest + fun ignoreOpenedAnswersByDefault() { + openedAnswerIdsReaderForTesting = { emptySet() } + } + + @AfterTest + fun clearOpenedAnswersReader() { + openedAnswerIdsReaderForTesting = null + } + @Test fun seededQuestionAnswerListKeepsClickedPositionForSwitching() = runTest { val navigator = QuestionAnswerNavigator( @@ -43,7 +55,6 @@ class QuestionAnswerNavigatorTest { initialPreviousAnswers = listOf(answer(101L), answer(100L)), initialNextUrl = "https://www.zhihu.com/api/v4/questions/1/feeds?limit=20&order=updated&offset=20", order = "updated", - getAlreadyOpenedAnswerIds = { emptySet() }, environment = NoopEnvironment, ) @@ -57,7 +68,6 @@ class QuestionAnswerNavigatorTest { val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(answer(103L), answer(104L)), - getAlreadyOpenedAnswerIds = { emptySet() }, environment = NoopEnvironment, ) @@ -71,7 +81,6 @@ class QuestionAnswerNavigatorTest { val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(answer(104L), answer(105L)), - getAlreadyOpenedAnswerIds = { emptySet() }, environment = NoopEnvironment, ) navigator.pushAnswer(answer(101L).toCachedContent()) @@ -90,10 +99,10 @@ class QuestionAnswerNavigatorTest { @Test fun remainingAnswerSnapshotFiltersOpenedInitialCandidates() = runTest { val openedAnswerIds = setOf(104L, 106L) + openedAnswerIdsReaderForTesting = { ids -> ids.filter { it in openedAnswerIds }.toSet() } val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(103L, 104L, 105L, 106L, 107L).map(::answer), - getAlreadyOpenedAnswerIds = { ids -> ids.filter { it in openedAnswerIds }.toSet() }, environment = NoopEnvironment, ) @@ -113,11 +122,11 @@ class QuestionAnswerNavigatorTest { secondNextUrl to feedPage(listOf(105L, 106L), ""), ), ) + openedAnswerIdsReaderForTesting = { ids -> ids.filterTo(mutableSetOf()) { it == 104L } } val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(answer(103L)), initialNextUrl = firstNextUrl, - getAlreadyOpenedAnswerIds = { ids -> ids.filterTo(mutableSetOf()) { it == 104L } }, environment = environment, ) @@ -134,14 +143,14 @@ class QuestionAnswerNavigatorTest { @Test fun failedInitialCandidateLookupCanBeRetried() = runTest { var lookupAttempts = 0 + openedAnswerIdsReaderForTesting = { + lookupAttempts++ + if (lookupAttempts == 1) error("lookup failed") + emptySet() + } val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(answer(103L), answer(104L)), - getAlreadyOpenedAnswerIds = { - lookupAttempts++ - if (lookupAttempts == 1) error("lookup failed") - emptySet() - }, environment = NoopEnvironment, ) @@ -157,10 +166,10 @@ class QuestionAnswerNavigatorTest { @Test fun newlyDiscoveredPreviousAnswerInvalidatesCachedPreviousContent() = runTest { + openedAnswerIdsReaderForTesting = { setOf(103L) } val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(answer(103L)), - getAlreadyOpenedAnswerIds = { setOf(103L) }, environment = NoopEnvironment, ) navigator.previousAnswerContent = answer(101L).toCachedContent() @@ -175,14 +184,14 @@ class QuestionAnswerNavigatorTest { fun snapshotWaitsForInFlightInitialCandidateFiltering() = runTest { val lookupStarted = CompletableDeferred<Unit>() val finishLookup = CompletableDeferred<Unit>() + openedAnswerIdsReaderForTesting = { + lookupStarted.complete(Unit) + finishLookup.await() + emptySet() + } val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(answer(103L), answer(104L)), - getAlreadyOpenedAnswerIds = { - lookupStarted.complete(Unit) - finishLookup.await() - emptySet() - }, environment = NoopEnvironment, ) @@ -229,7 +238,6 @@ class QuestionAnswerNavigatorTest { answer(101L), answer(100L), ), - getAlreadyOpenedAnswerIds = { emptySet() }, environment = NoopEnvironment, ) @@ -244,7 +252,6 @@ class QuestionAnswerNavigatorTest { val navigator = QuestionAnswerNavigator( questionId = 1L, initialPreviousAnswers = listOf(article(201L)), - getAlreadyOpenedAnswerIds = { emptySet() }, environment = NoopEnvironment, ) @@ -254,10 +261,10 @@ class QuestionAnswerNavigatorTest { @Test fun seededNextAnswersAreCheckedAgainstOpenedHistoryBeforeDownNavigation() = runTest { val openedAnswerIds = setOf(104L, 106L) + openedAnswerIdsReaderForTesting = { ids -> ids.filter { it in openedAnswerIds }.toSet() } val navigator = QuestionAnswerNavigator( questionId = 1L, initialNextAnswers = listOf(103L, 104L, 105L, 106L, 107L).map(::answer), - getAlreadyOpenedAnswerIds = { ids -> ids.filter { it in openedAnswerIds }.toSet() }, environment = NoopEnvironment, ) @@ -276,10 +283,10 @@ class QuestionAnswerNavigatorTest { ) rounds.forEachIndexed { roundIndex, (candidateIds, openedAnswerIds) -> + openedAnswerIdsReaderForTesting = { ids -> ids.filter { it in openedAnswerIds }.toSet() } val navigator = QuestionAnswerNavigator( questionId = roundIndex + 1L, initialNextAnswers = candidateIds.map(::answer), - getAlreadyOpenedAnswerIds = { ids -> ids.filter { it in openedAnswerIds }.toSet() }, environment = NoopEnvironment, ) @@ -295,9 +302,9 @@ class QuestionAnswerNavigatorTest { fun directArticleScreenNavigatorSkipsOpenedAnswersWithoutQuestionScreenSeed() = runTest { val environment = FeedEnvironment(listOf(101L, 102L, 103L, 104L)) val openedAnswerIds = mutableSetOf(101L, 102L) + openedAnswerIdsReaderForTesting = { ids -> ids.filterTo(mutableSetOf()) { it in openedAnswerIds } } val navigator = QuestionAnswerNavigator( questionId = 1L, - getAlreadyOpenedAnswerIds = { ids -> ids.filterTo(mutableSetOf()) { it in openedAnswerIds } }, environment = environment, ) @@ -307,7 +314,6 @@ class QuestionAnswerNavigatorTest { openedAnswerIds += 103L val reopenedNavigator = QuestionAnswerNavigator( questionId = 1L, - getAlreadyOpenedAnswerIds = { ids -> ids.filterTo(mutableSetOf()) { it in openedAnswerIds } }, environment = environment, ) reopenedNavigator.pushAnswer(answer(101L).toCachedContent()) @@ -319,9 +325,9 @@ class QuestionAnswerNavigatorTest { fun directArticleScreenReentrySkipsAnswersViewedInPreviousSession() = runTest { val environment = FeedEnvironment(listOf(101L, 102L, 103L, 104L, 105L)) val openedAnswerIds = mutableSetOf(101L, 102L) + openedAnswerIdsReaderForTesting = { ids -> ids.filterTo(mutableSetOf()) { it in openedAnswerIds } } val navigator = QuestionAnswerNavigator( questionId = 1L, - getAlreadyOpenedAnswerIds = { ids -> ids.filterTo(mutableSetOf()) { it in openedAnswerIds } }, environment = environment, ) diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/notification/OnlineHomeNotificationTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/notification/OnlineHomeNotificationTest.kt index 6086f297e..c514fe64b 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/notification/OnlineHomeNotificationTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/notification/OnlineHomeNotificationTest.kt @@ -18,6 +18,7 @@ package com.github.zly2006.zhihu.notification import com.github.zly2006.zhihu.data.ZhihuJson +import com.github.zly2006.zhihu.platform.MapSettingsStore import com.github.zly2006.zhihu.platform.SettingsStore import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -147,25 +148,7 @@ class OnlineHomeNotificationTest { assertEquals(JsonPrimitive(3), value["count"]) } - private fun mapBackedSettingsStore(values: MutableMap<String, Any>): SettingsStore = SettingsStore( - getBoolean = { key, default -> values[key] as? Boolean ?: default }, - putBoolean = { key, value -> values[key] = value }, - getString = { key, default -> values[key] as? String ?: default }, - putString = { key, value -> values[key] = value }, - getStringOrNull = { key -> values[key] as? String }, - putStringSet = { key, value -> values[key] = value }, - getStringSet = { key, default -> - @Suppress("UNCHECKED_CAST") - (values[key] as? Set<String> ?: default) - }, - getInt = { key, default -> values[key] as? Int ?: default }, - putInt = { key, value -> values[key] = value }, - getLong = { key, default -> values[key] as? Long ?: default }, - putLong = { key, value -> values[key] = value }, - getFloat = { key, default -> values[key] as? Float ?: default }, - putFloat = { key, value -> values[key] = value }, - remove = values::remove, - ) + private fun mapBackedSettingsStore(values: MutableMap<String, Any>): SettingsStore = MapSettingsStore(values) private fun notificationClient(notifications: List<OnlineHomeNotification>): HttpClient = HttpClient( MockEngine { diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/platform/MapSettingsStore.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/platform/MapSettingsStore.kt new file mode 100644 index 000000000..f97b786a9 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/platform/MapSettingsStore.kt @@ -0,0 +1,57 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + */ + +package com.github.zly2006.zhihu.platform + +class MapSettingsStore( + private val values: MutableMap<String, Any> = mutableMapOf(), +) : SettingsStore { + override fun getBoolean(key: String, defaultValue: Boolean) = values[key] as? Boolean ?: defaultValue + + override fun putBoolean(key: String, value: Boolean) { + values[key] = value + } + + override fun getString(key: String, defaultValue: String) = values[key] as? String ?: defaultValue + + override fun putString(key: String, value: String) { + values[key] = value + } + + override fun getStringOrNull(key: String) = values[key] as? String + + override fun putStringSet(key: String, value: Set<String>) { + values[key] = value + } + + override fun getStringSet(key: String, defaultValue: Set<String>): Set<String> = + (values[key] as? Iterable<*>)?.filterIsInstance<String>()?.toSet() ?: defaultValue + + override fun getInt(key: String, defaultValue: Int) = values[key] as? Int ?: defaultValue + + override fun putInt(key: String, value: Int) { + values[key] = value + } + + override fun getLong(key: String, defaultValue: Long) = values[key] as? Long ?: defaultValue + + override fun putLong(key: String, value: Long) { + values[key] = value + } + + override fun getFloat(key: String, defaultValue: Float) = values[key] as? Float ?: defaultValue + + override fun putFloat(key: String, value: Float) { + values[key] = value + } + + override fun remove(key: String) { + values.remove(key) + } +} diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayerTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayerTest.kt index 54daea486..ebe6d5d85 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayerTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/reading/ReadingPlayerTest.kt @@ -17,7 +17,7 @@ package com.github.zly2006.zhihu.reading -import com.github.zly2006.zhihu.platform.SettingsStore +import com.github.zly2006.zhihu.platform.MapSettingsStore import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.Test @@ -610,20 +610,5 @@ class ReadingPlayerTest { assertFalse(normalized.shouldLoadComments) } - private fun settingsStore(values: MutableMap<String, Any>) = SettingsStore( - getBoolean = { key, default -> values[key] as? Boolean ?: default }, - putBoolean = { key, value -> values[key] = value }, - getString = { key, default -> values[key] as? String ?: default }, - putString = { key, value -> values[key] = value }, - getStringOrNull = { key -> values[key] as? String }, - putStringSet = { key, value -> values[key] = value }, - getStringSet = { key, default -> values[key] as? Set<String> ?: default }, - getInt = { key, default -> values[key] as? Int ?: default }, - putInt = { key, value -> values[key] = value }, - getLong = { key, default -> values[key] as? Long ?: default }, - putLong = { key, value -> values[key] = value }, - getFloat = { key, default -> values[key] as? Float ?: default }, - putFloat = { key, value -> values[key] = value }, - remove = values::remove, - ) + private fun settingsStore(values: MutableMap<String, Any>) = MapSettingsStore(values) } diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseSelectionTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseSelectionTest.kt index 29437554c..0c4a60a16 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseSelectionTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/CollectionBrowseSelectionTest.kt @@ -70,7 +70,7 @@ class CollectionBrowseSelectionTest { assertTrue( shouldRefreshCollectionDataOnActivation( isActive = true, - useTestCollections = false, + useLocalCollections = false, refreshOnNextActivation = true, ), ) @@ -81,7 +81,7 @@ class CollectionBrowseSelectionTest { assertFalse( shouldRefreshCollectionDataOnActivation( isActive = true, - useTestCollections = false, + useLocalCollections = false, refreshOnNextActivation = false, ), ) @@ -92,14 +92,14 @@ class CollectionBrowseSelectionTest { assertFalse( shouldRefreshCollectionDataOnActivation( isActive = false, - useTestCollections = false, + useLocalCollections = false, refreshOnNextActivation = true, ), ) assertFalse( shouldRefreshCollectionDataOnActivation( isActive = true, - useTestCollections = true, + useLocalCollections = true, refreshOnNextActivation = true, ), ) diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetStateTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetStateTest.kt index 155391e88..322c4b7c6 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetStateTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ReadingPlayerOverlayOffsetStateTest.kt @@ -22,39 +22,39 @@ import kotlin.test.assertEquals class ReadingPlayerOverlayOffsetStateTest { @Test - fun staleOwnerCannotUpdateOrClearCurrentArticleOffset() { + fun staleRouteCannotUpdateOrClearCurrentArticleOffset() { val state = ReadingPlayerOverlayOffsetState() - val oldArticle = Any() - val currentArticle = Any() + val oldRoute = "old" + val currentRoute = "current" - state.activate(oldArticle) - state.update(oldArticle, -80f) - state.activate(currentArticle) - state.update(currentArticle, -40f) + state.beginRoute(oldRoute) + state.update(oldRoute, -80f) + state.beginRoute(currentRoute) + state.update(currentRoute, -40f) - state.deactivate(oldArticle) - state.update(oldArticle, -120f) + state.endRoute(oldRoute) + state.update(oldRoute, -120f) assertEquals(-40f, state.verticalOffsetPx) - state.deactivate(currentArticle) + state.endRoute(currentRoute) assertEquals(0f, state.verticalOffsetPx) } @Test - fun sessionResetKeepsOwnerWhileRouteRevokeRemovesIt() { + fun sessionResetKeepsRouteWhileRouteClearRemovesIt() { val state = ReadingPlayerOverlayOffsetState() - val article = Any() + val route = "article" - state.activate(article) - state.update(article, -80f) + state.beginRoute(route) + state.update(route, -80f) state.resetOffset() assertEquals(0f, state.verticalOffsetPx) - state.update(article, -30f) + state.update(route, -30f) assertEquals(-30f, state.verticalOffsetPx) - state.revokeOwner() - state.update(article, -60f) + state.clearRoute() + state.update(route, -60f) assertEquals(0f, state.verticalOffsetPx) } } diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ZhihuMainPreferencesTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ZhihuMainPreferencesTest.kt index 965ffba2f..269cc6f60 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ZhihuMainPreferencesTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/ui/ZhihuMainPreferencesTest.kt @@ -47,6 +47,18 @@ class ZhihuMainPreferencesTest { ) } + @Test + fun desktopDefaultSelectionIncludesAllTopLevelDestinations() { + assertEquals( + linkedSetOf(Home.name, Follow.name, HotList.name, Daily.name, OnlineHistory.name, MyCollections.name, Account.name), + defaultBottomBarSelectionKeys(duo3HomeAccount = false, maximumSelection = null), + ) + assertEquals( + linkedSetOf(Home.name, Follow.name, HotList.name, Daily.name, OnlineHistory.name, MyCollections.name), + defaultBottomBarSelectionKeys(duo3HomeAccount = true, maximumSelection = null), + ) + } + @Test fun normalizeBottomBarSelectionKeepsAccountAsSeparateTabWhenHomeAccountIsOff() { val normalized = normalizeBottomBarSelection( diff --git a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationSupportTest.kt b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationSupportTest.kt index caec1e108..7313e9971 100644 --- a/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationSupportTest.kt +++ b/shared/src/commonTest/kotlin/com/github/zly2006/zhihu/viewmodel/local/LocalRecommendationSupportTest.kt @@ -21,17 +21,27 @@ import com.github.zly2006.zhihu.data.CommonFeed import com.github.zly2006.zhihu.data.Feed import com.github.zly2006.zhihu.navigation.Article import com.github.zly2006.zhihu.navigation.ArticleType +import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment import com.github.zly2006.zhihu.viewmodel.local.LocalContentAffinity import com.github.zly2006.zhihu.viewmodel.local.LocalReasonPreference +import io.ktor.client.HttpClient import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonArray import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue class LocalRecommendationSupportTest { + @AfterTest + fun clearTestOverrides() { + localRecommendationEngineForTesting = null + crawlingFeedArrayForTesting = null + crawlingNowMillisForTesting = null + } + @Test fun buildFallbackRecommendationsRanksRecentResultsAndCreatesEntries() = runTest { val database = testLocalContentDatabase() @@ -370,13 +380,14 @@ class LocalRecommendationSupportTest { ) val taskId = dao.getTasksByStatus(CrawlingStatus.NotStarted).single().id val requestedUrls = mutableListOf<String>() + crawlingFeedArrayForTesting = { url -> + requestedUrls.add(url) + JsonArray(emptyList()) + } + crawlingNowMillisForTesting = { 42L } val executor = CrawlingExecutor( dao = dao, - fetchFeedArray = { url -> - requestedUrls.add(url) - JsonArray(emptyList()) - }, - nowMillis = { 42L }, + environment = NoopLocalRecommendationEnvironment, ) executor.executeTask( @@ -398,20 +409,26 @@ class LocalRecommendationSupportTest { dao: LocalContentDao, initializeContentIfNeeded: suspend () -> Unit = {}, startScheduling: () -> Unit = {}, - stopScheduling: () -> Unit = {}, executeTask: suspend (CrawlingTask) -> Unit = {}, - ): LocalRecommendationEngine = LocalRecommendationEngine( - dao = dao, - feedGenerator = FeedGenerator(dao), - userBehaviorAnalyzer = UserBehaviorAnalyzer(dao), - initializeContentIfNeeded = initializeContentIfNeeded, - startScheduling = startScheduling, - stopScheduling = stopScheduling, - executeTask = executeTask, - ) + ): LocalRecommendationEngine { + localRecommendationEngineForTesting = LocalRecommendationEngineTestOverrides( + initializeContent = initializeContentIfNeeded, + startScheduling = startScheduling, + executeTask = executeTask, + ) + return LocalRecommendationEngine(dao, CrawlingExecutor(dao, NoopLocalRecommendationEnvironment)) + } private fun testLocalContentDatabase(): LocalContentDatabase = getLocalContentDatabase( createTempDirectory("local-recommendation-support-room").resolve("local-content.db").toFile(), ) + + private object NoopLocalRecommendationEnvironment : ZhihuApiEnvironment { + override fun httpClient(): HttpClient = error("测试不应创建 HTTP client") + + override fun authenticatedCookies(): Map<String, String> = emptyMap() + + override suspend fun handleFetchFailure(tag: String?, error: Exception) = Unit + } } diff --git a/shared/src/iosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.ios.kt b/shared/src/iosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.ios.kt index 7ccceb013..619a8080a 100644 --- a/shared/src/iosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.ios.kt +++ b/shared/src/iosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.ios.kt @@ -31,18 +31,38 @@ import platform.UIKit.UIPasteboard internal actual val nativeIsDesktop: Boolean = false +actual val platformName: String = "iOS" + +actual val isAigcVoteSupported: Boolean = false + @Composable @OptIn(ExperimentalForeignApi::class) -actual fun rememberExternalUrlOpener(): (String) -> Unit = remember { - { url -> NSURL.URLWithString(url)?.let(UIApplication.sharedApplication::openURL) } +actual fun rememberExternalUrlOpener(): ExternalUrlOpener = remember { + object : ExternalUrlOpener { + override fun invoke(url: String) { + NSURL.URLWithString(url)?.let(UIApplication.sharedApplication::openURL) + } + } +} + +@Composable +actual fun rememberImageGalleryOpener(): ImageGalleryOpener { + val openExternalUrl = rememberExternalUrlOpener() + return remember(openExternalUrl) { + object : ImageGalleryOpener { + override fun invoke(urls: List<String>, initialIndex: Int) { + if (urls.isNotEmpty()) { + urls[initialIndex.coerceIn(0, urls.lastIndex)].let(openExternalUrl) + } + } + } + } } internal actual fun copyNativePlainText(text: String) { UIPasteboard.generalPasteboard.string = text } -internal actual fun requestNativeQrLogin() = Unit - @OptIn(ExperimentalForeignApi::class) internal actual fun nativeAccountFilePath(): String = "${nativeAppPrivateDirectoryPath()}/account.json" @@ -60,5 +80,7 @@ internal actual fun nativeSettingsStore(relativePath: String): SettingsStore = n @Composable actual fun rememberUserMessageSink(): UserMessageSink = remember { - UserMessageSink(showShortMessage = ::println) + object : UserMessageSink { + override fun showShortMessage(message: String) = println(message) + } } diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.jvm.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.jvm.kt new file mode 100644 index 000000000..27433900e --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.jvm.kt @@ -0,0 +1,6 @@ +package com.github.zly2006.zhihu.account + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO + +internal actual val accountHttpClientEngineFactory: HttpClientEngineFactory<*> = CIO diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/JvmAccountStore.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/JvmAccountStore.kt new file mode 100644 index 000000000..b0c45012b --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/JvmAccountStore.kt @@ -0,0 +1,13 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * Licensed under the GNU Affero General Public License version 3. + */ + +package com.github.zly2006.zhihu.account + +import androidx.compose.runtime.Composable +import com.github.zly2006.zhihu.desktop.defaultDesktopAccountStore + +@Composable +actual fun rememberZhihuAccountStore(): ZhihuAccountStore = defaultDesktopAccountStore diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/JvmLoginUi.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/JvmLoginUi.kt new file mode 100644 index 000000000..6f2b3da79 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/account/JvmLoginUi.kt @@ -0,0 +1,134 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package com.github.zly2006.zhihu.account + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toComposeImageBitmap +import com.github.zly2006.zhihu.desktop.defaultDesktopAccountStore +import com.github.zly2006.zhihu.platform.platformName +import com.github.zly2006.zhihu.ui.components.DesktopRiskControlWebView +import com.github.zly2006.zhihu.ui.components.DesktopWebviewComp +import io.ktor.client.HttpClient +import kotlinx.coroutines.launch +import org.jetbrains.skia.Image +import java.util.Base64 +import java.util.TimeZone + +actual val supportedLoginMethods: List<LoginMethod> = listOf( + LoginMethod.Phone, + LoginMethod.Qr, + LoginMethod.Web, +) + +actual val isLoginRiskControlSupported: Boolean = true + +@Composable +actual fun rememberLoginHttpClient(cookies: MutableMap<String, String>): HttpClient { + val store = defaultDesktopAccountStore + val httpClient = remember(store) { store.client.temporaryHttpClient(cookies) } + DisposableEffect(httpClient) { + onDispose(httpClient::close) + } + return httpClient +} + +actual val phoneLoginDeviceInfo: ZhihuPhoneLoginDeviceInfo + get() { + val runtime = Runtime.getRuntime() + return ZhihuPhoneLoginDeviceInfo( + timezoneOffsetSeconds = TimeZone.getDefault().rawOffset / 1_000L, + appInstallTimeMillis = 0, + notificationEnabled = false, + bluetoothAvailable = false, + phoneBrand = platformName, + phoneModel = System.getProperty("os.arch").orEmpty(), + androidRelease = "12", + cpuType = System.getProperty("os.arch").orEmpty(), + cpuCount = runtime.availableProcessors(), + cpuUsage = "0.0", + totalMemoryMegabytes = (runtime.maxMemory() / 1_048_576L).toInt(), + freeMemoryMegabytes = (runtime.freeMemory() / 1_048_576L).toInt(), + totalStorageMegabytes = 0, + freeStorageMegabytes = 0, + ) + } + +actual fun decodePhoneLoginCaptchaImage(content: String) = runCatching { + val encoded = content.substringAfter("base64,", content) + Image.makeFromEncoded(Base64.getDecoder().decode(encoded)).toComposeImageBitmap() +}.getOrNull() + +@Composable +actual fun QrLoginPane(onLoginSuccess: (String) -> Unit) { + val store = defaultDesktopAccountStore + SharedQrLoginPane( + onLoginSuccess = { cookies -> + if (store.login(cookies.toMutableMap())) { + onLoginSuccess(store.session.username) + true + } else { + false + } + }, + initialCookies = store.session.cookies, + ) +} + +@Composable +actual fun WebLoginPane(onLoginSuccess: (String) -> Unit) { + val store = defaultDesktopAccountStore + val scope = rememberCoroutineScope() + var currentUrl by remember { mutableStateOf<String?>(null) } + var isVerifying by remember { mutableStateOf(false) } + + DesktopWebviewComp( + url = ZHIHU_SIGNIN_URL, + modifier = Modifier.fillMaxSize(), + initialCookies = store.session.cookies, + onPageFinished = { currentUrl = it }, + onCookiesChanged = { cookies -> + if (currentUrl == ZHIHU_HOME_URL && !isVerifying) { + isVerifying = true + scope.launch { + try { + if (store.login(cookies.toMutableMap())) { + onLoginSuccess(store.session.username) + } + } finally { + isVerifying = false + } + } + } + }, + ) +} + +@Composable +actual fun LoginRiskControlPane( + url: String, + cookies: Map<String, String>, + onCookiesChanged: (Map<String, String>) -> Unit, +) = DesktopRiskControlWebView(url, cookies, onCookiesChanged) diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/desktop/DesktopInfra.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/desktop/DesktopInfra.kt index 78b68c4f5..2f2a66b71 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/desktop/DesktopInfra.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/desktop/DesktopInfra.kt @@ -17,46 +17,13 @@ package com.github.zly2006.zhihu.desktop -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.github.zly2006.zhihu.account.SharedQrLoginPane -import com.github.zly2006.zhihu.account.ZhihuAccountClient import com.github.zly2006.zhihu.account.ZhihuAccountRepository -import com.github.zly2006.zhihu.account.ZhihuAccountSession import com.github.zly2006.zhihu.account.ZhihuAccountSessionStore -import com.github.zly2006.zhihu.data.executeZhihuAuthenticatedRequest -import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig +import com.github.zly2006.zhihu.account.ZhihuAccountStore import com.github.zly2006.zhihu.navigation.NavDestination -import com.github.zly2006.zhihu.theme.ZhihuTheme -import com.github.zly2006.zhihu.ui.DesktopZhihuMain -import com.github.zly2006.zhihu.ui.components.DesktopRiskControlWebView -import com.github.zly2006.zhihu.util.signZhihuFetchRequest -import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.awt.Desktop @@ -72,82 +39,12 @@ import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText -typealias DesktopAccountData = ZhihuAccountSession - -private val defaultDesktopAccountState = MutableStateFlow(ZhihuAccountSession()) - -private val defaultDesktopAccountClient by lazy { - createDesktopAccountClient(desktopZhihuLegacyAccountFile()) { - defaultDesktopAccountState.value = it - } -} - -private fun createDesktopAccountClient( - accountFile: Path, - onSessionChanged: (ZhihuAccountSession) -> Unit, -): ZhihuAccountClient = - ZhihuAccountClient( - repository = ZhihuAccountRepository(PathAccountSessionStore(accountFile)), - createClient = { cookies, session, onCookieChanged, _ -> - createDesktopHttpClient(cookies, session.userAgent, onCookieChanged) - }, - onSessionChanged = onSessionChanged, - ) - -private fun createDesktopHttpClient( - cookies: MutableMap<String, String>, - userAgent: String, - onCookieChanged: () -> Unit = {}, -): HttpClient = HttpClient(CIO) { - installZhihuCommonClientConfig( - cookies = cookies, - userAgent = userAgent, - onCookieChanged = onCookieChanged, +val defaultDesktopAccountStore by lazy { + ZhihuAccountStore( + repository = ZhihuAccountRepository(PathAccountSessionStore(desktopZhihuLegacyAccountFile())), ) } -class DesktopAccountStore( - accountFile: Path = desktopZhihuLegacyAccountFile(), -) { - private val usesDefaultAccountFile = accountFile == desktopZhihuLegacyAccountFile() - private val mutableAccountState = - if (usesDefaultAccountFile) defaultDesktopAccountState else MutableStateFlow(ZhihuAccountSession()) - private val accountClient = - if (usesDefaultAccountFile) { - defaultDesktopAccountClient - } else { - createDesktopAccountClient(accountFile) { - mutableAccountState.value = it - } - } - val accountState: StateFlow<DesktopAccountData> = mutableAccountState.asStateFlow() - - init { - accountClient.load() - } - - fun load(): DesktopAccountData = accountClient.load() - - fun save(data: DesktopAccountData) = accountClient.save(data) - - fun clear() = accountClient.clear() - - fun httpClient(): HttpClient = accountClient.httpClient() - - fun createHttpClient(cookies: MutableMap<String, String>): HttpClient = - accountClient.temporaryHttpClient(cookies) - - suspend fun <T> withAuthenticatedClient( - block: suspend (client: HttpClient, cookies: Map<String, String>) -> T, - ): T = accountClient.withAuthenticatedClient(block) - - suspend fun verifyAndSave(cookies: MutableMap<String, String>): Boolean = - accountClient.verifyAndSave(cookies) - - suspend fun refreshAndSaveProfile(): ZhihuAccountSession? = - accountClient.refreshAndSaveProfile() -} - private class PathAccountSessionStore( private val accountFile: Path, ) : ZhihuAccountSessionStore { @@ -167,29 +64,11 @@ private class PathAccountSessionStore( } } -/** - * 签名后发起认证请求并返回响应的便捷方法。 - */ -suspend fun <T> DesktopAccountStore.signedWithResponse( - url: String, - block: suspend HttpRequestBuilder.() -> Unit = {}, - transform: suspend (HttpResponse) -> T, -): T { - val response = executeZhihuAuthenticatedRequest( - client = httpClient(), - url = url, - ) { - signZhihuFetchRequest(load().cookies) - block() - } - return transform(response) -} - -suspend fun DesktopAccountStore.saveImageToDownloads( +suspend fun ZhihuAccountStore.saveImageToDownloads( url: String, filePrefix: String, ): File = withContext(Dispatchers.IO) { - val imageBytes = httpClient().get(url).body<ByteArray>() + val imageBytes = client.httpClient().get(url).body<ByteArray>() val downloadsDir = desktopZhihuDownloadsDir() val file = File(downloadsDir, desktopImageFileName(filePrefix, url)) file.writeBytes(imageBytes) @@ -207,93 +86,6 @@ private fun desktopImageFileName( return "${filePrefix}_${System.currentTimeMillis()}.$extension" } -internal object DesktopLoginRequests { - val version = MutableStateFlow(0) - - fun requestLogin() { - version.update { it + 1 } - } -} - -@Composable -fun DesktopQrLoginScreen( - store: DesktopAccountStore = remember { DesktopAccountStore() }, -) { - var statusText by remember { mutableStateOf("正在获取二维码") } - var isLoggedIn by remember { mutableStateOf(false) } - var didCheckSavedAccount by remember { mutableStateOf(false) } - val loginRequestVersion by DesktopLoginRequests.version.collectAsState() - - LaunchedEffect(Unit) { - val savedData = store.load() - val cookies = savedData.cookies - if (savedData.login && cookies.isNotEmpty()) { - statusText = "正在验证已备份 cookie" - if (store.verifyAndSave(cookies)) { - statusText = "已使用备份 cookie 登录:${store.load().username}" - isLoggedIn = true - return@LaunchedEffect - } - } - didCheckSavedAccount = true - } - - LaunchedEffect(loginRequestVersion) { - if (loginRequestVersion > 0) { - statusText = "正在获取二维码" - isLoggedIn = false - didCheckSavedAccount = true - } - } - - ZhihuTheme { - if (isLoggedIn) { - DesktopZhihuMain() - } else if (didCheckSavedAccount) { - SharedQrLoginPane( - createClient = { cookies -> store.createHttpClient(cookies) }, - onLoginSuccess = { cookies -> - store.verifyAndSave(cookies.toMutableMap()).also { success -> - if (success) { - isLoggedIn = true - } - } - }, - initialCookies = store.load().cookies, - qrReadyMessage = "请打开知乎++ App 扫一扫", - onQrReady = { - notifyUser("需要扫码登录 JVM 端") - }, - readRiskControlCookies = ::readDesktopRiskControlCookies, - riskControlContent = { url, cookies, onCookiesChanged -> - DesktopRiskControlWebView( - url = url, - cookies = cookies, - onCookiesChanged = onCookiesChanged, - ) - }, - ) - } else { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - Spacer(modifier = Modifier.size(16.dp)) - Text(statusText) - } - } - } -} - -private fun notifyUser(message: String) { - runCatching { - ProcessBuilder("terminal-notifier", "-message", message, "-sound", "default") - .start() - } -} - class DesktopHistoryStorage( private val historyFile: File = desktopZhihuDataFile("history.json"), ) { @@ -380,7 +172,3 @@ internal fun openDesktopExternalUrl(url: String): Boolean = runCatching { internal fun copyDesktopPlainText(text: String) = Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null) - -private fun readDesktopRiskControlCookies( - @Suppress("UNUSED_PARAMETER") url: String?, -): Map<String, String> = emptyMap() diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.jvm.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.jvm.kt index 5696b9627..9111225d3 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.jvm.kt @@ -18,6 +18,10 @@ package com.github.zly2006.zhihu.editor import androidx.compose.runtime.Composable +import com.github.zly2006.zhihu.platform.platformName + +actual val isImagePickerSupported: Boolean = false @Composable -actual fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): (() -> Unit)? = null +actual fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): ImagePickerLauncher = + error("$platformName 暂不支持图片选择") diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.jvm.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.jvm.kt deleted file mode 100644 index 8cefa437f..000000000 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPinPublisher.jvm.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.editor - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.github.zly2006.zhihu.viewmodel.DesktopPaginationEnvironment - -@Composable -actual fun rememberZhihuPinPublisher(): ZhihuPinPublisher { - val environment = remember { DesktopPaginationEnvironment() } - return remember(environment) { - ZhihuApiPinPublisher(environment) - } -} diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.jvm.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.jvm.kt index 9fbe22602..43769c543 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.jvm.kt @@ -25,8 +25,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.platform.asComposeFontFamily +import com.github.zly2006.zhihu.account.ZhihuAccountStore import com.github.zly2006.zhihu.data.toCookieHeaderString -import com.github.zly2006.zhihu.desktop.DesktopAccountStore +import com.github.zly2006.zhihu.desktop.defaultDesktopAccountStore import com.github.zly2006.zhihu.desktop.desktopZhihuDataFile import com.hrm.latex.renderer.font.MathFont import io.ktor.client.call.body @@ -43,7 +44,7 @@ private val LM_MATH_URLS = listOf( @Composable actual fun rememberMarkdownMathFont(): MathFont? { - val store = remember { DesktopAccountStore() } + val store = defaultDesktopAccountStore var mathFont by remember { mutableStateOf<MathFont?>(null) } LaunchedEffect(store) { @@ -57,8 +58,8 @@ actual fun rememberMarkdownMathFont(): MathFont? { @Composable actual fun rememberMarkdownImageRequestHeaders(): MarkdownImageRequestHeaders { - val store = remember { DesktopAccountStore() } - val session = remember(store) { store.load() } + val store = defaultDesktopAccountStore + val session = remember(store) { store.session } return MarkdownImageRequestHeaders( cookieHeader = session.cookies.toCookieHeaderString(), userAgent = session.userAgent, @@ -66,7 +67,7 @@ actual fun rememberMarkdownImageRequestHeaders(): MarkdownImageRequestHeaders { } @OptIn(ExperimentalTextApi::class) -private suspend fun loadDesktopMathFont(store: DesktopAccountStore): MathFont = withContext(Dispatchers.IO) { +private suspend fun loadDesktopMathFont(store: ZhihuAccountStore): MathFont = withContext(Dispatchers.IO) { val fontFile = desktopZhihuDataFile("latex-fonts/v$FONT_VERSION").resolve("latinmodern-math.otf") if (!fontFile.exists()) { downloadDesktopMathFont(store, fontFile) @@ -77,11 +78,11 @@ private suspend fun loadDesktopMathFont(store: DesktopAccountStore): MathFont = } private suspend fun downloadDesktopMathFont( - store: DesktopAccountStore, + store: ZhihuAccountStore, fontFile: File, ) { var lastError: Exception? = null - val client = store.httpClient() + val client = store.client.httpClient() for (url in LM_MATH_URLS) { try { val bytes = client.get(url).body<ByteArray>() diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/platform/JvmPlatformCapabilities.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/platform/JvmPlatformCapabilities.kt index ee439de23..b532a1dfc 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/platform/JvmPlatformCapabilities.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/platform/JvmPlatformCapabilities.kt @@ -21,9 +21,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import com.github.zly2006.zhihu.desktop.DesktopAccountStore import com.github.zly2006.zhihu.desktop.DesktopPropertiesFile import com.github.zly2006.zhihu.desktop.copyDesktopPlainText +import com.github.zly2006.zhihu.desktop.defaultDesktopAccountStore import com.github.zly2006.zhihu.desktop.desktopZhihuDataDir import com.github.zly2006.zhihu.desktop.openDesktopExternalUrl import com.github.zly2006.zhihu.desktop.saveImageToDownloads @@ -42,101 +42,112 @@ fun desktopSettingsStore(): SettingsStore { val propertiesFile = DesktopPropertiesFile("settings.properties", "Zhihu++ desktop settings") val properties = propertiesFile.properties - return SettingsStore( - getBoolean = { key, defaultValue -> - properties.getProperty(key)?.toBooleanStrictOrNull() ?: defaultValue - }, - putBoolean = { key, value -> - properties.setProperty(key, value.toString()) + return object : SettingsStore { + override fun getBoolean(key: String, defaultValue: Boolean) = properties.getProperty(key)?.toBooleanStrictOrNull() ?: defaultValue + + override fun putBoolean(key: String, value: Boolean) = write(key, value.toString()) + + override fun getString(key: String, defaultValue: String) = properties.getProperty(key) ?: defaultValue + + override fun putString(key: String, value: String) = write(key, value) + + override fun getStringOrNull(key: String) = properties.getProperty(key) + + override fun putStringSet(key: String, value: Set<String>) = write(key, value.joinToString("\u001F")) + + override fun getStringSet(key: String, defaultValue: Set<String>) = properties + .getProperty(key) + ?.split("\u001F") + ?.filter(String::isNotEmpty) + ?.toSet() ?: defaultValue + + override fun getInt(key: String, defaultValue: Int) = properties.getProperty(key)?.toIntOrNull() ?: defaultValue + + override fun putInt(key: String, value: Int) = write(key, value.toString()) + + override fun getLong(key: String, defaultValue: Long) = properties.getProperty(key)?.toLongOrNull() ?: defaultValue + + override fun putLong(key: String, value: Long) = write(key, value.toString()) + + override fun getFloat(key: String, defaultValue: Float) = properties.getProperty(key)?.toFloatOrNull() ?: defaultValue + + override fun putFloat(key: String, value: Float) = write(key, value.toString()) + + override fun remove(key: String) { + properties.remove(key) propertiesFile.save() - }, - getString = { key, defaultValue -> - properties.getProperty(key) ?: defaultValue - }, - putString = { key, value -> + } + + private fun write(key: String, value: String) { properties.setProperty(key, value) propertiesFile.save() - }, - getStringOrNull = { key -> - properties.getProperty(key) - }, - putStringSet = { key, value -> - properties.setProperty(key, value.joinToString("\u001F")) - propertiesFile.save() - }, - getStringSet = { key, defaultValue -> - properties - .getProperty(key) - ?.split("\u001F") - ?.filter { it.isNotEmpty() } - ?.toSet() ?: defaultValue - }, - getInt = { key, defaultValue -> - properties.getProperty(key)?.toIntOrNull() ?: defaultValue - }, - putInt = { key, value -> - properties.setProperty(key, value.toString()) - propertiesFile.save() - }, - getLong = { key, defaultValue -> - properties.getProperty(key)?.toLongOrNull() ?: defaultValue - }, - putLong = { key, value -> - properties.setProperty(key, value.toString()) - propertiesFile.save() - }, - getFloat = { key, defaultValue -> - properties.getProperty(key)?.toFloatOrNull() ?: defaultValue - }, - putFloat = { key, value -> - properties.setProperty(key, value.toString()) - propertiesFile.save() - }, - remove = { key -> - properties.remove(key) - propertiesFile.save() - }, - ) + } + } } @Composable -actual fun rememberExternalUrlOpener(): (String) -> Unit = remember { { url -> openDesktopExternalUrl(url) } } +actual fun rememberExternalUrlOpener(): ExternalUrlOpener = remember { + object : ExternalUrlOpener { + override fun invoke(url: String) { + openDesktopExternalUrl(url) + } + } +} @Composable -actual fun rememberSystemUrlOpener(): (String) -> Unit = rememberExternalUrlOpener() +actual fun rememberSystemUrlOpener(): SystemUrlOpener = remember { + object : SystemUrlOpener { + override fun invoke(url: String) { + openDesktopExternalUrl(url) + } + } +} @Composable -actual fun rememberZhihuWebUrlOpener(): (String) -> Unit = rememberExternalUrlOpener() +actual fun rememberZhihuWebUrlOpener(): ZhihuWebUrlOpener = remember { + object : ZhihuWebUrlOpener { + override fun invoke(url: String) { + openDesktopExternalUrl(url) + } + } +} @Composable -actual fun rememberImagePreviewOpener(): (String) -> Unit = rememberExternalUrlOpener() +actual fun rememberImagePreviewOpener(): ImagePreviewOpener = remember { + object : ImagePreviewOpener { + override fun invoke(url: String) { + openDesktopExternalUrl(url) + } + } +} @Composable -actual fun rememberImageGalleryOpener(): (List<String>, Int) -> Unit { - val openExternalUrl = rememberExternalUrlOpener() - return remember(openExternalUrl) { - { urls, initialIndex -> +actual fun rememberImageGalleryOpener(): ImageGalleryOpener = remember { + object : ImageGalleryOpener { + override fun invoke(urls: List<String>, initialIndex: Int) { if (urls.isNotEmpty()) { - urls[initialIndex.coerceIn(0, urls.lastIndex)].let(openExternalUrl) + openDesktopExternalUrl(urls[initialIndex.coerceIn(0, urls.lastIndex)]) } } } } @Composable -actual fun rememberImageSaver(): (String) -> Unit { +actual fun rememberImageSaver(): ImageSaver { val scope = rememberCoroutineScope() val userMessages = rememberUserMessageSink() - val store = remember { DesktopAccountStore() } + val store = defaultDesktopAccountStore return remember(scope, userMessages, store) { - { imageUrl -> - scope.launch { - runCatching { - store.saveImageToDownloads(imageUrl, "image") - }.onSuccess { file -> - userMessages.showShortMessage("已保存图片: ${file.absolutePath}") - }.onFailure { error -> - userMessages.showShortMessage("保存失败: ${error.message}") + object : ImageSaver { + override fun invoke(url: String) { + scope.launch { + runCatching { + store.saveImageToDownloads(url, "image") + }.onSuccess { file -> + userMessages.showShortMessage("已保存图片: ${file.absolutePath}") + }.onFailure { error -> + userMessages.showShortMessage("保存失败: ${error.message}") + } } } } @@ -144,35 +155,43 @@ actual fun rememberImageSaver(): (String) -> Unit { } @Composable -actual fun rememberImageSharer(): (String) -> Unit { +actual fun rememberImageSharer(): ImageSharer { val userMessages = rememberUserMessageSink() return remember(userMessages) { - { imageUrl -> - runCatching { - copyDesktopPlainText(imageUrl) - userMessages.showShortMessage("已复制图片链接") - }.onFailure { error -> - userMessages.showShortMessage("分享失败: ${error.message}") + object : ImageSharer { + override fun invoke(url: String) { + runCatching { + copyDesktopPlainText(url) + userMessages.showShortMessage("已复制图片链接") + }.onFailure { error -> + userMessages.showShortMessage("分享失败: ${error.message}") + } } } } } @Composable -actual fun rememberPlainTextClipboard(): (label: String, text: String) -> Unit = - remember { { _, text -> runCatching { copyDesktopPlainText(text) } } } +actual fun rememberPlainTextClipboard(): PlainTextClipboard = + remember { + object : PlainTextClipboard { + override fun invoke(label: String, text: String) { + runCatching { copyDesktopPlainText(text) } + } + } + } @Composable actual fun rememberUserMessageSink(): UserMessageSink = remember { - UserMessageSink( - showShortMessage = { message -> + object : UserMessageSink { + override fun showShortMessage(message: String) { println(message) runCatching { ProcessBuilder("terminal-notifier", "-message", message, "-sound", "default") .start() } - }, - ) + } + } } @Composable @@ -191,3 +210,21 @@ actual fun PlatformPredictiveBackHandler( @Composable actual fun rememberIsLiteVariant(): Boolean = false + +internal actual val platformBottomBarItemLimit: Int? = null + +actual val platformName: String = "JVM" + +actual val isJvm: Boolean = true + +actual val isNative: Boolean = false + +actual val isAigcVoteSupported: Boolean = false + +actual val isBlocklistNlpSupported: Boolean = false + +actual val isSentenceSimilaritySupported: Boolean = false + +actual val isArticleHtmlExportSupported: Boolean = true + +actual val isArticleImageExportSupported: Boolean = true diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/reading/JvmReadingPlayer.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/reading/JvmReadingPlayer.kt index 65b352d97..dd3fb2485 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/reading/JvmReadingPlayer.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/reading/JvmReadingPlayer.kt @@ -18,6 +18,11 @@ package com.github.zly2006.zhihu.reading import androidx.compose.runtime.Composable +import com.github.zly2006.zhihu.platform.platformName + +actual val isReadingPlayerSupported: Boolean = false @Composable -actual fun rememberReadingPlayerController(): ReadingPlayerController = UnsupportedReadingPlayerController +internal actual fun rememberSupportedReadingPlayerController(): ReadingPlayerController { + error("$platformName 暂不支持内容朗读播放器") +} diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/DesktopZhihuMain.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/DesktopZhihuMain.kt index c95cabc7f..1ac9f286f 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/DesktopZhihuMain.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/DesktopZhihuMain.kt @@ -26,6 +26,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,7 +36,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import com.github.zly2006.zhihu.data.fetchHighestQualityZhihuVideoUrl -import com.github.zly2006.zhihu.desktop.DesktopAccountStore +import com.github.zly2006.zhihu.desktop.defaultDesktopAccountStore import com.github.zly2006.zhihu.desktop.openDesktopExternalUrl import com.github.zly2006.zhihu.navigation.Account import com.github.zly2006.zhihu.navigation.Article @@ -55,6 +56,7 @@ import com.github.zly2006.zhihu.navigation.Pin import com.github.zly2006.zhihu.navigation.Question import com.github.zly2006.zhihu.navigation.TopLevelDestination import com.github.zly2006.zhihu.navigation.Video +import com.github.zly2006.zhihu.platform.platformBottomBarItemLimit import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.platform.rememberUserMessageSink import com.github.zly2006.zhihu.theme.ThemeManager @@ -69,8 +71,8 @@ import com.github.zly2006.zhihu.ui.subscreens.normalizeBottomBarSelection import com.github.zly2006.zhihu.ui.subscreens.resolveValidStartDestinationKey import com.github.zly2006.zhihu.util.signZhihuFetchRequest import com.github.zly2006.zhihu.viewmodel.ArticleViewModel -import com.github.zly2006.zhihu.viewmodel.desktopArticleAnswerSwitchState import com.github.zly2006.zhihu.viewmodel.prepareDesktopPendingContentOpen +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -84,8 +86,10 @@ import kotlinx.coroutines.withContext @Composable fun DesktopZhihuMain() { val navController = rememberNavController() - val accountStore = remember { DesktopAccountStore() } - val httpClient = accountStore.httpClient() + val accountStore = defaultDesktopAccountStore + val accounts by accountStore.accountsState.collectAsState() + val accountSession = accounts.session + val httpClient = remember(accountStore, accountSession) { accountStore.client.httpClient() } val coroutineScope = rememberCoroutineScope() val userMessages = rememberUserMessageSink() var mainTabNavigationTarget by remember { mutableStateOf<TopLevelDestination?>(null) } @@ -144,7 +148,7 @@ fun DesktopZhihuMain() { else -> return } coroutineScope.launch { - val cookies = accountStore.load().cookies + val cookies = accountStore.session.cookies val videoUrl = withContext(Dispatchers.IO) { runCatching { fetchHighestQualityZhihuVideoUrl( @@ -199,7 +203,7 @@ fun DesktopZhihuMain() { preferenceState = rememberDesktopZhihuMainPreferenceState(), isDarkTheme = ThemeManager.isDarkTheme(), articleEnterTransition = { - when (desktopArticleAnswerSwitchState.answerTransitionDirection) { + when (sharedArticleAnswerSwitchState.answerTransitionDirection) { ArticleAnswerTransitionDirection.VERTICAL_NEXT -> slideInVertically(tween(300)) { it } + fadeIn(tween(300)) ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> @@ -212,7 +216,7 @@ fun DesktopZhihuMain() { } }, articleExitTransition = { - when (desktopArticleAnswerSwitchState.answerTransitionDirection) { + when (sharedArticleAnswerSwitchState.answerTransitionDirection) { ArticleAnswerTransitionDirection.VERTICAL_NEXT -> slideOutVertically(tween(300)) { -it } + fadeOut(tween(300)) ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> @@ -249,10 +253,11 @@ private fun rememberDesktopZhihuMainPreferenceState(): ZhihuMainPreferenceState val selectedKeys = normalizeBottomBarSelection( settings.getStringSet( BOTTOM_BAR_ITEMS_PREFERENCE_KEY, - defaultBottomBarSelectionKeys(duo3HomeAccount), + defaultBottomBarSelectionKeys(duo3HomeAccount, platformBottomBarItemLimit), ), duo3HomeAccount, enforceMinimumSelection = true, + maximumSelection = platformBottomBarItemLimit, ) val orderedSelectedKeys = bottomBarItemOrderFromPreference( settings.getStringOrNull(BOTTOM_BAR_ITEM_ORDER_PREFERENCE_KEY), diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/JvmUiRuntimes.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/JvmUiRuntimes.kt index 552354135..cf7867d0b 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/JvmUiRuntimes.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/JvmUiRuntimes.kt @@ -21,28 +21,23 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.unit.em -import com.github.zly2006.zhihu.desktop.DesktopAccountStore -import com.github.zly2006.zhihu.desktop.DesktopLoginRequests import com.github.zly2006.zhihu.desktop.openDesktopExternalUrl -import com.github.zly2006.zhihu.markdown.RenderMarkdown import com.github.zly2006.zhihu.navigation.Article import com.github.zly2006.zhihu.notification.NotificationSettingsStore import com.github.zly2006.zhihu.platform.UserMessageSink -import com.github.zly2006.zhihu.platform.rememberSettingsStore +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberUserMessageSink -import com.github.zly2006.zhihu.ui.subscreens.DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY import com.github.zly2006.zhihu.ui.subscreens.desktopVersionName import com.github.zly2006.zhihu.util.Log import com.github.zly2006.zhihu.viewmodel.DesktopPaginationEnvironment @@ -63,23 +58,27 @@ import javax.swing.filechooser.FileNameExtensionFilter actual fun rememberArticleTtsState(): TtsState = DesktopArticleSpeechController.currentTtsState @Composable -actual fun rememberArticleSpeechToggler(): (title: String, content: String) -> Unit { +actual fun rememberArticleSpeechToggler(): ArticleSpeechToggler { val userMessages = rememberUserMessageSink() val coroutineScope = rememberCoroutineScope() return remember(userMessages, coroutineScope) { - { title, content -> - DesktopArticleSpeechController.toggleSpeech(title, content, coroutineScope, userMessages) + object : ArticleSpeechToggler { + override fun invoke(title: String, content: String) { + DesktopArticleSpeechController.toggleSpeech(title, content, coroutineScope, userMessages) + } } } } @Composable -actual fun rememberArticleBrowserOpener(): (Article) -> Unit { +actual fun rememberArticleBrowserOpener(): ArticleBrowserOpener { val userMessages = rememberUserMessageSink() return remember(userMessages) { - { article -> - if (openDesktopExternalUrl(articleWebUrl(article))) { - userMessages.showMessage("已发送到浏览器") + object : ArticleBrowserOpener { + override fun invoke(article: Article) { + if (openDesktopExternalUrl(articleWebUrl(article))) { + userMessages.showMessage("已发送到浏览器") + } } } } @@ -228,30 +227,34 @@ actual fun rememberHomeIsDebuggable(): Boolean = true @Composable actual fun rememberBlocklistRuleImporter( userMessages: UserMessageSink, -): (((String) -> Unit) -> Unit) { + onImported: (String) -> Unit, +): BlocklistRuleImporter { val database = remember { val databaseFile = desktopContentFilterDatabaseFile() databaseFile.parentFile?.mkdirs() getContentFilterDatabase(databaseFile) } val coroutineScope = rememberCoroutineScope() - return remember(database, userMessages) { - { onImported -> - val selectedFile = chooseBlocklistImportFile() - if (selectedFile != null) { - coroutineScope.launch { - try { - val summary = importBlocklistBackupFromJsonText( - keywordDao = database.blockedKeywordDao(), - userDao = database.blockedUserDao(), - questionAuthorDao = database.blockedQuestionAuthorDao(), - topicDao = database.blockedTopicDao(), - text = selectedFile.readText(), - ) - onImported(summary) - } catch (e: Exception) { - Log.e("BlocklistSettings", "Failed to import blocklist", e) - userMessages.showShortMessage("导入失败: ${e.message}") + val currentOnImported by rememberUpdatedState(onImported) + return remember(database, userMessages, coroutineScope) { + object : BlocklistRuleImporter { + override fun invoke() { + val selectedFile = chooseBlocklistImportFile() + if (selectedFile != null) { + coroutineScope.launch { + try { + val summary = importBlocklistBackupFromJsonText( + keywordDao = database.blockedKeywordDao(), + userDao = database.blockedUserDao(), + questionAuthorDao = database.blockedQuestionAuthorDao(), + topicDao = database.blockedTopicDao(), + text = selectedFile.readText(), + ) + currentOnImported(summary) + } catch (e: Exception) { + Log.e("BlocklistSettings", "Failed to import blocklist", e) + userMessages.showShortMessage("导入失败: ${e.message}") + } } } } @@ -260,24 +263,26 @@ actual fun rememberBlocklistRuleImporter( } @Composable -actual fun rememberBlocklistRuleExporter(): suspend () -> String { +actual fun rememberBlocklistRuleExporter(): BlocklistRuleExporter { val database = remember { val databaseFile = desktopContentFilterDatabaseFile() databaseFile.parentFile?.mkdirs() getContentFilterDatabase(databaseFile) } return remember(database) { - suspend { - val file = File(desktopContentFilterDatabaseFile().parentFile, "zhihupp_blocklist.json") - file.writeText( - encodeBlocklistBackup( - keywordDao = database.blockedKeywordDao(), - userDao = database.blockedUserDao(), - questionAuthorDao = database.blockedQuestionAuthorDao(), - topicDao = database.blockedTopicDao(), - ), - ) - "已导出到 ${file.absolutePath}" + object : BlocklistRuleExporter { + override suspend fun invoke(): String { + val file = File(desktopContentFilterDatabaseFile().parentFile, "zhihupp_blocklist.json") + file.writeText( + encodeBlocklistBackup( + keywordDao = database.blockedKeywordDao(), + userDao = database.blockedUserDao(), + questionAuthorDao = database.blockedQuestionAuthorDao(), + topicDao = database.blockedTopicDao(), + ), + ) + return "已导出到 ${file.absolutePath}" + } } } } @@ -295,34 +300,11 @@ private fun chooseBlocklistImportFile(): File? { } } -@Composable -actual fun rememberAccountSettingsAccountState(): androidx.compose.runtime.State<AccountSettingsAccountState> { - val accountStore = remember { DesktopAccountStore() } - val account = accountStore.accountState.collectAsState() - return remember(account) { - derivedStateOf { account.value.toAccountSettingsAccountState() } - } -} - -@Composable -actual fun rememberAccountQrLoginRequester(): () -> Unit = remember { - { DesktopLoginRequests.requestLogin() } -} - @Composable actual fun rememberAppVersionInfo(): String = desktopVersionName() -private fun com.github.zly2006.zhihu.account.ZhihuAccountSession.toAccountSettingsAccountState(): AccountSettingsAccountState = - AccountSettingsAccountState( - login = login, - username = username, - avatarUrl = profile?.avatarUrl, - id = profile?.id ?: "", - urlToken = profile?.urlToken, - ) - @Composable -actual fun rememberArticleHost(): ArticleHost? = null +actual fun consumePendingCommentId(content: com.github.zly2006.zhihu.navigation.NavDestination): String? = null @Composable actual fun ArticleWebViewContent( @@ -335,18 +317,7 @@ actual fun ArticleWebViewContent( onRememberedScrollYSyncChange: (Boolean) -> Unit, onImageLoadFailed: () -> Unit, onDoubleTap: () -> Unit, -) { - RenderMarkdown( - html = html, - modifier = Modifier, - selectable = true, - enableScroll = false, - header = {}, - footer = {}, - useTiqianRenderer = rememberSettingsStore() - .getBoolean(DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY, false), - ) -} +): Unit = error("$platformName 暂不支持文章 WebView 渲染") actual fun Modifier.articleMarkdownSelectionWorkaround(): Modifier = this @@ -354,32 +325,26 @@ actual fun Modifier.articleMarkdownSelectionWorkaround(): Modifier = this * 桌面端不支持 WebView */ @Composable -actual fun ZhihuHtmlWebViewContent(html: String) = Unit +actual fun ZhihuHtmlWebViewContent(html: String): Unit = error("$platformName 暂不支持 HTML WebView 渲染") -actual fun supportsZhihuHtmlWebView(): Boolean = false +actual val isLegacyWebViewSupported: Boolean = false @Composable actual fun rememberNotificationEnvironment( settingsStore: NotificationSettingsStore, -): com.github.zly2006.zhihu.viewmodel.NotificationEnvironment { - val userMessages = rememberUserMessageSink() - val store = remember { DesktopAccountStore() } - return remember(store, settingsStore, userMessages) { - DesktopPaginationEnvironment( - store = store, - notificationSettingsStore = settingsStore, - showFetchFailureMessage = userMessages::showMessage, - ) - } +): com.github.zly2006.zhihu.viewmodel.NotificationEnvironment = remember(settingsStore) { + DesktopPaginationEnvironment( + notificationSettingsStore = settingsStore, + ) } @Composable actual fun QuestionDetailWebViewContent( questionId: Long, html: String, -) = Unit // TODO: 桌面端问题 WebView - -actual fun supportsQuestionDetailWebView(): Boolean = false +) { + error("$platformName 暂不支持问题详情 WebView 渲染") +} actual fun Modifier.questionSelectionWorkaround(): Modifier = this diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/components/DesktopWebviewComp.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/components/DesktopWebviewComp.kt index edf3be906..dc50a0fe9 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/components/DesktopWebviewComp.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/components/DesktopWebviewComp.kt @@ -115,7 +115,7 @@ fun DesktopWebviewComp( /** * 桌面端风控验证 WebView 容器。 * - * 对应 Android 端 LoginActivity 中 QrLoginPane 的风险控制内容区域, + * 对应共享登录页中的二维码风控内容区域, * 负责加载风控验证 URL 并在用户完成验证后回传 cookies。 */ @Composable diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/JvmSubscreenRuntimes.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/JvmSubscreenRuntimes.kt index 9fc658139..c696c91b8 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/JvmSubscreenRuntimes.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/JvmSubscreenRuntimes.kt @@ -19,8 +19,9 @@ package com.github.zly2006.zhihu.ui.subscreens import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.github.zly2006.zhihu.desktop.DesktopAccountStore +import com.github.zly2006.zhihu.desktop.defaultDesktopAccountStore import com.github.zly2006.zhihu.desktop.openDesktopExternalUrl +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.updater.SchematicVersion import com.github.zly2006.zhihu.updater.extractGithubReleaseNotes @@ -29,51 +30,82 @@ import com.github.zly2006.zhihu.updater.fetchNightlyZhihuRelease import com.mikepenz.aboutlibraries.Libs import io.ktor.client.HttpClient import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.io.File import java.util.Properties internal val desktopSystemUpdateState = MutableStateFlow<SystemUpdateState>(SystemUpdateState.NoUpdate) @Composable -actual fun rememberSystemUpdateRuntime(): SystemUpdateRuntime { +actual fun rememberSystemUpdateState(): StateFlow<SystemUpdateState> = desktopSystemUpdateState + +@Composable +actual fun rememberSystemUpdateChecker(): SystemUpdateChecker { val settings = rememberSettingsStore() - val accountStore = remember { DesktopAccountStore() } + val accountStore = defaultDesktopAccountStore return remember(settings, accountStore) { - SystemUpdateRuntime( - state = desktopSystemUpdateState, - checkForUpdate = { + object : SystemUpdateChecker { + override suspend fun check() { checkDesktopUpdate( - client = accountStore.httpClient(), + client = accountStore.client.httpClient(), githubToken = settings.getStringOrNull("githubToken")?.takeIf { it.isNotBlank() }, checkNightly = settings.getBoolean("checkNightlyUpdates", false), skippedVersion = settings.getStringOrNull(PREF_SKIPPED_VERSION), state = desktopSystemUpdateState, ) - }, - skipVersion = { version -> + } + } + } +} + +@Composable +actual fun rememberSystemUpdateVersionSkipper(): SystemUpdateVersionSkipper { + val settings = rememberSettingsStore() + return remember(settings) { + object : SystemUpdateVersionSkipper { + override fun skip(version: String) { settings.putString(PREF_SKIPPED_VERSION, version) desktopSystemUpdateState.value = SystemUpdateState.Latest - }, - resetToNoUpdate = { desktopSystemUpdateState.value = SystemUpdateState.NoUpdate }, - downloadUpdate = { url -> - runCatching { - if (url.isBlank()) { - error("下载链接为空") - } - openDesktopExternalUrl(url) - }.onFailure { - desktopSystemUpdateState.value = SystemUpdateState.Error(it.message ?: "无法打开浏览器") + } + } + } +} + +@Composable +actual fun rememberSystemUpdateDownloader(): SystemUpdateDownloader = remember { + object : SystemUpdateDownloader { + override suspend fun download(url: String) { + runCatching { + if (url.isBlank()) { + error("下载链接为空") } - }, - installDownloadedUpdate = { - desktopSystemUpdateState.value = SystemUpdateState.Error("桌面端不支持 APK 更新安装") - }, - setError = { message -> desktopSystemUpdateState.value = SystemUpdateState.Error(message) }, - supportsApkInstall = false, - ) + openDesktopExternalUrl(url) + }.onFailure { + desktopSystemUpdateState.value = SystemUpdateState.Error(it.message ?: "无法打开浏览器") + } + } + } +} + +@Composable +actual fun rememberDownloadedSystemUpdateInstaller(): DownloadedSystemUpdateInstaller = remember { + object : DownloadedSystemUpdateInstaller { + override suspend fun install() { + desktopSystemUpdateState.value = SystemUpdateState.Error("$platformName 暂不支持 APK 更新安装") + } } } +actual fun resetSystemUpdateState() { + desktopSystemUpdateState.value = SystemUpdateState.NoUpdate +} + +actual fun setSystemUpdateError(message: String) { + desktopSystemUpdateState.value = SystemUpdateState.Error(message) +} + +actual val isApkUpdateInstallSupported: Boolean = false + private const val PREF_SKIPPED_VERSION = "skippedVersion" private suspend fun checkDesktopUpdate( @@ -135,7 +167,7 @@ private suspend fun checkDesktopUpdate( internal fun desktopVersionName(): String = System.getProperty("zhihu.version") - ?: SystemUpdateRuntime::class.java.`package`?.implementationVersion + ?: SystemUpdateState::class.java.`package`?.implementationVersion ?: readDesktopVersionFromGradleProperties() ?: "0.0.0" @@ -154,8 +186,8 @@ private fun readDesktopVersionFromGradleProperties(): String? { } @Composable -actual fun rememberDeveloperRuntimeInfo(): DeveloperRuntimeInfo = - DeveloperRuntimeInfo(networkStatus = "网络状态:桌面端使用系统网络") +actual fun rememberDeveloperInfo(): DeveloperInfoSnapshot = + DeveloperInfoSnapshot(networkStatus = "网络状态:桌面端使用系统网络") @Composable actual fun rememberOpenSourceLicensesLibraries(): Libs = remember { @@ -195,8 +227,12 @@ private fun loadDesktopAboutLibrariesJson(): String? { } } +actual val isWebViewCustomFontSupported: Boolean = false + @Composable actual fun WebViewCustomFontSettings( customFontName: String?, onCustomFontNameChange: (String?) -> Unit, -) = Unit // TODO: 桌面端 WebView 自定义字体设置 +) { + error("$platformName 暂不支持 WebView 自定义字体设置") +} diff --git a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.jvm.kt b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.jvm.kt index ae12adfce..d4c998315 100644 --- a/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.jvm.kt @@ -24,10 +24,9 @@ import com.github.zly2006.zhihu.data.Feed import com.github.zly2006.zhihu.data.FeedDisplayItem import com.github.zly2006.zhihu.data.navDestination import com.github.zly2006.zhihu.data.target -import com.github.zly2006.zhihu.desktop.DesktopAccountStore import com.github.zly2006.zhihu.desktop.DesktopHistoryStorage -import com.github.zly2006.zhihu.desktop.DesktopLoginRequests import com.github.zly2006.zhihu.desktop.copyDesktopPlainText +import com.github.zly2006.zhihu.desktop.defaultDesktopAccountStore import com.github.zly2006.zhihu.desktop.desktopZhihuDataFile import com.github.zly2006.zhihu.desktop.desktopZhihuDownloadsDir import com.github.zly2006.zhihu.filter.ContentOpenEventSupport @@ -38,8 +37,6 @@ import com.github.zly2006.zhihu.navigation.NavDestination import com.github.zly2006.zhihu.notification.NotificationSettingsStore import com.github.zly2006.zhihu.notification.desktopNotificationSettingsStore import com.github.zly2006.zhihu.platform.desktopSettingsStore -import com.github.zly2006.zhihu.ui.ArticleAnswerSwitchState -import com.github.zly2006.zhihu.ui.homeFeedStartupCacheFileNames import com.github.zly2006.zhihu.util.Log import com.github.zly2006.zhihu.util.buildArticleExportFileName import com.github.zly2006.zhihu.util.buildCollectionExportZipFileName @@ -66,7 +63,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.put import java.awt.image.BufferedImage import java.io.File @@ -82,7 +78,6 @@ import com.github.zly2006.zhihu.util.buildOfflineArticleExportHtml as buildShare import io.ktor.http.ContentType as KtorContentType private val desktopContentFilterDb = getContentFilterDatabase() -internal val desktopArticleAnswerSwitchState = ArticleAnswerSwitchData() private var desktopPendingContentOpenIdentity: TrackedContentIdentity? = null private var desktopPendingContentOpenFrom: String? = null @@ -114,66 +109,36 @@ private fun consumeDesktopPendingContentOpenFrom(destination: NavDestination): S } class DesktopPaginationEnvironment( - private val store: DesktopAccountStore = DesktopAccountStore(), override val notificationSettingsStore: NotificationSettingsStore = desktopNotificationSettingsStore(), - private val showFetchFailureMessage: ((String) -> Unit)? = null, ) : PaginationEnvironment, CollectionContentEnvironment, NotificationEnvironment { + private val store = defaultDesktopAccountStore private val settingsStore = desktopSettingsStore() private val historyStorage = DesktopHistoryStorage() private val contentFilterDb = desktopContentFilterDb - private val localRecommendationEngine by lazy { createLocalRecommendationEngine() } + private val localRecommendationEngine by lazy { + val databaseFile = desktopZhihuDataFile("local-content.db") + databaseFile.parentFile?.mkdirs() + val dao = getLocalContentDatabase(databaseFile).contentDao() + buildLocalRecommendationEngine(dao, this) + } - override fun httpClient(): HttpClient = store.httpClient() + override fun httpClient(): HttpClient = store.client.httpClient() - override fun xsrfToken(): String = store.load().cookies["_xsrf"] ?: "" + override fun xsrfToken(): String = store.session.cookies["_xsrf"] ?: "" - override fun authenticatedCookies(): Map<String, String> = store.load().cookies + override fun authenticatedCookies(): Map<String, String> = store.session.cookies override suspend fun <T> withAuthenticatedClient( block: suspend (client: HttpClient, cookies: Map<String, String>) -> T, - ): T = store.withAuthenticatedClient(block) - - override suspend fun refreshAccountProfile() { - store.refreshAndSaveProfile() - } - - override fun requestLogin(): Boolean { - DesktopLoginRequests.requestLogin() - return true - } - - override fun clearAccountSession() { - store.clear() - } - - override fun currentAccountId(): String = store - .load() - .profile - ?.id - .orEmpty() - - override suspend fun verifyLogin(cookies: Map<String, String>): Boolean = - store.verifyAndSave(cookies.toMutableMap()) - - override fun saveCookies(cookies: Map<String, String>) { - store.save(store.load().copy(login = true, cookies = cookies.toMutableMap())) - } - - override fun logout() { - homeFeedStartupCacheFileNames().forEach { fileName -> - desktopZhihuDataFile(fileName).delete() - } - clearAccountSession() - } + ): T = store.client.withAuthenticatedClient(block) override suspend fun handleFetchFailure( tag: String?, error: Exception, ) { Log.e(tag ?: "PaginationViewModel", "Failed to fetch feeds", error) - showFetchFailureMessage?.invoke("加载失败: ${error.message}") } override fun feedDisplaySettings(): FeedDisplaySettings = FeedDisplaySettings( @@ -184,8 +149,6 @@ class DesktopPaginationEnvironment( override fun localHistory(): List<NavDestination> = historyStorage.history - override fun articleAnswerSwitchState(): ArticleAnswerSwitchState? = desktopArticleAnswerSwitchState - override suspend fun postHistoryDestination(destination: NavDestination) { historyStorage.add(destination) } @@ -321,7 +284,7 @@ class DesktopPaginationEnvironment( override suspend fun clearAllHistory() { historyStorage.clearAndSave() - if (store.load().cookies["d_c0"] == null) return + if (store.session.cookies["d_c0"] == null) return postSigned("https://api.zhihu.com/read_history/batch_del") { contentType(KtorContentType.Application.Json) setBody( @@ -394,7 +357,7 @@ class DesktopPaginationEnvironment( writeJpegImage(file, (bitmap as BufferedImage).toJpegImage()) } - override fun articleImageExportRenderer(loadAssetText: (String) -> String): ArticleImageExportRenderer = + override fun articleImageExportRenderer(): ArticleImageExportRenderer = DesktopArticleExportRenderer() override suspend fun exportCollectionItemsToHtmlZip( @@ -497,22 +460,6 @@ class DesktopPaginationEnvironment( override suspend fun handleCollectionExportFailure(error: Exception) { Log.e("CollectionContentViewModel", "Failed to export collection HTML zip", error) } - - private fun createLocalRecommendationEngine(): LocalRecommendationEngine { - val databaseFile = desktopZhihuDataFile("local-content.db") - databaseFile.parentFile?.mkdirs() - val dao = getLocalContentDatabase(databaseFile).contentDao() - return buildLocalRecommendationEngine( - dao = dao, - fetchFeedArray = { url -> - fetchJson(url, "") - ?.get("data") - ?.jsonArray ?: JsonArray(emptyList()) - }, - logWarning = { message -> Log.w("LocalRecommendationEngine", message) }, - logError = { message, throwable -> Log.e("LocalRecommendationEngine", message, throwable) }, - ) - } } private data class DesktopPreparedExportContent( diff --git a/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedContentFilterPipelineTest.kt b/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedContentFilterPipelineTest.kt index c9e9c4119..0d48f3726 100644 --- a/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedContentFilterPipelineTest.kt +++ b/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedContentFilterPipelineTest.kt @@ -50,7 +50,6 @@ class FeedContentFilterPipelineTest { ), ) - val notified = mutableListOf<List<FilterableContent>>() val result = FeedContentFilterPipeline( settings = FeedFilterSettings(), blockedKeywordDao = database.blockedKeywordDao(), @@ -58,8 +57,6 @@ class FeedContentFilterPipelineTest { blockedQuestionAuthorDao = database.blockedQuestionAuthorDao(), blockedTopicDao = database.blockedTopicDao(), blockedKeywordService = keywordService, - htmlToText = { it }, - onNlpBlocked = { notified.add(it) }, ).filter( listOf( filterable("keep", authorId = "ok-user"), @@ -82,7 +79,6 @@ class FeedContentFilterPipelineTest { ), result.blocked.map { it.second }, ) - assertEquals(listOf(listOf("nlp hit")), notified.map { list -> list.map { it.title } }) database.close() } diff --git a/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedFilterSettingsTest.kt b/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedFilterSettingsTest.kt index 5a3502fe0..9a4245991 100644 --- a/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedFilterSettingsTest.kt +++ b/shared/src/jvmTest/kotlin/com/github/zly2006/zhihu/viewmodel/filter/FeedFilterSettingsTest.kt @@ -17,6 +17,7 @@ package com.github.zly2006.zhihu.viewmodel.filter +import com.github.zly2006.zhihu.platform.MapSettingsStore import com.github.zly2006.zhihu.platform.SettingsStore import kotlin.test.Test import kotlin.test.assertEquals @@ -57,23 +58,6 @@ class FeedFilterSettingsTest { private fun mapBackedSettingsStore(vararg values: Pair<String, Any>): SettingsStore { val map = values.toMap() - return SettingsStore( - getBoolean = { key, default -> map[key] as? Boolean ?: default }, - putBoolean = { _, _ -> }, - getString = { key, default -> map[key] as? String ?: default }, - putString = { _, _ -> }, - getStringOrNull = { key -> map[key] as? String }, - putStringSet = { _, _ -> }, - getStringSet = { key, default -> - (map[key] as? Iterable<*>)?.filterIsInstance<String>()?.toSet() ?: default - }, - getInt = { key, default -> map[key] as? Int ?: default }, - putInt = { _, _ -> }, - getLong = { key, default -> map[key] as? Long ?: default }, - putLong = { _, _ -> }, - getFloat = { key, default -> map[key] as? Float ?: default }, - putFloat = { _, _ -> }, - remove = { _ -> }, - ) + return MapSettingsStore(map.toMutableMap()) } } diff --git a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/account/MacosQrLoginScreen.kt b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/account/MacosQrLoginScreen.kt deleted file mode 100644 index 0ff83f7dd..000000000 --- a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/account/MacosQrLoginScreen.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.account - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.github.zly2006.zhihu.platform.MacosUserMessageHost -import com.github.zly2006.zhihu.platform.UserMessageDuration -import com.github.zly2006.zhihu.platform.macosQrLoginRequestVersion -import com.github.zly2006.zhihu.platform.showMacosUserMessage -import com.github.zly2006.zhihu.theme.ZhihuTheme -import com.github.zly2006.zhihu.ui.MacosZhihuMain -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.time.Duration.Companion.seconds - -@Composable -fun MacosQrLoginScreen() { - val store = remember { NativeAccountStore() } - var statusText by remember { mutableStateOf("正在获取二维码") } - var isLoggedIn by remember { mutableStateOf(false) } - var didCheckSavedAccount by remember { mutableStateOf(false) } - val loginRequestVersion by macosQrLoginRequestVersion.collectAsState() - - LaunchedEffect(Unit) { - try { - val savedData = store.load() - val cookies = savedData.cookies - if (savedData.login && cookies.isNotEmpty()) { - statusText = "正在验证已备份 cookie" - val verified = withTimeoutOrNull(10.seconds) { - store.verifyAndSave(cookies) - } == true - if (verified) { - statusText = "已使用备份 cookie 登录:${store.load().username}" - isLoggedIn = true - } - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - statusText = "备份 cookie 验证失败,正在获取二维码" - showMacosUserMessage( - "备份 cookie 验证失败:${error.message ?: "未知错误"}", - UserMessageDuration.Long, - ) - } finally { - didCheckSavedAccount = true - } - } - - LaunchedEffect(loginRequestVersion) { - if (loginRequestVersion > 0) { - statusText = "正在获取二维码" - isLoggedIn = false - didCheckSavedAccount = true - } - } - - ZhihuTheme { - MacosUserMessageHost { - if (isLoggedIn) { - MacosZhihuMain() - } else if (didCheckSavedAccount) { - SharedQrLoginPane( - createClient = { cookies -> store.createHttpClient(cookies) }, - onLoginSuccess = { cookies -> - store.verifyAndSave(cookies.toMutableMap()).also { success -> - if (success) { - isLoggedIn = true - } - } - }, - initialCookies = store.load().cookies, - qrReadyMessage = "请打开知乎++ App 扫一扫", - onQrReady = { - showMacosUserMessage("需要扫码登录 macOS 端") - }, - ) - } else { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - Spacer(modifier = Modifier.size(16.dp)) - Text(statusText) - } - } - } - } -} diff --git a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosMarkdownImageViewer.kt b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosMarkdownImageViewer.kt new file mode 100644 index 000000000..80337eb3f --- /dev/null +++ b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosMarkdownImageViewer.kt @@ -0,0 +1,223 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +@file:OptIn( + kotlinx.cinterop.BetaInteropApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package com.github.zly2006.zhihu.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.github.zly2006.zhihu.account.ZhihuAccountStore +import com.github.zly2006.zhihu.account.defaultNativeAccountStore +import io.ktor.client.call.body +import io.ktor.client.request.get +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.useContents +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import platform.AppKit.NSApplication +import platform.AppKit.NSBackingStoreBuffered +import platform.AppKit.NSColor +import platform.AppKit.NSEvent +import platform.AppKit.NSFloatingWindowLevel +import platform.AppKit.NSImage +import platform.AppKit.NSImageAlignCenter +import platform.AppKit.NSImageScaleProportionallyUpOrDown +import platform.AppKit.NSImageView +import platform.AppKit.NSScreen +import platform.AppKit.NSTextAlignmentCenter +import platform.AppKit.NSTextField +import platform.AppKit.NSView +import platform.AppKit.NSViewHeightSizable +import platform.AppKit.NSViewWidthSizable +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowStyleMaskBorderless +import platform.AppKit.labelWithString +import platform.CoreGraphics.CGRect +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSData +import platform.Foundation.dataWithBytes + +private const val VIEWER_SCREEN_FRACTION = 0.8 + +@Composable +actual fun rememberImageGalleryOpener(): ImageGalleryOpener { + val scope = rememberCoroutineScope() + val accountStore = defaultNativeAccountStore + val userMessages = rememberUserMessageSink() + val controller = remember(scope, accountStore, userMessages) { + MacosMarkdownImageViewerController(scope, accountStore, userMessages) + } + + DisposableEffect(controller) { + onDispose(controller::dispose) + } + + return remember(controller) { + object : ImageGalleryOpener { + override fun invoke(urls: List<String>, initialIndex: Int) { + if (urls.isNotEmpty()) { + controller.toggle(urls[initialIndex.coerceIn(0, urls.lastIndex)]) + } + } + } + } +} + +internal class MacosMarkdownImageViewerController( + private val scope: CoroutineScope, + private val accountStore: ZhihuAccountStore, + private val userMessages: UserMessageSink, +) { + private var window: NSWindow? = null + private var activeImageUrl: String? = null + private var loadJob: Job? = null + + fun toggle(imageUrl: String) { + if (activeImageUrl == imageUrl && window?.isVisible() == true) { + dismiss() + return + } + + dismiss() + activeImageUrl = imageUrl + val viewerFrame = viewerFrame() + val contentFrame = viewerFrame.useContents { + CGRectMake(0.0, 0.0, size.width, size.height) + } + val viewerWindow = window ?: createWindow(viewerFrame).also { window = it } + viewerWindow.setFrame(viewerFrame, display = true) + viewerWindow.contentView = ClosingLoadingView( + frame = contentFrame, + onClose = ::dismiss, + ) + viewerWindow.makeKeyAndOrderFront(null) + + loadJob = scope.launch { + runCatching { + val imageBytes = accountStore.client + .httpClient() + .get(imageUrl) + .body<ByteArray>() + val imageData = imageBytes.usePinned { pinned -> + NSData.dataWithBytes(pinned.addressOf(0), imageBytes.size.toULong()) + } + NSImage(data = imageData) + }.onSuccess { image -> + if (activeImageUrl == imageUrl) { + viewerWindow.contentView = ClosingImageView( + frame = viewerWindow.contentView?.bounds ?: CGRectMake(0.0, 0.0, 0.0, 0.0), + image = image, + onClose = ::dismiss, + ) + } + }.onFailure { error -> + if (activeImageUrl == imageUrl) { + userMessages.showShortMessage("图片加载失败: ${error.message}") + dismiss() + } + } + } + } + + fun dispose() { + dismiss() + val viewerWindow = window + window = null + viewerWindow?.close() + } + + private fun viewerFrame(): kotlinx.cinterop.CValue<CGRect> { + val screen = NSApplication.sharedApplication.keyWindow?.screen ?: NSScreen.mainScreen + val visibleFrame = screen?.visibleFrame ?: CGRectMake(0.0, 0.0, 960.0, 720.0) + return visibleFrame.useContents { + val viewerWidth = size.width * VIEWER_SCREEN_FRACTION + val viewerHeight = size.height * VIEWER_SCREEN_FRACTION + CGRectMake( + origin.x + (size.width - viewerWidth) / 2.0, + origin.y + (size.height - viewerHeight) / 2.0, + viewerWidth, + viewerHeight, + ) + } + } + + private fun createWindow(viewerFrame: kotlinx.cinterop.CValue<CGRect>): NSWindow = + NSWindow( + contentRect = viewerFrame, + styleMask = NSWindowStyleMaskBorderless, + backing = NSBackingStoreBuffered, + defer = false, + ).apply { + backgroundColor = NSColor.blackColor.colorWithAlphaComponent(0.92) + opaque = false + hasShadow = true + level = NSFloatingWindowLevel + movableByWindowBackground = true + releasedWhenClosed = false + } + + private fun dismiss() { + loadJob?.cancel() + loadJob = null + window?.orderOut(null) + activeImageUrl = null + } +} + +private class ClosingLoadingView( + frame: kotlinx.cinterop.CValue<CGRect>, + private val onClose: () -> Unit, +) : NSView(frame = frame) { + init { + val label = NSTextField.labelWithString("正在加载图片…").apply { + textColor = NSColor.whiteColor + alignment = NSTextAlignmentCenter + this.frame = this@ClosingLoadingView.bounds + setAutoresizingMask(NSViewWidthSizable or NSViewHeightSizable) + } + addSubview(label) + } + + override fun mouseDown(event: NSEvent) { + onClose() + } +} + +private class ClosingImageView( + frame: kotlinx.cinterop.CValue<CGRect>, + image: NSImage, + private val onClose: () -> Unit, +) : NSImageView(frame = frame) { + init { + this.image = image + imageAlignment = NSImageAlignCenter + imageScaling = NSImageScaleProportionallyUpOrDown + setAutoresizingMask(NSViewWidthSizable or NSViewHeightSizable) + } + + override fun mouseDown(event: NSEvent) { + onClose() + } +} diff --git a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosSettingsStore.kt b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosSettingsStore.kt index 4446acd61..4d3fcc5e3 100644 --- a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosSettingsStore.kt +++ b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosSettingsStore.kt @@ -30,29 +30,47 @@ private const val STRING_SET_SEPARATOR = '\u001F' internal actual fun nativeSettingsStore(relativePath: String): SettingsStore { val propertiesFile = MacosPropertiesFile("${nativeAppPrivateDirectoryPath()}/$relativePath") - return SettingsStore( - getBoolean = { key, defaultValue -> - propertiesFile[key]?.toBooleanStrictOrNull() ?: defaultValue - }, - putBoolean = { key, value -> propertiesFile[key] = value.toString() }, - getString = { key, defaultValue -> propertiesFile[key] ?: defaultValue }, - putString = { key, value -> propertiesFile[key] = value }, - getStringOrNull = propertiesFile::get, - putStringSet = { key, value -> propertiesFile[key] = value.joinToString(STRING_SET_SEPARATOR.toString()) }, - getStringSet = { key, defaultValue -> - propertiesFile[key] - ?.split(STRING_SET_SEPARATOR) - ?.filter(String::isNotEmpty) - ?.toSet() ?: defaultValue - }, - getInt = { key, defaultValue -> propertiesFile[key]?.toIntOrNull() ?: defaultValue }, - putInt = { key, value -> propertiesFile[key] = value.toString() }, - getLong = { key, defaultValue -> propertiesFile[key]?.toLongOrNull() ?: defaultValue }, - putLong = { key, value -> propertiesFile[key] = value.toString() }, - getFloat = { key, defaultValue -> propertiesFile[key]?.toFloatOrNull() ?: defaultValue }, - putFloat = { key, value -> propertiesFile[key] = value.toString() }, - remove = propertiesFile::remove, - ) + return object : SettingsStore { + override fun getBoolean(key: String, defaultValue: Boolean) = propertiesFile[key]?.toBooleanStrictOrNull() ?: defaultValue + + override fun putBoolean(key: String, value: Boolean) { + propertiesFile[key] = value.toString() + } + + override fun getString(key: String, defaultValue: String) = propertiesFile[key] ?: defaultValue + + override fun putString(key: String, value: String) { + propertiesFile[key] = value + } + + override fun getStringOrNull(key: String) = propertiesFile[key] + + override fun putStringSet(key: String, value: Set<String>) { + propertiesFile[key] = value.joinToString(STRING_SET_SEPARATOR.toString()) + } + + override fun getStringSet(key: String, defaultValue: Set<String>) = propertiesFile[key]?.split(STRING_SET_SEPARATOR)?.filter(String::isNotEmpty)?.toSet() ?: defaultValue + + override fun getInt(key: String, defaultValue: Int) = propertiesFile[key]?.toIntOrNull() ?: defaultValue + + override fun putInt(key: String, value: Int) { + propertiesFile[key] = value.toString() + } + + override fun getLong(key: String, defaultValue: Long) = propertiesFile[key]?.toLongOrNull() ?: defaultValue + + override fun putLong(key: String, value: Long) { + propertiesFile[key] = value.toString() + } + + override fun getFloat(key: String, defaultValue: Float) = propertiesFile[key]?.toFloatOrNull() ?: defaultValue + + override fun putFloat(key: String, value: Float) { + propertiesFile[key] = value.toString() + } + + override fun remove(key: String) = propertiesFile.remove(key) + } } private class MacosPropertiesFile( diff --git a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosUserMessageHost.kt b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosUserMessageHost.kt index c5c2dffc5..2c3e90503 100644 --- a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosUserMessageHost.kt +++ b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/MacosUserMessageHost.kt @@ -62,8 +62,9 @@ fun MacosUserMessageHost(content: @Composable () -> Unit) { @Composable actual fun rememberUserMessageSink(): UserMessageSink = remember { - UserMessageSink( - showShortMessage = { message -> showMacosUserMessage(message) }, - showLongMessage = { message -> showMacosUserMessage(message, UserMessageDuration.Long) }, - ) + object : UserMessageSink { + override fun showShortMessage(message: String) = showMacosUserMessage(message) + + override fun showLongMessage(message: String) = showMacosUserMessage(message, UserMessageDuration.Long) + } } diff --git a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.macos.kt b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.macos.kt index 9645d30f3..a9dce7aa0 100644 --- a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.macos.kt +++ b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.macos.kt @@ -21,12 +21,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.github.zly2006.zhihu.data.macosAppDataDirectoryPath import com.github.zly2006.zhihu.data.macosBackgroundUiDebugDataDirectoryPath +import com.github.zly2006.zhihu.ui.subscreens.MACOS_QUIT_ON_WINDOW_CLOSE_PREFERENCE_KEY import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import platform.AppKit.NSModalResponseOK import platform.AppKit.NSOpenPanel import platform.AppKit.NSPasteboard @@ -37,14 +34,21 @@ import platform.Foundation.NSURL internal actual val nativeIsDesktop: Boolean = true -private val macosQrLoginRequests = MutableStateFlow(0) +actual val platformName: String = "macOS" -internal val macosQrLoginRequestVersion: StateFlow<Int> = macosQrLoginRequests.asStateFlow() +fun isMacosQuitOnWindowCloseEnabled(): Boolean = + nativeSettingsStore("settings.properties").getBoolean(MACOS_QUIT_ON_WINDOW_CLOSE_PREFERENCE_KEY, false) + +actual val isAigcVoteSupported: Boolean = false @Composable @OptIn(ExperimentalForeignApi::class) -actual fun rememberExternalUrlOpener(): (String) -> Unit = remember { - { url -> NSURL.URLWithString(url)?.let(NSWorkspace.sharedWorkspace::openURL) } +actual fun rememberExternalUrlOpener(): ExternalUrlOpener = remember { + object : ExternalUrlOpener { + override fun invoke(url: String) { + NSURL.URLWithString(url)?.let(NSWorkspace.sharedWorkspace::openURL) + } + } } @OptIn(ExperimentalForeignApi::class) @@ -55,10 +59,6 @@ internal actual fun copyNativePlainText(text: String) { } } -internal actual fun requestNativeQrLogin() { - macosQrLoginRequests.update { it + 1 } -} - internal actual fun nativeAccountFilePath(): String = macosBackgroundUiDebugDataDirectoryPath()?.let { "$it/account.json" } ?: "${NSHomeDirectory()}/.zhihu-plus-plus/account.json" @@ -96,3 +96,5 @@ fun showMacosUserMessage( "macOS user message queue is unavailable" } } + +internal actual val platformBottomBarItemLimit: Int? = null diff --git a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.android.kt b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/ui/MacosWindowChrome.kt similarity index 51% rename from shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.android.kt rename to shared/src/macosMain/kotlin/com/github/zly2006/zhihu/ui/MacosWindowChrome.kt index a8aa0cd3b..347ec9837 100644 --- a/shared/src/androidMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuAnswerPublisher.android.kt +++ b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/ui/MacosWindowChrome.kt @@ -15,17 +15,34 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package com.github.zly2006.zhihu.editor +package com.github.zly2006.zhihu.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.github.zly2006.zhihu.data.asApiEnvironment +import androidx.compose.ui.Modifier -@Composable -actual fun rememberZhihuAnswerPublisher(): ZhihuAnswerPublisher { - val context = LocalContext.current.applicationContext - return remember(context) { - ZhihuApiAnswerPublisher(context.asApiEnvironment()) - } -} +data class MacosWindowNavigationItem( + val identifier: String, + val title: String, + val systemSymbolName: String, + val sectionTitle: String, + val selected: Boolean, + val action: () -> Unit, +) + +data class MacosWindowToolbarItem( + val identifier: String, + val label: String, + val systemSymbolName: String, + val action: () -> Unit, +) + +data class MacosWindowChrome( + val navigationItems: List<MacosWindowNavigationItem>, + val trailingToolbarItems: List<MacosWindowToolbarItem>, +) + +typealias MacosWindowChromeHost = + @Composable ( + chrome: MacosWindowChrome, + content: @Composable (Modifier) -> Unit, + ) -> Unit diff --git a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/ui/MacosZhihuMain.kt b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/ui/MacosZhihuMain.kt index 20c9d2b2b..acec0578f 100644 --- a/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/ui/MacosZhihuMain.kt +++ b/shared/src/macosMain/kotlin/com/github/zly2006/zhihu/ui/MacosZhihuMain.kt @@ -26,15 +26,17 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import com.github.zly2006.zhihu.account.NativeAccountStore +import com.github.zly2006.zhihu.account.defaultNativeAccountStore import com.github.zly2006.zhihu.data.fetchHighestQualityZhihuVideoUrl import com.github.zly2006.zhihu.navigation.Account import com.github.zly2006.zhihu.navigation.Article @@ -52,8 +54,10 @@ import com.github.zly2006.zhihu.navigation.Notification import com.github.zly2006.zhihu.navigation.OnlineHistory import com.github.zly2006.zhihu.navigation.Pin import com.github.zly2006.zhihu.navigation.Question +import com.github.zly2006.zhihu.navigation.Search import com.github.zly2006.zhihu.navigation.TopLevelDestination import com.github.zly2006.zhihu.navigation.Video +import com.github.zly2006.zhihu.platform.platformBottomBarItemLimit import com.github.zly2006.zhihu.platform.rememberExternalUrlOpener import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.platform.rememberUserMessageSink @@ -69,8 +73,8 @@ import com.github.zly2006.zhihu.ui.subscreens.normalizeBottomBarSelection import com.github.zly2006.zhihu.ui.subscreens.resolveValidStartDestinationKey import com.github.zly2006.zhihu.util.signZhihuFetchRequest import com.github.zly2006.zhihu.viewmodel.ArticleViewModel -import com.github.zly2006.zhihu.viewmodel.nativeArticleAnswerSwitchState import com.github.zly2006.zhihu.viewmodel.prepareNativePendingContentOpen +import com.github.zly2006.zhihu.viewmodel.sharedArticleAnswerSwitchState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -81,15 +85,19 @@ import kotlinx.coroutines.withContext * 窗口宿主只调用这个入口;所有页面、布局和导航图仍由共享 [ZhihuMain] 提供。 */ @Composable -fun MacosZhihuMain() { +fun MacosZhihuMain(windowChrome: MacosWindowChromeHost? = null) { val navController = rememberNavController() - val accountStore = remember { NativeAccountStore() } - val httpClient = accountStore.httpClient() + val accountStore = defaultNativeAccountStore + val accounts by accountStore.accountsState.collectAsState() + val accountSession = accounts.session + val httpClient = remember(accountStore, accountSession) { accountStore.client.httpClient() } val coroutineScope = rememberCoroutineScope() val openExternalUrl = rememberExternalUrlOpener() val userMessages = rememberUserMessageSink() + val preferenceState = rememberMacosZhihuMainPreferenceState() var mainTabNavigationTarget by remember { mutableStateOf<TopLevelDestination?>(null) } var currentMainTabOpenFrom by remember { mutableStateOf<String?>(null) } + var currentMainTabDestination by remember { mutableStateOf(preferenceState.startDestination) } fun navigateToMainTabs() { navController.navigate(MainTabs) { @@ -144,7 +152,7 @@ fun MacosZhihuMain() { else -> return } coroutineScope.launch { - val cookies = accountStore.load().cookies + val cookies = accountStore.session.cookies val videoUrl = withContext(Dispatchers.Default) { runCatching { fetchHighestQualityZhihuVideoUrl( @@ -186,51 +194,120 @@ fun MacosZhihuMain() { } } - ZhihuMain( - navController = navController, - mainTabNavigationTarget = mainTabNavigationTarget, - navigate = ::navigate, - setCurrentMainTabOpenFrom = { currentMainTabOpenFrom = it }, - consumeMainTabNavigationTarget = { destination -> - if (mainTabNavigationTarget == destination) { - mainTabNavigationTarget = null - } - }, - preferenceState = rememberMacosZhihuMainPreferenceState(), - isDarkTheme = ThemeManager.isDarkTheme(), - articleEnterTransition = { - when (nativeArticleAnswerSwitchState.answerTransitionDirection) { - ArticleAnswerTransitionDirection.VERTICAL_NEXT -> - slideInVertically(tween(300)) { it } + fadeIn(tween(300)) - ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> - slideInVertically(tween(300)) { -it } + fadeIn(tween(300)) - ArticleAnswerTransitionDirection.HORIZONTAL_NEXT -> - slideInHorizontally(tween(300)) { it } + fadeIn(tween(300)) - ArticleAnswerTransitionDirection.HORIZONTAL_PREVIOUS -> - slideInHorizontally(tween(300)) { -it } + fadeIn(tween(300)) - else -> slideInHorizontally(tween(300)) { it } - } - }, - articleExitTransition = { - when (nativeArticleAnswerSwitchState.answerTransitionDirection) { - ArticleAnswerTransitionDirection.VERTICAL_NEXT -> - slideOutVertically(tween(300)) { -it } + fadeOut(tween(300)) - ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> - slideOutVertically(tween(300)) { it } + fadeOut(tween(300)) - ArticleAnswerTransitionDirection.HORIZONTAL_NEXT -> - slideOutHorizontally(tween(300)) { -it } + fadeOut(tween(300)) - ArticleAnswerTransitionDirection.HORIZONTAL_PREVIOUS -> - slideOutHorizontally(tween(300)) { it } + fadeOut(tween(300)) - else -> ExitTransition.None + val content: @Composable (Modifier) -> Unit = { modifier -> + ZhihuMain( + modifier = modifier, + navController = navController, + mainTabNavigationTarget = mainTabNavigationTarget, + navigate = ::navigate, + setCurrentMainTabOpenFrom = { currentMainTabOpenFrom = it }, + consumeMainTabNavigationTarget = { destination -> + if (mainTabNavigationTarget == destination) { + mainTabNavigationTarget = null + } + }, + preferenceState = preferenceState, + isDarkTheme = ThemeManager.isDarkTheme(), + showMainNavigationBar = windowChrome == null, + showHomeTopActions = windowChrome == null, + onCurrentMainTabDestinationChange = { currentMainTabDestination = it }, + articleEnterTransition = { + when (sharedArticleAnswerSwitchState.answerTransitionDirection) { + ArticleAnswerTransitionDirection.VERTICAL_NEXT -> + slideInVertically(tween(300)) { it } + fadeIn(tween(300)) + ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> + slideInVertically(tween(300)) { -it } + fadeIn(tween(300)) + ArticleAnswerTransitionDirection.HORIZONTAL_NEXT -> + slideInHorizontally(tween(300)) { it } + fadeIn(tween(300)) + ArticleAnswerTransitionDirection.HORIZONTAL_PREVIOUS -> + slideInHorizontally(tween(300)) { -it } + fadeIn(tween(300)) + else -> slideInHorizontally(tween(300)) { it } + } + }, + articleExitTransition = { + when (sharedArticleAnswerSwitchState.answerTransitionDirection) { + ArticleAnswerTransitionDirection.VERTICAL_NEXT -> + slideOutVertically(tween(300)) { -it } + fadeOut(tween(300)) + ArticleAnswerTransitionDirection.VERTICAL_PREVIOUS -> + slideOutVertically(tween(300)) { it } + fadeOut(tween(300)) + ArticleAnswerTransitionDirection.HORIZONTAL_NEXT -> + slideOutHorizontally(tween(300)) { -it } + fadeOut(tween(300)) + ArticleAnswerTransitionDirection.HORIZONTAL_PREVIOUS -> + slideOutHorizontally(tween(300)) { it } + fadeOut(tween(300)) + else -> ExitTransition.None + } + }, + articleContent = { article: Article, navEntry -> + val articleViewModel: ArticleViewModel = viewModel(navEntry) { + ArticleViewModel(article, httpClient, userMessages) + } + ArticleScreen(article, articleViewModel) + }, + ) + } + + if (windowChrome == null) { + content(Modifier) + } else { + val navigationItems = preferenceState.selectedBottomBarItemKeys.mapNotNull { destinationName -> + val destination = navDestinationFromName(destinationName) + val action = { + mainTabNavigationTarget = destination + navigateToMainTabs() } - }, - articleContent = { article: Article, navEntry -> - val articleViewModel: ArticleViewModel = viewModel(navEntry) { - ArticleViewModel(article, httpClient, userMessages) + when (destination) { + Home -> MacosWindowNavigationItem(Home.name, "首页", "house", "内容", Home == currentMainTabDestination, action) + Follow -> MacosWindowNavigationItem(Follow.name, "关注", "person.2", "内容", Follow == currentMainTabDestination, action) + HotList -> MacosWindowNavigationItem(HotList.name, "热榜", "flame", "内容", HotList == currentMainTabDestination, action) + Daily -> MacosWindowNavigationItem(Daily.name, "日报", "newspaper", "内容", Daily == currentMainTabDestination, action) + OnlineHistory -> MacosWindowNavigationItem( + OnlineHistory.name, + "历史", + "clock.arrow.circlepath", + "资料", + OnlineHistory == currentMainTabDestination, + action, + ) + MyCollections -> MacosWindowNavigationItem( + MyCollections.name, + "收藏", + "bookmark", + "资料", + MyCollections == currentMainTabDestination, + action, + ) + Account -> MacosWindowNavigationItem( + Account.name, + "账号", + "person.crop.circle", + "账号", + Account == currentMainTabDestination, + action, + ) + else -> null } - ArticleScreen(article, articleViewModel) - }, - ) + } + windowChrome( + MacosWindowChrome( + navigationItems = navigationItems, + trailingToolbarItems = listOf( + MacosWindowToolbarItem( + identifier = "search", + label = "搜索", + systemSymbolName = "magnifyingglass", + action = { navigate(Search()) }, + ), + MacosWindowToolbarItem( + identifier = "notifications", + label = "通知", + systemSymbolName = "bell", + action = { navigate(Notification) }, + ), + ), + ), + content, + ) + } } @Composable @@ -244,10 +321,11 @@ private fun rememberMacosZhihuMainPreferenceState(): ZhihuMainPreferenceState { val selectedKeys = normalizeBottomBarSelection( settings.getStringSet( BOTTOM_BAR_ITEMS_PREFERENCE_KEY, - defaultBottomBarSelectionKeys(duo3HomeAccount), + defaultBottomBarSelectionKeys(duo3HomeAccount, platformBottomBarItemLimit), ), duo3HomeAccount, enforceMinimumSelection = true, + maximumSelection = platformBottomBarItemLimit, ) val orderedSelectedKeys = bottomBarItemOrderFromPreference( settings.getStringOrNull(BOTTOM_BAR_ITEM_ORDER_PREFERENCE_KEY), diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.native.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.native.kt new file mode 100644 index 000000000..30da514ea --- /dev/null +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/AccountHttpClientEngine.native.kt @@ -0,0 +1,6 @@ +package com.github.zly2006.zhihu.account + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.darwin.Darwin + +internal actual val accountHttpClientEngineFactory: HttpClientEngineFactory<*> = Darwin diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeAccountStore.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeAccountStore.kt index b6f0a1932..619d6c00b 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeAccountStore.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeAccountStore.kt @@ -17,62 +17,18 @@ package com.github.zly2006.zhihu.account -import com.github.zly2006.zhihu.data.installZhihuCommonClientConfig import com.github.zly2006.zhihu.platform.nativeAccountFilePath -import io.ktor.client.HttpClient -import io.ktor.client.engine.darwin.Darwin import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import platform.Foundation.NSFileManager import platform.Foundation.NSString import platform.Foundation.NSUTF8StringEncoding import platform.Foundation.create import platform.Foundation.dataUsingEncoding -internal class NativeAccountStore { - private val accountClient = defaultNativeAccountClient - val accountState: StateFlow<ZhihuAccountSession> = defaultNativeAccountState.asStateFlow() - - fun load(): ZhihuAccountSession = accountClient.load() - - fun save(session: ZhihuAccountSession) = accountClient.save(session) - - fun clear() = accountClient.clear() - - fun httpClient(): HttpClient = accountClient.httpClient() - - fun createHttpClient(cookies: MutableMap<String, String>): HttpClient = - accountClient.temporaryHttpClient(cookies) - - suspend fun <T> withAuthenticatedClient( - block: suspend (client: HttpClient, cookies: Map<String, String>) -> T, - ): T = accountClient.withAuthenticatedClient(block) - - suspend fun verifyAndSave(cookies: MutableMap<String, String>): Boolean = - accountClient.verifyAndSave(cookies) - - suspend fun refreshAndSaveProfile(): ZhihuAccountSession? = - accountClient.refreshAndSaveProfile() -} - -private val defaultNativeAccountState = MutableStateFlow(ZhihuAccountSession()) - -private val defaultNativeAccountClient by lazy { - ZhihuAccountClient( +internal val defaultNativeAccountStore by lazy { + ZhihuAccountStore( repository = ZhihuAccountRepository(NativeFileAccountSessionStore()), - createClient = { cookies, session, onCookieChanged, _ -> - HttpClient(Darwin) { - installZhihuCommonClientConfig( - cookies = cookies, - userAgent = session.userAgent, - onCookieChanged = onCookieChanged, - ) - } - }, - onSessionChanged = { defaultNativeAccountState.value = it }, ) } diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeAccountStoreUi.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeAccountStoreUi.kt new file mode 100644 index 000000000..99baeb5a1 --- /dev/null +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeAccountStoreUi.kt @@ -0,0 +1,12 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * Licensed under the GNU Affero General Public License version 3. + */ + +package com.github.zly2006.zhihu.account + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberZhihuAccountStore(): ZhihuAccountStore = defaultNativeAccountStore diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeLoginUi.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeLoginUi.kt new file mode 100644 index 000000000..cc4bb161e --- /dev/null +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/account/NativeLoginUi.kt @@ -0,0 +1,94 @@ +/* + * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. + * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation (version 3 only). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package com.github.zly2006.zhihu.account + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.toComposeImageBitmap +import com.github.zly2006.zhihu.platform.platformName +import io.ktor.client.HttpClient +import org.jetbrains.skia.Image +import kotlin.io.encoding.Base64 + +actual val supportedLoginMethods: List<LoginMethod> = listOf( + LoginMethod.Phone, + LoginMethod.Qr, +) + +actual val isLoginRiskControlSupported: Boolean = false + +@Composable +actual fun rememberLoginHttpClient(cookies: MutableMap<String, String>): HttpClient { + val store = defaultNativeAccountStore + val httpClient = remember(store) { store.client.temporaryHttpClient(cookies) } + DisposableEffect(httpClient) { + onDispose(httpClient::close) + } + return httpClient +} + +actual val phoneLoginDeviceInfo: ZhihuPhoneLoginDeviceInfo + get() = ZhihuPhoneLoginDeviceInfo( + timezoneOffsetSeconds = 0, + appInstallTimeMillis = 0, + notificationEnabled = false, + bluetoothAvailable = false, + phoneBrand = platformName, + phoneModel = platformName, + androidRelease = "12", + cpuType = platformName, + cpuCount = 1, + cpuUsage = "0.0", + totalMemoryMegabytes = 0, + freeMemoryMegabytes = 0, + totalStorageMegabytes = 0, + freeStorageMegabytes = 0, + ) + +actual fun decodePhoneLoginCaptchaImage(content: String) = runCatching { + val encoded = content.substringAfter("base64,", content) + Image.makeFromEncoded(Base64.Default.decode(encoded)).toComposeImageBitmap() +}.getOrNull() + +@Composable +actual fun QrLoginPane(onLoginSuccess: (String) -> Unit) { + val store = defaultNativeAccountStore + SharedQrLoginPane( + onLoginSuccess = { cookies -> + if (store.login(cookies.toMutableMap())) { + onLoginSuccess(store.session.username) + true + } else { + false + } + }, + initialCookies = store.session.cookies, + ) +} + +@Composable +actual fun WebLoginPane(onLoginSuccess: (String) -> Unit): Unit = + error("$platformName 暂不支持网页登录") + +@Composable +actual fun LoginRiskControlPane( + url: String, + cookies: Map<String, String>, + onCookiesChanged: (Map<String, String>) -> Unit, +): Unit = error("$platformName 暂不支持登录风控验证") diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.native.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.native.kt index 5696b9627..9111225d3 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.native.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ImagePickerRuntime.native.kt @@ -18,6 +18,10 @@ package com.github.zly2006.zhihu.editor import androidx.compose.runtime.Composable +import com.github.zly2006.zhihu.platform.platformName + +actual val isImagePickerSupported: Boolean = false @Composable -actual fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): (() -> Unit)? = null +actual fun rememberImagePickerLauncher(onPicked: (PickedImage) -> Unit): ImagePickerLauncher = + error("$platformName 暂不支持图片选择") diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPublisherEnvironment.native.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPublisherEnvironment.native.kt deleted file mode 100644 index ea7ebdbc3..000000000 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/editor/ZhihuPublisherEnvironment.native.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Zhihu++ - Free & Ad-Free Zhihu client for all platforms. - * Copyright (C) 2024-2026, zly2006 <i@zly2006.me> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation (version 3 only). - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package com.github.zly2006.zhihu.editor - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.github.zly2006.zhihu.account.NativeAccountStore -import com.github.zly2006.zhihu.util.Log -import com.github.zly2006.zhihu.viewmodel.ZhihuApiEnvironment -import io.ktor.client.HttpClient - -@Composable -internal fun rememberZhihuPublisherEnvironment(): ZhihuApiEnvironment { - val store = remember { NativeAccountStore() } - return remember(store) { - NativeZhihuPublisherEnvironment(store) - } -} - -private class NativeZhihuPublisherEnvironment( - private val store: NativeAccountStore, -) : ZhihuApiEnvironment { - override fun httpClient(): HttpClient = store.httpClient() - - override fun authenticatedCookies(): Map<String, String> = store.load().cookies - - override fun xsrfToken(): String = store.load().cookies["_xsrf"] ?: "" - - override suspend fun handleFetchFailure( - tag: String?, - error: Exception, - ) { - Log.e(tag ?: "ZhihuPublisherEnvironment", "Failed to fetch Zhihu API", error) - } -} diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.native.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.native.kt index 06a48020a..d448d7a80 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.native.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/markdown/MarkdownRuntime.native.kt @@ -27,7 +27,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.platform.Font -import com.github.zly2006.zhihu.account.NativeAccountStore +import com.github.zly2006.zhihu.account.ZhihuAccountStore +import com.github.zly2006.zhihu.account.defaultNativeAccountStore import com.github.zly2006.zhihu.data.toCookieHeaderString import com.github.zly2006.zhihu.platform.nativeAppPrivateDirectoryPath import com.hrm.latex.renderer.font.MathFont @@ -52,7 +53,7 @@ private val LM_MATH_URLS = listOf( @Composable actual fun rememberMarkdownMathFont(): MathFont? { - val store = remember { NativeAccountStore() } + val store = defaultNativeAccountStore var mathFont by remember { mutableStateOf<MathFont?>(null) } LaunchedEffect(store) { @@ -64,8 +65,8 @@ actual fun rememberMarkdownMathFont(): MathFont? { @Composable actual fun rememberMarkdownImageRequestHeaders(): MarkdownImageRequestHeaders { - val store = remember { NativeAccountStore() } - val session = remember(store) { store.load() } + val store = defaultNativeAccountStore + val session = remember(store) { store.session } return MarkdownImageRequestHeaders( cookieHeader = session.cookies.toCookieHeaderString(), userAgent = session.userAgent, @@ -73,7 +74,7 @@ actual fun rememberMarkdownImageRequestHeaders(): MarkdownImageRequestHeaders { } @OptIn(ExperimentalForeignApi::class) -private suspend fun loadNativeMathFont(store: NativeAccountStore): MathFont { +private suspend fun loadNativeMathFont(store: ZhihuAccountStore): MathFont { val fontFilePath = "${nativeAppPrivateDirectoryPath()}/latex-fonts/v$FONT_VERSION/latinmodern-math.otf" val fontBytes = NSFileManager.defaultManager .contentsAtPath(fontFilePath) @@ -90,11 +91,14 @@ private suspend fun loadNativeMathFont(store: NativeAccountStore): MathFont { } @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private suspend fun downloadNativeMathFont(store: NativeAccountStore, fontFilePath: String): ByteArray { +private suspend fun downloadNativeMathFont(store: ZhihuAccountStore, fontFilePath: String): ByteArray { var lastError: Exception? = null for (url in LM_MATH_URLS) { try { - val bytes = store.httpClient().get(url).body<ByteArray>() + val bytes = store.client + .httpClient() + .get(url) + .body<ByteArray>() if (!isOpenTypeFont(bytes)) continue val parentDirectory = fontFilePath.substringBeforeLast('/') val fileManager = NSFileManager.defaultManager diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.kt index ae248a7b1..ab81992ea 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformBridge.kt @@ -26,8 +26,6 @@ internal val nativeAppVersionName: String internal expect fun copyNativePlainText(text: String) -internal expect fun requestNativeQrLogin() - internal expect fun nativeAccountFilePath(): String internal expect fun nativeAppPrivateDirectoryPath(): String diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformCapabilities.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformCapabilities.kt index 78ff24df4..aa53572f8 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformCapabilities.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/platform/NativePlatformCapabilities.kt @@ -24,7 +24,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.BackHandler -import com.github.zly2006.zhihu.account.NativeAccountStore +import com.github.zly2006.zhihu.account.ZhihuAccountStore +import com.github.zly2006.zhihu.account.defaultNativeAccountStore import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.cinterop.BetaInteropApi @@ -39,40 +40,30 @@ import platform.Foundation.dataWithBytes import kotlin.time.Clock @Composable -actual fun rememberSystemUrlOpener(): (String) -> Unit = rememberExternalUrlOpener() +actual fun rememberSystemUrlOpener(): SystemUrlOpener = rememberExternalUrlOpener() @Composable -actual fun rememberZhihuWebUrlOpener(): (String) -> Unit = rememberExternalUrlOpener() +actual fun rememberZhihuWebUrlOpener(): ZhihuWebUrlOpener = rememberExternalUrlOpener() @Composable -actual fun rememberImagePreviewOpener(): (String) -> Unit = rememberExternalUrlOpener() +actual fun rememberImagePreviewOpener(): ImagePreviewOpener = rememberExternalUrlOpener() @Composable -actual fun rememberImageGalleryOpener(): (List<String>, Int) -> Unit { - val openExternalUrl = rememberExternalUrlOpener() - return remember(openExternalUrl) { - { urls, initialIndex -> - if (urls.isNotEmpty()) { - urls[initialIndex.coerceIn(0, urls.lastIndex)].let(openExternalUrl) - } - } - } -} - -@Composable -actual fun rememberImageSaver(): (String) -> Unit { +actual fun rememberImageSaver(): ImageSaver { val scope = rememberCoroutineScope() val userMessages = rememberUserMessageSink() - val accountStore = remember { NativeAccountStore() } + val accountStore = defaultNativeAccountStore return remember(scope, userMessages, accountStore) { - { imageUrl -> - scope.launch { - runCatching { - saveNativeImageToDownloads(accountStore, imageUrl) - }.onSuccess { filePath -> - userMessages.showShortMessage("已保存图片: $filePath") - }.onFailure { error -> - userMessages.showShortMessage("保存失败: ${error.message}") + object : ImageSaver { + override fun invoke(url: String) { + scope.launch { + runCatching { + saveNativeImageToDownloads(accountStore, url) + }.onSuccess { filePath -> + userMessages.showShortMessage("已保存图片: $filePath") + }.onFailure { error -> + userMessages.showShortMessage("保存失败: ${error.message}") + } } } } @@ -80,15 +71,17 @@ actual fun rememberImageSaver(): (String) -> Unit { } @Composable -actual fun rememberImageSharer(): (String) -> Unit { +actual fun rememberImageSharer(): ImageSharer { val userMessages = rememberUserMessageSink() return remember(userMessages) { - { imageUrl -> - runCatching { - copyNativePlainText(imageUrl) - userMessages.showShortMessage("已复制图片链接") - }.onFailure { error -> - userMessages.showShortMessage("分享失败: ${error.message}") + object : ImageSharer { + override fun invoke(url: String) { + runCatching { + copyNativePlainText(url) + userMessages.showShortMessage("已复制图片链接") + }.onFailure { error -> + userMessages.showShortMessage("分享失败: ${error.message}") + } } } } @@ -96,10 +89,13 @@ actual fun rememberImageSharer(): (String) -> Unit { @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) private suspend fun saveNativeImageToDownloads( - accountStore: NativeAccountStore, + accountStore: ZhihuAccountStore, imageUrl: String, ): String { - val imageBytes = accountStore.httpClient().get(imageUrl).body<ByteArray>() + val imageBytes = accountStore.client + .httpClient() + .get(imageUrl) + .body<ByteArray>() val extension = imageUrl .substringBefore('?') .substringAfterLast('/') @@ -125,8 +121,10 @@ private suspend fun saveNativeImageToDownloads( } @Composable -actual fun rememberPlainTextClipboard(): (label: String, text: String) -> Unit = remember { - { _, text -> copyNativePlainText(text) } +actual fun rememberPlainTextClipboard(): PlainTextClipboard = remember { + object : PlainTextClipboard { + override fun invoke(label: String, text: String) = copyNativePlainText(text) + } } @OptIn(ExperimentalComposeUiApi::class) @@ -153,3 +151,15 @@ actual fun rememberAppPrivateDirectory(): Path = remember { Path(nativeAppPrivat @Composable actual fun rememberIsLiteVariant(): Boolean = false + +actual val isJvm: Boolean = false + +actual val isNative: Boolean = true + +actual val isBlocklistNlpSupported: Boolean = false + +actual val isSentenceSimilaritySupported: Boolean = false + +actual val isArticleHtmlExportSupported: Boolean = false + +actual val isArticleImageExportSupported: Boolean = false diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/reading/NativeReadingPlayer.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/reading/NativeReadingPlayer.kt index 65b352d97..dd3fb2485 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/reading/NativeReadingPlayer.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/reading/NativeReadingPlayer.kt @@ -18,6 +18,11 @@ package com.github.zly2006.zhihu.reading import androidx.compose.runtime.Composable +import com.github.zly2006.zhihu.platform.platformName + +actual val isReadingPlayerSupported: Boolean = false @Composable -actual fun rememberReadingPlayerController(): ReadingPlayerController = UnsupportedReadingPlayerController +internal actual fun rememberSupportedReadingPlayerController(): ReadingPlayerController { + error("$platformName 暂不支持内容朗读播放器") +} diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/NativeUiRuntimes.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/NativeUiRuntimes.kt index 6267d8dad..6e0032180 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/NativeUiRuntimes.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/NativeUiRuntimes.kt @@ -21,17 +21,15 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.unit.em -import com.github.zly2006.zhihu.account.NativeAccountStore -import com.github.zly2006.zhihu.markdown.RenderMarkdown import com.github.zly2006.zhihu.navigation.Article import com.github.zly2006.zhihu.notification.NotificationSettingsStore import com.github.zly2006.zhihu.platform.UserMessageSink @@ -40,11 +38,9 @@ import com.github.zly2006.zhihu.platform.nativeAppVersionName import com.github.zly2006.zhihu.platform.nativeBundledResourcePath import com.github.zly2006.zhihu.platform.nativeChooseBlocklistImportFilePath import com.github.zly2006.zhihu.platform.nativeIsDesktop +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberExternalUrlOpener -import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.platform.rememberUserMessageSink -import com.github.zly2006.zhihu.platform.requestNativeQrLogin -import com.github.zly2006.zhihu.ui.subscreens.DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY import com.github.zly2006.zhihu.viewmodel.NativePaginationEnvironment import com.github.zly2006.zhihu.viewmodel.NotificationEnvironment import com.github.zly2006.zhihu.viewmodel.filter.encodeBlocklistBackup @@ -71,29 +67,29 @@ import org.jetbrains.skia.Image as SkiaImage actual fun rememberArticleTtsState(): TtsState = NativeArticleSpeechController.currentState @Composable -actual fun rememberArticleSpeechToggler(): (title: String, content: String) -> Unit { +actual fun rememberArticleSpeechToggler(): ArticleSpeechToggler { val userMessages = rememberUserMessageSink() val coroutineScope = rememberCoroutineScope() val ttsState = NativeArticleSpeechController.currentState return remember(userMessages, coroutineScope, ttsState) { - { title, content -> - if (ttsState.isSpeaking) { - NativeArticleSpeechController.stopSpeaking() - } else if (ttsState !in listOf(TtsState.Error, TtsState.Uninitialized, TtsState.Initializing)) { - coroutineScope.launch { - try { - val textToRead = withContext(Dispatchers.Default) { - articleSpeechText(title, content) - } - if (textToRead.isNotBlank()) { - if (NativeArticleSpeechController.startSpeaking(textToRead)) { - userMessages.showMessage("开始朗读:$title") - } else { - userMessages.showMessage("朗读启动失败") + object : ArticleSpeechToggler { + override fun invoke(title: String, content: String) { + if (ttsState.isSpeaking) { + NativeArticleSpeechController.stopSpeaking() + } else if (ttsState !in listOf(TtsState.Error, TtsState.Uninitialized, TtsState.Initializing)) { + coroutineScope.launch { + try { + val textToRead = withContext(Dispatchers.Default) { + articleSpeechText(title, content) } - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { + if (textToRead.isNotBlank()) { + if (NativeArticleSpeechController.startSpeaking(textToRead)) { + userMessages.showMessage("开始朗读:$title") + } else { + userMessages.showMessage("朗读启动失败") + } + } + } catch (e: Exception) { userMessages.showMessage("朗读失败:${e.message}") } } @@ -104,10 +100,12 @@ actual fun rememberArticleSpeechToggler(): (title: String, content: String) -> U } @Composable -actual fun rememberArticleBrowserOpener(): (Article) -> Unit { +actual fun rememberArticleBrowserOpener(): ArticleBrowserOpener { val openExternalUrl = rememberExternalUrlOpener() return remember(openExternalUrl) { - { article -> openExternalUrl(articleWebUrl(article)) } + object : ArticleBrowserOpener { + override fun invoke(article: Article) = openExternalUrl(articleWebUrl(article)) + } } } @@ -117,7 +115,7 @@ actual fun rememberNotificationEnvironment( ): NotificationEnvironment = remember(settingsStore) { NativePaginationEnvironment(notificationSettingsStore = settingsStore) } @Composable -actual fun rememberArticleHost(): ArticleHost? = null +actual fun consumePendingCommentId(content: com.github.zly2006.zhihu.navigation.NavDestination): String? = null @Composable actual fun ArticleWebViewContent( @@ -130,18 +128,7 @@ actual fun ArticleWebViewContent( onRememberedScrollYSyncChange: (Boolean) -> Unit, onImageLoadFailed: () -> Unit, onDoubleTap: () -> Unit, -) { - RenderMarkdown( - html = html, - modifier = Modifier, - selectable = true, - enableScroll = false, - header = {}, - footer = {}, - useTiqianRenderer = !nativeIsDesktop && - rememberSettingsStore().getBoolean(DUO3_TIQIAN_MARKDOWN_PREFERENCE_KEY, false), - ) -} +): Unit = error("$platformName 暂不支持文章 WebView 渲染") actual fun Modifier.articleMarkdownSelectionWorkaround(): Modifier = this @@ -210,61 +197,44 @@ private fun readNativeFileBytes(filePath: String): ByteArray? { @Composable actual fun rememberHomeIsDebuggable(): Boolean = nativeIsDesktop -@Composable -actual fun rememberAccountSettingsAccountState(): androidx.compose.runtime.State<AccountSettingsAccountState> { - val accountStore = remember { NativeAccountStore() } - val account = accountStore.accountState.collectAsState() - return remember(account) { - derivedStateOf { - val session = account.value - AccountSettingsAccountState( - login = session.login, - username = session.username, - avatarUrl = session.profile?.avatarUrl, - id = session.profile?.id ?: "", - urlToken = session.profile?.urlToken, - ) - } - } -} - -@Composable -actual fun rememberAccountQrLoginRequester(): () -> Unit = remember { ::requestNativeQrLogin } - @Composable actual fun rememberAppVersionInfo(): String = nativeAppVersionName @Composable -actual fun ZhihuHtmlWebViewContent(html: String) = Unit +actual fun ZhihuHtmlWebViewContent(html: String): Unit = error("$platformName 暂不支持 HTML WebView 渲染") -actual fun supportsZhihuHtmlWebView(): Boolean = false +actual val isLegacyWebViewSupported: Boolean = false @Composable actual fun rememberBlocklistRuleImporter( userMessages: UserMessageSink, -): (((String) -> Unit) -> Unit) { + onImported: (String) -> Unit, +): BlocklistRuleImporter { val database = remember { getContentFilterDatabase() } val coroutineScope = rememberCoroutineScope() + val currentOnImported by rememberUpdatedState(onImported) return remember(database, coroutineScope, userMessages) { - { onImported -> - val selectedFilePath = nativeChooseBlocklistImportFilePath() - if (selectedFilePath != null) { - coroutineScope.launch { - try { - val text = readNativeFileBytes(selectedFilePath)?.decodeToString() - ?: error("读取文件失败") - val summary = importBlocklistBackupFromJsonText( - keywordDao = database.blockedKeywordDao(), - userDao = database.blockedUserDao(), - questionAuthorDao = database.blockedQuestionAuthorDao(), - topicDao = database.blockedTopicDao(), - text = text, - ) - onImported(summary) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - userMessages.showShortMessage("导入失败:${error.message}") + object : BlocklistRuleImporter { + override fun invoke() { + val selectedFilePath = nativeChooseBlocklistImportFilePath() + if (selectedFilePath != null) { + coroutineScope.launch { + try { + val text = readNativeFileBytes(selectedFilePath)?.decodeToString() + ?: error("读取文件失败") + val summary = importBlocklistBackupFromJsonText( + keywordDao = database.blockedKeywordDao(), + userDao = database.blockedUserDao(), + questionAuthorDao = database.blockedQuestionAuthorDao(), + topicDao = database.blockedTopicDao(), + text = text, + ) + currentOnImported(summary) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + userMessages.showShortMessage("导入失败:${error.message}") + } } } } @@ -274,42 +244,43 @@ actual fun rememberBlocklistRuleImporter( @Composable @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) -actual fun rememberBlocklistRuleExporter(): suspend () -> String { +actual fun rememberBlocklistRuleExporter(): BlocklistRuleExporter { val database = remember { getContentFilterDatabase() } return remember(database) { - suspend { - val text = encodeBlocklistBackup( - keywordDao = database.blockedKeywordDao(), - userDao = database.blockedUserDao(), - questionAuthorDao = database.blockedQuestionAuthorDao(), - topicDao = database.blockedTopicDao(), - ) - val outputDirectory = nativeAppPrivateDirectoryPath() - val outputFile = "$outputDirectory/zhihupp_blocklist.json" - val fileManager = NSFileManager.defaultManager - if (!fileManager.fileExistsAtPath(outputDirectory)) { - fileManager.createDirectoryAtPath( - outputDirectory, - withIntermediateDirectories = true, - attributes = null, - error = null, + object : BlocklistRuleExporter { + override suspend fun invoke(): String { + val text = encodeBlocklistBackup( + keywordDao = database.blockedKeywordDao(), + userDao = database.blockedUserDao(), + questionAuthorDao = database.blockedQuestionAuthorDao(), + topicDao = database.blockedTopicDao(), ) + val outputDirectory = nativeAppPrivateDirectoryPath() + val outputFile = "$outputDirectory/zhihupp_blocklist.json" + val fileManager = NSFileManager.defaultManager + if (!fileManager.fileExistsAtPath(outputDirectory)) { + fileManager.createDirectoryAtPath( + outputDirectory, + withIntermediateDirectories = true, + attributes = null, + error = null, + ) + } + val data = checkNotNull( + NSString.create(string = text).dataUsingEncoding(NSUTF8StringEncoding), + ) { "无法编码导出内容" } + check(fileManager.createFileAtPath(outputFile, contents = data, attributes = null)) { + "无法写入导出文件" + } + return "已导出到 $outputFile" } - val data = checkNotNull( - NSString.create(string = text).dataUsingEncoding(NSUTF8StringEncoding), - ) { "无法编码导出内容" } - check(fileManager.createFileAtPath(outputFile, contents = data, attributes = null)) { - "无法写入导出文件" - } - "已导出到 $outputFile" } } } @Composable -actual fun QuestionDetailWebViewContent(questionId: Long, html: String) = Unit - -actual fun supportsQuestionDetailWebView(): Boolean = false +actual fun QuestionDetailWebViewContent(questionId: Long, html: String): Unit = + error("$platformName 暂不支持问题详情 WebView 渲染") actual fun Modifier.questionSelectionWorkaround(): Modifier = this diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/components/NativeComponents.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/components/NativeComponents.kt index 5925111b2..d762fd0ac 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/components/NativeComponents.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/components/NativeComponents.kt @@ -19,6 +19,7 @@ package com.github.zly2006.zhihu.ui.components import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberPlainTextClipboard import com.github.zly2006.zhihu.platform.rememberUserMessageSink @@ -26,7 +27,7 @@ actual suspend fun extractFeedKeywords( title: String, excerpt: String?, ): List<com.github.zly2006.zhihu.nlp.KeywordWithWeight> = - throw UnsupportedOperationException("当前平台暂不支持关键词提取") + error("$platformName 暂不支持关键词提取") actual val feedKeywordExtractionAvailable: Boolean = false diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/NativeSubscreenRuntimes.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/NativeSubscreenRuntimes.kt index cb82963b6..c87bc7d54 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/NativeSubscreenRuntimes.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/ui/subscreens/NativeSubscreenRuntimes.kt @@ -19,10 +19,12 @@ package com.github.zly2006.zhihu.ui.subscreens import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.github.zly2006.zhihu.account.NativeAccountStore +import com.github.zly2006.zhihu.account.ZhihuAccountStore +import com.github.zly2006.zhihu.account.defaultNativeAccountStore import com.github.zly2006.zhihu.platform.nativeAppVersionName import com.github.zly2006.zhihu.platform.nativeBundledResourcePath import com.github.zly2006.zhihu.platform.nativeIsDesktop +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.platform.rememberExternalUrlOpener import com.github.zly2006.zhihu.platform.rememberSettingsStore import com.github.zly2006.zhihu.ui.NativeArticleSpeechController @@ -38,6 +40,7 @@ import kotlinx.cinterop.readBytes import kotlinx.cinterop.reinterpret import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import platform.Foundation.NSFileManager private const val PREF_SKIPPED_VERSION = "skippedVersion" @@ -45,27 +48,45 @@ private const val PREF_SKIPPED_VERSION = "skippedVersion" private val nativeSystemUpdateState = MutableStateFlow<SystemUpdateState>(SystemUpdateState.NoUpdate) @Composable -actual fun rememberSystemUpdateRuntime(): SystemUpdateRuntime { +actual fun rememberSystemUpdateState(): StateFlow<SystemUpdateState> = nativeSystemUpdateState + +@Composable +actual fun rememberSystemUpdateChecker(): SystemUpdateChecker { val settings = rememberSettingsStore() - val accountStore = remember { NativeAccountStore() } - val openExternalUrl = rememberExternalUrlOpener() - return remember(settings, accountStore, openExternalUrl) { - SystemUpdateRuntime( - state = nativeSystemUpdateState, - checkForUpdate = { + val accountStore = defaultNativeAccountStore + return remember(settings, accountStore) { + object : SystemUpdateChecker { + override suspend fun check() { checkNativeUpdate( accountStore = accountStore, githubToken = settings.getStringOrNull("githubToken")?.takeIf { it.isNotBlank() }, checkNightly = settings.getBoolean("checkNightlyUpdates", false), skippedVersion = settings.getStringOrNull(PREF_SKIPPED_VERSION), ) - }, - skipVersion = { version -> + } + } + } +} + +@Composable +actual fun rememberSystemUpdateVersionSkipper(): SystemUpdateVersionSkipper { + val settings = rememberSettingsStore() + return remember(settings) { + object : SystemUpdateVersionSkipper { + override fun skip(version: String) { settings.putString(PREF_SKIPPED_VERSION, version) nativeSystemUpdateState.value = SystemUpdateState.Latest - }, - resetToNoUpdate = { nativeSystemUpdateState.value = SystemUpdateState.NoUpdate }, - downloadUpdate = { url -> + } + } + } +} + +@Composable +actual fun rememberSystemUpdateDownloader(): SystemUpdateDownloader { + val openExternalUrl = rememberExternalUrlOpener() + return remember(openExternalUrl) { + object : SystemUpdateDownloader { + override suspend fun download(url: String) { try { require(url.isNotBlank()) { "下载链接为空" } openExternalUrl(url) @@ -74,18 +95,32 @@ actual fun rememberSystemUpdateRuntime(): SystemUpdateRuntime { } catch (error: Exception) { nativeSystemUpdateState.value = SystemUpdateState.Error(error.message ?: "无法打开浏览器") } - }, - installDownloadedUpdate = { - nativeSystemUpdateState.value = SystemUpdateState.Error("桌面端不支持 APK 更新安装") - }, - setError = { message -> nativeSystemUpdateState.value = SystemUpdateState.Error(message) }, - supportsApkInstall = false, - ) + } + } + } +} + +@Composable +actual fun rememberDownloadedSystemUpdateInstaller(): DownloadedSystemUpdateInstaller = remember { + object : DownloadedSystemUpdateInstaller { + override suspend fun install() { + nativeSystemUpdateState.value = SystemUpdateState.Error("$platformName 暂不支持 APK 更新安装") + } } } +actual fun resetSystemUpdateState() { + nativeSystemUpdateState.value = SystemUpdateState.NoUpdate +} + +actual fun setSystemUpdateError(message: String) { + nativeSystemUpdateState.value = SystemUpdateState.Error(message) +} + +actual val isApkUpdateInstallSupported: Boolean = false + private suspend fun checkNativeUpdate( - accountStore: NativeAccountStore, + accountStore: ZhihuAccountStore, githubToken: String?, checkNightly: Boolean, skippedVersion: String?, @@ -93,14 +128,14 @@ private suspend fun checkNativeUpdate( try { nativeSystemUpdateState.value = SystemUpdateState.Checking val currentVersion = SchematicVersion.fromString(nativeAppVersionName) - var latestResponse = fetchLatestZhihuRelease(accountStore.httpClient(), githubToken) + var latestResponse = fetchLatestZhihuRelease(accountStore.client.httpClient(), githubToken) var latestVersion = latestResponse.tagName.takeIf { it.isNotBlank() }?.let(SchematicVersion::fromString) var isNightly = false var releaseNotes = latestResponse.body?.let(::extractGithubReleaseNotes) if (checkNightly) { try { - val nightlyResponse = fetchNightlyZhihuRelease(accountStore.httpClient(), githubToken) + val nightlyResponse = fetchNightlyZhihuRelease(accountStore.client.httpClient(), githubToken) if (nightlyResponse.tagName == "nightly") { latestResponse = nightlyResponse latestVersion = SchematicVersion( @@ -147,8 +182,8 @@ private suspend fun checkNativeUpdate( } @Composable -actual fun rememberDeveloperRuntimeInfo(): DeveloperRuntimeInfo = - DeveloperRuntimeInfo( +actual fun rememberDeveloperInfo(): DeveloperInfoSnapshot = + DeveloperInfoSnapshot( networkStatus = if (nativeIsDesktop) { "网络状态:桌面端使用系统网络" } else { @@ -179,8 +214,12 @@ private fun loadNativeAboutLibrariesJson(): String? { @Composable actual fun rememberShowFullVariantLicenses(): Boolean = false +actual val isWebViewCustomFontSupported: Boolean = false + @Composable actual fun WebViewCustomFontSettings( customFontName: String?, onCustomFontNameChange: (String?) -> Unit, -) = Unit +) { + error("$platformName 暂不支持 WebView 自定义字体设置") +} diff --git a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.native.kt b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.native.kt index 1a7815d06..39cb68611 100644 --- a/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.native.kt +++ b/shared/src/nativeMain/kotlin/com/github/zly2006/zhihu/viewmodel/PaginationEnvironment.native.kt @@ -19,8 +19,8 @@ package com.github.zly2006.zhihu.viewmodel import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.github.zly2006.zhihu.account.NativeAccountStore import com.github.zly2006.zhihu.account.NativeHistoryStorage +import com.github.zly2006.zhihu.account.defaultNativeAccountStore import com.github.zly2006.zhihu.data.Feed import com.github.zly2006.zhihu.data.FeedDisplayItem import com.github.zly2006.zhihu.data.target @@ -33,8 +33,7 @@ import com.github.zly2006.zhihu.notification.NotificationSettingsStore import com.github.zly2006.zhihu.notification.nativeNotificationSettingsStore import com.github.zly2006.zhihu.platform.copyNativePlainText import com.github.zly2006.zhihu.platform.nativeSettingsStore -import com.github.zly2006.zhihu.platform.requestNativeQrLogin -import com.github.zly2006.zhihu.ui.ArticleAnswerSwitchState +import com.github.zly2006.zhihu.platform.platformName import com.github.zly2006.zhihu.util.Log import com.github.zly2006.zhihu.viewmodel.filter.BlockedKeywordService import com.github.zly2006.zhihu.viewmodel.filter.BlockedQuestionAuthor @@ -57,11 +56,9 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.put import io.ktor.http.ContentType as KtorContentType -internal val nativeArticleAnswerSwitchState = ArticleAnswerSwitchData() private var nativePendingContentOpenIdentity: TrackedContentIdentity? = null private var nativePendingContentOpenFrom: String? = null @@ -95,12 +92,11 @@ actual fun rememberPaginationEnvironment(allowGuestAccess: Boolean): PaginationE remember(allowGuestAccess) { NativePaginationEnvironment() } internal class NativePaginationEnvironment( - private val accountStore: NativeAccountStore = NativeAccountStore(), override val notificationSettingsStore: NotificationSettingsStore = nativeNotificationSettingsStore(), - private val showFetchFailureMessage: ((String) -> Unit)? = null, ) : PaginationEnvironment, CollectionContentEnvironment, NotificationEnvironment { + private val accountStore = defaultNativeAccountStore private val settingsStore = nativeSettingsStore("settings.properties") private val historyStorage = NativeHistoryStorage() private val contentFilterDatabase = getContentFilterDatabase() @@ -108,53 +104,20 @@ internal class NativePaginationEnvironment( getNativeLocalContentDatabase()?.contentDao()?.let { dao -> buildLocalRecommendationEngine( dao = dao, - fetchFeedArray = { url -> fetchJson(url, "")?.get("data")?.jsonArray ?: JsonArray(emptyList()) }, - logWarning = { message -> Log.w("LocalRecommendationEngine", message) }, - logError = { message, throwable -> Log.e("LocalRecommendationEngine", message, throwable) }, + environment = this, ) } } - override fun httpClient(): HttpClient = accountStore.httpClient() + override fun httpClient(): HttpClient = accountStore.client.httpClient() - override fun authenticatedCookies(): Map<String, String> = accountStore.load().cookies + override fun authenticatedCookies(): Map<String, String> = accountStore.session.cookies override suspend fun <T> withAuthenticatedClient( block: suspend (client: HttpClient, cookies: Map<String, String>) -> T, - ): T = accountStore.withAuthenticatedClient(block) + ): T = accountStore.client.withAuthenticatedClient(block) - override fun xsrfToken(): String = accountStore.load().cookies["_xsrf"].orEmpty() - - override suspend fun refreshAccountProfile() { - accountStore.refreshAndSaveProfile() - } - - override fun requestLogin(): Boolean { - requestNativeQrLogin() - return true - } - - override fun clearAccountSession() = accountStore.clear() - - override fun currentAccountId(): String = accountStore - .load() - .profile - ?.id - .orEmpty() - - override suspend fun verifyLogin(cookies: Map<String, String>): Boolean = - accountStore.verifyAndSave(cookies.toMutableMap()) - - override fun saveCookies(cookies: Map<String, String>) { - accountStore.save( - accountStore.load().copy( - login = true, - cookies = cookies.toMutableMap(), - ), - ) - } - - override fun logout() = clearAccountSession() + override fun xsrfToken(): String = accountStore.session.cookies["_xsrf"].orEmpty() override fun feedDisplaySettings(): FeedDisplaySettings = FeedDisplaySettings( qualityFilterMode = QualityFilterMode.OFF, @@ -165,8 +128,6 @@ internal class NativePaginationEnvironment( override suspend fun postHistoryDestination(destination: NavDestination) = historyStorage.add(destination) - override fun articleAnswerSwitchState(): ArticleAnswerSwitchState = nativeArticleAnswerSwitchState - override fun setPlainTextClipboard(label: String, text: String) = copyNativePlainText(text) override suspend fun isUserBlocked(userId: String): Boolean = @@ -286,7 +247,7 @@ internal class NativePaginationEnvironment( override suspend fun clearAllHistory() { historyStorage.clearAndSave() - if (accountStore.load().cookies["d_c0"] == null) return + if (accountStore.session.cookies["d_c0"] == null) return postSigned("https://api.zhihu.com/read_history/batch_del") { contentType(KtorContentType.Application.Json) setBody( @@ -305,7 +266,7 @@ internal class NativePaginationEnvironment( items: List<CollectionItem>, includeImages: Boolean, onProgress: suspend (CollectionHtmlExportProgress) -> Unit, - ): CollectionHtmlExportResult = error("当前平台暂不支持收藏夹 HTML 压缩包导出") + ): CollectionHtmlExportResult = error("$platformName 暂不支持收藏夹 HTML 压缩包导出") override suspend fun handleCollectionExportFailure(error: Exception) { Log.e("CollectionContentViewModel", "Failed to export collection HTML zip", error) @@ -317,6 +278,5 @@ internal class NativePaginationEnvironment( override suspend fun handleFetchFailure(tag: String?, error: Exception) { Log.e(tag ?: "PaginationViewModel", "Failed to fetch feeds", error) - showFetchFailureMessage?.invoke("加载失败: ${error.message}") } }