功能:大屏 APK 原生播报及项目入口
具体项目页由 APK 原生轮询并播报三次,总览页不播报。总览项目卡片通过项目编码进入对应大屏。
This commit is contained in:
@@ -2,19 +2,27 @@
|
||||
|
||||
这是景区排队叫号系统的原生 Android 薄壳,只承载两个公开页面:
|
||||
|
||||
- 项目列表:`https://queue.nianxx.cn/admin/display`
|
||||
- 单项目展示:`https://queue.nianxx.cn/display/{项目编码或公示 Token}`
|
||||
- 总览页面:`https://queue.nianxx.cn/admin/display`
|
||||
- 单项目页面:`https://queue.nianxx.cn/display/{项目编号或公示 Token}`
|
||||
|
||||
## 现场能力
|
||||
|
||||
- 无浏览器地址栏的沉浸式展示,并保持屏幕常亮
|
||||
- 总览页面只展示项目状态,不进行语音播报
|
||||
- 单项目页面由 APK 每 3 秒读取公开快照接口,发现新叫号批次后调用系统中文 TTS
|
||||
- 每次叫号由 Android 原生层连续播报三次,并使用系统媒体音量通道
|
||||
- 接管项目卡的网页全屏请求,返回键退出全屏
|
||||
- 允许叫号音频无需额外手势播放
|
||||
- 支持上述两个 HTTPS 页面作为 Android 深链
|
||||
- 仅允许 `queue.nianxx.cn` 的公开公示路径,阻止后台、员工端、外域和明文 HTTP 导航
|
||||
- 网络或证书异常时显示原生重试页
|
||||
- 网络、证书或中文语音引擎异常时显示原生提示
|
||||
|
||||
APK 仍依赖线上服务和 Android System WebView,不会把实时队列数据离线复制进安装包。
|
||||
APK 仍依赖线上服务和 Android System WebView,不会把实时队列数据离线复制进安装包。叫号检测和播报都由 APK 原生层负责,不依赖线上网页是否包含语音代码;普通浏览器访问单项目页面时不会播报。
|
||||
|
||||
## 设备要求
|
||||
|
||||
- 系统已安装并启用支持简体中文的文字转语音引擎
|
||||
- 设备媒体音量已打开
|
||||
- Android System WebView 可以访问 `https://queue.nianxx.cn`
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.nianxx.queue.display"
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = 4
|
||||
versionName = "1.2.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.nianxx.queue.display;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
final class CallAnnouncementTracker {
|
||||
private boolean initialized;
|
||||
private String lastCallKey;
|
||||
|
||||
String accept(String callKey, String announcement) {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
lastCallKey = callKey;
|
||||
return null;
|
||||
}
|
||||
if (Objects.equals(lastCallKey, callKey)) {
|
||||
return null;
|
||||
}
|
||||
lastCallKey = callKey;
|
||||
return callKey != null ? announcement : null;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
initialized = false;
|
||||
lastCallKey = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import android.widget.Toast;
|
||||
public final class MainActivity extends Activity {
|
||||
private FrameLayout root;
|
||||
private WebView webView;
|
||||
private QueueAnnouncementPlayer announcementPlayer;
|
||||
private DisplayAnnouncementPoller announcementPoller;
|
||||
private ProgressBar progressBar;
|
||||
private LinearLayout errorView;
|
||||
private TextView errorDetail;
|
||||
@@ -47,11 +49,20 @@ public final class MainActivity extends Activity {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureWindow();
|
||||
buildContentView();
|
||||
announcementPlayer = new QueueAnnouncementPlayer(
|
||||
this,
|
||||
() -> runOnUiThread(() -> Toast.makeText(
|
||||
this,
|
||||
R.string.speech_unavailable,
|
||||
Toast.LENGTH_LONG).show()));
|
||||
announcementPoller = new DisplayAnnouncementPoller(announcementPlayer::announce);
|
||||
setContentView(root);
|
||||
enterImmersiveMode();
|
||||
registerPredictiveBackCallback();
|
||||
|
||||
String initialUrl = NavigationPolicy.initialUrlOrStart(getIntent().getDataString());
|
||||
lastAllowedUrl = initialUrl;
|
||||
updateAnnouncementPolling();
|
||||
webView.loadUrl(initialUrl);
|
||||
}
|
||||
|
||||
@@ -60,6 +71,8 @@ public final class MainActivity extends Activity {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
String target = NavigationPolicy.initialUrlOrStart(intent.getDataString());
|
||||
lastAllowedUrl = target;
|
||||
updateAnnouncementPolling();
|
||||
exitWebFullscreen();
|
||||
if (!target.equals(webView.getUrl())) {
|
||||
webView.loadUrl(target);
|
||||
@@ -71,10 +84,12 @@ public final class MainActivity extends Activity {
|
||||
super.onResume();
|
||||
webView.onResume();
|
||||
enterImmersiveMode();
|
||||
updateAnnouncementPolling();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
announcementPoller.stop();
|
||||
webView.onPause();
|
||||
super.onPause();
|
||||
}
|
||||
@@ -104,6 +119,8 @@ public final class MainActivity extends Activity {
|
||||
return;
|
||||
}
|
||||
if (NavigationPolicy.isProjectDisplay(lastAllowedUrl)) {
|
||||
lastAllowedUrl = NavigationPolicy.START_URL;
|
||||
updateAnnouncementPolling();
|
||||
webView.loadUrl(NavigationPolicy.START_URL);
|
||||
return;
|
||||
}
|
||||
@@ -120,6 +137,8 @@ public final class MainActivity extends Activity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
announcementPoller.close();
|
||||
announcementPlayer.shutdown();
|
||||
if (customView != null) {
|
||||
root.removeView(customView);
|
||||
customView = null;
|
||||
@@ -139,7 +158,6 @@ public final class MainActivity extends Activity {
|
||||
private void configureWindow() {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
enterImmersiveMode();
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@@ -205,7 +223,7 @@ public final class MainActivity extends Activity {
|
||||
settings.setGeolocationEnabled(false);
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
|
||||
settings.setUserAgentString(settings.getUserAgentString() + " XQKQueueDisplay/1.0");
|
||||
settings.setUserAgentString(settings.getUserAgentString() + " XQKQueueDisplay/1.2.0");
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
settings.setSafeBrowsingEnabled(true);
|
||||
}
|
||||
@@ -299,6 +317,15 @@ public final class MainActivity extends Activity {
|
||||
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) {
|
||||
@@ -326,12 +353,17 @@ public final class MainActivity extends Activity {
|
||||
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);
|
||||
|
||||
@@ -48,6 +48,15 @@ final class NavigationPolicy {
|
||||
&& 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";
|
||||
}
|
||||
|
||||
private static URI parse(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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 QueueAnnouncement() {
|
||||
}
|
||||
|
||||
static List<String> utterances(String value) {
|
||||
if (value == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String text = value.trim();
|
||||
if (text.isEmpty() || text.length() > MAX_TEXT_LENGTH) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Collections.nCopies(REPEAT_COUNT, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package cn.nianxx.queue.display;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.AudioManager;
|
||||
import android.speech.tts.TextToSpeech;
|
||||
import android.speech.tts.UtteranceProgressListener;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
final class QueueAnnouncementPlayer {
|
||||
private static final String TAG = "XQKQueueDisplay";
|
||||
|
||||
interface Listener {
|
||||
void onSpeechUnavailable();
|
||||
}
|
||||
|
||||
private final Context context;
|
||||
private final Listener listener;
|
||||
private TextToSpeech engine;
|
||||
private boolean ready;
|
||||
private String pendingText;
|
||||
private long utteranceSequence;
|
||||
|
||||
QueueAnnouncementPlayer(Context context, Listener listener) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
void announce(String value) {
|
||||
List<String> utterances = QueueAnnouncement.utterances(value);
|
||||
if (utterances.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String text = utterances.get(0);
|
||||
if (ready) {
|
||||
speakNow(text);
|
||||
return;
|
||||
}
|
||||
pendingText = text;
|
||||
if (engine == null) {
|
||||
Log.i(TAG, "Initializing Android TTS");
|
||||
engine = new TextToSpeech(context, this::onInitialized);
|
||||
}
|
||||
}
|
||||
|
||||
void shutdown() {
|
||||
pendingText = null;
|
||||
ready = false;
|
||||
if (engine == null) {
|
||||
return;
|
||||
}
|
||||
engine.stop();
|
||||
engine.shutdown();
|
||||
engine = null;
|
||||
}
|
||||
|
||||
private void onInitialized(int status) {
|
||||
TextToSpeech currentEngine = engine;
|
||||
if (currentEngine == null) {
|
||||
return;
|
||||
}
|
||||
if (status != TextToSpeech.SUCCESS) {
|
||||
fail("initialization failed with status " + status);
|
||||
return;
|
||||
}
|
||||
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()
|
||||
.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");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDone(String utteranceId) {
|
||||
Log.i(TAG, "TTS utterance completed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String utteranceId) {
|
||||
Log.e(TAG, "TTS utterance failed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String utteranceId, int errorCode) {
|
||||
Log.e(TAG, "TTS utterance failed with code " + errorCode);
|
||||
}
|
||||
});
|
||||
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 speakNow(String text) {
|
||||
TextToSpeech currentEngine = engine;
|
||||
if (!ready || currentEngine == null) {
|
||||
return;
|
||||
}
|
||||
currentEngine.stop();
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Queued " + acceptedCount + " of " + utterances.size() + " TTS utterances");
|
||||
if (acceptedCount == 0) {
|
||||
listener.onSpeechUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
listener.onSpeechUnavailable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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<String> values) {
|
||||
List<String> numbers = new ArrayList<>();
|
||||
for (String value : values) {
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
numbers.add(value.trim());
|
||||
}
|
||||
}
|
||||
if (numbers.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);
|
||||
}
|
||||
}
|
||||
return spoken.toString();
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,5 @@
|
||||
<string name="retry">重新加载</string>
|
||||
<string name="blocked_navigation">已阻止离开公示页面</string>
|
||||
<string name="ssl_error">安全连接校验失败,请联系管理员</string>
|
||||
<string name="speech_unavailable">未检测到可用的中文语音引擎,请在系统设置中启用中文文字转语音</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.nianxx.queue.display;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class CallAnnouncementTrackerTest {
|
||||
@Test
|
||||
public void establishesBaselineThenReturnsOnlyNewAnnouncements() {
|
||||
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", "请零零零二号,前往入口。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsNextBatchAfterCurrentCallClears() {
|
||||
CallAnnouncementTracker tracker = new CallAnnouncementTracker();
|
||||
|
||||
assertNull(tracker.accept(null, null));
|
||||
assertEquals(
|
||||
"请零零零一号,前往入口。",
|
||||
tracker.accept("1:2026-08-11T00:00:00Z", "请零零零一号,前往入口。"));
|
||||
assertNull(tracker.accept(null, null));
|
||||
assertEquals(
|
||||
"请零零零二号,前往入口。",
|
||||
tracker.accept("2:2026-08-11T00:01:00Z", "请零零零二号,前往入口。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetRequiresANewBaseline() {
|
||||
CallAnnouncementTracker tracker = new CallAnnouncementTracker();
|
||||
assertNull(tracker.accept("1:first", "first"));
|
||||
assertEquals("second", tracker.accept("2:second", "second"));
|
||||
|
||||
tracker.reset();
|
||||
|
||||
assertNull(tracker.accept("3:third", "third"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -47,4 +48,13 @@ public final class NavigationPolicyTest {
|
||||
assertTrue(NavigationPolicy.isProjectDisplay("https://queue.nianxx.cn/display/YYHHC"));
|
||||
assertFalse(NavigationPolicy.isProjectDisplay(NavigationPolicy.START_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createsSnapshotUrlOnlyForDirectProjectDisplays() {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.nianxx.queue.display;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class QueueAnnouncementTest {
|
||||
@Test
|
||||
public void repeatsValidAnnouncementThreeTimes() {
|
||||
List<String> utterances = QueueAnnouncement.utterances(" 请零零零一六号,前往入口。 ");
|
||||
|
||||
assertEquals(3, utterances.size());
|
||||
assertEquals("请零零零一六号,前往入口。", utterances.get(0));
|
||||
assertEquals(utterances.get(0), utterances.get(1));
|
||||
assertEquals(utterances.get(0), utterances.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoresMissingOrUnreasonablyLongText() {
|
||||
assertTrue(QueueAnnouncement.utterances(null).isEmpty());
|
||||
assertTrue(QueueAnnouncement.utterances(" ").isEmpty());
|
||||
assertTrue(QueueAnnouncement.utterances("1".repeat(201)).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.nianxx.queue.display;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class QueueCallTextTest {
|
||||
@Test
|
||||
public void formatsOneNumberDigitByDigit() {
|
||||
assertEquals(
|
||||
"请零零零一六号,前往入口。",
|
||||
QueueCallText.format(List.of("00016")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatsMultipleNumbersAsRange() {
|
||||
assertEquals(
|
||||
"请零零零一六号至零零零一八号,前往入口。",
|
||||
QueueCallText.format(List.of("00016", "00017", "00018")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoresMissingNumbers() {
|
||||
assertNull(QueueCallText.format(List.of("", " ")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user