功能:大屏 APK 原生播报及项目入口

具体项目页由 APK 原生轮询并播报三次,总览页不播报。总览项目卡片通过项目编码进入对应大屏。
This commit is contained in:
2026-08-11 00:58:17 +08:00
parent 5ad8bfcd03
commit e4e3e6a735
27 changed files with 728 additions and 236 deletions

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -6,4 +6,5 @@
<string name="retry">重新加载</string>
<string name="blocked_navigation">已阻止离开公示页面</string>
<string name="ssl_error">安全连接校验失败,请联系管理员</string>
<string name="speech_unavailable">未检测到可用的中文语音引擎,请在系统设置中启用中文文字转语音</string>
</resources>

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}

View File

@@ -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());
}
}

View File

@@ -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("", " ")));
}
}