diff --git a/.project-docs/30-worklog/tasks/20260811-native-display-f2456ea9.md b/.project-docs/30-worklog/tasks/20260811-native-display-f2456ea9.md new file mode 100644 index 0000000..936b9a0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260811-native-display-f2456ea9.md @@ -0,0 +1,85 @@ +# Task: Implement native Android display screens + +## Identity + +- Task ID: 20260811-native-display-f2456ea9 +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\PythonProjects\XQKqueue +- Base commit: e4e3e6a735c1a1a485d87a979b80c1ebe5803b6f +- Owner: codex +- Status: Blocked at documentation completion gate; Android 1.5.2 range-display fix and APK are complete and ready for target-device acceptance + +## Scope + +- Replace the Android WebView presentation shell with native Android overview and project-detail screens. +- Call the existing public overview and project snapshot APIs directly from the APK. +- Preserve project navigation, polling, new-call deduplication, three-repeat announcement playback, immersive landscape display, error recovery, and deliverable APK documentation. +- Replace Android system TTS with APK-embedded Mandarin PCM audio fragments composed from the server-provided ticket numbers. +- Keep the existing server and browser UI behavior unchanged unless an API-contract defect is proven. + +## Intent And Constraints + +- The user explicitly chose native Android because the target display device remains unreliable with WebView rendering and system TTS. +- The overview screen must never announce calls; only a concrete `/display/{identifier}` project screen may announce. +- The target panel is 1920x1080 landscape, but layout must use available dimensions/density rather than hard-coded physical pixels. +- Existing uncommitted landscape/WebView work and deliverables were explicitly adopted as the starting worktree state. +- Facts must be verified against current server and Android source; unknown API fields must not be invented. +- The server contract and database must remain unchanged; the APK owns number-to-audio composition. +- Embedded voice assets must have a documented, distributable source and a uniform decoder-independent PCM WAV format. + +## Outcome + +- Replaced the Android WebView shell with native platform Views for the project overview and project detail screens. +- Added strict public-display DTO parsing, a typed HTTP client, and one generation-safe fixed-delay polling loop per active screen. +- The overview polls every 5 seconds, supports an adaptive 1-4 column project-card grid and DPAD/touch navigation, and has no announcement coordinator. +- The project screen polls every 3 seconds; the same successful snapshot updates the UI and drives announcement deduplication. +- Preserved first-snapshot silence and replaced system TTS with 13 APK-embedded Mandarin PCM WAV fragments. The app composes strict five-digit single/range calls locally, streams each new batch three times through `AudioTrack`, and never includes the project name. +- Rapid new batches remain FIFO ordered, with 350 ms gaps between repeats and batches. Pause, navigation away, audio-focus loss, or shutdown stops the active/pending queue; overview construction has no announcement path. +- Streaming uses bounded 16 KiB writes and transient media audio focus instead of a long static native buffer, reducing Android TV implementation and memory risk. +- Added stable loading, empty, 404, fatal-error, cached-offline, and delayed-update states; forecast pages rotate every 6 seconds independently of the 3-second refresh. +- Reused the existing scenic logo and water artwork in native resources and retained density-adaptive fixed-landscape behavior. +- Removed the obsolete WebView-only announcement poller. After target-device feedback proved the first eSpeak voice assets did not produce Chinese speech, restored the project's original browser WAV fragments from Git history and remapped them to the Android composer. Produced debug APK version 1.5.1 (versionCode 8). +- Target-TV evidence then showed a two-ticket batch rendering only `00010 至`. The live snapshot was verified to contain both `00010` and `00011`, proving the API and formatter were complete. Replaced the current-number platform auto-size dependency with an API-23-compatible `SingleLineFitTextView` that measures the real content width and height, chooses the largest fitting size, never ellipsizes, and can shrink below the nominal 30sp floor rather than hide the last ticket. Produced debug APK version 1.5.2 (versionCode 9). + +## Verification + +- `android\gradlew.bat clean testDebugUnitTest lintDebug assembleDebug`: PASS after the final 1.5.2 range-display fix; 56 JVM tests in 12 suites, 0 failures/errors/skipped; lint 0 errors / 24 warnings. +- Web Vitest: PASS, 18 files / 66 tests. +- Web TypeScript checks and Vite production build: PASS. +- `go test ./...` in `server`: PASS. +- `git diff --check`: PASS (only repository line-ending notices). +- APK source and deliverable SHA-256 match: `5CB13105178EA5630EA124D0BE98A189E1B7F0557B2672C8CFAD3B6002FCBBEB`. +- `aapt`: package `cn.nianxx.queue.display`, versionCode 8, versionName 1.5.1, minSdk 23, targetSdk 36, landscape activity, no TTS service query, and all 13 voice WAV resources packaged. +- `apksigner verify`: PASS with Android Debug certificate (v1/v2 signatures). +- Actual voice resources are locked by JVM tests for exact names, strict WAV parsing, PCM16 mono 16 kHz format, frame alignment, and common sample rate. Git-history source, mapping, hashes, failed 1.5.0 diagnosis, and 1.5.1 ASR evidence are recorded in `50-evidence/topics/20260811-native-display-f2456ea9__embedded-audio-assets.md`. +- The 1.5.0 eSpeak `cmn` audio failed calibrated SAPI and Vosk recognition and was replaced. The restored legacy WAVs recover the ticket phonemes and sentence anchors with Vosk average confidence around 0.84-0.89. No ADB device or configured emulator was available, so target-TV listening remains required. +- Final audio read-only Sol review: PASS; no P0/P1 findings after streaming, audio-focus cancellation, and stop/release serialization fixes. +- Range-display regression: an API-25 Robolectric view test reproduces the real `00010 至 00011` batch through the complete native `ProjectDisplayView` and locks full text/single-line/no-ellipsis behavior. A narrow ASCII view test asserts the final measured width and font height fit the real content bounds. A deterministic test calls the production two-stage sizing decision and locks largest-fitting selection, below-30sp fallback, and the 1sp terminal boundary. The pre-fix API-25 case kept the initial 74sp because the old helper only enabled platform auto-size on API 26+. +- Live API verification on 2026-08-12: `/api/display/YYHHC/snapshot` returned `current_batch.tickets=[00010,00011]` and `latest_ticket_number=00011`; this excluded the server contract and parser as the truncation source. +- APK source and deliverable SHA-256 match after the final 1.5.2 build: `0D56C10B8CE706893F3EA8E59B21329847517F56F7347ABEA127AB30C6C71D77`; `aapt` reports versionCode 9 / versionName 1.5.2 and `apksigner` verifies v1/v2 signatures. +- Final range-display read-only Sol review: code and APK PASS with no P0/P1 implementation findings. Overall project-task status remains FAIL solely because the mandatory project-document drift gate is blocked by the pre-existing initializer `FOREIGN_DOC` state described below. +- Task Documentation Gate: `check_doc_drift.py --task-id 20260811-native-display-f2456ea9` remains BLOCKED because the document initializer created canonical templates that the feature-task boundary classifies as `FOREIGN_DOC`; product source, task-owned record, and task-prefixed evidence are otherwise present. `task_context complete` was not run because the drift prerequisite did not pass. + +## Follow-ups + +- Install the 1.5.2 debug APK on the target 1920x1080 television and confirm `00010 至 00011` plus a wider representative range show both endpoints; also smoke-test overview navigation, project refresh, DPAD focus, Home/resume silence, media volume/focus routing, and every digit in representative calls. +- Confirm and archive the ownership/redistribution basis for the legacy project WAVs; Git history contains the files and usage but no original generator, speaker, or separate license record. +- Build the production release with the operator's long-lived signing certificate rather than the debug certificate. +- Revalidate orientation behavior before moving to targetSdk 37 because the Android 16 compatibility property is temporary. +- Documentation gate: obtain human authorization to re-register this task in Integration mode so the initializer-created canonical `.project-docs` templates can be owned without committing, hiding, or deleting them. + +## Promotion Candidates + +- Target: architecture/system overview and data-flow documentation. + Proposal: record that the dedicated Android display client is native Views rather than WebView; exactly one active screen poller owns network refresh; a project snapshot is the single source for both rendering and embedded-audio announcements; the overview screen cannot announce by construction. + Evidence: native Activity/data/view/audio implementation, 53 JVM tests, lint/build results, and API/audio evidence under `50-evidence/topics/`. + Future impact: Android display changes should extend the native DTO/view boundary and must not introduce a second snapshot request or announcement path on overview. + Conflicts: none known; the canonical files were initialized as placeholders and were intentionally not promoted during this feature task. + Confirmation: the user explicitly approved replacing the current Android presentation with native pages, taking over the existing worktree changes, and replacing TTS with embedded audio. +- Target: the `maintain-project-docs` initialization workflow. + Proposal: provide an explicit first-install ownership mode so initializer-created canonical templates do not appear as foreign feature-task drift. + Evidence: `check_doc_drift.py` classified every non-overwritten initializer template as `FOREIGN_DOC`; see `60-reflection/cases/20260811-native-display-f2456ea9__initialization-drift-deadlock.md`. + Future impact: avoids a completion deadlock when the document system is first introduced during a repository task. + Conflicts: none with product behavior; this concerns task-document workflow only. + Confirmation: required before changing task mode or shared skill behavior. diff --git a/.project-docs/30-worklog/tasks/20260812-integrate-native-display-1a52b7c9.md b/.project-docs/30-worklog/tasks/20260812-integrate-native-display-1a52b7c9.md new file mode 100644 index 0000000..8b7712c --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260812-integrate-native-display-1a52b7c9.md @@ -0,0 +1,52 @@ +# Task: Integrate native Android display + +## Identity + +- Task ID: 20260812-integrate-native-display-1a52b7c9 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\PythonProjects\XQKqueue +- Base commit: e4e3e6a735c1a1a485d87a979b80c1ebe5803b6f +- Owner: codex +- Status: Ready for bootstrap commit + +## Scope + +- Adopt and integrate the completed native Android display work already present in the main worktree. +- Include the native overview and project screens, public display API client, detail-only announcements, embedded Mandarin WAV playback, 1920 x 1080 landscape behavior, range-number fitting, tests, and the updated debug APK. +- Preserve the existing responsive Web display adjustment that was developed and verified in the same adopted worktree. +- Carry the source task's task-owned evidence into version control without rewriting its historical record. + +## Intent And Constraints + +- The user explicitly authorized taking over the existing changes and later requested that they be merged and pushed to `main`. +- `origin/main` and local `main` both pointed to `e4e3e6a735c1a1a485d87a979b80c1ebe5803b6f` when integration began. +- The overview screen must never announce. Only `/display/{project-code}` may poll announcement state and play audio. +- Audio must use the APK's embedded Mandarin PCM assets and repeat each announcement three times; it must not depend on Android TTS. +- A batch range such as `00010 至 00011` must remain complete, single-line, and unellipsized on the supported Android UI. +- Source task records are retained as historical evidence. Canonical project-memory promotion will be completed from a clean follow-up Integration task whose base includes this bootstrap commit, because the source task's previously uncommitted task-owned documents are foreign to this Integration task's base. + +## Outcome + +- Prepared one bootstrap integration unit containing the adopted product changes, deliverable APK, source task record, evidence, and reflection. +- Canonical project-memory promotion and remote push remain pending. + +## Verification + +- Android: `clean testDebugUnitTest lintDebug assembleDebug` passed with 56 tests, zero failures, and zero lint errors. +- Web: 66 Vitest tests and production build/typecheck passed during the source task. +- Server: `go test ./...` passed during the source task. +- APK: versionCode 9 / versionName 1.5.2, minSdk 23, targetSdk 36, v1/v2 signatures valid. +- Source and deliverable APK SHA-256 matched `0D56C10B8CE706893F3EA8E59B21329847517F56F7347ABEA127AB30C6C71D77`. +- Independent range-display review found no P0/P1 code or APK blocker; the remaining blocker was only the project-document ownership transition addressed by this bootstrap step. + +## Follow-ups + +- Start a follow-up Integration task at the bootstrap commit, promote durable facts into canonical project memory, run the final documentation gate, obtain a fresh read-only review, and push `main`. + +## Promotion Candidates + +- Native Android Views are the supported large-screen application implementation; the Web display remains a separate browser surface. +- The snapshot response is the single source for project-screen UI and announcement state. +- Announcement lifecycle is structurally detail-only, with cancellation when leaving the project screen or pausing the Activity. +- Embedded PCM assets and `AudioTrack` streaming replace device TTS for appliance compatibility. diff --git a/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__embedded-audio-assets.md b/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__embedded-audio-assets.md new file mode 100644 index 0000000..9c9737c --- /dev/null +++ b/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__embedded-audio-assets.md @@ -0,0 +1,61 @@ +# Embedded announcement audio evidence + +## Current accepted source + +Android version 1.5.1 reuses the queue announcement WAV fragments that already existed in this repository's browser implementation. They were first added in commit `6d911271e6bf5b0120edc04c91506077239e96e5`, moved byte-for-byte into `web/src/assets/queue-voice/` in commit `4405bf96f7ad8029fbf62741d92ddaab7b99c096`, and later removed from the browser bundle in commit `5ad8bfcd0377e6dfb8c703d0834d248633a5b939` when that implementation changed to `speechSynthesis`. + +The user confirmed that the project already had these WAV assets; that evidence redirected the fix to reuse the project's own prior assets instead of generating another voice. Repository history does not record the original generator, speaker, recording author, or a separate license/NOTICE. Therefore this record treats them as project-owned legacy assets in scope for this repair; it does not claim that they are open source or independently cleared for third-party redistribution. + +## Android mapping + +| Android resource | Historical source / operation | Spoken content | +| --- | --- | --- | +| `voice_prompt.wav` | `please.wav` | 请 | +| `voice_digit_0.wav` ... `voice_digit_9.wav` | corresponding `digit-0.wav` ... `digit-9.wav` | 零 ... 九 | +| `voice_range.wav` | PCM concatenation of `number.wav` then `through.wav` | 号至 | +| `voice_suffix.wav` | PCM concatenation of `number.wav` then `entrance.wav` | 号,前往入口 | + +The two concatenated resources contain one RIFF header and the source PCM samples in the listed order. Android creates its repeat and batch gaps, so the historical `gap.wav` is not packaged. + +## Format and integrity + +All 13 Android resources parse as RIFF PCM format tag 1, 16-bit, mono, 16 kHz, with aligned sample data. `EmbeddedVoiceAssetsTest` locks the exact resource set and validates every committed file through the production WAV parser. + +| Resource | SHA-256 | +| --- | --- | +| `voice_digit_0.wav` | `DA7E001528495F75473F99A6BBC275B9C4B7E5FC0888FA9495F86E16231DDC61` | +| `voice_digit_1.wav` | `2248F327AC9386ECEADB98A4A24F857C218596406EE519F268D67FEEF785971D` | +| `voice_digit_2.wav` | `DFCD91AA933F78E5CC4BB18B84A9F00D5365C7992B15EB7948960BFBE4505EE6` | +| `voice_digit_3.wav` | `98FD18056AF18D3B951E6777D5CA2EE7971005F9FBFD5054E8DADDDA4913778F` | +| `voice_digit_4.wav` | `073B17DCC81F416799644A34A75723CE1B75A8D90D06679D4F0E6F96DB9CBBFE` | +| `voice_digit_5.wav` | `80DFA4AD87E752CE23F49B123D91E9E06F691267CBD3ECA94FF12213C73739D7` | +| `voice_digit_6.wav` | `131A747A9D0C8825F8C69907E19E9FB83935D2EB6FEB1137F6EF0311F96F197E` | +| `voice_digit_7.wav` | `7DAF943934080733D7F6704CE58FCADC736965A578DC0A091CBFDCCB8E4B9BE4` | +| `voice_digit_8.wav` | `1406CF912AB2756A6A6600749767FFA0181832DADB28FA0CD81FBB0F1BE47A48` | +| `voice_digit_9.wav` | `BE72EC45438D4D2835E11B91380715CE36650CC465D5EE5465B540B1B8686F8F` | +| `voice_prompt.wav` | `C066CAF5107994D4B7F7E96E6818AF54E8BB785914FD5881FA223C18C3CF9BDE` | +| `voice_range.wav` | `9018BF8EF743F6A6FC2323BDE7313CCFE12D5E14A19207E87C2EE3B3A19D9C7B` | +| `voice_suffix.wav` | `010CF52AE95B0F541A105DB43741832A75FB670A3D02A72A032D94D9AC95182E` | + +## Semantic diagnosis and acceptance + +The 1.5.0 eSpeak NG `cmn` assets were rejected after the target user reported that they did not sound Chinese. Reproduction showed correct byte order and repetition, but two Chinese recognizers returned unrelated text at approximately 0.001-0.002 confidence. Direct unfragmented synthesis from the same engine also failed, confirming the source voice mapping rather than Android composition as the cause. + +The restored legacy assets were evaluated in the actual Android token order: + +- `00017`, one pass: `请 + 0 + 0 + 0 + 1 + 7 + 号 + 前往入口`. +- `00017-00020`, one pass: `请 + 0 + 0 + 0 + 1 + 7 + 号至 + 0 + 0 + 0 + 2 + 0 + 号 + 前往入口`. +- Android repeats the pass three times with two 350 ms zero-PCM gaps. + +Vosk small-cn 0.22 recovered the complete target phoneme sequence and sentence structure from final Android-composed samples, with average word confidence approximately 0.84-0.89. Its text used homophones/segmentation such as `一期` for `一七` and `好` for `号`, while all three repeats remained stable. Windows zh-CN SAPI also recovered the sentence anchors but was less accurate on isolated digit fragments. A digit calibration sequence recovered 零 through 九 in order. These are calibrated automated red-flag checks, not a substitute for human acceptance. + +The final 1.5.1 single-number preview produced from the committed Android resources is 11.516125 seconds and has SHA-256 `9FFDA2837EC40DAF4AC626FF093DDC64223A6F9E352FA3F8E198A48C97C285D3`. + +## Verification and remaining risk + +- `gradlew.bat clean testDebugUnitTest lintDebug assembleDebug`: PASS; 53 JVM tests, no failures/errors/skips; lint has zero errors and 23 warnings. +- APK version: versionCode 8, versionName 1.5.1. +- APK contains exactly the 13 required `res/raw/voice_*.wav` resources. +- APK SHA-256: `5CB13105178EA5630EA124D0BE98A189E1B7F0557B2672C8CFAD3B6002FCBBEB`. + +Remaining acceptance: listen to representative single and range announcements on the target television and confirm every digit. The historical clips have uneven source loudness; they were kept byte-exact rather than normalized because processing would invalidate their prior use and requires renewed listening/ASR review. diff --git a/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__public-display-api.md b/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__public-display-api.md new file mode 100644 index 0000000..364944b --- /dev/null +++ b/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__public-display-api.md @@ -0,0 +1,35 @@ +# Evidence: Public display API contract + +- Date: 2026-08-11 +- Status: Verified +- Scope: Native Android overview and project-detail implementation +- Confidence: High +- Sources: `server/internal/httpapi/public.go`, related Go tests, and live HTTPS responses from `queue.nianxx.cn` +- Last verified: 2026-08-11 +- Stale trigger: public display routes or DTOs change, or production is deployed from a server revision that differs from this repository + +## Confirmed endpoints + +- `GET /api/display/overview` returns `{ projects: [...], server_time }` without authentication. +- `GET /api/display/{identifier}/snapshot` accepts a normalized project code or a 40-128 character display token. +- Invalid or unknown identifiers return `DISPLAY_NOT_FOUND` with HTTP 404. + +## Confirmed overview project fields + +- `id`, `code`, `name`, `status` +- `waiting_count`, `waiting_ticket_count`, `waiting_people_count` +- `issued_ticket_count`, nullable `latest_ticket_number`, `experienced_people` +- nullable `current_batch`, `estimated_wait`, `last_updated_at` + +## Confirmed snapshot fields + +- Project: `project_name`, `status`, nested `project` +- Queue: `revision`, the three waiting counts, issued/latest/experienced metrics +- Calls: nullable `current_batch`, `recent_batches` +- Timing: `estimated_wait`, `server_time`, `last_updated_at` + +Each call batch exposes `batch_number`, `sequence`, `status`, `call_mode`, requested/ticket/people counts, `called_at`, and tickets. Each ticket exposes public display number fields, status, and party size; no personal visitor fields are present. + +## Implementation consequence + +The native detail screen can render and decide announcements from one snapshot request every three seconds. A second announcement-only request is unnecessary. The first successful snapshot after entering or resuming a project must establish a baseline and must not replay the current batch. diff --git a/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__range-display-fit.md b/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__range-display-fit.md new file mode 100644 index 0000000..3c92416 --- /dev/null +++ b/.project-docs/50-evidence/topics/20260811-native-display-f2456ea9__range-display-fit.md @@ -0,0 +1,48 @@ +# Native range-display truncation evidence + +## Reported symptom + +On the target 1920x1080 television, a batch that called tickets `00010` and `00011` rendered the main current-call field as only `00010 至`; the latest-ticket metric at the right still showed `00011`. + +## Boundary verification + +The production public snapshot was read on 2026-08-12 from `GET https://queue.nianxx.cn/api/display/YYHHC/snapshot`. It contained: + +- `latest_ticket_number: "00011"` +- `current_batch.ticket_count: 2` +- `current_batch.tickets[0].display_number: "00010"` +- `current_batch.tickets[1].display_number: "00011"` + +`DisplayFormatting.currentBatchNumbers` therefore produces the complete string `00010 至 00011`. This rules out the server response, JSON parser, and range formatter as the cause. + +## Root cause + +The original native current-number `TextView` started at 74sp, allowed one line, and called `NativeDisplayUi.autoSize`. That helper only activated Android platform auto-size on API 26 and later, although the APK supports API 23. On API 23-25, long range text stayed at 74sp and could be silently clipped by the half-width current-call panel. Platform behavior and display density also made the guarantee device-dependent on newer releases. + +An API-25 Robolectric regression using a full `ProjectDisplayView`, 1920x1080 exact layout, and the real two-ticket snapshot was run before the fix. Its red signal confirmed the legacy view retained the initial 74sp rather than applying a fitting policy. + +## Fix and invariant + +`SingleLineFitTextView` now owns current-call fitting on API 23 and later. On text, size, or padding changes it: + +- computes actual content width and height after compound padding; +- measures the real text and font metrics with a copy of the active `TextPaint`; +- binary-searches for the largest whole-sp size that fits both dimensions; +- enforces a single line and never ellipsizes; +- falls below the normal 30sp readability floor only when necessary to preserve the complete ticket range. + +Short single numbers still receive the largest fitting size, up to 74sp. + +## Verification + +- `gradlew.bat clean testDebugUnitTest lintDebug assembleDebug`: PASS. +- JVM tests: 56 across 12 suites, 0 failures, 0 errors, 0 skipped. +- Lint: 0 errors, 24 warnings. +- `ProjectDisplayViewTest` locks the full `00010 至 00011` string, one-line behavior, and no ellipsis on API 25. +- A narrow ASCII control asserts the fitted `TextPaint` width and font height are within actual content bounds; deterministic sizing tests call the production two-stage decision and lock largest-fitting selection, full-size selection, below-floor fallback, and the 1sp terminal boundary. +- APK: versionCode 9, versionName 1.5.2; Android Debug v1/v2 signatures verified. +- Build and deliverable SHA-256: `0D56C10B8CE706893F3EA8E59B21329847517F56F7347ABEA127AB30C6C71D77`. + +## Remaining acceptance + +Robolectric's host font metrics are not the target television's font metrics. The production code uses the device's real `TextPaint`, but final acceptance still requires installing 1.5.2 on the target television and visually confirming both endpoints for `00010 至 00011` and at least one wider representative range. diff --git a/.project-docs/60-reflection/cases/20260811-native-display-f2456ea9__initialization-drift-deadlock.md b/.project-docs/60-reflection/cases/20260811-native-display-f2456ea9__initialization-drift-deadlock.md new file mode 100644 index 0000000..7264e79 --- /dev/null +++ b/.project-docs/60-reflection/cases/20260811-native-display-f2456ea9__initialization-drift-deadlock.md @@ -0,0 +1,27 @@ +# Reflection: Project-docs initialization and feature drift + +- Task: `20260811-native-display-f2456ea9` +- Date: 2026-08-11 +- Status: Confirmed workflow gap in this repository state + +## What happened + +The repository had no `.project-docs/` tree. The required initializer created the canonical templates before the feature task was claimed. The feature task then correctly limited its intentional documentation writes to its own task record and prefixed evidence/reflection records. + +At completion, `check_doc_drift.py` compared the feature task's recorded base commit with the worktree. Because every initializer-created canonical template was still untracked, the script classified all of them as protected `FOREIGN_DOC` changes. A feature task cannot accept those paths, while removing them would leave a partial document system and violate the initialization contract. + +## Why it matters + +On a repository that first adopts this skill during an already dirty feature workflow, the documented sequence “initialize, then claim a feature task” cannot by itself produce a passing Task Documentation Gate unless the initialization tree is first established in a separate canonical baseline. + +## Better workflow + +Initialize `.project-docs/` in a serialized integration task and establish that tree as a reviewed repository baseline before starting feature work. If the initializer must run during feature intake, the tooling should provide an explicit, human-confirmed initialization ownership path rather than requiring agents to hide, delete, or manually reclassify protected files. + +## Promotion candidate + +- Target: `maintain-project-docs` initialization workflow and/or drift script. +- Proposal: add a first-install mode that atomically records initializer-owned canonical files, or explicitly require and guide a serialized initialization integration before feature task creation. +- Evidence: this task's drift output lists every initializer template as `FOREIGN_DOC` even though no feature agent edited those templates after initialization. +- Future impact: prevents completion deadlocks on repositories adopting the document system for the first time. +- Human confirmation: required before changing the shared skill or committing a new canonical documentation baseline. diff --git a/android/README.md b/android/README.md index 1d0e163..efff90c 100644 --- a/android/README.md +++ b/android/README.md @@ -1,39 +1,51 @@ -# Android 公示屏 APK +# Android 原生公示屏 APK -这是景区排队叫号系统的原生 Android 薄壳,只承载两个公开页面: +这是景区排队叫号系统的原生 Android 大屏应用。应用直接请求公开展示接口并使用 Android Views 绘制页面,不再加载网页,也不依赖 Android System WebView。 -- 总览页面:`https://queue.nianxx.cn/admin/display` -- 单项目页面:`https://queue.nianxx.cn/display/{项目编号或公示 Token}` +## 页面与数据流 + +- 默认打开总览页,对应 `https://queue.nianxx.cn/admin/display` 的业务数据。 +- 总览每 5 秒请求 `GET /api/display/overview`,展示项目卡片;总览状态不会创建播报任务。 +- 点击或使用遥控器确认项目卡片后,进入该项目详情,对应 `/display/{项目编号或公示 Token}`。 +- 项目详情每 3 秒请求一次 `GET /api/display/{identifier}/snapshot`。同一份快照同时更新界面并进行新叫号检测,不会重复请求。 +- 返回键从项目详情回到总览;在总览再次返回会退出应用。 +- 支持以 `https://queue.nianxx.cn/admin/display` 和 `https://queue.nianxx.cn/display/{identifier}` 作为 Android 深链启动。 ## 现场能力 -- 无浏览器地址栏的沉浸式展示,并保持屏幕常亮 -- 总览页面只展示项目状态,不进行语音播报 -- 单项目页面由 APK 每 3 秒读取公开快照接口,发现新叫号批次后调用系统中文 TTS -- 每次叫号由 Android 原生层连续播报三次,并使用系统媒体音量通道 -- 接管项目卡的网页全屏请求,返回键退出全屏 -- 支持上述两个 HTTPS 页面作为 Android 深链 -- 仅允许 `queue.nianxx.cn` 的公开公示路径,阻止后台、员工端、外域和明文 HTTP 导航 -- 网络、证书或中文语音引擎异常时显示原生提示 +- 原生浅色景区大屏界面:总览自适应项目网格,详情显示当前叫号、四项统计和四行等候预估。 +- 固定横屏、沉浸式全屏并保持屏幕常亮;布局使用 dp/sp 和可用窗口尺寸,适配 1920×1080 横屏及不同显示密度。 +- 支持触摸、遥控器方向键、确认键和清晰的焦点状态。 +- 有缓存数据时,断网不会清空大屏,而是在顶部显示连接状态和上次更新时间;首次加载失败可直接重试。 +- 预告超过四行时每 6 秒翻页,3 秒数据刷新不会重置当前页。 +- 首次进入或从后台恢复时,第一份项目快照只建立叫号基线,避免重复播报屏幕上已有号码。 +- 发现新叫号后使用 APK 内置普通话音频和媒体音量通道连续播报三遍;快速连续叫号时按批次顺序排队,确保每批都完整播报三遍。 +- 播报通过流式 `AudioTrack` 输出并申请临时媒体音频焦点,避免与电视上的其他媒体源同时混播;若其他应用持续占用音频焦点,本次播报会失败并提示检查播放源。 +- 离开项目页或应用进入后台时,立即停止轮询和当前/待播语音。 -APK 仍依赖线上服务和 Android System WebView,不会把实时队列数据离线复制进安装包。叫号检测和播报都由 APK 原生层负责,不依赖线上网页是否包含语音代码;普通浏览器访问单项目页面时不会播报。 +## 横屏兼容说明 + +应用通过 `screenOrientation="landscape"` 固定横屏。Android 16(API 36)默认可能在 `sw600dp` 及以上大屏忽略方向限制,因此 `MainActivity` 同时声明 Android 16 提供的临时兼容属性,使 targetSdk 36 的专用公示屏继续使用横屏兼容模式。 + +该兼容属性从 targetSdk 37 起不再生效;升级 targetSdk 前需要重新验证目标设备策略。设备厂商、设备所有者或桌面窗口模式仍可能覆盖应用方向请求。 ## 设备要求 -- 系统已安装并启用支持简体中文的文字转语音引擎 -- 设备媒体音量已打开 -- Android System WebView 可以访问 `https://queue.nianxx.cn` +- 推荐 1920×1080 横屏。 +- 叫号音频已内置在 APK 中,无需安装或启用系统中文 TTS 引擎。 +- 设备媒体音量已打开,音频输出路由已连接到大屏扬声器。 +- 设备能够通过 HTTPS 访问 `https://queue.nianxx.cn`。 ## 构建 -需要 JDK 17+ 和 Android SDK 36(Build Tools 36.1.0): +需要 JDK 17+、Android SDK 36 和 Build Tools 36.1.0: ```powershell $env:ANDROID_HOME = 'C:\Users\你的用户名\AppData\Local\Android\Sdk' .\gradlew.bat testDebugUnitTest lintDebug assembleDebug ``` -可安装 APK 位于 `app/build/outputs/apk/debug/app-debug.apk`。Debug APK 适合现场安装验证;正式发布前应使用单位自有密钥生成签名 Release APK。 +可安装 APK 位于 `app/build/outputs/apk/debug/app-debug.apk`。当前版本为 `versionCode 9`、`versionName 1.5.2`。 ## 安装与深链验证 @@ -41,3 +53,5 @@ $env:ANDROID_HOME = 'C:\Users\你的用户名\AppData\Local\Android\Sdk' & "$env:ANDROID_HOME\platform-tools\adb.exe" install -r .\app\build\outputs\apk\debug\app-debug.apk & "$env:ANDROID_HOME\platform-tools\adb.exe" shell am start -a android.intent.action.VIEW -d 'https://queue.nianxx.cn/display/YYHHC' ``` + +Debug APK 适合现场安装验证;正式发布前应使用单位长期保管的 Android 签名证书构建 Release APK,以便后续安全升级。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 6af8941..d09ddd5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId = "cn.nianxx.queue.display" minSdk = 23 targetSdk = 36 - versionCode = 4 - versionName = "1.2.0" + versionCode = 9 + versionName = "1.5.2" } buildTypes { @@ -21,6 +21,10 @@ android { } } + buildFeatures { + buildConfig = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -29,10 +33,13 @@ android { testOptions { unitTests { returnDefaultValues = true + includeAndroidResources = true } } } dependencies { testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20260522") + testImplementation("org.robolectric:robolectric:4.14.1") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 239727a..8f0c1fb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,12 +4,6 @@ - - - - - - + android:launchMode="singleTask" + android:screenOrientation="landscape"> + + diff --git a/android/app/src/main/java/cn/nianxx/queue/display/AnnouncementCoordinator.java b/android/app/src/main/java/cn/nianxx/queue/display/AnnouncementCoordinator.java new file mode 100644 index 0000000..7fae24e --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/AnnouncementCoordinator.java @@ -0,0 +1,44 @@ +package cn.nianxx.queue.display; + +import java.util.ArrayList; +import java.util.List; + +final class AnnouncementCoordinator { + interface Listener { + void onAnnouncement(String text); + } + + private final CallAnnouncementTracker tracker = new CallAnnouncementTracker(); + private final Listener listener; + + AnnouncementCoordinator(Listener listener) { + if (listener == null) { + throw new IllegalArgumentException("listener is required"); + } + this.listener = listener; + } + + void accept(DisplayModels.Batch batch) { + String callKey = batch == null ? null : batch.callKey(); + String instruction = batch == null ? null : QueueCallText.format(ticketNumbers(batch)); + String accepted = tracker.accept(callKey, instruction); + if (accepted != null) { + listener.onAnnouncement(accepted); + } + } + + void reset() { + tracker.reset(); + } + + private static List ticketNumbers(DisplayModels.Batch batch) { + List numbers = new ArrayList<>(batch.tickets.size()); + for (DisplayModels.Ticket ticket : batch.tickets) { + String number = ticket.displayNumber == null || ticket.displayNumber.trim().isEmpty() + ? ticket.ticketNumber + : ticket.displayNumber; + numbers.add(number); + } + return numbers; + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/CallAnnouncementTracker.java b/android/app/src/main/java/cn/nianxx/queue/display/CallAnnouncementTracker.java index 3bb12f6..f34e6e0 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/CallAnnouncementTracker.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/CallAnnouncementTracker.java @@ -7,6 +7,9 @@ final class CallAnnouncementTracker { private String lastCallKey; String accept(String callKey, String announcement) { + if (callKey != null && (announcement == null || announcement.trim().isEmpty())) { + return null; + } if (!initialized) { initialized = true; lastCallKey = callKey; @@ -16,7 +19,7 @@ final class CallAnnouncementTracker { return null; } lastCallKey = callKey; - return callKey != null ? announcement : null; + return callKey == null ? null : announcement; } void reset() { diff --git a/android/app/src/main/java/cn/nianxx/queue/display/DisplayAnnouncementPoller.java b/android/app/src/main/java/cn/nianxx/queue/display/DisplayAnnouncementPoller.java deleted file mode 100644 index b503106..0000000 --- a/android/app/src/main/java/cn/nianxx/queue/display/DisplayAnnouncementPoller.java +++ /dev/null @@ -1,202 +0,0 @@ -package cn.nianxx.queue.display; - -import android.os.Handler; -import android.os.Looper; -import android.util.Log; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -final class DisplayAnnouncementPoller { - interface Listener { - void onAnnouncement(String text); - } - - private static final String TAG = "XQKQueueDisplay"; - private static final long POLL_INTERVAL_SECONDS = 3; - private static final int NETWORK_TIMEOUT_MILLIS = 5_000; - - private final Listener listener; - private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor( - runnable -> new Thread(runnable, "queue-announcement-poller")); - private final CallAnnouncementTracker tracker = new CallAnnouncementTracker(); - private ScheduledFuture pollingTask; - private String activeSnapshotUrl; - private long generation; - private boolean closed; - private boolean initialSnapshotLogged; - - DisplayAnnouncementPoller(Listener listener) { - this.listener = listener; - } - - synchronized void start(String snapshotUrl) { - if (closed || snapshotUrl == null) { - return; - } - if (Objects.equals(activeSnapshotUrl, snapshotUrl) - && pollingTask != null - && !pollingTask.isCancelled()) { - return; - } - stopLocked(); - activeSnapshotUrl = snapshotUrl; - tracker.reset(); - long taskGeneration = ++generation; - pollingTask = executor.scheduleWithFixedDelay( - () -> poll(snapshotUrl, taskGeneration), - 0, - POLL_INTERVAL_SECONDS, - TimeUnit.SECONDS); - Log.i(TAG, "Project announcement polling started"); - } - - synchronized void stop() { - stopLocked(); - } - - synchronized void close() { - if (closed) { - return; - } - closed = true; - stopLocked(); - executor.shutdownNow(); - mainHandler.removeCallbacksAndMessages(null); - } - - private void poll(String snapshotUrl, long taskGeneration) { - try { - CallSnapshot snapshot = fetchSnapshot(snapshotUrl); - String announcement; - boolean firstSnapshot; - synchronized (this) { - if (!isActive(snapshotUrl, taskGeneration)) { - return; - } - firstSnapshot = !initialSnapshotLogged; - initialSnapshotLogged = true; - announcement = tracker.accept(snapshot.callKey, snapshot.announcement); - } - if (firstSnapshot) { - Log.i(TAG, "Initial project snapshot received; current call present=" - + (snapshot.callKey != null)); - } - if (announcement == null) { - return; - } - mainHandler.post(() -> { - synchronized (DisplayAnnouncementPoller.this) { - if (!isActive(snapshotUrl, taskGeneration)) { - return; - } - } - Log.i(TAG, "New call batch detected; sending announcement to Android TTS"); - listener.onAnnouncement(announcement); - }); - } catch (Exception error) { - synchronized (this) { - if (!isActive(snapshotUrl, taskGeneration)) { - return; - } - } - Log.w(TAG, "Snapshot polling failed: " + error.getClass().getSimpleName()); - } - } - - private synchronized boolean isActive(String snapshotUrl, long taskGeneration) { - return !closed - && generation == taskGeneration - && Objects.equals(activeSnapshotUrl, snapshotUrl); - } - - private void stopLocked() { - generation += 1; - activeSnapshotUrl = null; - initialSnapshotLogged = false; - tracker.reset(); - if (pollingTask != null) { - pollingTask.cancel(true); - pollingTask = null; - } - } - - private static CallSnapshot fetchSnapshot(String snapshotUrl) throws IOException, JSONException { - HttpURLConnection connection = (HttpURLConnection) new URL(snapshotUrl).openConnection(); - try { - connection.setConnectTimeout(NETWORK_TIMEOUT_MILLIS); - connection.setReadTimeout(NETWORK_TIMEOUT_MILLIS); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Accept", "application/json"); - connection.setRequestProperty("User-Agent", "XQKQueueDisplay/1.2"); - connection.setUseCaches(false); - int status = connection.getResponseCode(); - if (status != HttpURLConnection.HTTP_OK) { - throw new IOException("Unexpected HTTP status " + status); - } - StringBuilder body = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader( - connection.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - body.append(line); - } - } - return parseSnapshot(body.toString()); - } finally { - connection.disconnect(); - } - } - - private static CallSnapshot parseSnapshot(String body) throws JSONException { - JSONObject batch = new JSONObject(body).optJSONObject("current_batch"); - if (batch == null) { - return new CallSnapshot(null, null); - } - int batchNumber = batch.optInt("batch_number", batch.optInt("sequence", -1)); - String calledAt = batch.optString("called_at", "").trim(); - String callKey = batchNumber + ":" + calledAt; - JSONArray tickets = batch.optJSONArray("tickets"); - List numbers = new ArrayList<>(); - if (tickets != null) { - for (int index = 0; index < tickets.length(); index += 1) { - JSONObject ticket = tickets.optJSONObject(index); - if (ticket == null) { - continue; - } - String number = ticket.optString("display_number", "").trim(); - if (number.isEmpty()) { - number = ticket.optString("ticket_number", "").trim(); - } - numbers.add(number); - } - } - return new CallSnapshot(callKey, QueueCallText.format(numbers)); - } - - private static final class CallSnapshot { - final String callKey; - final String announcement; - - CallSnapshot(String callKey, String announcement) { - this.callKey = callKey; - this.announcement = announcement; - } - } -} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/DisplayApiClient.java b/android/app/src/main/java/cn/nianxx/queue/display/DisplayApiClient.java new file mode 100644 index 0000000..23287ba --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/DisplayApiClient.java @@ -0,0 +1,161 @@ +package cn.nianxx.queue.display; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.io.UnsupportedEncodingException; + +final class DisplayApiClient { + private static final int TIMEOUT_MILLIS = 5_000; + + private final String baseUrl; + private final String userAgent; + + DisplayApiClient(String baseUrl, String userAgent) { + if (baseUrl == null || baseUrl.trim().isEmpty()) { + throw new IllegalArgumentException("baseUrl is required"); + } + if (userAgent == null || userAgent.trim().isEmpty()) { + throw new IllegalArgumentException("userAgent is required"); + } + this.baseUrl = stripTrailingSlash(baseUrl.trim()); + this.userAgent = userAgent.trim(); + } + + Result getOverview() { + return get("/api/display/overview", DisplayJsonParser::parseOverview); + } + + Result getSnapshot(String identifier) { + if (identifier == null || identifier.trim().isEmpty()) { + return Result.parseError("Display identifier is required", + new IllegalArgumentException("identifier is required")); + } + String encoded; + try { + encoded = URLEncoder.encode(identifier.trim(), "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException impossible) { + return Result.networkError("UTF-8 is unavailable", impossible); + } + return get("/api/display/" + encoded + "/snapshot", DisplayJsonParser::parseSnapshot); + } + + private Result get(String path, BodyParser parser) { + HttpURLConnection connection = null; + try { + URL url = buildUrl(path); + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(TIMEOUT_MILLIS); + connection.setReadTimeout(TIMEOUT_MILLIS); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("User-Agent", userAgent); + connection.setUseCaches(false); + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) { + return Result.httpError(status, readBody(connection.getErrorStream())); + } + String body = readBody(connection.getInputStream()); + try { + return Result.success(parser.parse(body)); + } catch (DisplayJsonParser.ParseException error) { + return Result.parseError(error.getMessage(), error); + } + } catch (IOException | URISyntaxException error) { + return Result.networkError(error.getMessage(), error); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private URL buildUrl(String path) throws IOException, URISyntaxException { + URI uri = new URI(baseUrl + path); + String scheme = uri.getScheme(); + if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { + throw new IOException("Unsupported URL scheme"); + } + return uri.toURL(); + } + + private static String readBody(InputStream stream) throws IOException { + if (stream == null) { + return ""; + } + StringBuilder body = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + char[] buffer = new char[4096]; + int count; + while ((count = reader.read(buffer)) != -1) { + body.append(buffer, 0, count); + } + } + return body.toString(); + } + + private static String stripTrailingSlash(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') { + end -= 1; + } + if (end == 0) { + throw new IllegalArgumentException("baseUrl is invalid"); + } + return value.substring(0, end); + } + + private interface BodyParser { + T parse(String body) throws DisplayJsonParser.ParseException; + } + + static final class Result { + enum Kind { SUCCESS, HTTP_ERROR, NETWORK_ERROR, PARSE_ERROR } + + final Kind kind; + final T value; + final int httpStatus; + final String message; + final Throwable cause; + + private Result(Kind kind, T value, int httpStatus, String message, Throwable cause) { + this.kind = kind; + this.value = value; + this.httpStatus = httpStatus; + this.message = message; + this.cause = cause; + } + + boolean isSuccess() { + return kind == Kind.SUCCESS; + } + + static Result success(T value) { + return new Result<>(Kind.SUCCESS, value, 0, null, null); + } + + static Result httpError(int status, String body) { + return new Result<>(Kind.HTTP_ERROR, null, status, body, null); + } + + static Result networkError(String message, Throwable cause) { + return new Result<>(Kind.NETWORK_ERROR, null, 0, safeMessage(message), cause); + } + + static Result parseError(String message, Throwable cause) { + return new Result<>(Kind.PARSE_ERROR, null, 0, safeMessage(message), cause); + } + + private static String safeMessage(String message) { + return message == null || message.trim().isEmpty() ? "Unknown error" : message; + } + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/DisplayFormatting.java b/android/app/src/main/java/cn/nianxx/queue/display/DisplayFormatting.java new file mode 100644 index 0000000..9407e37 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/DisplayFormatting.java @@ -0,0 +1,136 @@ +package cn.nianxx.queue.display; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class DisplayFormatting { + static final String UNKNOWN = "暂无"; + static final int FORECAST_GROUP_SIZE = 30; + static final int ROWS_PER_PAGE = 4; + private static final Pattern NUMBER_SUFFIX = Pattern.compile("^(.*?)(\\d+)$"); + + private DisplayFormatting() {} + + static String currentBatchNumbers(DisplayModels.Batch batch) { + if (batch == null || batch.tickets.isEmpty()) { + return UNKNOWN; + } + List numbers = new ArrayList<>(); + for (DisplayModels.Ticket ticket : batch.tickets) { + String number = usableTicketNumber(ticket); + if (number != null) { + numbers.add(number); + } + } + if (numbers.isEmpty()) { + return UNKNOWN; + } + if (numbers.size() == 1) { + return numbers.get(0); + } + return numbers.get(0) + " 至 " + numbers.get(numbers.size() - 1); + } + + static String formatEstimatedWait(DisplayModels.Eta eta) { + if (eta == null || !eta.available) { + return "暂不可估算"; + } + if (eta.minMinutes == eta.maxMinutes) { + return "约 " + eta.maxMinutes + " 分钟"; + } + return "预计 " + eta.minMinutes + " 至 " + eta.maxMinutes + " 分钟"; + } + + static List forecastRows(DisplayModels.Batch batch, + long waitingTicketCount, DisplayModels.Eta eta) { + if (batch == null || batch.tickets.isEmpty() || waitingTicketCount <= 0 + || eta == null || !eta.available) { + return Collections.emptyList(); + } + String lastNumber = usableTicketNumber(batch.tickets.get(batch.tickets.size() - 1)); + if (lastNumber == null) { + return Collections.emptyList(); + } + Matcher matcher = NUMBER_SUFFIX.matcher(lastNumber); + if (!matcher.matches()) { + return Collections.emptyList(); + } + long current; + try { + current = Long.parseLong(matcher.group(2)); + } catch (NumberFormatException error) { + return Collections.emptyList(); + } + String prefix = matcher.group(1); + int width = matcher.group(2).length(); + long rowCount = ceilDivide(waitingTicketCount, FORECAST_GROUP_SIZE); + if (rowCount > Integer.MAX_VALUE) { + return Collections.emptyList(); + } + List rows = new ArrayList<>((int) rowCount); + for (long row = 0; row < rowCount; row += 1) { + long startOffset = row * FORECAST_GROUP_SIZE + 1; + long endOffset = Math.min(waitingTicketCount, (row + 1) * FORECAST_GROUP_SIZE); + if (current > Long.MAX_VALUE - endOffset) { + return Collections.emptyList(); + } + String start = ticketNumber(prefix, current + startOffset, width); + String end = ticketNumber(prefix, current + endOffset, width); + String range = startOffset == endOffset ? start : start + " 至 " + end; + long min = proportionalMinutes(eta.minMinutes, endOffset, waitingTicketCount); + long max = proportionalMinutes(eta.maxMinutes, endOffset, waitingTicketCount); + String wait = min == max ? "约 " + max + " 分钟" : min + " 至 " + max + " 分钟"; + rows.add(new DisplayModels.ForecastRow(range, wait)); + } + return Collections.unmodifiableList(rows); + } + + static int pageCount(List rows) { + if (rows == null || rows.isEmpty()) { + return 1; + } + return (rows.size() + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; + } + + static List page(List rows, int pageIndex) { + if (rows == null || rows.isEmpty() || pageIndex < 0) { + return Collections.emptyList(); + } + long fromLong = (long) pageIndex * ROWS_PER_PAGE; + if (fromLong >= rows.size()) { + return Collections.emptyList(); + } + int from = (int) fromLong; + int to = Math.min(rows.size(), from + ROWS_PER_PAGE); + return Collections.unmodifiableList(new ArrayList<>(rows.subList(from, to))); + } + + private static String usableTicketNumber(DisplayModels.Ticket ticket) { + if (ticket == null) { + return null; + } + if (ticket.displayNumber != null && !ticket.displayNumber.trim().isEmpty()) { + return ticket.displayNumber.trim(); + } + if (ticket.ticketNumber != null && !ticket.ticketNumber.trim().isEmpty()) { + return ticket.ticketNumber.trim(); + } + return null; + } + + private static long proportionalMinutes(long totalMinutes, long offset, long waitingCount) { + return Math.round((double) totalMinutes * (double) offset / (double) waitingCount); + } + + private static long ceilDivide(long value, long divisor) { + return 1 + (value - 1) / divisor; + } + + private static String ticketNumber(String prefix, long value, int width) { + return prefix + String.format(Locale.ROOT, "%0" + width + "d", value); + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/DisplayJsonParser.java b/android/app/src/main/java/cn/nianxx/queue/display/DisplayJsonParser.java new file mode 100644 index 0000000..67c35e3 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/DisplayJsonParser.java @@ -0,0 +1,202 @@ +package cn.nianxx.queue.display; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +final class DisplayJsonParser { + private DisplayJsonParser() {} + + static DisplayModels.Overview parseOverview(String json) throws ParseException { + try { + JSONObject root = objectFrom(json); + JSONArray projectsJson = array(root, "projects"); + List projects = new ArrayList<>(); + for (int i = 0; i < projectsJson.length(); i += 1) { + projects.add(project(objectAt(projectsJson, i))); + } + return new DisplayModels.Overview(projects, string(root, "server_time")); + } catch (JSONException | IllegalArgumentException error) { + throw new ParseException("Invalid display overview", error); + } + } + + static DisplayModels.Snapshot parseSnapshot(String json) throws ParseException { + try { + JSONObject root = objectFrom(json); + JSONObject projectJson = object(root, "project"); + DisplayModels.Project project = new DisplayModels.Project( + string(projectJson, "id"), string(projectJson, "name"), + string(projectJson, "status")); + return new DisplayModels.Snapshot( + string(root, "project_name"), string(root, "status"), project, + nonNegativeLong(root, "revision"), nonNegativeLong(root, "waiting_count"), + nonNegativeLong(root, "waiting_ticket_count"), + nonNegativeLong(root, "waiting_people_count"), + nonNegativeLong(root, "issued_ticket_count"), + nullableString(root, "latest_ticket_number"), + nonNegativeLong(root, "experienced_people"), nullableBatch(root, "current_batch"), + batches(array(root, "recent_batches")), eta(object(root, "estimated_wait")), + string(root, "server_time"), string(root, "last_updated_at")); + } catch (JSONException | IllegalArgumentException error) { + throw new ParseException("Invalid display snapshot", error); + } + } + + private static DisplayModels.ProjectSummary project(JSONObject value) throws JSONException { + return new DisplayModels.ProjectSummary( + string(value, "id"), string(value, "code"), string(value, "name"), + string(value, "status"), nonNegativeLong(value, "waiting_count"), + nonNegativeLong(value, "waiting_ticket_count"), + nonNegativeLong(value, "waiting_people_count"), + nonNegativeLong(value, "issued_ticket_count"), + nullableString(value, "latest_ticket_number"), + nonNegativeLong(value, "experienced_people"), nullableBatch(value, "current_batch"), + eta(object(value, "estimated_wait")), string(value, "last_updated_at")); + } + + private static DisplayModels.Batch nullableBatch(JSONObject owner, String key) + throws JSONException { + requirePresent(owner, key); + if (owner.isNull(key)) { + return null; + } + return batch(object(owner, key)); + } + + private static List batches(JSONArray values) throws JSONException { + List result = new ArrayList<>(); + for (int i = 0; i < values.length(); i += 1) { + result.add(batch(objectAt(values, i))); + } + return result; + } + + private static DisplayModels.Batch batch(JSONObject value) throws JSONException { + JSONArray ticketsJson = array(value, "tickets"); + List tickets = new ArrayList<>(); + for (int i = 0; i < ticketsJson.length(); i += 1) { + JSONObject ticket = objectAt(ticketsJson, i); + tickets.add(new DisplayModels.Ticket( + string(ticket, "ticket_number"), string(ticket, "display_number"), + string(ticket, "status"), nonNegativeLong(ticket, "party_size"))); + } + return new DisplayModels.Batch( + nonNegativeLong(value, "batch_number"), nonNegativeLong(value, "sequence"), + string(value, "status"), string(value, "call_mode"), + nonNegativeLong(value, "requested_count"), nonNegativeLong(value, "ticket_count"), + nonNegativeLong(value, "people_count"), string(value, "called_at"), tickets); + } + + private static DisplayModels.Eta eta(JSONObject value) throws JSONException { + boolean available = bool(value, "available"); + long estimate = nonNegativeLong(value, "estimate_minutes"); + long min = nonNegativeLong(value, "min_minutes"); + long max = nonNegativeLong(value, "max_minutes"); + if (min > max) { + throw new JSONException("min_minutes exceeds max_minutes"); + } + return new DisplayModels.Eta(available, estimate, min, max, + optionalNullableString(value, "reason")); + } + + private static JSONObject objectFrom(String json) throws JSONException { + if (json == null) { + throw new JSONException("body is null"); + } + return new JSONObject(json); + } + + private static JSONObject object(JSONObject owner, String key) throws JSONException { + Object value = required(owner, key); + if (!(value instanceof JSONObject)) { + throw new JSONException(key + " must be an object"); + } + return (JSONObject) value; + } + + private static JSONObject objectAt(JSONArray array, int index) throws JSONException { + Object value = array.get(index); + if (!(value instanceof JSONObject)) { + throw new JSONException("array item " + index + " must be an object"); + } + return (JSONObject) value; + } + + private static JSONArray array(JSONObject owner, String key) throws JSONException { + Object value = required(owner, key); + if (!(value instanceof JSONArray)) { + throw new JSONException(key + " must be an array"); + } + return (JSONArray) value; + } + + private static String string(JSONObject owner, String key) throws JSONException { + Object value = required(owner, key); + if (!(value instanceof String) || ((String) value).trim().isEmpty()) { + throw new JSONException(key + " must be a non-empty string"); + } + return (String) value; + } + + private static String nullableString(JSONObject owner, String key) throws JSONException { + requirePresent(owner, key); + if (owner.isNull(key)) { + return null; + } + return string(owner, key); + } + + private static String optionalNullableString(JSONObject owner, String key) throws JSONException { + if (!owner.has(key) || owner.isNull(key)) { + return null; + } + return string(owner, key); + } + + private static long nonNegativeLong(JSONObject owner, String key) throws JSONException { + Object value = required(owner, key); + if (!(value instanceof Number)) { + throw new JSONException(key + " must be a number"); + } + Number number = (Number) value; + double decimal = number.doubleValue(); + long integer = number.longValue(); + if (!Double.isFinite(decimal) || decimal != integer || integer < 0) { + throw new JSONException(key + " must be a non-negative integer"); + } + return integer; + } + + private static boolean bool(JSONObject owner, String key) throws JSONException { + Object value = required(owner, key); + if (!(value instanceof Boolean)) { + throw new JSONException(key + " must be a boolean"); + } + return (Boolean) value; + } + + private static Object required(JSONObject owner, String key) throws JSONException { + requirePresent(owner, key); + Object value = owner.get(key); + if (value == JSONObject.NULL) { + throw new JSONException(key + " must not be null"); + } + return value; + } + + private static void requirePresent(JSONObject owner, String key) throws JSONException { + if (!owner.has(key)) { + throw new JSONException("missing required field " + key); + } + } + + static final class ParseException extends Exception { + ParseException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/DisplayModels.java b/android/app/src/main/java/cn/nianxx/queue/display/DisplayModels.java new file mode 100644 index 0000000..9d52c36 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/DisplayModels.java @@ -0,0 +1,181 @@ +package cn.nianxx.queue.display; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class DisplayModels { + private DisplayModels() {} + + static final class Overview { + final List projects; + final String serverTime; + + Overview(List projects, String serverTime) { + this.projects = immutableCopy(projects); + this.serverTime = serverTime; + } + } + + static final class ProjectSummary { + final String id; + final String code; + final String name; + final String status; + final long waitingCount; + final long waitingTicketCount; + final long waitingPeopleCount; + final long issuedTicketCount; + final String latestTicketNumber; + final long experiencedPeople; + final Batch currentBatch; + final Eta estimatedWait; + final String lastUpdatedAt; + + ProjectSummary(String id, String code, String name, String status, long waitingCount, + long waitingTicketCount, long waitingPeopleCount, long issuedTicketCount, + String latestTicketNumber, long experiencedPeople, Batch currentBatch, + Eta estimatedWait, String lastUpdatedAt) { + this.id = id; + this.code = code; + this.name = name; + this.status = status; + this.waitingCount = waitingCount; + this.waitingTicketCount = waitingTicketCount; + this.waitingPeopleCount = waitingPeopleCount; + this.issuedTicketCount = issuedTicketCount; + this.latestTicketNumber = latestTicketNumber; + this.experiencedPeople = experiencedPeople; + this.currentBatch = currentBatch; + this.estimatedWait = estimatedWait; + this.lastUpdatedAt = lastUpdatedAt; + } + } + + static final class Snapshot { + final String projectName; + final String status; + final Project project; + final long revision; + final long waitingCount; + final long waitingTicketCount; + final long waitingPeopleCount; + final long issuedTicketCount; + final String latestTicketNumber; + final long experiencedPeople; + final Batch currentBatch; + final List recentBatches; + final Eta estimatedWait; + final String serverTime; + final String lastUpdatedAt; + + Snapshot(String projectName, String status, Project project, long revision, + long waitingCount, long waitingTicketCount, long waitingPeopleCount, + long issuedTicketCount, String latestTicketNumber, long experiencedPeople, + Batch currentBatch, List recentBatches, Eta estimatedWait, + String serverTime, String lastUpdatedAt) { + this.projectName = projectName; + this.status = status; + this.project = project; + this.revision = revision; + this.waitingCount = waitingCount; + this.waitingTicketCount = waitingTicketCount; + this.waitingPeopleCount = waitingPeopleCount; + this.issuedTicketCount = issuedTicketCount; + this.latestTicketNumber = latestTicketNumber; + this.experiencedPeople = experiencedPeople; + this.currentBatch = currentBatch; + this.recentBatches = immutableCopy(recentBatches); + this.estimatedWait = estimatedWait; + this.serverTime = serverTime; + this.lastUpdatedAt = lastUpdatedAt; + } + } + + static final class Project { + final String id; + final String name; + final String status; + + Project(String id, String name, String status) { + this.id = id; + this.name = name; + this.status = status; + } + } + + static final class Batch { + final long batchNumber; + final long sequence; + final String status; + final String callMode; + final long requestedCount; + final long ticketCount; + final long peopleCount; + final String calledAt; + final List tickets; + + Batch(long batchNumber, long sequence, String status, String callMode, + long requestedCount, long ticketCount, long peopleCount, String calledAt, + List tickets) { + this.batchNumber = batchNumber; + this.sequence = sequence; + this.status = status; + this.callMode = callMode; + this.requestedCount = requestedCount; + this.ticketCount = ticketCount; + this.peopleCount = peopleCount; + this.calledAt = calledAt; + this.tickets = immutableCopy(tickets); + } + + String callKey() { + return batchNumber + ":" + calledAt; + } + } + + static final class Ticket { + final String ticketNumber; + final String displayNumber; + final String status; + final long partySize; + + Ticket(String ticketNumber, String displayNumber, String status, long partySize) { + this.ticketNumber = ticketNumber; + this.displayNumber = displayNumber; + this.status = status; + this.partySize = partySize; + } + } + + static final class Eta { + final boolean available; + final long estimateMinutes; + final long minMinutes; + final long maxMinutes; + final String reason; + + Eta(boolean available, long estimateMinutes, long minMinutes, long maxMinutes, + String reason) { + this.available = available; + this.estimateMinutes = estimateMinutes; + this.minMinutes = minMinutes; + this.maxMinutes = maxMinutes; + this.reason = reason; + } + } + + static final class ForecastRow { + final String range; + final String wait; + + ForecastRow(String range, String wait) { + this.range = range; + this.wait = wait; + } + } + + private static List immutableCopy(List values) { + return Collections.unmodifiableList(new ArrayList<>(values)); + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/EmbeddedWavComposer.java b/android/app/src/main/java/cn/nianxx/queue/display/EmbeddedWavComposer.java new file mode 100644 index 0000000..36b637c --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/EmbeddedWavComposer.java @@ -0,0 +1,390 @@ +package cn.nianxx.queue.display; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.ArrayDeque; +import java.util.Deque; + +final class EmbeddedWavComposer { + static final int REPEAT_COUNT = 3; + static final int REPEAT_GAP_MILLIS = 350; + + private EmbeddedWavComposer() { + } + + interface StreamSink { + void play(); + int write(byte[] data, int offset, int length); + void awaitPlayback(long frames) throws InterruptedException; + void stop(); + void release(); + } + + interface StreamSinkFactory { + StreamSink create(Runnable cancelCurrent); + } + + static final class StreamPlaybackQueue { + private static final int CHUNK_BYTES = 16 * 1024; + + private final Object lock = new Object(); + private final Deque pending = new ArrayDeque<>(); + private final StreamSinkFactory factory; + private final Runnable failureCallback; + private final byte[] batchGap; + private final Thread worker; + private StreamSink active; + private StreamSink stopping; + private boolean shutdown; + private boolean failureReported; + private long generation; + + StreamPlaybackQueue(StreamSinkFactory factory, Runnable failureCallback, + int sampleRate, String threadName) { + this.factory = factory; + this.failureCallback = failureCallback; + batchGap = silence(sampleRate, REPEAT_GAP_MILLIS); + worker = new Thread(this::run, threadName); + worker.start(); + } + + void enqueue(byte[] pcm) { + synchronized (lock) { + if (!shutdown) { + pending.addLast(pcm); + lock.notifyAll(); + } + } + } + + void stop() { + cancel(false); + } + + void shutdown() { + cancel(true); + worker.interrupt(); + } + + private void cancel(boolean permanently) { + StreamSink sink; + synchronized (lock) { + if (shutdown) { + return; + } + shutdown = permanently; + generation += 1; + pending.clear(); + sink = active; + if (sink != null) { + stopping = sink; + } + lock.notifyAll(); + } + if (sink != null) { + try { + sink.stop(); + } catch (RuntimeException error) { + reportFailureOnce(); + } finally { + synchronized (lock) { + if (stopping == sink) { + stopping = null; + lock.notifyAll(); + } + } + } + } + } + + private void run() { + while (true) { + byte[] pcm; + long itemGeneration; + synchronized (lock) { + while (!shutdown && pending.isEmpty()) { + try { + lock.wait(); + } catch (InterruptedException ignored) { + if (shutdown) { + return; + } + } + } + if (shutdown) { + return; + } + pcm = pending.removeFirst(); + itemGeneration = generation; + } + playOne(pcm, itemGeneration); + } + } + + private void playOne(byte[] pcm, long itemGeneration) { + StreamSink sink = null; + try { + sink = factory.create(() -> cancel(false)); + synchronized (lock) { + if (shutdown || itemGeneration != generation) { + return; + } + active = sink; + sink.play(); + } + long frames = writeAll(sink, pcm, itemGeneration) / 2L; + frames += writeAll(sink, batchGap, itemGeneration) / 2L; + if (isCurrent(itemGeneration)) { + sink.awaitPlayback(frames); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (RuntimeException error) { + if (isCurrent(itemGeneration)) { + reportFailureOnce(); + } + } finally { + boolean interrupted = false; + synchronized (lock) { + while (stopping == sink) { + try { + lock.wait(); + } catch (InterruptedException ignored) { + interrupted = true; + } + } + if (active == sink) { + active = null; + } + } + if (sink != null) { + try { + sink.release(); + } catch (RuntimeException error) { + if (isCurrent(itemGeneration)) { + reportFailureOnce(); + } + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + private int writeAll(StreamSink sink, byte[] pcm, long itemGeneration) { + int offset = 0; + while (offset < pcm.length && isCurrent(itemGeneration)) { + int length = Math.min(CHUNK_BYTES, pcm.length - offset); + int written = sink.write(pcm, offset, length); + if (written <= 0 || written > length || (written & 1) != 0) { + throw new IllegalStateException("Invalid PCM write result " + written); + } + offset += written; + } + return offset; + } + + private boolean isCurrent(long itemGeneration) { + synchronized (lock) { + return !shutdown && itemGeneration == generation; + } + } + + private void reportFailureOnce() { + synchronized (lock) { + if (shutdown || failureReported) { + return; + } + failureReported = true; + } + failureCallback.run(); + } + } + + static WavClip parseWav(String name, byte[] wav) throws IOException { + if (wav == null || wav.length < 12 + || !asciiEquals(wav, 0, "RIFF") + || !asciiEquals(wav, 8, "WAVE")) { + throw new IOException(name + " is not a RIFF/WAVE file"); + } + long riffSize = unsignedLittleEndianInt(wav, 4); + if (riffSize != wav.length - 8L) { + throw new IOException(name + " has an inconsistent RIFF size"); + } + + Integer sampleRate = null; + byte[] pcm = null; + boolean sawFormat = false; + boolean sawData = false; + int offset = 12; + while (offset < wav.length) { + if (wav.length - offset < 8) { + throw new IOException(name + " has a truncated WAV chunk header"); + } + long chunkSizeLong = unsignedLittleEndianInt(wav, offset + 4); + if (chunkSizeLong > Integer.MAX_VALUE) { + throw new IOException(name + " has an oversized WAV chunk"); + } + int chunkSize = (int) chunkSizeLong; + int dataOffset = offset + 8; + long paddedEnd = (long) dataOffset + chunkSize + (chunkSize & 1); + if (paddedEnd > wav.length) { + throw new IOException(name + " has a truncated WAV chunk"); + } + if (asciiEquals(wav, offset, "fmt ")) { + if (sawFormat) { + throw new IOException(name + " has duplicate fmt chunks"); + } + sawFormat = true; + if (chunkSize < 16) { + throw new IOException(name + " has a truncated fmt chunk"); + } + int audioFormat = littleEndianShort(wav, dataOffset); + int channels = littleEndianShort(wav, dataOffset + 2); + int rate = littleEndianInt(wav, dataOffset + 4); + long byteRate = unsignedLittleEndianInt(wav, dataOffset + 8); + int blockAlign = littleEndianShort(wav, dataOffset + 12); + int bitsPerSample = littleEndianShort(wav, dataOffset + 14); + if (audioFormat != 1 || channels != 1 || bitsPerSample != 16) { + throw new IOException(name + " must be PCM16 mono WAV"); + } + if (rate <= 0 || blockAlign != 2 || byteRate != (long) rate * blockAlign) { + throw new IOException(name + " has an inconsistent PCM format"); + } + sampleRate = rate; + } else if (asciiEquals(wav, offset, "data")) { + if (sawData) { + throw new IOException(name + " has duplicate data chunks"); + } + sawData = true; + if ((chunkSize & 1) != 0) { + throw new IOException(name + " PCM data is not frame-aligned"); + } + pcm = Arrays.copyOfRange(wav, dataOffset, dataOffset + chunkSize); + } + offset = (int) paddedEnd; + } + if (sampleRate == null || pcm == null || pcm.length == 0) { + throw new IOException(name + " has incomplete PCM audio data"); + } + return new WavClip(sampleRate, pcm); + } + + static byte[] silence(int sampleRate, int millis) { + int frames = (int) (((long) sampleRate * millis + 500L) / 1000L); + return new byte[Math.multiplyExact(frames, 2)]; + } + + static final class ClipSet { + private final int sampleRate; + private final byte[][] digits; + private final byte[] prompt; + private final byte[] range; + private final byte[] suffix; + + ClipSet(WavClip prompt, WavClip[] digits, WavClip range, WavClip suffix) { + if (prompt == null || digits == null || digits.length != 10 || range == null || suffix == null) { + throw new IllegalArgumentException("All announcement clips are required"); + } + sampleRate = prompt.sampleRate; + this.prompt = prompt.pcm; + this.range = sameFormat(prompt, range, "voice_range"); + this.suffix = sameFormat(prompt, suffix, "voice_suffix"); + this.digits = new byte[10][]; + for (int digit = 0; digit < this.digits.length; digit += 1) { + this.digits[digit] = sameFormat(prompt, digits[digit], "voice_digit_" + digit); + } + } + + int sampleRate() { + return sampleRate; + } + + byte[] compose(QueueAnnouncement announcement) { + if (announcement == null) { + throw new IllegalArgumentException("announcement is required"); + } + ByteArrayOutputStream onePass = new ByteArrayOutputStream(); + append(onePass, prompt); + appendNumber(onePass, announcement.firstNumber()); + if (announcement.isRange()) { + append(onePass, range); + appendNumber(onePass, announcement.lastNumber()); + } + append(onePass, suffix); + + byte[] pass = onePass.toByteArray(); + byte[] gap = silence(sampleRate, REPEAT_GAP_MILLIS); + int totalLength = Math.addExact( + Math.multiplyExact(pass.length, REPEAT_COUNT), + Math.multiplyExact(gap.length, REPEAT_COUNT - 1)); + ByteArrayOutputStream result = new ByteArrayOutputStream(totalLength); + for (int repeat = 0; repeat < REPEAT_COUNT; repeat += 1) { + append(result, pass); + if (repeat + 1 < REPEAT_COUNT) { + append(result, gap); + } + } + return result.toByteArray(); + } + + private void appendNumber(ByteArrayOutputStream output, String number) { + for (int index = 0; index < number.length(); index += 1) { + append(output, digits[number.charAt(index) - '0']); + } + } + + private static byte[] sameFormat(WavClip expected, WavClip actual, String name) { + if (actual == null || expected.sampleRate != actual.sampleRate) { + throw new IllegalArgumentException(name + " format does not match voice_prompt"); + } + return actual.pcm; + } + } + + static final class WavClip { + final int sampleRate; + final byte[] pcm; + + WavClip(int sampleRate, byte[] pcm) { + if (sampleRate <= 0 || pcm == null || pcm.length == 0 || (pcm.length & 1) != 0) { + throw new IllegalArgumentException("PCM16 mono clip is invalid"); + } + this.sampleRate = sampleRate; + this.pcm = pcm.clone(); + } + } + + private static void append(ByteArrayOutputStream output, byte[] bytes) { + output.write(bytes, 0, bytes.length); + } + + private static boolean asciiEquals(byte[] data, int offset, String value) { + if (offset < 0 || offset + value.length() > data.length) { + return false; + } + for (int index = 0; index < value.length(); index += 1) { + if (data[offset + index] != (byte) value.charAt(index)) { + return false; + } + } + return true; + } + + private static int littleEndianShort(byte[] data, int offset) { + return (data[offset] & 0xff) | ((data[offset + 1] & 0xff) << 8); + } + + private static int littleEndianInt(byte[] data, int offset) { + return (int) unsignedLittleEndianInt(data, offset); + } + + private static long unsignedLittleEndianInt(byte[] data, int offset) { + return (data[offset] & 0xffL) + | ((data[offset + 1] & 0xffL) << 8) + | ((data[offset + 2] & 0xffL) << 16) + | ((data[offset + 3] & 0xffL) << 24); + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java b/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java index 6ca6318..38e4af2 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java @@ -3,97 +3,98 @@ package cn.nianxx.queue.display; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; -import android.content.pm.ApplicationInfo; import android.graphics.Color; -import android.net.http.SslError; import android.os.Build; import android.os.Bundle; -import android.view.Gravity; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.view.WindowInsetsController; import android.view.WindowManager; -import android.webkit.CookieManager; -import android.webkit.PermissionRequest; -import android.webkit.SslErrorHandler; -import android.webkit.WebChromeClient; -import android.webkit.WebResourceError; -import android.webkit.WebResourceRequest; -import android.webkit.WebResourceResponse; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.Button; import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.TextView; import android.widget.Toast; public final class MainActivity extends Activity { + private static final String TAG = "XQKQueueDisplay"; + private static final String API_BASE_URL = "https://queue.nianxx.cn"; + private static final long OVERVIEW_POLL_INTERVAL_MILLIS = 5_000L; + private static final long PROJECT_POLL_INTERVAL_MILLIS = 3_000L; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); private FrameLayout root; - private WebView webView; + private DisplayApiClient apiClient; private QueueAnnouncementPlayer announcementPlayer; - private DisplayAnnouncementPoller announcementPoller; - private ProgressBar progressBar; - private LinearLayout errorView; - private TextView errorDetail; - private View customView; - private WebChromeClient.CustomViewCallback customViewCallback; - private String lastAllowedUrl = NavigationPolicy.START_URL; - private boolean mainFrameFailed; + private AnnouncementCoordinator announcementCoordinator; + private PollingLoop pollingLoop; + private NavigationPolicy.ScreenTarget currentTarget; + private OverviewView overviewView; + private ProjectDisplayView projectDisplayView; + private boolean resumed; + private boolean hasCurrentData; + private long lastSuccessEpochMillis; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); configureWindow(); - buildContentView(); + + root = new FrameLayout(this); + root.setBackgroundColor(Color.rgb(236, 245, 239)); + setContentView(root); + + apiClient = new DisplayApiClient( + API_BASE_URL, + "XQKQueueDisplay/" + BuildConfig.VERSION_NAME); announcementPlayer = new QueueAnnouncementPlayer( this, - () -> runOnUiThread(() -> Toast.makeText( + () -> Toast.makeText( this, R.string.speech_unavailable, - Toast.LENGTH_LONG).show())); - announcementPoller = new DisplayAnnouncementPoller(announcementPlayer::announce); - setContentView(root); + Toast.LENGTH_LONG).show()); + enterImmersiveMode(); registerPredictiveBackCallback(); - - String initialUrl = NavigationPolicy.initialUrlOrStart(getIntent().getDataString()); - lastAllowedUrl = initialUrl; - updateAnnouncementPolling(); - webView.loadUrl(initialUrl); + navigateTo(NavigationPolicy.initialTarget(getIntent().getDataString())); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); - String target = NavigationPolicy.initialUrlOrStart(intent.getDataString()); - lastAllowedUrl = target; - updateAnnouncementPolling(); - exitWebFullscreen(); - if (!target.equals(webView.getUrl())) { - webView.loadUrl(target); - } + navigateTo(NavigationPolicy.initialTarget(intent.getDataString())); } @Override protected void onResume() { super.onResume(); - webView.onResume(); + resumed = true; enterImmersiveMode(); - updateAnnouncementPolling(); + startCurrentPolling(); } @Override protected void onPause() { - announcementPoller.stop(); - webView.onPause(); + resumed = false; + stopCurrentPolling(); + if (announcementCoordinator != null) { + announcementCoordinator.reset(); + } + announcementPlayer.stop(); super.onPause(); } + @Override + protected void onDestroy() { + closeCurrentPolling(); + announcementPlayer.shutdown(); + mainHandler.removeCallbacksAndMessages(null); + root.removeAllViews(); + super.onDestroy(); + } + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); @@ -110,18 +111,8 @@ public final class MainActivity extends Activity { } private void handleBackNavigation() { - if (customView != null) { - exitWebFullscreen(); - return; - } - if (webView.canGoBack()) { - webView.goBack(); - return; - } - if (NavigationPolicy.isProjectDisplay(lastAllowedUrl)) { - lastAllowedUrl = NavigationPolicy.START_URL; - updateAnnouncementPolling(); - webView.loadUrl(NavigationPolicy.START_URL); + if (currentTarget != null && currentTarget.isProject()) { + navigateTo(NavigationPolicy.overview()); return; } finishAfterTransition(); @@ -135,24 +126,178 @@ public final class MainActivity extends Activity { } } - @Override - protected void onDestroy() { - announcementPoller.close(); - announcementPlayer.shutdown(); - if (customView != null) { - root.removeView(customView); - customView = null; - if (customViewCallback != null) { - customViewCallback.onCustomViewHidden(); - customViewCallback = null; - } + private void navigateTo(NavigationPolicy.ScreenTarget target) { + NavigationPolicy.ScreenTarget destination = target == null + ? NavigationPolicy.overview() + : target; + closeCurrentPolling(); + announcementPlayer.stop(); + announcementCoordinator = null; + currentTarget = destination; + hasCurrentData = false; + lastSuccessEpochMillis = 0L; + overviewView = null; + projectDisplayView = null; + root.removeAllViews(); + + if (destination.isProject()) { + showProjectScreen(destination.identifier()); + } else { + showOverviewScreen(); } - root.removeView(webView); - webView.stopLoading(); - webView.setWebChromeClient(null); - webView.setWebViewClient(null); - webView.destroy(); - super.onDestroy(); + if (resumed) { + startCurrentPolling(); + } + Log.i(TAG, "Native screen opened: " + destination); + } + + private void showOverviewScreen() { + overviewView = new OverviewView(this, new OverviewView.Listener() { + @Override + public void onProjectSelected(String code) { + NavigationPolicy.ScreenTarget target = NavigationPolicy.project(code); + if (target == null) { + Toast.makeText( + MainActivity.this, + R.string.page_unavailable_detail, + Toast.LENGTH_SHORT).show(); + return; + } + navigateTo(target); + } + + @Override + public void onRetry() { + restartCurrentPolling(); + } + }); + root.addView(overviewView, matchParent()); + overviewView.showLoading(); + + PollingLoop> loop = new PollingLoop<>( + OVERVIEW_POLL_INTERVAL_MILLIS, + apiClient::getOverview, + this::dispatchToMain, + this::handleOverviewResult); + pollingLoop = loop; + } + + private void showProjectScreen(String identifier) { + announcementCoordinator = new AnnouncementCoordinator(text -> { + if (!resumed || currentTarget == null || !currentTarget.isProject()) { + return; + } + Log.i(TAG, "New call detected; queueing embedded audio announcement"); + announcementPlayer.announce(text); + }); + projectDisplayView = new ProjectDisplayView(this, this::restartCurrentPolling); + root.addView(projectDisplayView, matchParent()); + projectDisplayView.showLoading(); + + PollingLoop> loop = new PollingLoop<>( + PROJECT_POLL_INTERVAL_MILLIS, + () -> apiClient.getSnapshot(identifier), + this::dispatchToMain, + this::handleSnapshotResult); + pollingLoop = loop; + } + + private void handleOverviewResult(DisplayApiClient.Result result) { + OverviewView view = overviewView; + if (view == null || currentTarget == null || currentTarget.isProject()) { + return; + } + if (result.isSuccess()) { + hasCurrentData = true; + lastSuccessEpochMillis = System.currentTimeMillis(); + if (result.value.projects.isEmpty()) { + view.showEmpty(); + } else { + view.showProjects(result.value); + } + view.hideStale(); + return; + } + logPollingFailure("overview", result); + if (hasCurrentData) { + view.showStale( + result.kind == DisplayApiClient.Result.Kind.NETWORK_ERROR, + lastSuccessEpochMillis); + } else { + view.showFatalError(getString(R.string.page_unavailable_detail)); + } + } + + private void handleSnapshotResult(DisplayApiClient.Result result) { + ProjectDisplayView view = projectDisplayView; + if (view == null || currentTarget == null || !currentTarget.isProject()) { + return; + } + if (result.isSuccess()) { + hasCurrentData = true; + lastSuccessEpochMillis = System.currentTimeMillis(); + view.showData(result.value); + view.hideStale(); + handleAnnouncement(result.value); + return; + } + logPollingFailure("project", result); + if (hasCurrentData) { + view.showStale( + result.kind == DisplayApiClient.Result.Kind.NETWORK_ERROR, + lastSuccessEpochMillis); + } else if (result.httpStatus == 404) { + view.showNotFound(); + } else { + view.showFatalError(getString(R.string.page_unavailable_detail)); + } + } + + private void handleAnnouncement(DisplayModels.Snapshot snapshot) { + if (announcementCoordinator != null) { + announcementCoordinator.accept(snapshot.currentBatch); + } + } + + private void restartCurrentPolling() { + if (!resumed || pollingLoop == null) { + return; + } + pollingLoop.stop(); + pollingLoop.start(); + } + + private void startCurrentPolling() { + if (pollingLoop != null) { + pollingLoop.start(); + } + } + + private void stopCurrentPolling() { + if (pollingLoop != null) { + pollingLoop.stop(); + } + } + + private void closeCurrentPolling() { + if (pollingLoop != null) { + pollingLoop.close(); + pollingLoop = null; + } + } + + private void dispatchToMain(Runnable action) { + mainHandler.post(action); + } + + private static void logPollingFailure( + String screen, + DisplayApiClient.Result result) { + String detail = result.cause == null + ? "" + : ", " + result.cause.getClass().getSimpleName(); + Log.w(TAG, screen + " polling failed (" + result.kind + ", HTTP " + + result.httpStatus + detail + ")"); } private void configureWindow() { @@ -181,255 +326,9 @@ public final class MainActivity extends Activity { | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } - private void buildContentView() { - root = new FrameLayout(this); - root.setBackgroundColor(Color.BLACK); - - webView = createWebView(); - root.addView(webView, matchParent()); - - progressBar = new ProgressBar(this); - FrameLayout.LayoutParams progressLayout = new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.CENTER); - root.addView(progressBar, progressLayout); - - errorView = createErrorView(); - errorView.setVisibility(View.GONE); - root.addView(errorView, matchParent()); - } - - @SuppressLint("SetJavaScriptEnabled") - @SuppressWarnings("deprecation") - private WebView createWebView() { - WebView view = new WebView(this); - view.setBackgroundColor(Color.BLACK); - view.setOverScrollMode(View.OVER_SCROLL_NEVER); - view.setLongClickable(false); - view.setOnLongClickListener(ignored -> true); - - boolean debuggable = (getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; - WebView.setWebContentsDebuggingEnabled(debuggable); - - WebSettings settings = view.getSettings(); - settings.setJavaScriptEnabled(true); - settings.setDomStorageEnabled(true); - settings.setMediaPlaybackRequiresUserGesture(false); - settings.setJavaScriptCanOpenWindowsAutomatically(false); - settings.setSupportMultipleWindows(false); - settings.setAllowFileAccess(false); - settings.setAllowContentAccess(false); - settings.setGeolocationEnabled(false); - settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); - settings.setCacheMode(WebSettings.LOAD_DEFAULT); - settings.setUserAgentString(settings.getUserAgentString() + " XQKQueueDisplay/1.2.0"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - settings.setSafeBrowsingEnabled(true); - } - - CookieManager cookieManager = CookieManager.getInstance(); - cookieManager.setAcceptCookie(true); - cookieManager.setAcceptThirdPartyCookies(view, false); - - view.setWebViewClient(new DisplayWebViewClient()); - view.setWebChromeClient(new DisplayWebChromeClient()); - return view; - } - - private LinearLayout createErrorView() { - LinearLayout panel = new LinearLayout(this); - panel.setOrientation(LinearLayout.VERTICAL); - panel.setGravity(Gravity.CENTER); - panel.setPadding(dp(32), dp(32), dp(32), dp(32)); - panel.setBackgroundColor(Color.rgb(245, 248, 246)); - - TextView title = new TextView(this); - title.setText(R.string.page_unavailable_title); - title.setTextColor(Color.rgb(24, 54, 44)); - title.setTextSize(24); - title.setGravity(Gravity.CENTER); - title.setTypeface(title.getTypeface(), android.graphics.Typeface.BOLD); - panel.addView(title, wrapContentWithBottomMargin(dp(12))); - - errorDetail = new TextView(this); - errorDetail.setText(R.string.page_unavailable_detail); - errorDetail.setTextColor(Color.rgb(67, 88, 81)); - errorDetail.setTextSize(16); - errorDetail.setGravity(Gravity.CENTER); - panel.addView(errorDetail, wrapContentWithBottomMargin(dp(24))); - - Button retry = new Button(this); - retry.setText(R.string.retry); - retry.setTextColor(Color.WHITE); - retry.setBackgroundColor(Color.rgb(15, 107, 77)); - retry.setOnClickListener(ignored -> { - showLoading(); - webView.loadUrl(NavigationPolicy.initialUrlOrStart(lastAllowedUrl)); - }); - panel.addView(retry, new LinearLayout.LayoutParams(dp(160), dp(52))); - return panel; - } - - private void showLoading() { - mainFrameFailed = false; - errorView.setVisibility(View.GONE); - progressBar.setVisibility(View.VISIBLE); - } - - private void showError(int messageResource) { - mainFrameFailed = true; - progressBar.setVisibility(View.GONE); - errorDetail.setText(messageResource); - errorView.setVisibility(View.VISIBLE); - } - - private void exitWebFullscreen() { - if (customView == null) { - return; - } - root.removeView(customView); - customView = null; - webView.setVisibility(View.VISIBLE); - if (customViewCallback != null) { - WebChromeClient.CustomViewCallback callback = customViewCallback; - customViewCallback = null; - callback.onCustomViewHidden(); - } - enterImmersiveMode(); - } - - private FrameLayout.LayoutParams matchParent() { + private static FrameLayout.LayoutParams matchParent() { return new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); } - - private LinearLayout.LayoutParams wrapContentWithBottomMargin(int bottomMargin) { - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT); - params.bottomMargin = bottomMargin; - return params; - } - - private int dp(int value) { - return Math.round(value * getResources().getDisplayMetrics().density); - } - - private void updateAnnouncementPolling() { - String snapshotUrl = NavigationPolicy.snapshotUrl(lastAllowedUrl); - if (snapshotUrl == null) { - announcementPoller.stop(); - } else { - announcementPoller.start(snapshotUrl); - } - } - - private final class DisplayWebViewClient extends WebViewClient { - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - if (!request.isForMainFrame()) { - return false; - } - return blockIfOutsideApp(request.getUrl().toString()); - } - - @Override - @SuppressWarnings("deprecation") - public boolean shouldOverrideUrlLoading(WebView view, String url) { - return blockIfOutsideApp(url); - } - - private boolean blockIfOutsideApp(String url) { - if (NavigationPolicy.isAllowed(url)) { - return false; - } - Toast.makeText(MainActivity.this, R.string.blocked_navigation, Toast.LENGTH_SHORT).show(); - return true; - } - - @Override - public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { - if (NavigationPolicy.isAllowed(url)) { - lastAllowedUrl = url; - updateAnnouncementPolling(); - } - showLoading(); - } - - @Override - public void onPageFinished(WebView view, String url) { - if (NavigationPolicy.isAllowed(url)) { - lastAllowedUrl = url; - updateAnnouncementPolling(); - } - progressBar.setVisibility(View.GONE); - if (!mainFrameFailed) { - errorView.setVisibility(View.GONE); - } - } - - @Override - public void onReceivedError( - WebView view, - WebResourceRequest request, - WebResourceError error) { - if (request.isForMainFrame()) { - showError(R.string.page_unavailable_detail); - } - } - - @Override - public void onReceivedHttpError( - WebView view, - WebResourceRequest request, - WebResourceResponse errorResponse) { - if (request.isForMainFrame() && errorResponse.getStatusCode() >= 400) { - showError(R.string.page_unavailable_detail); - } - } - - @Override - public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { - handler.cancel(); - showError(R.string.ssl_error); - } - } - - private final class DisplayWebChromeClient extends WebChromeClient { - @Override - public void onProgressChanged(WebView view, int newProgress) { - if (!mainFrameFailed && newProgress < 100 && customView == null) { - progressBar.setVisibility(View.VISIBLE); - } else if (newProgress == 100) { - progressBar.setVisibility(View.GONE); - } - } - - @Override - public void onShowCustomView(View view, CustomViewCallback callback) { - if (customView != null) { - callback.onCustomViewHidden(); - return; - } - customView = view; - customViewCallback = callback; - webView.setVisibility(View.GONE); - progressBar.setVisibility(View.GONE); - errorView.setVisibility(View.GONE); - root.addView(customView, matchParent()); - enterImmersiveMode(); - } - - @Override - public void onHideCustomView() { - exitWebFullscreen(); - } - - @Override - public void onPermissionRequest(PermissionRequest request) { - request.deny(); - } - } } diff --git a/android/app/src/main/java/cn/nianxx/queue/display/NativeDisplayUi.java b/android/app/src/main/java/cn/nianxx/queue/display/NativeDisplayUi.java new file mode 100644 index 0000000..1b28edb --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/NativeDisplayUi.java @@ -0,0 +1,103 @@ +package cn.nianxx.queue.display; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.StateListDrawable; +import android.os.Build; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +final class NativeDisplayUi { + private NativeDisplayUi() {} + + static int dp(Context context, float value) { + return Math.round(TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, value, context.getResources().getDisplayMetrics())); + } + + static TextView text(Context context, float sp, int color, boolean bold) { + TextView view = new TextView(context); + view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp); + view.setTextColor(color); + view.setGravity(Gravity.CENTER_VERTICAL); + if (bold) view.setTypeface(view.getTypeface(), android.graphics.Typeface.BOLD); + return view; + } + + static void autoSize(TextView view, int minSp, int maxSp) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + view.setAutoSizeTextTypeUniformWithConfiguration(minSp, maxSp, 1, + TypedValue.COMPLEX_UNIT_SP); + } + } + + static Button retryButton(Context context, View.OnClickListener listener) { + Button button = new Button(context); + button.setText(R.string.native_display_retry); + button.setTextColor(Color.WHITE); + button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + button.setTypeface(button.getTypeface(), android.graphics.Typeface.BOLD); + button.setAllCaps(false); + button.setMinWidth(dp(context, 160)); + button.setMinHeight(dp(context, 48)); + button.setBackgroundResource(R.drawable.native_display_button); + button.setOnClickListener(listener); + return button; + } + + static ImageView addScenicBackground(FrameLayout root) { + ImageView background = new ImageView(root.getContext()); + background.setImageResource(R.drawable.native_display_water_background); + background.setScaleType(ImageView.ScaleType.CENTER_CROP); + background.setAlpha(0.16f); + background.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + background.setContentDescription(null); + root.addView(background, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + return background; + } + + static ImageView logo(Context context) { + ImageView logo = new ImageView(context); + logo.setImageResource(R.drawable.native_display_logo); + logo.setScaleType(ImageView.ScaleType.CENTER_CROP); + logo.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + logo.setContentDescription(null); + return logo; + } + + static String formatUpdateTime(long epochMs) { + if (epochMs <= 0L) return "--:--"; + return new SimpleDateFormat("MM-dd HH:mm:ss", Locale.CHINA).format(new Date(epochMs)); + } + + static GradientDrawable rounded(int color, float radiusDp, Context context) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(color); + drawable.setCornerRadius(dp(context, radiusDp)); + return drawable; + } + + static StateListDrawable focusCardBackground(Context context) { + GradientDrawable focused = rounded(Color.WHITE, 18, context); + focused.setStroke(dp(context, 3), context.getColor(R.color.native_display_focus)); + GradientDrawable normal = rounded(Color.WHITE, 18, context); + normal.setStroke(dp(context, 1), context.getColor(R.color.native_display_border)); + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_focused}, focused); + states.addState(new int[]{android.R.attr.state_pressed}, focused); + states.addState(new int[]{}, normal); + return states; + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java b/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java index 2a4db2e..50d98c6 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java @@ -2,63 +2,146 @@ package cn.nianxx.queue.display; import java.net.URI; import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Objects; import java.util.regex.Pattern; final class NavigationPolicy { - static final String START_URL = "https://queue.nianxx.cn/admin/display"; - private static final String HOST = "queue.nianxx.cn"; + private static final String OVERVIEW_PATH = "/admin/display"; + private static final String OVERVIEW_API_PATH = "/api/display/overview"; private static final String DISPLAY_PREFIX = "/display/"; - private static final Pattern DISPLAY_IDENTIFIER = Pattern.compile("[A-Za-z0-9_-]{2,128}"); + private static final Pattern PROJECT_CODE = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]{1,23}"); + private static final Pattern DISPLAY_TOKEN = Pattern.compile("[A-Za-z0-9_-]{40,128}"); + private static final ScreenTarget OVERVIEW = new ScreenTarget(Kind.OVERVIEW, null); + + enum Kind { + OVERVIEW, + PROJECT + } + + static final class ScreenTarget { + private final Kind kind; + private final String identifier; + + private ScreenTarget(Kind kind, String identifier) { + this.kind = kind; + this.identifier = identifier; + } + + Kind kind() { + return kind; + } + + String identifier() { + return identifier; + } + + boolean isProject() { + return kind == Kind.PROJECT; + } + + String canonicalDisplayUrl() { + return absoluteUrl(isProject() ? DISPLAY_PREFIX + identifier : OVERVIEW_PATH); + } + + String apiUrl() { + return absoluteUrl(isProject() + ? "/api/display/" + identifier + "/snapshot" + : OVERVIEW_API_PATH); + } + + @Override + public boolean equals(Object value) { + if (this == value) { + return true; + } + if (!(value instanceof ScreenTarget)) { + return false; + } + ScreenTarget other = (ScreenTarget) value; + return kind == other.kind && Objects.equals(identifier, other.identifier); + } + + @Override + public int hashCode() { + return Objects.hash(kind, identifier); + } + + @Override + public String toString() { + return isProject() ? "PROJECT" : "OVERVIEW"; + } + } private NavigationPolicy() { } - static String initialUrlOrStart(String candidate) { - return isAllowed(candidate) ? candidate : START_URL; + static ScreenTarget overview() { + return OVERVIEW; } - static boolean isAllowed(String candidate) { - URI uri = parse(candidate); + static ScreenTarget project(String candidate) { + String identifier = normalizeIdentifier(candidate); + return identifier == null ? null : new ScreenTarget(Kind.PROJECT, identifier); + } + + static ScreenTarget initialTarget(String candidate) { + ScreenTarget target = parse(candidate); + return target == null ? OVERVIEW : target; + } + + static ScreenTarget parse(String candidate) { + URI uri = parseUri(candidate); if (uri == null || !"https".equalsIgnoreCase(uri.getScheme()) - || !HOST.equalsIgnoreCase(uri.getHost()) - || uri.getRawUserInfo() != null - || (uri.getPort() != -1 && uri.getPort() != 443)) { - return false; + || !hasTrustedAuthority(uri) + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + return null; } String path = uri.getRawPath(); - if ("/admin/display".equals(path)) { - return true; + if (OVERVIEW_PATH.equals(path)) { + return OVERVIEW; } if (path == null || !path.startsWith(DISPLAY_PREFIX)) { - return false; - } - - String identifier = path.substring(DISPLAY_PREFIX.length()); - return DISPLAY_IDENTIFIER.matcher(identifier).matches(); - } - - static boolean isProjectDisplay(String candidate) { - URI uri = parse(candidate); - return isAllowed(candidate) - && uri != null - && uri.getRawPath() != null - && uri.getRawPath().startsWith(DISPLAY_PREFIX); - } - - static String snapshotUrl(String candidate) { - URI uri = parse(candidate); - if (!isProjectDisplay(candidate) || uri == null) { return null; } - String identifier = uri.getRawPath().substring(DISPLAY_PREFIX.length()); - return "https://" + HOST + "/api/display/" + identifier + "/snapshot"; + return project(path.substring(DISPLAY_PREFIX.length())); } - private static URI parse(String candidate) { - if (candidate == null || candidate.isBlank()) { + private static boolean hasTrustedAuthority(URI uri) { + if (!HOST.equalsIgnoreCase(uri.getHost()) || uri.getRawUserInfo() != null) { + return false; + } + String authority = uri.getRawAuthority(); + return HOST.equalsIgnoreCase(authority) + || (HOST + ":443").equalsIgnoreCase(authority); + } + + private static String normalizeIdentifier(String candidate) { + if (candidate == null) { + return null; + } + String identifier = candidate.trim(); + if (PROJECT_CODE.matcher(identifier).matches()) { + return identifier.toUpperCase(Locale.ROOT); + } + return DISPLAY_TOKEN.matcher(identifier).matches() ? identifier : null; + } + + private static String absoluteUrl(String path) { + try { + return new URI("https", null, HOST, -1, path, null, null).toASCIIString(); + } catch (URISyntaxException impossible) { + throw new IllegalStateException("Trusted display path was invalid", impossible); + } + } + + private static URI parseUri(String candidate) { + if (candidate == null || candidate.trim().isEmpty()) { return null; } try { diff --git a/android/app/src/main/java/cn/nianxx/queue/display/OverviewView.java b/android/app/src/main/java/cn/nianxx/queue/display/OverviewView.java new file mode 100644 index 0000000..8017900 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/OverviewView.java @@ -0,0 +1,210 @@ +package cn.nianxx.queue.display; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.GridLayout; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import java.util.Collections; +import java.util.List; + +@SuppressLint("ViewConstructor") +public final class OverviewView extends FrameLayout { + public interface Listener { + void onProjectSelected(String code); + void onRetry(); + } + + private final Listener listener; + private final LinearLayout content; + private final TextView staleBanner; + private final GridLayout grid; + private List projects = Collections.emptyList(); + private int columns; + private boolean hasShownProjects; + + public OverviewView(Context context, Listener listener) { + super(context); + this.listener = listener; + setBackgroundColor(context.getColor(R.color.native_display_surface)); + NativeDisplayUi.addScenicBackground(this); + + ScrollView scroll = new ScrollView(context); + scroll.setFillViewport(true); + scroll.setClipToPadding(false); + addView(scroll, new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + + content = new LinearLayout(context); + content.setOrientation(LinearLayout.VERTICAL); + int outer = getResources().getDimensionPixelSize(R.dimen.native_display_outer_padding); + content.setPadding(outer, outer, outer, outer); + scroll.addView(content, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + LinearLayout header = new LinearLayout(context); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + ImageLogoLayout logo = new ImageLogoLayout(context); + header.addView(logo, new LinearLayout.LayoutParams(NativeDisplayUi.dp(context, 122), NativeDisplayUi.dp(context, 58))); + TextView title = NativeDisplayUi.text(context, 30, context.getColor(R.color.native_display_primary_dark), true); + title.setText(R.string.native_display_app_title); + header.addView(title, new LinearLayout.LayoutParams(0, NativeDisplayUi.dp(context, 64), 1f)); + TextView center = NativeDisplayUi.text(context, 22, context.getColor(R.color.native_display_secondary), true); + center.setText(R.string.native_display_center_title); + center.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + header.addView(center, new LinearLayout.LayoutParams(0, NativeDisplayUi.dp(context, 64), .5f)); + content.addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + + staleBanner = NativeDisplayUi.text(context, 16, context.getColor(R.color.native_display_stale_text), true); + staleBanner.setGravity(Gravity.CENTER); + staleBanner.setBackgroundResource(R.drawable.native_display_stale_banner); + staleBanner.setVisibility(GONE); + LinearLayout.LayoutParams bannerParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, NativeDisplayUi.dp(context, 48)); + bannerParams.topMargin = NativeDisplayUi.dp(context, 8); + content.addView(staleBanner, bannerParams); + + grid = new GridLayout(context); + grid.setAlignmentMode(GridLayout.ALIGN_BOUNDS); + grid.setUseDefaultMargins(false); + LinearLayout.LayoutParams gridParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + gridParams.topMargin = getResources().getDimensionPixelSize(R.dimen.native_display_gap); + content.addView(grid, gridParams); + } + + public void showLoading() { + projects = Collections.emptyList(); + hasShownProjects = false; + showState(getContext().getString(R.string.native_display_loading_projects), null, false); + } + + public void showProjects(DisplayModels.Overview overview) { + if (overview == null || overview.projects.isEmpty()) { + showEmpty(); + return; + } + projects = overview.projects; + grid.setVisibility(VISIBLE); + boolean initial = !hasShownProjects; + hasShownProjects = true; + rebuildGrid(initial); + } + + public void showStale(boolean offline, long lastUpdateEpochMs) { + staleBanner.setText(getContext().getString(R.string.native_display_stale_message, + getContext().getString(offline ? R.string.native_display_offline : R.string.native_display_delayed), + NativeDisplayUi.formatUpdateTime(lastUpdateEpochMs))); + staleBanner.setVisibility(VISIBLE); + } + + public void hideStale() { + staleBanner.setVisibility(GONE); + } + + public void showFatalError(String detail) { + hasShownProjects = false; + showState(getContext().getString(R.string.native_display_overview_error), detail, true); + } + + public void showEmpty() { + projects = Collections.emptyList(); + hasShownProjects = false; + showState(getContext().getString(R.string.native_display_empty), null, false); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (w != oldw && !projects.isEmpty()) rebuildGrid(false); + } + + private void showState(String title, String detail, boolean retry) { + grid.removeAllViews(); + grid.setVisibility(VISIBLE); + LinearLayout state = new LinearLayout(getContext()); + state.setOrientation(LinearLayout.VERTICAL); + state.setGravity(Gravity.CENTER); + state.setMinimumHeight(NativeDisplayUi.dp(getContext(), 300)); + TextView titleView = NativeDisplayUi.text(getContext(), 28, + getContext().getColor(retry ? R.color.native_display_danger : R.color.native_display_ink), true); + titleView.setGravity(Gravity.CENTER); + titleView.setText(title); + state.addView(titleView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + if (detail != null && !detail.trim().isEmpty()) { + TextView detailView = NativeDisplayUi.text(getContext(), 17, + getContext().getColor(R.color.native_display_muted), false); + detailView.setGravity(Gravity.CENTER); + detailView.setText(detail); + LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + detailParams.topMargin = NativeDisplayUi.dp(getContext(), 10); + state.addView(detailView, detailParams); + } + if (retry) { + LinearLayout.LayoutParams retryParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, NativeDisplayUi.dp(getContext(), 52)); + retryParams.gravity = Gravity.CENTER_HORIZONTAL; + retryParams.topMargin = NativeDisplayUi.dp(getContext(), 20); + View retryButton = NativeDisplayUi.retryButton(getContext(), v -> listener.onRetry()); + state.addView(retryButton, retryParams); + retryButton.post(retryButton::requestFocus); + } + grid.setColumnCount(1); + grid.addView(state, new GridLayout.LayoutParams( + GridLayout.spec(0), GridLayout.spec(0, 1f))); + ViewGroup.LayoutParams params = state.getLayoutParams(); + params.width = Math.max(1, getWidth() - getPaddingLeft() - getPaddingRight()); + state.setLayoutParams(params); + } + + private void rebuildGrid(boolean requestFirstFocus) { + int available = getWidth() - content.getPaddingLeft() - content.getPaddingRight(); + if (available <= 0) { + post(() -> rebuildGrid(requestFirstFocus)); + return; + } + int gap = getResources().getDimensionPixelSize(R.dimen.native_display_gap); + int minWidth = getResources().getDimensionPixelSize(R.dimen.native_display_card_min_width); + int newColumns = Math.max(1, Math.min(4, (available + gap) / (minWidth + gap))); + columns = newColumns; + int cardWidth = (available - gap * (columns - 1)) / columns; + String focusedCode = null; + View focused = grid.findFocus(); + if (focused instanceof ProjectCardView) { + focusedCode = ((ProjectCardView) focused).projectCode(); + } + grid.removeAllViews(); + grid.setColumnCount(columns); + ProjectCardView focusTarget = null; + for (int index = 0; index < projects.size(); index++) { + ProjectCardView card = new ProjectCardView(getContext(), listener::onProjectSelected); + card.bind(projects.get(index)); + int row = index / columns; + int column = index % columns; + GridLayout.LayoutParams params = new GridLayout.LayoutParams(GridLayout.spec(row), GridLayout.spec(column)); + params.width = cardWidth; + params.height = ViewGroup.LayoutParams.WRAP_CONTENT; + params.rightMargin = column == columns - 1 ? 0 : gap; + params.bottomMargin = gap; + grid.addView(card, params); + if (focusedCode != null && focusedCode.equals(card.projectCode())) focusTarget = card; + if (focusTarget == null && focusedCode == null && requestFirstFocus && index == 0) focusTarget = card; + } + if (focusTarget != null) { + ProjectCardView target = focusTarget; + target.post(target::requestFocus); + } + } + + private static final class ImageLogoLayout extends FrameLayout { + ImageLogoLayout(Context context) { + super(context); + addView(NativeDisplayUi.logo(context), new FrameLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + } + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/PollingLoop.java b/android/app/src/main/java/cn/nianxx/queue/display/PollingLoop.java new file mode 100644 index 0000000..9cc174f --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/PollingLoop.java @@ -0,0 +1,123 @@ +package cn.nianxx.queue.display; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +final class PollingLoop implements AutoCloseable { + interface Fetcher { + T fetch(); + } + + interface Dispatcher { + void dispatch(Runnable callback); + } + + interface Listener { + void onResult(T result); + } + + private final long delayMillis; + private final Fetcher fetcher; + private final Dispatcher dispatcher; + private final Listener listener; + private final ScheduledExecutorService executor; + private final boolean ownsExecutor; + + private ScheduledFuture scheduledTask; + private long generation; + private boolean closed; + + PollingLoop(long delayMillis, Fetcher fetcher, Dispatcher dispatcher, + Listener listener) { + this(delayMillis, fetcher, dispatcher, listener, + Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "queue-display-poller"); + thread.setDaemon(true); + return thread; + }), true); + } + + PollingLoop(long delayMillis, Fetcher fetcher, Dispatcher dispatcher, + Listener listener, ScheduledExecutorService executor) { + this(delayMillis, fetcher, dispatcher, listener, executor, false); + } + + private PollingLoop(long delayMillis, Fetcher fetcher, Dispatcher dispatcher, + Listener listener, ScheduledExecutorService executor, boolean ownsExecutor) { + if (delayMillis <= 0) { + throw new IllegalArgumentException("delayMillis must be positive"); + } + if (fetcher == null || dispatcher == null || listener == null || executor == null) { + throw new IllegalArgumentException("polling collaborators are required"); + } + this.delayMillis = delayMillis; + this.fetcher = fetcher; + this.dispatcher = dispatcher; + this.listener = listener; + this.executor = executor; + this.ownsExecutor = ownsExecutor; + } + + synchronized void start() { + if (closed || isRunning()) { + return; + } + long taskGeneration = ++generation; + scheduledTask = executor.scheduleWithFixedDelay( + () -> poll(taskGeneration), 0, delayMillis, TimeUnit.MILLISECONDS); + } + + synchronized void stop() { + generation += 1; + if (scheduledTask != null) { + scheduledTask.cancel(true); + scheduledTask = null; + } + } + + synchronized boolean isRunning() { + return !closed && scheduledTask != null && !scheduledTask.isCancelled() + && !scheduledTask.isDone(); + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + stop(); + closed = true; + if (ownsExecutor) { + executor.shutdownNow(); + } + } + + private void poll(long taskGeneration) { + T result; + try { + result = fetcher.fetch(); + } catch (RuntimeException ignored) { + return; + } + synchronized (this) { + if (!isCurrent(taskGeneration)) { + return; + } + } + dispatcher.dispatch(() -> { + synchronized (PollingLoop.this) { + if (!isCurrent(taskGeneration)) { + return; + } + } + listener.onResult(result); + }); + } + + private boolean isCurrent(long taskGeneration) { + return !closed && generation == taskGeneration && scheduledTask != null + && !scheduledTask.isCancelled(); + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/ProjectCardView.java b/android/app/src/main/java/cn/nianxx/queue/display/ProjectCardView.java new file mode 100644 index 0000000..fd3a32f --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/ProjectCardView.java @@ -0,0 +1,117 @@ +package cn.nianxx.queue.display; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +@SuppressLint("ViewConstructor") +public final class ProjectCardView extends LinearLayout { + public interface Listener { + void onProjectSelected(String code); + } + + private final TextView nameView; + private final TextView statusView; + private final TextView currentView; + private final TextView waitingView; + private final TextView latestView; + private String projectCode = ""; + + public ProjectCardView(Context context, Listener listener) { + super(context); + setOrientation(VERTICAL); + setGravity(Gravity.CENTER_VERTICAL); + int padding = NativeDisplayUi.dp(context, 20); + setPadding(padding, padding, padding, padding); + setFocusable(true); + setClickable(true); + setBackground(NativeDisplayUi.focusCardBackground(context)); + setElevation(NativeDisplayUi.dp(context, 4)); + setStateListAnimator(null); + + nameView = NativeDisplayUi.text(context, 27, context.getColor(R.color.native_display_ink), true); + nameView.setMaxLines(2); + nameView.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); + NativeDisplayUi.autoSize(nameView, 18, 27); + addView(nameView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1.3f)); + + statusView = NativeDisplayUi.text(context, 16, context.getColor(R.color.native_display_primary), true); + addView(statusView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, .6f)); + + currentView = NativeDisplayUi.text(context, 24, context.getColor(R.color.native_display_primary_dark), true); + currentView.setMaxLines(1); + NativeDisplayUi.autoSize(currentView, 16, 24); + addView(currentView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); + + waitingView = NativeDisplayUi.text(context, 18, context.getColor(R.color.native_display_ink), false); + addView(waitingView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, .75f)); + + latestView = NativeDisplayUi.text(context, 15, context.getColor(R.color.native_display_muted), false); + latestView.setMaxLines(1); + addView(latestView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, .65f)); + + setOnClickListener(v -> { + if (!projectCode.isEmpty()) listener.onProjectSelected(projectCode); + }); + setOnKeyListener((v, keyCode, event) -> { + if (event.getAction() == KeyEvent.ACTION_UP + && (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER)) { + performClick(); + return true; + } + return false; + }); + setOnFocusChangeListener((v, hasFocus) -> animate() + .scaleX(hasFocus ? 1.025f : 1f) + .scaleY(hasFocus ? 1.025f : 1f) + .translationZ(hasFocus ? NativeDisplayUi.dp(context, 6) : 0) + .setDuration(130) + .start()); + } + + public void bind(DisplayModels.ProjectSummary project) { + projectCode = project.code == null ? "" : project.code.trim(); + String name = project.name == null ? "" : project.name; + String current = DisplayFormatting.currentBatchNumbers(project.currentBatch); + nameView.setText(name); + statusView.setText(statusLabel(project.status)); + currentView.setText(getContext().getString(R.string.native_display_current_value, current)); + waitingView.setText(getContext().getString(R.string.native_display_waiting_summary, + project.waitingPeopleCount, project.waitingTicketCount)); + String latest = project.latestTicketNumber == null || project.latestTicketNumber.trim().isEmpty() + ? getContext().getString(R.string.native_display_placeholder) : project.latestTicketNumber; + latestView.setText(getContext().getString(R.string.native_display_latest_ticket, latest)); + setContentDescription(getContext().getString(R.string.native_display_project_summary, + name, project.waitingPeopleCount, project.waitingTicketCount, current)); + setEnabled(!projectCode.isEmpty()); + setAlpha(isEnabled() ? 1f : .6f); + } + + String projectCode() { + return projectCode; + } + + private static String statusLabel(String status) { + if (status == null) return ""; + if ("RUNNING".equalsIgnoreCase(status) || "ACTIVE".equalsIgnoreCase(status) + || "OPEN".equalsIgnoreCase(status)) return "开放中"; + if ("NOT_OPEN".equalsIgnoreCase(status)) return "未开放"; + if ("PAUSED".equalsIgnoreCase(status)) return "已暂停"; + if ("ENDED".equalsIgnoreCase(status) || "CLOSED".equalsIgnoreCase(status)) return "已结束"; + return status; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int width = MeasureSpec.getSize(widthMeasureSpec); + int desiredHeight = Math.round(width * 9f / 16f); + int height = resolveSize(desiredHeight, heightMeasureSpec); + super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/ProjectDisplayView.java b/android/app/src/main/java/cn/nianxx/queue/display/ProjectDisplayView.java new file mode 100644 index 0000000..34221c9 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/ProjectDisplayView.java @@ -0,0 +1,383 @@ +package cn.nianxx.queue.display; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.GridLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +@SuppressLint("ViewConstructor") +public final class ProjectDisplayView extends FrameLayout { + public interface Listener { + void onRetry(); + } + + private static final long CLOCK_INTERVAL_MS = 1_000L; + private static final long PAGE_INTERVAL_MS = 6_000L; + + private final Listener listener; + private final Handler handler = new Handler(Looper.getMainLooper()); + private final LinearLayout dataContent; + private final TextView staleBanner; + private final FrameLayout stateLayer; + private final TextView clockView; + private final TextView dateView; + private final TextView projectNameView; + private final TextView projectStatusView; + private final SingleLineFitTextView currentView; + private final TextView currentSubView; + private final TextView[] metricValues = new TextView[4]; + private final LinearLayout[] forecastSlots = new LinearLayout[4]; + private final TextView[] forecastRanges = new TextView[4]; + private final TextView[] forecastWaits = new TextView[4]; + private final TextView pageIndicator; + private List forecastRows = Collections.emptyList(); + private int forecastPage; + private boolean pageRotationScheduled; + + private final Runnable clockTick = new Runnable() { + @Override public void run() { + updateClock(); + handler.postDelayed(this, CLOCK_INTERVAL_MS); + } + }; + private final Runnable pageTick = new Runnable() { + @Override public void run() { + pageRotationScheduled = false; + int pages = DisplayFormatting.pageCount(forecastRows); + if (pages > 1) { + forecastPage = (forecastPage + 1) % pages; + renderForecastPage(); + schedulePageRotation(); + } + } + }; + + public ProjectDisplayView(Context context, Listener listener) { + super(context); + this.listener = listener; + setBackgroundColor(context.getColor(R.color.native_display_surface)); + NativeDisplayUi.addScenicBackground(this); + + dataContent = new LinearLayout(context); + dataContent.setOrientation(LinearLayout.VERTICAL); + int outer = getResources().getDimensionPixelSize(R.dimen.native_display_outer_padding); + dataContent.setPadding(outer, NativeDisplayUi.dp(context, 14), outer, NativeDisplayUi.dp(context, 14)); + addView(dataContent, new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + + staleBanner = NativeDisplayUi.text(context, 15, context.getColor(R.color.native_display_stale_text), true); + staleBanner.setGravity(Gravity.CENTER); + staleBanner.setBackgroundResource(R.drawable.native_display_stale_banner); + staleBanner.setVisibility(GONE); + dataContent.addView(staleBanner, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, NativeDisplayUi.dp(context, 42))); + + LinearLayout header = new LinearLayout(context); + header.setGravity(Gravity.CENTER_VERTICAL); + dataContent.addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1.05f)); + + LinearLayout brandTime = new LinearLayout(context); + brandTime.setGravity(Gravity.CENTER_VERTICAL); + header.addView(brandTime, new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)); + brandTime.addView(NativeDisplayUi.logo(context), new LinearLayout.LayoutParams( + NativeDisplayUi.dp(context, 138), NativeDisplayUi.dp(context, 68))); + LinearLayout timeColumn = new LinearLayout(context); + timeColumn.setOrientation(LinearLayout.VERTICAL); + timeColumn.setGravity(Gravity.CENTER_VERTICAL); + brandTime.addView(timeColumn, new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)); + clockView = NativeDisplayUi.text(context, 30, context.getColor(R.color.native_display_primary_dark), true); + dateView = NativeDisplayUi.text(context, 15, context.getColor(R.color.native_display_muted), false); + timeColumn.addView(clockView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1.1f)); + timeColumn.addView(dateView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, .8f)); + + LinearLayout projectColumn = new LinearLayout(context); + projectColumn.setOrientation(LinearLayout.VERTICAL); + projectColumn.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + header.addView(projectColumn, new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1.3f)); + projectNameView = NativeDisplayUi.text(context, 42, context.getColor(R.color.native_display_primary_dark), true); + projectNameView.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + projectNameView.setMaxLines(2); + NativeDisplayUi.autoSize(projectNameView, 23, 42); + projectColumn.addView(projectNameView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1.4f)); + projectStatusView = NativeDisplayUi.text(context, 16, context.getColor(R.color.native_display_secondary), true); + projectStatusView.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + projectColumn.addView(projectStatusView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, .6f)); + + LinearLayout primaryRegion = new LinearLayout(context); + primaryRegion.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams primaryParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 2.25f); + primaryParams.topMargin = NativeDisplayUi.dp(context, 10); + dataContent.addView(primaryRegion, primaryParams); + + LinearLayout currentPanel = new LinearLayout(context); + currentPanel.setOrientation(LinearLayout.VERTICAL); + currentPanel.setGravity(Gravity.CENTER); + currentPanel.setBackgroundResource(R.drawable.native_display_primary_panel); + primaryRegion.addView(currentPanel, new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1.08f)); + TextView currentLabel = NativeDisplayUi.text(context, 19, Color.WHITE, true); + currentLabel.setText(R.string.native_display_now_calling); + currentLabel.setGravity(Gravity.CENTER); + currentPanel.addView(currentLabel, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, .55f)); + currentView = new SingleLineFitTextView(context); + currentView.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 74); + currentView.setTextColor(Color.WHITE); + currentView.setTypeface(currentView.getTypeface(), android.graphics.Typeface.BOLD); + currentView.setId(R.id.native_display_current_number); + currentView.setGravity(Gravity.CENTER); + currentView.setFitTextSizeRange(30, 74); + currentPanel.addView(currentView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1.55f)); + currentSubView = NativeDisplayUi.text(context, 16, 0xFFD8EEE0, false); + currentSubView.setGravity(Gravity.CENTER); + currentPanel.addView(currentSubView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, .55f)); + + GridLayout metrics = new GridLayout(context); + metrics.setColumnCount(2); + metrics.setRowCount(2); + LinearLayout.LayoutParams metricParams = new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f); + metricParams.leftMargin = NativeDisplayUi.dp(context, 14); + primaryRegion.addView(metrics, metricParams); + int[] labels = {R.string.native_display_latest_number, R.string.native_display_issued_tickets, + R.string.native_display_waiting_people, R.string.native_display_experienced_people}; + for (int i = 0; i < labels.length; i++) { + LinearLayout tile = metricTile(labels[i], i); + GridLayout.LayoutParams tileParams = new GridLayout.LayoutParams( + GridLayout.spec(i / 2, 1f), GridLayout.spec(i % 2, 1f)); + tileParams.width = 0; + tileParams.height = 0; + int halfGap = NativeDisplayUi.dp(context, 5); + tileParams.setMargins(i % 2 == 0 ? 0 : halfGap, i / 2 == 0 ? 0 : halfGap, + i % 2 == 0 ? halfGap : 0, i / 2 == 0 ? halfGap : 0); + metrics.addView(tile, tileParams); + } + + LinearLayout forecastPanel = new LinearLayout(context); + forecastPanel.setOrientation(LinearLayout.VERTICAL); + forecastPanel.setBackgroundResource(R.drawable.native_display_panel); + LinearLayout.LayoutParams forecastParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 2.3f); + forecastParams.topMargin = NativeDisplayUi.dp(context, 12); + dataContent.addView(forecastPanel, forecastParams); + LinearLayout forecastHeader = new LinearLayout(context); + forecastHeader.setGravity(Gravity.CENTER_VERTICAL); + forecastPanel.addView(forecastHeader, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, .65f)); + TextView forecastTitle = NativeDisplayUi.text(context, 20, context.getColor(R.color.native_display_primary_dark), true); + forecastTitle.setText(R.string.native_display_forecast); + forecastHeader.addView(forecastTitle, new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)); + pageIndicator = NativeDisplayUi.text(context, 14, context.getColor(R.color.native_display_muted), false); + pageIndicator.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + pageIndicator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + forecastHeader.addView(pageIndicator, new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, .3f)); + for (int i = 0; i < 4; i++) { + LinearLayout row = new LinearLayout(context); + row.setGravity(Gravity.CENTER_VERTICAL); + if (i % 2 == 1) row.setBackgroundColor(0x0D075D31); + forecastSlots[i] = row; + forecastRanges[i] = NativeDisplayUi.text(context, 21, context.getColor(R.color.native_display_ink), true); + forecastWaits[i] = NativeDisplayUi.text(context, 18, context.getColor(R.color.native_display_secondary), false); + forecastWaits[i].setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + row.addView(forecastRanges[i], new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)); + row.addView(forecastWaits[i], new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, .75f)); + forecastPanel.addView(row, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); + } + + stateLayer = new FrameLayout(context); + stateLayer.setBackgroundColor(context.getColor(R.color.native_display_surface)); + stateLayer.setVisibility(GONE); + addView(stateLayer, new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + updateClock(); + } + + public void showLoading() { + showState(getContext().getString(R.string.native_display_loading), null, false); + } + + public void showData(DisplayModels.Snapshot snapshot) { + if (snapshot == null) { + showFatalError(null); + return; + } + stateLayer.setVisibility(GONE); + dataContent.setVisibility(VISIBLE); + projectNameView.setText(snapshot.projectName == null ? "" : snapshot.projectName); + projectStatusView.setText(statusLabel(snapshot.status)); + String current = DisplayFormatting.currentBatchNumbers(snapshot.currentBatch); + currentView.setText(current); + currentSubView.setText(snapshot.currentBatch == null + ? R.string.native_display_waiting_next_call + : R.string.native_display_proceed_to_entrance); + metricValues[0].setText(snapshot.latestTicketNumber == null || snapshot.latestTicketNumber.trim().isEmpty() + ? DisplayFormatting.UNKNOWN : snapshot.latestTicketNumber); + metricValues[1].setText(String.valueOf(snapshot.issuedTicketCount)); + metricValues[2].setText(String.valueOf(snapshot.waitingPeopleCount)); + metricValues[3].setText(String.valueOf(snapshot.experiencedPeople)); + List updatedRows = DisplayFormatting.forecastRows(snapshot.currentBatch, + snapshot.waitingTicketCount, snapshot.estimatedWait); + int previousPages = DisplayFormatting.pageCount(forecastRows); + int updatedPages = DisplayFormatting.pageCount(updatedRows); + boolean rowsChanged = !sameForecastRows(forecastRows, updatedRows); + forecastRows = updatedRows; + if (rowsChanged && forecastPage >= updatedPages) forecastPage = 0; + renderForecastPage(); + if (updatedPages <= 1) { + handler.removeCallbacks(pageTick); + pageRotationScheduled = false; + } else if (previousPages <= 1 || !pageRotationScheduled) { + schedulePageRotation(); + } + String summary = snapshot.projectName + ",等候" + snapshot.waitingPeopleCount + + "人,当前叫号" + current; + dataContent.setContentDescription(summary); + } + + public void showStale(boolean offline, long lastUpdateEpochMs) { + staleBanner.setText(getContext().getString(R.string.native_display_stale_message, + getContext().getString(offline ? R.string.native_display_offline : R.string.native_display_delayed), + NativeDisplayUi.formatUpdateTime(lastUpdateEpochMs))); + staleBanner.setVisibility(VISIBLE); + } + + public void hideStale() { + staleBanner.setVisibility(GONE); + } + + public void showFatalError(String detail) { + showState(getContext().getString(R.string.native_display_error_title), detail, true); + } + + public void showNotFound() { + showState(getContext().getString(R.string.native_display_not_found), null, true); + } + + @Override protected void onAttachedToWindow() { + super.onAttachedToWindow(); + handler.removeCallbacks(clockTick); + handler.post(clockTick); + schedulePageRotation(); + } + + @Override protected void onDetachedFromWindow() { + handler.removeCallbacks(clockTick); + handler.removeCallbacks(pageTick); + pageRotationScheduled = false; + super.onDetachedFromWindow(); + } + + private LinearLayout metricTile(int labelRes, int index) { + LinearLayout tile = new LinearLayout(getContext()); + tile.setOrientation(LinearLayout.VERTICAL); + tile.setGravity(Gravity.CENTER); + tile.setBackgroundResource(R.drawable.native_display_panel); + metricValues[index] = NativeDisplayUi.text(getContext(), 34, + getContext().getColor(R.color.native_display_primary_dark), true); + metricValues[index].setGravity(Gravity.CENTER); + metricValues[index].setText(R.string.native_display_placeholder); + TextView label = NativeDisplayUi.text(getContext(), 15, + getContext().getColor(R.color.native_display_muted), false); + label.setText(labelRes); + label.setGravity(Gravity.CENTER); + tile.addView(metricValues[index], new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1.25f)); + tile.addView(label, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, .75f)); + return tile; + } + + private void renderForecastPage() { + List page = DisplayFormatting.page(forecastRows, forecastPage); + for (int i = 0; i < forecastSlots.length; i++) { + if (i < page.size()) { + forecastRanges[i].setText(page.get(i).range); + forecastWaits[i].setText(page.get(i).wait); + forecastSlots[i].setAlpha(1f); + } else { + forecastRanges[i].setText(i == 0 && forecastRows.isEmpty() + ? R.string.native_display_forecast_empty : R.string.native_display_placeholder); + forecastWaits[i].setText(""); + forecastSlots[i].setAlpha(i == 0 && forecastRows.isEmpty() ? 1f : .35f); + } + } + int pages = DisplayFormatting.pageCount(forecastRows); + pageIndicator.setText(pages > 1 + ? getContext().getString(R.string.native_display_page_indicator, forecastPage + 1, pages) : ""); + } + + private void schedulePageRotation() { + if (!pageRotationScheduled && isAttachedToWindow() + && DisplayFormatting.pageCount(forecastRows) > 1) { + pageRotationScheduled = true; + handler.postDelayed(pageTick, PAGE_INTERVAL_MS); + } + } + + private void updateClock() { + Date now = new Date(); + clockView.setText(new SimpleDateFormat("HH:mm:ss", Locale.CHINA).format(now)); + dateView.setText(new SimpleDateFormat("yyyy年M月d日 EEEE", Locale.CHINA).format(now)); + } + + private void showState(String title, String detail, boolean retry) { + handler.removeCallbacks(pageTick); + pageRotationScheduled = false; + dataContent.setVisibility(GONE); + stateLayer.removeAllViews(); + stateLayer.setVisibility(VISIBLE); + LinearLayout state = new LinearLayout(getContext()); + state.setOrientation(LinearLayout.VERTICAL); + state.setGravity(Gravity.CENTER); + int padding = NativeDisplayUi.dp(getContext(), 24); + state.setPadding(padding, padding, padding, padding); + TextView titleView = NativeDisplayUi.text(getContext(), 32, + getContext().getColor(retry ? R.color.native_display_danger : R.color.native_display_ink), true); + titleView.setGravity(Gravity.CENTER); + titleView.setText(title); + state.addView(titleView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + if (detail != null && !detail.trim().isEmpty()) { + TextView detailView = NativeDisplayUi.text(getContext(), 18, + getContext().getColor(R.color.native_display_muted), false); + detailView.setGravity(Gravity.CENTER); + detailView.setText(detail); + LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + detailParams.topMargin = NativeDisplayUi.dp(getContext(), 12); + state.addView(detailView, detailParams); + } + if (retry) { + LinearLayout.LayoutParams retryParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, NativeDisplayUi.dp(getContext(), 52)); + retryParams.gravity = Gravity.CENTER_HORIZONTAL; + retryParams.topMargin = NativeDisplayUi.dp(getContext(), 22); + View retryButton = NativeDisplayUi.retryButton(getContext(), v -> listener.onRetry()); + state.addView(retryButton, retryParams); + retryButton.post(retryButton::requestFocus); + } + stateLayer.addView(state, new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + } + + private static String statusLabel(String status) { + if (status == null) return ""; + if ("RUNNING".equalsIgnoreCase(status) || "ACTIVE".equalsIgnoreCase(status) + || "OPEN".equalsIgnoreCase(status)) return "实时开放"; + if ("NOT_OPEN".equalsIgnoreCase(status)) return "未开放"; + if ("PAUSED".equalsIgnoreCase(status)) return "暂停叫号"; + if ("ENDED".equalsIgnoreCase(status) || "CLOSED".equalsIgnoreCase(status)) return "已结束"; + return status; + } + + private static boolean sameForecastRows(List left, + List right) { + if (left.size() != right.size()) return false; + for (int i = 0; i < left.size(); i++) { + DisplayModels.ForecastRow a = left.get(i); + DisplayModels.ForecastRow b = right.get(i); + if (!a.range.equals(b.range) || !a.wait.equals(b.wait)) return false; + } + return true; + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java index 163cfde..854bf63 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java @@ -1,23 +1,47 @@ package cn.nianxx.queue.display; -import java.util.Collections; -import java.util.List; - final class QueueAnnouncement { - private static final int REPEAT_COUNT = 3; - private static final int MAX_TEXT_LENGTH = 200; + private static final int SINGLE_LENGTH = 5; + private static final int RANGE_LENGTH = 11; - private QueueAnnouncement() { + private final String firstNumber; + private final String lastNumber; + + private QueueAnnouncement(String firstNumber, String lastNumber) { + this.firstNumber = firstNumber; + this.lastNumber = lastNumber; } - static List utterances(String value) { + static QueueAnnouncement parse(String value) { if (value == null) { - return Collections.emptyList(); + return null; } - String text = value.trim(); - if (text.isEmpty() || text.length() > MAX_TEXT_LENGTH) { - return Collections.emptyList(); + if (value.length() == SINGLE_LENGTH && QueueCallText.isTicketNumber(value)) { + return new QueueAnnouncement(value, value); } - return Collections.nCopies(REPEAT_COUNT, text); + if (value.length() != RANGE_LENGTH || value.charAt(SINGLE_LENGTH) != '-') { + return null; + } + + String first = value.substring(0, SINGLE_LENGTH); + String last = value.substring(SINGLE_LENGTH + 1); + if (!QueueCallText.isTicketNumber(first) + || !QueueCallText.isTicketNumber(last) + || first.compareTo(last) >= 0) { + return null; + } + return new QueueAnnouncement(first, last); + } + + String firstNumber() { + return firstNumber; + } + + String lastNumber() { + return lastNumber; + } + + boolean isRange() { + return !firstNumber.equals(lastNumber); } } diff --git a/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java index d055e52..4fe361d 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java @@ -1,17 +1,26 @@ package cn.nianxx.queue.display; import android.content.Context; +import android.content.res.Resources; import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioFocusRequest; import android.media.AudioManager; -import android.speech.tts.TextToSpeech; -import android.speech.tts.UtteranceProgressListener; +import android.media.AudioTrack; +import android.os.Handler; +import android.os.Looper; +import android.os.Build; import android.util.Log; -import java.util.List; -import java.util.Locale; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayDeque; +import java.util.Deque; final class QueueAnnouncementPlayer { private static final String TAG = "XQKQueueDisplay"; + private static final int PCM_FORMAT = AudioFormat.ENCODING_PCM_16BIT; interface Listener { void onSpeechUnavailable(); @@ -19,134 +28,276 @@ final class QueueAnnouncementPlayer { private final Context context; private final Listener listener; - private TextToSpeech engine; - private boolean ready; - private String pendingText; - private long utteranceSequence; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final Object lock = new Object(); + private final Deque pendingAnnouncements = new ArrayDeque<>(); + private final Thread preloadThread; + private boolean shutdown; + private boolean unavailableNotificationScheduled; + private boolean preloadFailed; + private EmbeddedWavComposer.ClipSet clips; + private EmbeddedWavComposer.StreamPlaybackQueue playbackQueue; QueueAnnouncementPlayer(Context context, Listener listener) { this.context = context.getApplicationContext(); this.listener = listener; + preloadThread = new Thread(this::preload, "queue-announcement-preload"); + preloadThread.start(); } void announce(String value) { - List utterances = QueueAnnouncement.utterances(value); - if (utterances.isEmpty()) { + QueueAnnouncement announcement = QueueAnnouncement.parse(value); + if (announcement == null) { return; } - String text = utterances.get(0); - if (ready) { - speakNow(text); - return; + synchronized (lock) { + if (shutdown) { + return; + } + if (preloadFailed) { + return; + } + if (playbackQueue == null) { + pendingAnnouncements.addLast(announcement); + } else { + playbackQueue.enqueue(clips.compose(announcement)); + } } - pendingText = text; - if (engine == null) { - Log.i(TAG, "Initializing Android TTS"); - engine = new TextToSpeech(context, this::onInitialized); + } + + void stop() { + EmbeddedWavComposer.StreamPlaybackQueue queue; + synchronized (lock) { + pendingAnnouncements.clear(); + queue = playbackQueue; + } + if (queue != null) { + queue.stop(); } } void shutdown() { - pendingText = null; - ready = false; - if (engine == null) { - return; + EmbeddedWavComposer.StreamPlaybackQueue queue; + synchronized (lock) { + if (shutdown) { + return; + } + shutdown = true; + pendingAnnouncements.clear(); + queue = playbackQueue; } - engine.stop(); - engine.shutdown(); - engine = null; + if (queue != null) { + queue.shutdown(); + } + preloadThread.interrupt(); } - private void onInitialized(int status) { - TextToSpeech currentEngine = engine; - if (currentEngine == null) { + private void preload() { + try { + clips = loadClips(context); + logMediaVolume(); + } catch (IOException | RuntimeException error) { + Log.e(TAG, "Embedded announcement audio unavailable", error); + synchronized (lock) { + preloadFailed = true; + pendingAnnouncements.clear(); + } + notifyUnavailable(); return; } - if (status != TextToSpeech.SUCCESS) { - fail("initialization failed with status " + status); - return; + EmbeddedWavComposer.StreamPlaybackQueue queue = new EmbeddedWavComposer.StreamPlaybackQueue( + cancelCurrent -> new AudioTrackSink(context, clips.sampleRate(), cancelCurrent), + this::notifyUnavailable, + clips.sampleRate(), + "queue-announcement-audio"); + synchronized (lock) { + if (shutdown) { + queue.shutdown(); + return; + } + playbackQueue = queue; + while (!pendingAnnouncements.isEmpty()) { + queue.enqueue(clips.compose(pendingAnnouncements.removeFirst())); + } } - int language = currentEngine.setLanguage(Locale.SIMPLIFIED_CHINESE); - if (language == TextToSpeech.LANG_MISSING_DATA - || language == TextToSpeech.LANG_NOT_SUPPORTED) { - fail("Simplified Chinese is unavailable with status " + language); - return; - } - currentEngine.setSpeechRate(0.9f); - currentEngine.setAudioAttributes(new AudioAttributes.Builder() + } + + private static final class AudioTrackSink implements EmbeddedWavComposer.StreamSink { + private static final AudioAttributes ATTRIBUTES = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build()); - currentEngine.setOnUtteranceProgressListener(new UtteranceProgressListener() { - @Override - public void onStart(String utteranceId) { - Log.i(TAG, "TTS utterance started"); - } + .build(); - @Override - public void onDone(String utteranceId) { - Log.i(TAG, "TTS utterance completed"); - } + private final AudioTrack track; + private final AudioManager audioManager; + private final AudioFocusRequest focusRequest; + private final AudioManager.OnAudioFocusChangeListener focusListener; + private volatile boolean stopped; + private boolean focusHeld; - @Override - public void onError(String utteranceId) { - Log.e(TAG, "TTS utterance failed"); + AudioTrackSink(Context context, int sampleRate, Runnable cancelCurrent) { + int minimum = AudioTrack.getMinBufferSize( + sampleRate, AudioFormat.CHANNEL_OUT_MONO, PCM_FORMAT); + if (minimum <= 0) { + throw new IllegalStateException("No valid AudioTrack stream buffer size"); } + audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + throw new IllegalStateException("Audio service is unavailable"); + } + focusListener = change -> { + if (change == AudioManager.AUDIOFOCUS_LOSS + || change == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { + cancelCurrent.run(); + } + }; + focusRequest = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes(ATTRIBUTES) + .setOnAudioFocusChangeListener(focusListener) + .build() + : null; + track = new AudioTrack.Builder() + .setAudioAttributes(ATTRIBUTES) + .setAudioFormat(new AudioFormat.Builder() + .setEncoding(PCM_FORMAT) + .setSampleRate(sampleRate) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build()) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(minimum) + .build(); + if (track.getState() != AudioTrack.STATE_INITIALIZED) { + track.release(); + throw new IllegalStateException("Streaming AudioTrack failed to initialize"); + } + } - @Override - public void onError(String utteranceId, int errorCode) { - Log.e(TAG, "TTS utterance failed with code " + errorCode); + @Override + public void play() { + int result = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? audioManager.requestAudioFocus(focusRequest) + : audioManager.requestAudioFocus( + focusListener, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + throw new IllegalStateException("Media audio focus was denied"); + } + focusHeld = true; + track.play(); + } + + @Override + public int write(byte[] data, int offset, int length) { + return track.write(data, offset, length, AudioTrack.WRITE_BLOCKING); + } + + @Override + public void awaitPlayback(long frames) throws InterruptedException { + while (!stopped && Integer.toUnsignedLong(track.getPlaybackHeadPosition()) < frames) { + try { + Thread.sleep(20L); + } catch (InterruptedException error) { + throw error; + } + } + } + + @Override + public void stop() { + stopped = true; + try { + int state = track.getPlayState(); + if (state == AudioTrack.PLAYSTATE_PLAYING) { + track.pause(); + } + track.flush(); + state = track.getPlayState(); + if (state == AudioTrack.PLAYSTATE_PLAYING + || state == AudioTrack.PLAYSTATE_PAUSED) { + track.stop(); + } + } catch (IllegalStateException ignored) { + // Cancellation is idempotent across device-specific state transitions. + } + } + + @Override + public void release() { + try { + track.release(); + } finally { + if (focusHeld) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioManager.abandonAudioFocusRequest(focusRequest); + } else { + audioManager.abandonAudioFocus(focusListener); + } + focusHeld = false; + } + } + } + } + + private void notifyUnavailable() { + synchronized (lock) { + if (shutdown || unavailableNotificationScheduled) { + return; + } + unavailableNotificationScheduled = true; + } + mainHandler.post(() -> { + synchronized (lock) { + if (shutdown) { + return; + } + // Linearize the callback before shutdown so no notification can begin afterward. + listener.onSpeechUnavailable(); } }); - ready = true; - Log.i(TAG, "Android TTS ready for Simplified Chinese"); - AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - if (audioManager != null) { - Log.i(TAG, "Media volume is " - + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - + " of " - + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)); - } - String text = pendingText; - pendingText = null; - if (text != null) { - speakNow(text); + } + + private void logMediaVolume() { + AudioManager manager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (manager != null) { + Log.i(TAG, "Media volume is " + manager.getStreamVolume(AudioManager.STREAM_MUSIC) + + " of " + manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)); } } - private void speakNow(String text) { - TextToSpeech currentEngine = engine; - if (!ready || currentEngine == null) { - return; + private static EmbeddedWavComposer.ClipSet loadClips(Context context) throws IOException { + EmbeddedWavComposer.WavClip prompt = loadClip(context, "voice_prompt"); + EmbeddedWavComposer.WavClip range = loadClip(context, "voice_range"); + EmbeddedWavComposer.WavClip suffix = loadClip(context, "voice_suffix"); + EmbeddedWavComposer.WavClip[] digits = new EmbeddedWavComposer.WavClip[10]; + for (int digit = 0; digit < digits.length; digit += 1) { + digits[digit] = loadClip(context, "voice_digit_" + digit); } - currentEngine.stop(); - List utterances = QueueAnnouncement.utterances(text); - int acceptedCount = 0; - for (String utterance : utterances) { - String utteranceId = "queue-call-" + (++utteranceSequence); - if (currentEngine.speak( - utterance, - TextToSpeech.QUEUE_ADD, - null, - utteranceId) == TextToSpeech.SUCCESS) { - acceptedCount += 1; - } + EmbeddedWavComposer.ClipSet result = + new EmbeddedWavComposer.ClipSet(prompt, digits, range, suffix); + Log.i(TAG, "Cached embedded PCM announcement clips at " + result.sampleRate() + " Hz"); + return result; + } + + private static EmbeddedWavComposer.WavClip loadClip(Context context, String name) + throws IOException { + Resources resources = context.getResources(); + int resourceId = resources.getIdentifier(name, "raw", context.getPackageName()); + if (resourceId == 0) { + throw new IOException("Missing raw resource " + name); } - Log.i(TAG, "Queued " + acceptedCount + " of " + utterances.size() + " TTS utterances"); - if (acceptedCount == 0) { - listener.onSpeechUnavailable(); + try (InputStream input = resources.openRawResource(resourceId)) { + return EmbeddedWavComposer.parseWav(name, readAll(input)); } } - private void fail(String reason) { - Log.e(TAG, "Android TTS unavailable: " + reason); - TextToSpeech failedEngine = engine; - engine = null; - ready = false; - pendingText = null; - if (failedEngine != null) { - failedEngine.shutdown(); + private static byte[] readAll(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); } - listener.onSpeechUnavailable(); + return output.toByteArray(); } } diff --git a/android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java b/android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java index d63bffd..44093f6 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java @@ -1,43 +1,39 @@ package cn.nianxx.queue.display; -import java.util.ArrayList; import java.util.List; final class QueueCallText { - private static final String[] SPOKEN_DIGITS = { - "零", "一", "二", "三", "四", "五", "六", "七", "八", "九" - }; - private QueueCallText() { } static String format(List values) { - List numbers = new ArrayList<>(); - for (String value : values) { - if (value != null && !value.trim().isEmpty()) { - numbers.add(value.trim()); - } - } - if (numbers.isEmpty()) { + if (values == null || values.isEmpty()) { return null; } - String first = spokenNumber(numbers.get(0)); - String calledNumbers = numbers.size() == 1 - ? first + "号" - : first + "号至" + spokenNumber(numbers.get(numbers.size() - 1)) + "号"; - return "请" + calledNumbers + ",前往入口。"; - } - - private static String spokenNumber(String value) { - StringBuilder spoken = new StringBuilder(); - for (int index = 0; index < value.length(); index += 1) { - char character = value.charAt(index); - if (character >= '0' && character <= '9') { - spoken.append(SPOKEN_DIGITS[character - '0']); - } else { - spoken.append(character); + for (String value : values) { + if (!isTicketNumber(value)) { + return null; } } - return spoken.toString(); + + String first = values.get(0); + String last = values.get(values.size() - 1); + if (first.compareTo(last) > 0) { + return null; + } + return first.equals(last) ? first : first + "-" + last; + } + + static boolean isTicketNumber(String value) { + if (value == null || value.length() != 5) { + return false; + } + for (int index = 0; index < value.length(); index += 1) { + char character = value.charAt(index); + if (character < '0' || character > '9') { + return false; + } + } + return true; } } diff --git a/android/app/src/main/java/cn/nianxx/queue/display/SingleLineFitTextView.java b/android/app/src/main/java/cn/nianxx/queue/display/SingleLineFitTextView.java new file mode 100644 index 0000000..599a6bc --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/SingleLineFitTextView.java @@ -0,0 +1,113 @@ +package cn.nianxx.queue.display; + +import android.content.Context; +import android.graphics.Paint; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.TextView; + +/** A single-line TextView that selects the largest whole-sp size that fully fits. */ +public final class SingleLineFitTextView extends TextView { + interface SizeFits { + boolean fits(int textSizeSp); + } + + private int minimumTextSizeSp = 30; + private int maximumTextSizeSp = 74; + private boolean fitting; + + public SingleLineFitTextView(Context context) { + this(context, null); + } + + public SingleLineFitTextView(Context context, AttributeSet attrs) { + super(context, attrs); + setSingleLine(true); + setEllipsize(null); + } + + public void setFitTextSizeRange(int minimumSp, int maximumSp) { + if (minimumSp <= 0 || maximumSp < minimumSp) { + throw new IllegalArgumentException("Invalid text-size range"); + } + minimumTextSizeSp = minimumSp; + maximumTextSizeSp = maximumSp; + fitTextToContentBounds(); + } + + @Override protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { + super.onTextChanged(text, start, lengthBefore, lengthAfter); + fitTextToContentBounds(); + } + + @Override protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + fitTextToContentBounds(); + } + + @Override public void setPadding(int left, int top, int right, int bottom) { + super.setPadding(left, top, right, bottom); + fitTextToContentBounds(); + } + + private void fitTextToContentBounds() { + if (fitting || minimumTextSizeSp <= 0 || maximumTextSizeSp < minimumTextSizeSp) return; + int contentWidth = getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight(); + int contentHeight = getHeight() - getCompoundPaddingTop() - getCompoundPaddingBottom(); + CharSequence text = getText(); + if (contentWidth <= 0 || contentHeight <= 0 || text == null || text.length() == 0) return; + + TextPaint measuringPaint = new TextPaint(getPaint()); + SizeFits sizeFits = candidateSp -> { + measuringPaint.setTextSize(spToPx(candidateSp)); + float measuredWidth = measuringPaint.measureText(text, 0, text.length()); + Paint.FontMetrics metrics = measuringPaint.getFontMetrics(); + float measuredHeight = getIncludeFontPadding() + ? metrics.bottom - metrics.top + : metrics.descent - metrics.ascent; + return measuredWidth <= contentWidth && measuredHeight <= contentHeight; + }; + int selectedSp = chooseFittedTextSize(minimumTextSizeSp, maximumTextSizeSp, sizeFits); + + float selectedPx = spToPx(selectedSp); + if (Math.abs(getTextSize() - selectedPx) >= 0.5f) { + fitting = true; + try { + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, selectedPx); + } finally { + fitting = false; + } + } + } + + private float spToPx(int sp) { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, + getResources().getDisplayMetrics()); + } + + static int chooseLargestTextSize(int minimumSp, int maximumSp, SizeFits sizeFits) { + if (minimumSp <= 0 || maximumSp < minimumSp) { + throw new IllegalArgumentException("Invalid text-size range"); + } + int low = minimumSp; + int high = maximumSp; + int best = minimumSp; + while (low <= high) { + int candidate = low + (high - low) / 2; + if (sizeFits.fits(candidate)) { + best = candidate; + low = candidate + 1; + } else { + high = candidate - 1; + } + } + return best; + } + + static int chooseFittedTextSize(int preferredMinimumSp, int maximumSp, SizeFits sizeFits) { + int selectedSp = chooseLargestTextSize(preferredMinimumSp, maximumSp, sizeFits); + if (sizeFits.fits(selectedSp) || preferredMinimumSp == 1) return selectedSp; + return chooseLargestTextSize(1, preferredMinimumSp - 1, sizeFits); + } +} diff --git a/android/app/src/main/res/drawable-nodpi/native_display_logo.jpg b/android/app/src/main/res/drawable-nodpi/native_display_logo.jpg new file mode 100644 index 0000000..d556065 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/native_display_logo.jpg differ diff --git a/android/app/src/main/res/drawable-nodpi/native_display_water_background.jpg b/android/app/src/main/res/drawable-nodpi/native_display_water_background.jpg new file mode 100644 index 0000000..2afa6ae Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/native_display_water_background.jpg differ diff --git a/android/app/src/main/res/drawable/native_display_button.xml b/android/app/src/main/res/drawable/native_display_button.xml new file mode 100644 index 0000000..6e60db0 --- /dev/null +++ b/android/app/src/main/res/drawable/native_display_button.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/native_display_panel.xml b/android/app/src/main/res/drawable/native_display_panel.xml new file mode 100644 index 0000000..264df5f --- /dev/null +++ b/android/app/src/main/res/drawable/native_display_panel.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/native_display_primary_panel.xml b/android/app/src/main/res/drawable/native_display_primary_panel.xml new file mode 100644 index 0000000..a04559d --- /dev/null +++ b/android/app/src/main/res/drawable/native_display_primary_panel.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/native_display_stale_banner.xml b/android/app/src/main/res/drawable/native_display_stale_banner.xml new file mode 100644 index 0000000..4b0265d --- /dev/null +++ b/android/app/src/main/res/drawable/native_display_stale_banner.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/raw/voice_digit_0.wav b/android/app/src/main/res/raw/voice_digit_0.wav new file mode 100644 index 0000000..ed8b19a Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_0.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_1.wav b/android/app/src/main/res/raw/voice_digit_1.wav new file mode 100644 index 0000000..0f84aa5 Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_1.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_2.wav b/android/app/src/main/res/raw/voice_digit_2.wav new file mode 100644 index 0000000..9d144bb Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_2.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_3.wav b/android/app/src/main/res/raw/voice_digit_3.wav new file mode 100644 index 0000000..f199f32 Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_3.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_4.wav b/android/app/src/main/res/raw/voice_digit_4.wav new file mode 100644 index 0000000..7968ef4 Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_4.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_5.wav b/android/app/src/main/res/raw/voice_digit_5.wav new file mode 100644 index 0000000..bfc4797 Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_5.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_6.wav b/android/app/src/main/res/raw/voice_digit_6.wav new file mode 100644 index 0000000..0d4b986 Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_6.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_7.wav b/android/app/src/main/res/raw/voice_digit_7.wav new file mode 100644 index 0000000..26b7bdd Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_7.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_8.wav b/android/app/src/main/res/raw/voice_digit_8.wav new file mode 100644 index 0000000..acaa325 Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_8.wav differ diff --git a/android/app/src/main/res/raw/voice_digit_9.wav b/android/app/src/main/res/raw/voice_digit_9.wav new file mode 100644 index 0000000..3a1b0e1 Binary files /dev/null and b/android/app/src/main/res/raw/voice_digit_9.wav differ diff --git a/android/app/src/main/res/raw/voice_prompt.wav b/android/app/src/main/res/raw/voice_prompt.wav new file mode 100644 index 0000000..8547cdb Binary files /dev/null and b/android/app/src/main/res/raw/voice_prompt.wav differ diff --git a/android/app/src/main/res/raw/voice_range.wav b/android/app/src/main/res/raw/voice_range.wav new file mode 100644 index 0000000..8a4ae5a Binary files /dev/null and b/android/app/src/main/res/raw/voice_range.wav differ diff --git a/android/app/src/main/res/raw/voice_suffix.wav b/android/app/src/main/res/raw/voice_suffix.wav new file mode 100644 index 0000000..56fe7d0 Binary files /dev/null and b/android/app/src/main/res/raw/voice_suffix.wav differ diff --git a/android/app/src/main/res/values/native_display_colors.xml b/android/app/src/main/res/values/native_display_colors.xml new file mode 100644 index 0000000..be1744e --- /dev/null +++ b/android/app/src/main/res/values/native_display_colors.xml @@ -0,0 +1,15 @@ + + + #075D31 + #064624 + #627269 + #F7FBF8 + #FFFFFFFF + #173026 + #627269 + #3D075D31 + #FFF3C4 + #76520A + #A62929 + #0A7B41 + diff --git a/android/app/src/main/res/values/native_display_dimens.xml b/android/app/src/main/res/values/native_display_dimens.xml new file mode 100644 index 0000000..63f6b48 --- /dev/null +++ b/android/app/src/main/res/values/native_display_dimens.xml @@ -0,0 +1,6 @@ + + + 24dp + 16dp + 420dp + diff --git a/android/app/src/main/res/values/native_display_ids.xml b/android/app/src/main/res/values/native_display_ids.xml new file mode 100644 index 0000000..544d784 --- /dev/null +++ b/android/app/src/main/res/values/native_display_ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/values/native_display_strings.xml b/android/app/src/main/res/values/native_display_strings.xml new file mode 100644 index 0000000..74d639a --- /dev/null +++ b/android/app/src/main/res/values/native_display_strings.xml @@ -0,0 +1,30 @@ + + + 景区排队叫号系统 + 大屏中心 + 正在连接叫号服务 + 正在汇总项目状态 + 重新连接 + 当前无法显示叫号信息 + 大屏数据暂时不可用 + 此公示屏尚未绑定项目 + 当前没有可展示的项目 + 连接已中断 + 数据更新延迟 + %1$s · 上次更新 %2$s + 当前叫号 + 正在叫号 %1$s + 等待下一次叫号 + 请按现场提示前往入口 + %1$d 人等候 · %2$d 组 + 等候预估 + 暂无可用预估 + 最新取号码 + 累计取号数 + 累计等待人数 + 已体验人数 + 最新取号 %1$s + %1$s,等候%2$d人,%3$d组,当前叫号%4$s + + %1$d / %2$d + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4dc3fd0..0e05b83 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,5 +6,5 @@ 重新加载 已阻止离开公示页面 安全连接校验失败,请联系管理员 - 未检测到可用的中文语音引擎,请在系统设置中启用中文文字转语音 + 叫号音频播放失败,请检查媒体音量及其他播放源后重试 diff --git a/android/app/src/test/java/cn/nianxx/queue/display/AnnouncementCoordinatorTest.java b/android/app/src/test/java/cn/nianxx/queue/display/AnnouncementCoordinatorTest.java new file mode 100644 index 0000000..8f46251 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/AnnouncementCoordinatorTest.java @@ -0,0 +1,89 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class AnnouncementCoordinatorTest { + @Test + public void firstBatchIsBaselineAndOnlyANewBatchAnnounces() { + List announcements = new ArrayList<>(); + AnnouncementCoordinator coordinator = new AnnouncementCoordinator(announcements::add); + + coordinator.accept(batch(1, "2026-08-11T00:00:00Z", "00001")); + coordinator.accept(batch(1, "2026-08-11T00:00:00Z", "00001")); + coordinator.accept(batch(2, "2026-08-11T00:01:00Z", "00002")); + + assertEquals(Collections.singletonList("00002"), announcements); + } + + @Test + public void malformedNewBatchDoesNotConsumeItsCallKey() { + List announcements = new ArrayList<>(); + AnnouncementCoordinator coordinator = new AnnouncementCoordinator(announcements::add); + coordinator.accept(batch(1, "first", "00001")); + + coordinator.accept(batch(2, "second")); + coordinator.accept(batch(2, "second", "00002")); + + assertEquals(Collections.singletonList("00002"), announcements); + } + + @Test + public void resetMakesTheNextBatchASilentBaseline() { + List announcements = new ArrayList<>(); + AnnouncementCoordinator coordinator = new AnnouncementCoordinator(announcements::add); + coordinator.accept(batch(1, "first", "00001")); + coordinator.accept(batch(2, "second", "00002")); + + coordinator.reset(); + coordinator.accept(batch(3, "third", "00003")); + + assertEquals(Collections.singletonList("00002"), announcements); + } + + @Test + public void newMultiTicketBatchIsSubmittedOnceAsARange() { + List announcements = new ArrayList<>(); + AnnouncementCoordinator coordinator = new AnnouncementCoordinator(announcements::add); + coordinator.accept(batch(1, "first", "00001")); + + coordinator.accept(batch(2, "second", "00016", "00017", "00018")); + coordinator.accept(batch(2, "second", "00016", "00017", "00018")); + + assertEquals(Collections.singletonList("00016-00018"), announcements); + } + + @Test + public void malformedTicketInRangeDoesNotConsumeItsCallKey() { + List announcements = new ArrayList<>(); + AnnouncementCoordinator coordinator = new AnnouncementCoordinator(announcements::add); + coordinator.accept(batch(1, "first", "00001")); + + coordinator.accept(batch(2, "second", "00016", "invalid", "00018")); + coordinator.accept(batch(2, "second", "00016", "00017", "00018")); + + assertEquals(Collections.singletonList("00016-00018"), announcements); + } + + private static DisplayModels.Batch batch(long number, String calledAt, String... tickets) { + List values = new ArrayList<>(); + for (String ticket : tickets) { + values.add(new DisplayModels.Ticket(ticket, ticket, "CALLED", 1)); + } + return new DisplayModels.Batch( + number, + number, + "CALLED", + "TICKET", + values.size(), + values.size(), + values.size(), + calledAt, + values); + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java b/android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java index 4a47e42..1d41e06 100644 --- a/android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java +++ b/android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java @@ -7,28 +7,43 @@ import org.junit.Test; public final class CallAnnouncementTrackerTest { @Test - public void establishesBaselineThenReturnsOnlyNewAnnouncements() { + public void firstValidSnapshotIsBaselineAndDuplicatesStaySilent() { CallAnnouncementTracker tracker = new CallAnnouncementTracker(); - assertNull(tracker.accept("1:2026-08-11T00:00:00Z", "请零零零一号,前往入口。")); - assertNull(tracker.accept("1:2026-08-11T00:00:00Z", "请零零零一号,前往入口。")); - assertEquals( - "请零零零二号,前往入口。", - tracker.accept("2:2026-08-11T00:01:00Z", "请零零零二号,前往入口。")); + assertNull(tracker.accept("1:first", "first announcement")); + assertNull(tracker.accept("1:first", "duplicate announcement")); + assertEquals("second announcement", tracker.accept("2:second", "second announcement")); } @Test - public void acceptsNextBatchAfterCurrentCallClears() { + public void nullCurrentCallClearsBaselineForTheNextCall() { CallAnnouncementTracker tracker = new CallAnnouncementTracker(); assertNull(tracker.accept(null, null)); - assertEquals( - "请零零零一号,前往入口。", - tracker.accept("1:2026-08-11T00:00:00Z", "请零零零一号,前往入口。")); + assertEquals("first announcement", tracker.accept("1:first", "first announcement")); assertNull(tracker.accept(null, null)); - assertEquals( - "请零零零二号,前往入口。", - tracker.accept("2:2026-08-11T00:01:00Z", "请零零零二号,前往入口。")); + assertNull(tracker.accept(null, null)); + assertEquals("second announcement", tracker.accept("2:second", "second announcement")); + } + + @Test + public void emptyAnnouncementDoesNotConsumeANewCallKey() { + CallAnnouncementTracker tracker = new CallAnnouncementTracker(); + + assertNull(tracker.accept("1:first", "first announcement")); + assertNull(tracker.accept("2:second", " ")); + assertNull(tracker.accept("2:second", null)); + assertEquals("second announcement", tracker.accept("2:second", "second announcement")); + assertNull(tracker.accept("2:second", "duplicate announcement")); + } + + @Test + public void invalidFirstSnapshotDoesNotEstablishTheBaseline() { + CallAnnouncementTracker tracker = new CallAnnouncementTracker(); + + assertNull(tracker.accept("1:first", "")); + assertNull(tracker.accept("1:first", "first valid announcement")); + assertEquals("second announcement", tracker.accept("2:second", "second announcement")); } @Test diff --git a/android/app/src/test/java/cn/nianxx/queue/display/DisplayApiClientTest.java b/android/app/src/test/java/cn/nianxx/queue/display/DisplayApiClientTest.java new file mode 100644 index 0000000..4cf39d5 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/DisplayApiClientTest.java @@ -0,0 +1,149 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +public class DisplayApiClientTest { + @Test + public void sendsUserAgentAndReturnsTypedSuccess() throws Exception { + try (MiniServer server = new MiniServer(Collections.singletonList( + new Response(200, "{\"projects\":[],\"server_time\":\"2026-08-11T00:00:00Z\"}")))) { + DisplayApiClient.Result result = + new DisplayApiClient(server.baseUrl() + "/", "XQKQueueDisplay/test") + .getOverview(); + + assertTrue(result.isSuccess()); + assertEquals(DisplayApiClient.Result.Kind.SUCCESS, result.kind); + assertEquals("XQKQueueDisplay/test", server.awaitUserAgent()); + assertEquals(0, result.value.projects.size()); + } + } + + @Test + public void distinguishesHttpAndParseErrors() throws Exception { + try (MiniServer server = new MiniServer(Arrays.asList( + new Response(503, "temporarily unavailable"), new Response(200, "{}")))) { + DisplayApiClient client = new DisplayApiClient(server.baseUrl(), "test"); + DisplayApiClient.Result httpResult = client.getOverview(); + assertEquals(DisplayApiClient.Result.Kind.HTTP_ERROR, httpResult.kind); + assertEquals(503, httpResult.httpStatus); + assertEquals("temporarily unavailable", httpResult.message); + + DisplayApiClient.Result parseResult = client.getOverview(); + assertEquals(DisplayApiClient.Result.Kind.PARSE_ERROR, parseResult.kind); + } + } + + @Test + public void reportsNetworkFailure() throws Exception { + int unusedPort; + try (ServerSocket socket = new ServerSocket(0)) { + unusedPort = socket.getLocalPort(); + } + DisplayApiClient.Result result = + new DisplayApiClient("http://127.0.0.1:" + unusedPort, "test").getOverview(); + assertEquals(DisplayApiClient.Result.Kind.NETWORK_ERROR, result.kind); + } + + private static final class Response { + final int status; + final String body; + + Response(int status, String body) { + this.status = status; + this.body = body; + } + } + + private static final class MiniServer implements AutoCloseable { + private final ServerSocket socket; + private final Thread thread; + private final AtomicReference userAgent = new AtomicReference<>(); + private final AtomicReference failure = new AtomicReference<>(); + private final CountDownLatch firstRequest = new CountDownLatch(1); + + MiniServer(List responses) throws IOException { + socket = new ServerSocket(0); + thread = new Thread(() -> serve(responses), "display-api-client-test-server"); + thread.setDaemon(true); + thread.start(); + } + + String baseUrl() { + return "http://127.0.0.1:" + socket.getLocalPort(); + } + + String awaitUserAgent() throws Exception { + assertTrue(firstRequest.await(1, TimeUnit.SECONDS)); + if (failure.get() != null) { + throw new AssertionError(failure.get()); + } + return userAgent.get(); + } + + private void serve(List responses) { + try { + for (Response response : responses) { + try (Socket connection = socket.accept()) { + readRequest(connection); + writeResponse(connection, response); + } + } + } catch (IOException error) { + if (!socket.isClosed()) { + failure.set(error); + } + } finally { + firstRequest.countDown(); + } + } + + private void readRequest(Socket connection) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader( + connection.getInputStream(), StandardCharsets.US_ASCII)); + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + if (line.regionMatches(true, 0, "User-Agent:", 0, "User-Agent:".length())) { + userAgent.compareAndSet(null, line.substring("User-Agent:".length()).trim()); + } + } + firstRequest.countDown(); + } + + private void writeResponse(Socket connection, Response response) throws IOException { + byte[] body = response.body.getBytes(StandardCharsets.UTF_8); + String reason = response.status == 200 ? "OK" : "Error"; + String headers = "HTTP/1.1 " + response.status + " " + reason + "\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + "Content-Length: " + body.length + "\r\nConnection: close\r\n\r\n"; + OutputStream output = connection.getOutputStream(); + output.write(headers.getBytes(StandardCharsets.US_ASCII)); + output.write(body); + output.flush(); + } + + @Override + public void close() throws Exception { + socket.close(); + thread.join(1_000); + if (failure.get() != null) { + throw new AssertionError(failure.get()); + } + } + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/DisplayFormattingTest.java b/android/app/src/test/java/cn/nianxx/queue/display/DisplayFormattingTest.java new file mode 100644 index 0000000..cc7613c --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/DisplayFormattingTest.java @@ -0,0 +1,60 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; + +public class DisplayFormattingTest { + @Test + public void formatsCurrentBatchAsTicketRange() { + DisplayModels.Batch batch = batch("A009", "A011"); + assertEquals("A009 至 A011", DisplayFormatting.currentBatchNumbers(batch)); + assertEquals(DisplayFormatting.UNKNOWN, DisplayFormatting.currentBatchNumbers(null)); + } + + @Test + public void groupsForecastByThirtyAndScalesEtaProportionally() { + DisplayModels.Eta eta = new DisplayModels.Eta(true, 60, 30, 90, null); + List rows = DisplayFormatting.forecastRows( + batch("A001", "A010"), 75, eta); + + assertEquals(3, rows.size()); + assertEquals("A011 至 A040", rows.get(0).range); + assertEquals("12 至 36 分钟", rows.get(0).wait); + assertEquals("A071 至 A085", rows.get(2).range); + assertEquals("30 至 90 分钟", rows.get(2).wait); + } + + @Test + public void paginatesFourRowsAtATime() { + List rows = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9); + assertEquals(3, DisplayFormatting.pageCount(rows)); + assertEquals(Arrays.asList(5, 6, 7, 8), DisplayFormatting.page(rows, 1)); + assertEquals(Collections.singletonList(9), DisplayFormatting.page(rows, 2)); + assertTrue(DisplayFormatting.page(rows, 3).isEmpty()); + } + + @Test + public void unknownOrUnavailableForecastIsSafe() { + assertTrue(DisplayFormatting.forecastRows(null, 10, + new DisplayModels.Eta(true, 10, 10, 10, null)).isEmpty()); + assertTrue(DisplayFormatting.forecastRows(batch("ABC"), 10, + new DisplayModels.Eta(true, 10, 10, 10, null)).isEmpty()); + assertEquals("暂不可估算", DisplayFormatting.formatEstimatedWait( + new DisplayModels.Eta(false, 0, 0, 0, "queue_not_running"))); + } + + private static DisplayModels.Batch batch(String... numbers) { + List tickets = new ArrayList<>(); + for (String number : numbers) { + tickets.add(new DisplayModels.Ticket(number, number, "CALLED", 1)); + } + return new DisplayModels.Batch(1, 1, "CALLED", "TICKET", tickets.size(), + tickets.size(), tickets.size(), "2026-08-11T00:00:00Z", tickets); + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/DisplayJsonParserTest.java b/android/app/src/test/java/cn/nianxx/queue/display/DisplayJsonParserTest.java new file mode 100644 index 0000000..fd69477 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/DisplayJsonParserTest.java @@ -0,0 +1,80 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class DisplayJsonParserTest { + @Test + public void parsesCompleteOverview() throws Exception { + DisplayModels.Overview overview = DisplayJsonParser.parseOverview(""" + {"projects":[{"id":"p1","code":"A1","name":"East Gate","status":"RUNNING", + "waiting_count":2,"waiting_ticket_count":2,"waiting_people_count":5, + "issued_ticket_count":9,"latest_ticket_number":null,"experienced_people":12, + "current_batch":null,"estimated_wait":{"available":false,"estimate_minutes":0, + "min_minutes":0,"max_minutes":0,"reason":"queue_not_running"}, + "last_updated_at":"2026-08-11T00:00:00Z"}],"server_time":"2026-08-11T00:00:01Z"} + """); + + assertEquals(1, overview.projects.size()); + assertEquals("A1", overview.projects.get(0).code); + assertEquals(5, overview.projects.get(0).waitingPeopleCount); + assertNull(overview.projects.get(0).latestTicketNumber); + assertEquals("queue_not_running", overview.projects.get(0).estimatedWait.reason); + } + + @Test + public void parsesCompleteSnapshotAndBatch() throws Exception { + DisplayModels.Snapshot snapshot = DisplayJsonParser.parseSnapshot(validSnapshot()); + + assertEquals("East Gate", snapshot.projectName); + assertEquals("p1", snapshot.project.id); + assertEquals(7, snapshot.revision); + assertEquals("00012", snapshot.currentBatch.tickets.get(0).displayNumber); + assertEquals("3:2026-08-11T00:00:00Z", snapshot.currentBatch.callKey()); + assertEquals(1, snapshot.recentBatches.size()); + } + + @Test + public void missingRequiredFieldFailsWholeResponse() { + String malformed = validSnapshot().replace("\"revision\":7,", ""); + assertThrows(DisplayJsonParser.ParseException.class, + () -> DisplayJsonParser.parseSnapshot(malformed)); + } + + @Test + public void wrongRequiredTypeFailsWholeResponse() { + String malformed = validSnapshot().replace("\"waiting_count\":2", "\"waiting_count\":\"2\""); + assertThrows(DisplayJsonParser.ParseException.class, + () -> DisplayJsonParser.parseSnapshot(malformed)); + } + + @Test + public void malformedArrayItemFailsWholeResponse() { + String malformed = validSnapshot().replace("\"recent_batches\":[{", "\"recent_batches\":[null,{"); + assertThrows(DisplayJsonParser.ParseException.class, + () -> DisplayJsonParser.parseSnapshot(malformed)); + } + + private static String validSnapshot() { + String batch = """ + {"batch_number":3,"sequence":3,"status":"CALLED","call_mode":"TICKET", + "requested_count":1,"ticket_count":1,"people_count":2, + "called_at":"2026-08-11T00:00:00Z","tickets":[{"ticket_number":"00012", + "display_number":"00012","status":"CALLED","party_size":2}]} + """; + return "{" + + "\"project_name\":\"East Gate\",\"status\":\"RUNNING\"," + + "\"project\":{\"id\":\"p1\",\"name\":\"East Gate\",\"status\":\"RUNNING\"}," + + "\"revision\":7,\"waiting_count\":2,\"waiting_ticket_count\":2," + + "\"waiting_people_count\":5,\"issued_ticket_count\":12," + + "\"latest_ticket_number\":\"00014\",\"experienced_people\":30," + + "\"current_batch\":" + batch + ",\"recent_batches\":[" + batch + "]," + + "\"estimated_wait\":{\"available\":true,\"estimate_minutes\":10," + + "\"min_minutes\":5,\"max_minutes\":15}," + + "\"server_time\":\"2026-08-11T00:00:01Z\"," + + "\"last_updated_at\":\"2026-08-11T00:00:00Z\"}"; + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/EmbeddedVoiceAssetsTest.java b/android/app/src/test/java/cn/nianxx/queue/display/EmbeddedVoiceAssetsTest.java new file mode 100644 index 0000000..255dbd9 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/EmbeddedVoiceAssetsTest.java @@ -0,0 +1,85 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class EmbeddedVoiceAssetsTest { + private static final int EXPECTED_SAMPLE_RATE = 16_000; + private static final Set EXPECTED_NAMES = new TreeSet<>(Arrays.asList( + "voice_digit_0.wav", + "voice_digit_1.wav", + "voice_digit_2.wav", + "voice_digit_3.wav", + "voice_digit_4.wav", + "voice_digit_5.wav", + "voice_digit_6.wav", + "voice_digit_7.wav", + "voice_digit_8.wav", + "voice_digit_9.wav", + "voice_prompt.wav", + "voice_range.wav", + "voice_suffix.wav")); + + @Test + public void packagedVoiceSourceAssetsHaveTheExpectedPcmFormat() throws IOException { + Path rawDirectory = findRawDirectory(); + Set actualNames; + try (Stream files = Files.list(rawDirectory)) { + actualNames = files + .filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.startsWith("voice_") && name.endsWith(".wav")) + .collect(Collectors.toCollection(TreeSet::new)); + } + + assertEquals(EXPECTED_NAMES, actualNames); + Integer commonSampleRate = null; + for (String name : EXPECTED_NAMES) { + Path asset = rawDirectory.resolve(name); + assertTrue(name + " must exist as an Android raw resource", Files.isRegularFile(asset)); + + EmbeddedWavComposer.WavClip clip = EmbeddedWavComposer.parseWav( + name, + Files.readAllBytes(asset)); + assertEquals(name + " must be 16 kHz", EXPECTED_SAMPLE_RATE, clip.sampleRate); + assertFalse(name + " must contain PCM16 mono samples", clip.pcm.length == 0); + assertEquals(name + " PCM16 data must be frame-aligned", 0, clip.pcm.length & 1); + if (commonSampleRate == null) { + commonSampleRate = clip.sampleRate; + } else { + assertEquals(name + " must match the other assets' sample rate", + commonSampleRate.intValue(), + clip.sampleRate); + } + } + } + + private static Path findRawDirectory() { + Path workingDirectory = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize(); + Path[] candidates = { + workingDirectory.resolve("android/app/src/main/res/raw"), + workingDirectory.resolve("app/src/main/res/raw"), + workingDirectory.resolve("src/main/res/raw") + }; + for (Path candidate : candidates) { + if (Files.isDirectory(candidate)) { + return candidate; + } + } + throw new AssertionError("Cannot find Android raw resources from " + workingDirectory + + "; checked " + Arrays.toString(candidates)); + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java b/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java index 193aac9..395e5a2 100644 --- a/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java +++ b/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java @@ -8,53 +8,85 @@ import static org.junit.Assert.assertTrue; import org.junit.Test; public final class NavigationPolicyTest { - @Test - public void allowsOnlyTheTwoPublicPageFamilies() { - assertTrue(NavigationPolicy.isAllowed("https://queue.nianxx.cn/admin/display")); - assertTrue(NavigationPolicy.isAllowed("https://queue.nianxx.cn:443/admin/display")); - assertTrue(NavigationPolicy.isAllowed("https://queue.nianxx.cn/display/YYHHC")); - assertTrue(NavigationPolicy.isAllowed( - "https://queue.nianxx.cn/display/0123456789abcdef0123456789abcdef01234567")); + private static final String TOKEN = + "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789_-AB"; - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn/admin")); - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn/staff")); - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn/display/YYHHC/extra")); - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn/display/X")); + @Test + public void defaultsLaunchToOverview() { + NavigationPolicy.ScreenTarget missing = NavigationPolicy.initialTarget(null); + NavigationPolicy.ScreenTarget invalid = NavigationPolicy.initialTarget("https://example.com/"); + + assertEquals(NavigationPolicy.Kind.OVERVIEW, missing.kind()); + assertEquals(missing, invalid); + assertFalse(missing.isProject()); + assertNull(missing.identifier()); + assertEquals("https://queue.nianxx.cn/admin/display", missing.canonicalDisplayUrl()); + assertEquals("https://queue.nianxx.cn/api/display/overview", missing.apiUrl()); } @Test - public void rejectsUntrustedOrAmbiguousUrls() { - assertFalse(NavigationPolicy.isAllowed("http://queue.nianxx.cn/admin/display")); - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn.evil.test/display/YYHHC")); - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn@evil.test/display/YYHHC")); - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn:444/display/YYHHC")); - assertFalse(NavigationPolicy.isAllowed("https://queue.nianxx.cn/display/YY%2FHHC")); - assertFalse(NavigationPolicy.isAllowed("not a url")); - assertFalse(NavigationPolicy.isAllowed(null)); - } + public void parsesAndCanonicalizesProjectCodesLikeTheServer() { + NavigationPolicy.ScreenTarget target = + NavigationPolicy.parse("https://queue.nianxx.cn:443/display/east-ride"); - @Test - public void fallsBackToTheProjectListForInvalidDeepLinks() { + assertEquals(NavigationPolicy.Kind.PROJECT, target.kind()); + assertTrue(target.isProject()); + assertEquals("EAST-RIDE", target.identifier()); assertEquals( - NavigationPolicy.START_URL, - NavigationPolicy.initialUrlOrStart("https://example.com/display/YYHHC")); + "https://queue.nianxx.cn/display/EAST-RIDE", + target.canonicalDisplayUrl()); assertEquals( - "https://queue.nianxx.cn/display/YYHHC", - NavigationPolicy.initialUrlOrStart("https://queue.nianxx.cn/display/YYHHC")); + "https://queue.nianxx.cn/api/display/EAST-RIDE/snapshot", + target.apiUrl()); + assertEquals("EAST-RIDE", NavigationPolicy.project(" east-ride ").identifier()); } @Test - public void identifiesDirectProjectDisplaysForBackNavigation() { - assertTrue(NavigationPolicy.isProjectDisplay("https://queue.nianxx.cn/display/YYHHC")); - assertFalse(NavigationPolicy.isProjectDisplay(NavigationPolicy.START_URL)); + public void preservesCaseForValidatedBase64UrlDisplayTokens() { + NavigationPolicy.ScreenTarget target = NavigationPolicy.project(TOKEN); + + assertEquals(TOKEN, target.identifier()); + assertEquals("PROJECT", target.toString()); + assertFalse(target.toString().contains(TOKEN)); + assertEquals( + "https://queue.nianxx.cn/api/display/" + TOKEN + "/snapshot", + target.apiUrl()); } @Test - public void createsSnapshotUrlOnlyForDirectProjectDisplays() { + public void rejectsInvalidIdentifiersAndMultipleSegments() { + assertNull(NavigationPolicy.project("X")); + assertNull(NavigationPolicy.project("bad code")); + assertNull(NavigationPolicy.project("550e8400-e29b-41d4-a716-446655440000")); + assertNull(NavigationPolicy.project("0123456789abcdef0123456789abcdef0123456+")); + assertNull(NavigationPolicy.parse("https://queue.nianxx.cn/display/YYHHC/extra")); + assertNull(NavigationPolicy.parse("https://queue.nianxx.cn/display/YY%2FHHC")); + } + + @Test + public void acceptsOnlyTheExactTrustedHttpsOrigin() { assertEquals( - "https://queue.nianxx.cn/api/display/YYHHC/snapshot", - NavigationPolicy.snapshotUrl("https://queue.nianxx.cn/display/YYHHC")); - assertNull(NavigationPolicy.snapshotUrl(NavigationPolicy.START_URL)); - assertNull(NavigationPolicy.snapshotUrl("https://example.com/display/YYHHC")); + NavigationPolicy.Kind.OVERVIEW, + NavigationPolicy.parse("https://queue.nianxx.cn/admin/display").kind()); + assertEquals( + NavigationPolicy.Kind.OVERVIEW, + NavigationPolicy.parse("HTTPS://QUEUE.NIANXX.CN:443/admin/display").kind()); + + assertNull(NavigationPolicy.parse("http://queue.nianxx.cn/admin/display")); + assertNull(NavigationPolicy.parse("https://queue.nianxx.cn.evil.test/display/YYHHC")); + assertNull(NavigationPolicy.parse("https://queue.nianxx.cn@evil.test/display/YYHHC")); + assertNull(NavigationPolicy.parse("https://user@queue.nianxx.cn/display/YYHHC")); + assertNull(NavigationPolicy.parse("https://queue.nianxx.cn:444/display/YYHHC")); + assertNull(NavigationPolicy.parse("https://queue.nianxx.cn:/display/YYHHC")); + assertNull(NavigationPolicy.parse("https://queue.nianxx.cn./display/YYHHC")); + assertNull(NavigationPolicy.parse("not a url")); + } + + @Test + public void rejectsQueryAndFragmentInsteadOfSilentlyChangingTheirMeaning() { + assertNull(NavigationPolicy.parse( + "https://queue.nianxx.cn/admin/display?project=YYHHC")); + assertNull(NavigationPolicy.parse( + "https://queue.nianxx.cn/display/YYHHC#status")); } } diff --git a/android/app/src/test/java/cn/nianxx/queue/display/PollingLoopTest.java b/android/app/src/test/java/cn/nianxx/queue/display/PollingLoopTest.java new file mode 100644 index 0000000..31393a7 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/PollingLoopTest.java @@ -0,0 +1,77 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class PollingLoopTest { + @Test + public void neverRunsOverlappingRequestsAndUsesFixedDelay() throws Exception { + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + AtomicBoolean fetching = new AtomicBoolean(); + AtomicBoolean overlapped = new AtomicBoolean(); + AtomicInteger calls = new AtomicInteger(); + CountDownLatch results = new CountDownLatch(3); + PollingLoop loop = new PollingLoop<>(20, () -> { + if (!fetching.compareAndSet(false, true)) { + overlapped.set(true); + } + try { + Thread.sleep(30); + return calls.incrementAndGet(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + return -1; + } finally { + fetching.set(false); + } + }, Runnable::run, value -> results.countDown(), executor); + try { + loop.start(); + assertTrue(results.await(1, TimeUnit.SECONDS)); + assertFalse(overlapped.get()); + assertTrue(loop.isRunning()); + } finally { + loop.close(); + executor.shutdownNow(); + } + } + + @Test + public void stopInvalidatesAlreadyDispatchedCallbackByGeneration() throws Exception { + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + List callbacks = new CopyOnWriteArrayList<>(); + CountDownLatch fetched = new CountDownLatch(1); + AtomicInteger delivered = new AtomicInteger(); + PollingLoop loop = new PollingLoop<>(10_000, () -> { + fetched.countDown(); + return 1; + }, callbacks::add, value -> delivered.incrementAndGet(), executor); + try { + loop.start(); + assertTrue(fetched.await(1, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (callbacks.isEmpty() && System.nanoTime() < deadline) { + Thread.yield(); + } + assertEquals(1, callbacks.size()); + loop.stop(); + callbacks.get(0).run(); + assertEquals(0, delivered.get()); + assertFalse(loop.isRunning()); + } finally { + loop.close(); + executor.shutdownNow(); + } + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/ProjectDisplayViewTest.java b/android/app/src/test/java/cn/nianxx/queue/display/ProjectDisplayViewTest.java new file mode 100644 index 0000000..21e0809 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/ProjectDisplayViewTest.java @@ -0,0 +1,114 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.content.res.Configuration; +import android.os.Build; +import android.util.DisplayMetrics; +import android.view.View; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.GraphicsMode; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.N_MR1) +@GraphicsMode(GraphicsMode.Mode.LEGACY) +public class ProjectDisplayViewTest { + @Test + public void batchRangeFitsOnOneLineOnLegacyAndroid() { + Activity activity = landscapeActivity(1920, 1080, 2f); + ProjectDisplayView view = new ProjectDisplayView(activity, () -> {}); + activity.setContentView(view); + + DisplayModels.Batch batch = new DisplayModels.Batch(10, 10, "CALLED", "TICKET", + 3, 2, 2, "2026-08-12T10:24:08Z", Arrays.asList( + new DisplayModels.Ticket("00010", "00010", "CALLED", 1), + new DisplayModels.Ticket("00011", "00011", "CALLED", 1))); + DisplayModels.Snapshot snapshot = new DisplayModels.Snapshot( + "鸳鸯湖下湖自助手划船", "RUNNING", + new DisplayModels.Project("id", "鸳鸯湖下湖自助手划船", "RUNNING"), + 21, 0, 0, 0, 11, "00011", 2000, + batch, Collections.emptyList(), + new DisplayModels.Eta(false, 0, 0, 0, "queue_not_running"), + "2026-08-12T10:34:48Z", "2026-08-12T10:24:08Z"); + + view.showData(snapshot); + measureAndLayout(view, 1920, 1080); + Bitmap rendered = Bitmap.createBitmap(1920, 1080, Bitmap.Config.ARGB_8888); + view.draw(new Canvas(rendered)); + + SingleLineFitTextView current = view.findViewById(R.id.native_display_current_number); + assertNotNull(current); + assertEquals("00010 至 00011", current.getText().toString()); + assertEquals(1, current.getMaxLines()); + assertEquals(1, current.getLineCount()); + assertNull(current.getEllipsize()); + } + + @Test + public void productionSizeSelectionUsesPreferredThenFallbackRange() { + assertEquals(51, SingleLineFitTextView.chooseFittedTextSize(30, 74, size -> size <= 51)); + assertEquals(74, SingleLineFitTextView.chooseFittedTextSize(30, 74, size -> true)); + assertEquals(22, SingleLineFitTextView.chooseFittedTextSize(30, 74, size -> size <= 22)); + assertEquals(1, SingleLineFitTextView.chooseFittedTextSize(1, 74, size -> false)); + } + + @Test + public void asciiRangeFitsActualContentWidthAndHeight() { + Activity activity = landscapeActivity(1920, 1080, 2f); + SingleLineFitTextView current = new SingleLineFitTextView(activity); + current.setTextSize(74); + current.setFitTextSizeRange(30, 74); + current.setPadding(12, 4, 12, 4); + current.setText("WWWWW-WWWWW-WWWWW-WWWWW"); + + measureAndLayout(current, 260, 20); + current.draw(new Canvas(Bitmap.createBitmap(260, 20, Bitmap.Config.ARGB_8888))); + + int contentWidth = current.getWidth() + - current.getCompoundPaddingLeft() - current.getCompoundPaddingRight(); + int contentHeight = current.getHeight() + - current.getCompoundPaddingTop() - current.getCompoundPaddingBottom(); + Paint.FontMetrics metrics = current.getPaint().getFontMetrics(); + float textHeight = current.getIncludeFontPadding() + ? metrics.bottom - metrics.top + : metrics.descent - metrics.ascent; + assertTrue(current.getPaint().measureText(current.getText().toString()) <= contentWidth); + assertTrue(textHeight <= contentHeight); + assertEquals(1, current.getLineCount()); + assertNull(current.getEllipsize()); + } + + private static Activity landscapeActivity(int widthPx, int heightPx, float density) { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + Configuration configuration = new Configuration(activity.getResources().getConfiguration()); + configuration.orientation = Configuration.ORIENTATION_LANDSCAPE; + DisplayMetrics metrics = activity.getResources().getDisplayMetrics(); + metrics.widthPixels = widthPx; + metrics.heightPixels = heightPx; + metrics.density = density; + metrics.scaledDensity = density; + metrics.densityDpi = Math.round(DisplayMetrics.DENSITY_DEFAULT * density); + activity.getResources().updateConfiguration(configuration, metrics); + return activity; + } + + private static void measureAndLayout(View view, int widthPx, int heightPx) { + int widthSpec = View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(heightPx, View.MeasureSpec.EXACTLY); + view.measure(widthSpec, heightSpec); + view.layout(0, 0, widthPx, heightPx); + } + +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementPlayerTest.java b/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementPlayerTest.java new file mode 100644 index 0000000..f8e367e --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementPlayerTest.java @@ -0,0 +1,410 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public final class QueueAnnouncementPlayerTest { + @Test + public void streamQueuePreservesFifoAndReleasesEachSinkOnce() throws Exception { + List played = new ArrayList<>(); + AtomicInteger releases = new AtomicInteger(); + CountDownLatch completed = new CountDownLatch(2); + EmbeddedWavComposer.StreamPlaybackQueue queue = new EmbeddedWavComposer.StreamPlaybackQueue( + cancel -> new FakeSink(played, releases, completed), () -> {}, 10, "fifo-test"); + + queue.enqueue(new byte[]{1, 0}); + queue.enqueue(new byte[]{2, 0}); + + assertTrue(completed.await(2, TimeUnit.SECONDS)); + queue.shutdown(); + assertEquals(Arrays.asList(1, 2), played); + assertEquals(2, releases.get()); + } + + @Test + public void stopCompletesBeforeWorkerReleaseBegins() throws Exception { + StopReleaseHandshakeSink sink = new StopReleaseHandshakeSink(); + EmbeddedWavComposer.StreamPlaybackQueue queue = new EmbeddedWavComposer.StreamPlaybackQueue( + cancel -> sink, () -> {}, 10, "handshake-test"); + queue.enqueue(new byte[]{1, 0}); + assertTrue(sink.writeStarted.await(2, TimeUnit.SECONDS)); + + Thread control = new Thread(queue::stop); + control.start(); + assertTrue(sink.stopEntered.await(2, TimeUnit.SECONDS)); + assertFalse(sink.releaseEntered.await(100, TimeUnit.MILLISECONDS)); + sink.allowStopReturn.countDown(); + control.join(2000); + + assertTrue(sink.releaseEntered.await(2, TimeUnit.SECONDS)); + queue.shutdown(); + } + + @Test + public void focusLossUsesQueueCancellationHandshakeBeforeRelease() throws Exception { + FocusLossSink[] holder = new FocusLossSink[1]; + EmbeddedWavComposer.StreamPlaybackQueue queue = new EmbeddedWavComposer.StreamPlaybackQueue( + cancel -> holder[0] = new FocusLossSink(cancel), () -> {}, 10, "focus-test"); + queue.enqueue(new byte[]{1, 0}); + while (holder[0] == null) Thread.yield(); + FocusLossSink sink = holder[0]; + assertTrue(sink.writeStarted.await(2, TimeUnit.SECONDS)); + + Thread focus = new Thread(sink::loseFocus); + focus.start(); + assertTrue(sink.stopEntered.await(2, TimeUnit.SECONDS)); + assertFalse(sink.releaseEntered.await(100, TimeUnit.MILLISECONDS)); + sink.allowStopReturn.countDown(); + focus.join(2000); + + assertTrue(sink.releaseEntered.await(2, TimeUnit.SECONDS)); + assertEquals(1, sink.stopCount.get()); + queue.shutdown(); + } + + @Test + public void stopInterruptsBlockedWriteAndWorkerReleasesOnce() throws Exception { + BlockingSink sink = new BlockingSink(); + EmbeddedWavComposer.StreamPlaybackQueue queue = new EmbeddedWavComposer.StreamPlaybackQueue( + cancel -> sink, () -> {}, 10, "stop-test"); + queue.enqueue(new byte[]{1, 0}); + assertTrue(sink.writeStarted.await(2, TimeUnit.SECONDS)); + + queue.stop(); + + assertTrue(sink.released.await(2, TimeUnit.SECONDS)); + assertEquals(1, sink.stopCount.get()); + assertEquals(1, sink.releaseCount.get()); + queue.shutdown(); + } + + @Test + public void failuresNotifyOnceAndReleaseEachFailedSinkOnce() throws Exception { + AtomicInteger failures = new AtomicInteger(); + AtomicInteger releases = new AtomicInteger(); + CountDownLatch released = new CountDownLatch(2); + EmbeddedWavComposer.StreamPlaybackQueue queue = new EmbeddedWavComposer.StreamPlaybackQueue( + cancel -> new FailingSink(releases, released), failures::incrementAndGet, + 10, "failure-test"); + queue.enqueue(new byte[]{1, 0}); + queue.enqueue(new byte[]{2, 0}); + + assertTrue(released.await(2, TimeUnit.SECONDS)); + queue.shutdown(); + assertEquals(1, failures.get()); + assertEquals(2, releases.get()); + } + + @Test + public void wavParserAcceptsExtendedFmtAndOddPaddedUnknownChunk() throws Exception { + byte[] pcm = {1, 0, 2, 0}; + byte[] wav = wav(8000, 1, 1, 16, 16000, 2, pcm, true, true); + + EmbeddedWavComposer.WavClip clip = EmbeddedWavComposer.parseWav("test", wav); + + assertEquals(8000, clip.sampleRate); + assertArrayEquals(pcm, clip.pcm); + } + + @Test + public void wavParserRejectsTruncatedChunk() { + byte[] wav = wav(8000, 1, 1, 16, 16000, 2, new byte[]{1, 0}, false, false); + + assertThrows(IOException.class, + () -> EmbeddedWavComposer.parseWav("test", Arrays.copyOf(wav, wav.length - 1))); + } + + @Test + public void wavParserRejectsNonPcmEncoding() { + byte[] wav = wav(8000, 3, 1, 16, 16000, 2, new byte[]{1, 0}, false, false); + + assertThrows(IOException.class, () -> EmbeddedWavComposer.parseWav("test", wav)); + } + + @Test + public void wavParserRejectsInconsistentBlockAlign() { + byte[] wav = wav(8000, 1, 1, 16, 16000, 4, new byte[]{1, 0}, false, false); + + assertThrows(IOException.class, () -> EmbeddedWavComposer.parseWav("test", wav)); + } + + @Test + public void wavParserRejectsInconsistentByteRate() { + byte[] wav = wav(8000, 1, 1, 16, 8000, 2, new byte[]{1, 0}, false, false); + + assertThrows(IOException.class, () -> EmbeddedWavComposer.parseWav("test", wav)); + } + + @Test + public void wavParserRejectsDataThatIsNotFrameAligned() { + byte[] wav = wav(8000, 1, 1, 16, 16000, 2, new byte[]{1, 2, 3}, false, false); + + assertThrows(IOException.class, () -> EmbeddedWavComposer.parseWav("test", wav)); + } + + @Test + public void wavParserRejectsDuplicateFormatAndDataChunks() { + byte[] fmt = pcmFormat(8000, 1, 1, 16, 16000, 2, false); + assertThrows(IOException.class, () -> EmbeddedWavComposer.parseWav( + "fmt", wavFromChunks(chunk("fmt ", fmt), chunk("fmt ", fmt), + chunk("data", new byte[]{1, 0})))); + assertThrows(IOException.class, () -> EmbeddedWavComposer.parseWav( + "data", wavFromChunks(chunk("fmt ", fmt), chunk("data", new byte[]{1, 0}), + chunk("data", new byte[]{2, 0})))); + } + + @Test + public void clipSetRejectsMismatchedSampleRate() { + EmbeddedWavComposer.WavClip[] digits = digitClips(10); + digits[4] = clip(11, 14); + + assertThrows(IllegalArgumentException.class, () -> new EmbeddedWavComposer.ClipSet( + clip(10, 1), digits, clip(10, 2), clip(10, 3))); + } + + @Test + public void composesSingleNumberInExactTokenOrderThreeTimesWithTwoGaps() { + EmbeddedWavComposer.ClipSet clips = clipSet(); + byte[] pass = tokens(1, 10, 10, 10, 11, 17, 3); + byte[] gap = new byte[8]; // 10 Hz * 350 ms rounds to 4 PCM16 frames. + + byte[] actual = clips.compose(QueueAnnouncement.parse("00017")); + + assertArrayEquals(concat(pass, gap, pass, gap, pass), actual); + } + + @Test + public void composesRangeInExactTokenOrderThreeTimesWithNoTrailingGap() { + EmbeddedWavComposer.ClipSet clips = clipSet(); + byte[] pass = tokens(1, 10, 10, 10, 11, 17, 2, 10, 10, 10, 12, 10, 3); + byte[] gap = new byte[8]; + + byte[] actual = clips.compose(QueueAnnouncement.parse("00017-00020")); + + assertArrayEquals(concat(pass, gap, pass, gap, pass), actual); + assertEquals(pass.length * 3 + gap.length * 2, actual.length); + } + + private static EmbeddedWavComposer.ClipSet clipSet() { + return new EmbeddedWavComposer.ClipSet( + clip(10, 1), digitClips(10), clip(10, 2), clip(10, 3)); + } + + private static class FakeSink implements EmbeddedWavComposer.StreamSink { + final List played; + final AtomicInteger releases; + final CountDownLatch completed; + int marker; + + FakeSink(List played, AtomicInteger releases, CountDownLatch completed) { + this.played = played; + this.releases = releases; + this.completed = completed; + } + + public void play() {} + public int write(byte[] data, int offset, int length) { + if (marker == 0 && length > 0 && data[offset] != 0) marker = data[offset]; + return length; + } + public void awaitPlayback(long frames) { played.add(marker); completed.countDown(); } + public void stop() {} + public void release() { releases.incrementAndGet(); } + } + + private static final class BlockingSink implements EmbeddedWavComposer.StreamSink { + final CountDownLatch writeStarted = new CountDownLatch(1); + final CountDownLatch released = new CountDownLatch(1); + final CountDownLatch stopped = new CountDownLatch(1); + final AtomicInteger stopCount = new AtomicInteger(); + final AtomicInteger releaseCount = new AtomicInteger(); + + public void play() {} + public int write(byte[] data, int offset, int length) { + writeStarted.countDown(); + try { stopped.await(2, TimeUnit.SECONDS); } catch (InterruptedException ignored) {} + return -1; + } + public void awaitPlayback(long frames) {} + public void stop() { stopCount.incrementAndGet(); stopped.countDown(); } + public void release() { releaseCount.incrementAndGet(); released.countDown(); } + } + + private static final class FailingSink implements EmbeddedWavComposer.StreamSink { + final AtomicInteger releases; + final CountDownLatch released; + FailingSink(AtomicInteger releases, CountDownLatch released) { + this.releases = releases; this.released = released; + } + public void play() {} + public int write(byte[] data, int offset, int length) { throw new IllegalStateException(); } + public void awaitPlayback(long frames) {} + public void stop() {} + public void release() { releases.incrementAndGet(); released.countDown(); } + } + + private static final class StopReleaseHandshakeSink implements EmbeddedWavComposer.StreamSink { + final CountDownLatch writeStarted = new CountDownLatch(1); + final CountDownLatch stopEntered = new CountDownLatch(1); + final CountDownLatch allowStopReturn = new CountDownLatch(1); + final CountDownLatch releaseEntered = new CountDownLatch(1); + volatile boolean stopped; + public void play() {} + public int write(byte[] data, int offset, int length) { + writeStarted.countDown(); + while (!stopped) Thread.yield(); + return -1; + } + public void awaitPlayback(long frames) {} + public void stop() { + stopEntered.countDown(); + stopped = true; + try { allowStopReturn.await(2, TimeUnit.SECONDS); } catch (InterruptedException ignored) {} + } + public void release() { releaseEntered.countDown(); } + } + + private static final class FocusLossSink implements EmbeddedWavComposer.StreamSink { + final Runnable cancel; + final CountDownLatch writeStarted = new CountDownLatch(1); + final CountDownLatch stopEntered = new CountDownLatch(1); + final CountDownLatch allowStopReturn = new CountDownLatch(1); + final CountDownLatch releaseEntered = new CountDownLatch(1); + final AtomicInteger stopCount = new AtomicInteger(); + volatile boolean stopped; + FocusLossSink(Runnable cancel) { this.cancel = cancel; } + void loseFocus() { cancel.run(); } + public void play() {} + public int write(byte[] data, int offset, int length) { + writeStarted.countDown(); + while (!stopped) Thread.yield(); + return -1; + } + public void awaitPlayback(long frames) {} + public void stop() { + stopCount.incrementAndGet(); stopEntered.countDown(); stopped = true; + try { allowStopReturn.await(2, TimeUnit.SECONDS); } catch (InterruptedException ignored) {} + } + public void release() { releaseEntered.countDown(); } + } + + private static EmbeddedWavComposer.WavClip[] digitClips(int sampleRate) { + EmbeddedWavComposer.WavClip[] clips = new EmbeddedWavComposer.WavClip[10]; + for (int digit = 0; digit < clips.length; digit += 1) { + clips[digit] = clip(sampleRate, 10 + digit); + } + return clips; + } + + private static EmbeddedWavComposer.WavClip clip(int sampleRate, int marker) { + return new EmbeddedWavComposer.WavClip(sampleRate, new byte[]{(byte) marker, 0}); + } + + private static byte[] tokens(int... markers) { + byte[] bytes = new byte[markers.length * 2]; + for (int index = 0; index < markers.length; index += 1) { + bytes[index * 2] = (byte) markers[index]; + } + return bytes; + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (byte[] part : parts) { + output.write(part, 0, part.length); + } + return output.toByteArray(); + } + + private static byte[] wav(int sampleRate, int audioFormat, int channels, int bitsPerSample, + int byteRate, int blockAlign, byte[] pcm, boolean extendedFmt, boolean oddUnknownChunk) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + byte[] fmt = pcmFormat(sampleRate, audioFormat, channels, bitsPerSample, + byteRate, blockAlign, extendedFmt); + writeChunk(body, "fmt ", fmt); + if (oddUnknownChunk) { + writeChunk(body, "JUNK", new byte[]{7, 8, 9}); + } + writeChunk(body, "data", pcm); + + byte[] chunks = body.toByteArray(); + ByteArrayOutputStream result = new ByteArrayOutputStream(); + writeAscii(result, "RIFF"); + writeInt(result, chunks.length + 4); + writeAscii(result, "WAVE"); + result.write(chunks, 0, chunks.length); + return result.toByteArray(); + } + + private static byte[] pcmFormat(int sampleRate, int audioFormat, int channels, + int bitsPerSample, int byteRate, int blockAlign, boolean extendedFmt) { + byte[] fmt = new byte[extendedFmt ? 18 : 16]; + putShort(fmt, 0, audioFormat); putShort(fmt, 2, channels); + putInt(fmt, 4, sampleRate); putInt(fmt, 8, byteRate); + putShort(fmt, 12, blockAlign); putShort(fmt, 14, bitsPerSample); + return fmt; + } + + private static byte[] chunk(String id, byte[] data) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + writeChunk(output, id, data); + return output.toByteArray(); + } + + private static byte[] wavFromChunks(byte[]... chunks) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + for (byte[] chunk : chunks) body.write(chunk, 0, chunk.length); + ByteArrayOutputStream result = new ByteArrayOutputStream(); + writeAscii(result, "RIFF"); writeInt(result, body.size() + 4); writeAscii(result, "WAVE"); + byte[] bytes = body.toByteArray(); result.write(bytes, 0, bytes.length); + return result.toByteArray(); + } + + private static void writeChunk(ByteArrayOutputStream output, String id, byte[] data) { + writeAscii(output, id); + writeInt(output, data.length); + output.write(data, 0, data.length); + if ((data.length & 1) != 0) { + output.write(0); + } + } + + private static void writeAscii(ByteArrayOutputStream output, String value) { + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + output.write(bytes, 0, bytes.length); + } + + private static void writeInt(ByteArrayOutputStream output, int value) { + output.write(value & 0xff); + output.write((value >>> 8) & 0xff); + output.write((value >>> 16) & 0xff); + output.write((value >>> 24) & 0xff); + } + + private static void putShort(byte[] data, int offset, int value) { + data[offset] = (byte) value; + data[offset + 1] = (byte) (value >>> 8); + } + + private static void putInt(byte[] data, int offset, int value) { + data[offset] = (byte) value; + data[offset + 1] = (byte) (value >>> 8); + data[offset + 2] = (byte) (value >>> 16); + data[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java b/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java index b225d9f..75f6779 100644 --- a/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java +++ b/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java @@ -1,27 +1,42 @@ package cn.nianxx.queue.display; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import org.junit.Test; -import java.util.List; - public final class QueueAnnouncementTest { @Test - public void repeatsValidAnnouncementThreeTimes() { - List utterances = QueueAnnouncement.utterances(" 请零零零一六号,前往入口。 "); + public void parsesASingleNumberAndPreservesLeadingZeroes() { + QueueAnnouncement announcement = QueueAnnouncement.parse("00016"); - assertEquals(3, utterances.size()); - assertEquals("请零零零一六号,前往入口。", utterances.get(0)); - assertEquals(utterances.get(0), utterances.get(1)); - assertEquals(utterances.get(0), utterances.get(2)); + assertEquals("00016", announcement.firstNumber()); + assertEquals("00016", announcement.lastNumber()); + assertFalse(announcement.isRange()); } @Test - public void ignoresMissingOrUnreasonablyLongText() { - assertTrue(QueueAnnouncement.utterances(null).isEmpty()); - assertTrue(QueueAnnouncement.utterances(" ").isEmpty()); - assertTrue(QueueAnnouncement.utterances("1".repeat(201)).isEmpty()); + public void parsesAnAscendingRange() { + QueueAnnouncement announcement = QueueAnnouncement.parse("00016-00018"); + + assertEquals("00016", announcement.firstNumber()); + assertEquals("00018", announcement.lastNumber()); + assertTrue(announcement.isRange()); + } + + @Test + public void rejectsAnythingOutsideTheStrictWireFormat() { + assertNull(QueueAnnouncement.parse(null)); + assertNull(QueueAnnouncement.parse("")); + assertNull(QueueAnnouncement.parse(" 00016")); + assertNull(QueueAnnouncement.parse("00016 ")); + assertNull(QueueAnnouncement.parse("0016")); + assertNull(QueueAnnouncement.parse("0001A")); + assertNull(QueueAnnouncement.parse("00016")); + assertNull(QueueAnnouncement.parse("00016:00018")); + assertNull(QueueAnnouncement.parse("00018-00016")); + assertNull(QueueAnnouncement.parse("00016-00016")); } } diff --git a/android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java b/android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java index 6f38ebb..aeb9f7d 100644 --- a/android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java +++ b/android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java @@ -5,25 +5,35 @@ import static org.junit.Assert.assertNull; import org.junit.Test; +import java.util.Arrays; +import java.util.Collections; import java.util.List; public final class QueueCallTextTest { @Test - public void formatsOneNumberDigitByDigit() { - assertEquals( - "请零零零一六号,前往入口。", - QueueCallText.format(List.of("00016"))); + public void formatsOneNumberWithoutChangingItsDigits() { + assertEquals("00016", QueueCallText.format(Collections.singletonList("00016"))); } @Test - public void formatsMultipleNumbersAsRange() { - assertEquals( - "请零零零一六号至零零零一八号,前往入口。", - QueueCallText.format(List.of("00016", "00017", "00018"))); + public void formatsMultipleNumbersAsFirstAndLastRange() { + assertEquals("00016-00018", QueueCallText.format(Arrays.asList("00016", "00017", "00018"))); } @Test - public void ignoresMissingNumbers() { - assertNull(QueueCallText.format(List.of("", " "))); + public void equalFirstAndLastNumbersCollapseToSingleNumber() { + assertEquals("00016", QueueCallText.format(Arrays.asList("00016", "00016"))); + } + + @Test + public void rejectsAnEmptyBatchOrAnyMalformedNumber() { + assertNull(QueueCallText.format(null)); + assertNull(QueueCallText.format(Collections.emptyList())); + assertNull(QueueCallText.format(Collections.singletonList("0016"))); + assertNull(QueueCallText.format(Collections.singletonList(" 00016"))); + assertNull(QueueCallText.format(Collections.singletonList("0001A"))); + assertNull(QueueCallText.format(Collections.singletonList("00016"))); + assertNull(QueueCallText.format(Arrays.asList("00016", null, "00018"))); + assertNull(QueueCallText.format(Arrays.asList("00018", "00016"))); } } diff --git a/deliverables/android/README.md b/deliverables/android/README.md index 76fe7b4..cd89d32 100644 --- a/deliverables/android/README.md +++ b/deliverables/android/README.md @@ -1,8 +1,10 @@ -# Android APK 交付物 +# Android APK 交付件 -- `XQKQueue-Display-1.0.0-debug.apk`:已使用 Android Debug 证书签名,可直接安装验证。 -- `SHA256SUMS.txt`:安装包完整性校验值。 +- `XQKQueue-Display-1.5.2-debug.apk`:纯 Android 原生大屏应用,使用 Android Debug 证书签名,可直接安装验证。 +- `SHA256SUMS.txt`:安装包的 SHA-256 完整性校验值。 -该 APK 默认打开 `https://queue.nianxx.cn/admin/display`,也可显式唤起 `https://queue.nianxx.cn/display/YYHHC` 等单项目地址。 +1.5.2 保留 1.5.1 的项目原有普通话 PCM WAV,并修复批量叫号范围在部分电视上只显示首号和“至”的问题。当前号会按实际面板宽高缩放,完整显示首尾号码。无需设备安装或启用系统中文 TTS 引擎。应用直接读取公开展示接口并由原生 Views 绘制总览和项目详情。总览页不播报;进入具体项目详情后,只对新叫号按顺序播报三遍。 -正式分发前,请改用单位长期保管的 Android 签名证书构建 Release APK,以便后续安全升级。 +应用固定横屏并适配 1920×1080 大屏及不同显示密度。播报使用媒体音量和流式音轨,并申请临时媒体音频焦点;请确保设备媒体音量已打开,且没有其他应用持续独占音频输出。 + +这是 **debug 交付包,不是 release 包**。正式分发前请使用单位长期保管的 Android 签名证书构建 Release APK,以保证后续升级签名一致。 diff --git a/deliverables/android/SHA256SUMS.txt b/deliverables/android/SHA256SUMS.txt index b43382a..d38117b 100644 --- a/deliverables/android/SHA256SUMS.txt +++ b/deliverables/android/SHA256SUMS.txt @@ -1 +1 @@ -5504D25C1FDB1310FF85DADCB779BA5C07F48706514B23F8265B492CE21CAB30 XQKQueue-Display-1.0.0-debug.apk +0D56C10B8CE706893F3EA8E59B21329847517F56F7347ABEA127AB30C6C71D77 XQKQueue-Display-1.5.2-debug.apk diff --git a/deliverables/android/XQKQueue-Display-1.0.0-debug.apk b/deliverables/android/XQKQueue-Display-1.0.0-debug.apk deleted file mode 100644 index 4ea6dd4..0000000 Binary files a/deliverables/android/XQKQueue-Display-1.0.0-debug.apk and /dev/null differ diff --git a/deliverables/android/XQKQueue-Display-1.5.2-debug.apk b/deliverables/android/XQKQueue-Display-1.5.2-debug.apk new file mode 100644 index 0000000..e2cc68a Binary files /dev/null and b/deliverables/android/XQKQueue-Display-1.5.2-debug.apk differ diff --git a/web/src/styles.css b/web/src/styles.css index 6216451..6ab1847 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -6661,6 +6661,107 @@ body:has(.visitor-page--mobile) { } } +/* High-density display devices can expose a short CSS viewport even when the + physical panel is 1080p. Compact only that landscape fullscreen layout. */ +@media (max-height: 600px) and (min-aspect-ratio: 3 / 2) { + .screen-tile:is(:fullscreen, .screen-tile--standalone) { + grid-template-rows: auto minmax(128px, .8fr) minmax(0, 1.65fr); + gap: 8px; + padding-bottom: 10px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__header { + gap: 16px; + padding: 8px 24px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__logo { + width: 48px; + height: 32px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock { + gap: 10px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock time { + font-size: 2rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock span { + font-size: .72rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 { + font-size: 1.3rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current, + .screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast { + margin-inline: 24px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current { + gap: 6px; + padding: 22px 16px 10px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current > strong { + font-size: 3.2rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current > small { + top: 8px; + left: 12px; + font-size: .72rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics { + gap: 8px; + padding-top: 4px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric { + min-height: 38px; + padding: 4px 6px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric > span { + font-size: .65rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric > strong { + font-size: .85rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast { + grid-template-rows: auto minmax(0, 1fr); + padding: 8px 16px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-list-heading { + margin-top: 0; + font-size: .72rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol { + gap: 4px; + margin-top: 4px; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li { + min-height: 0; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li strong { + font-size: 1.3rem; + } + + .screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li span { + font-size: 1.05rem; + } +} + .single-display-screen { min-height: 100dvh; overflow: hidden;