diff --git a/.gitignore b/.gitignore index 524722d..afc1ecb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ web/*.tsbuildinfo web/vite.config.js web/vite.config.d.ts +# Android +android/.gradle/ +android/local.properties +android/**/build/ +android/.idea/ + # Local databases *.db *.sqlite diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..eeaccfd --- /dev/null +++ b/android/README.md @@ -0,0 +1,35 @@ +# Android 公示屏 APK + +这是景区排队叫号系统的原生 Android 薄壳,只承载两个公开页面: + +- 项目列表:`https://queue.nianxx.cn/admin/display` +- 单项目展示:`https://queue.nianxx.cn/display/{项目编码或公示 Token}` + +## 现场能力 + +- 无浏览器地址栏的沉浸式展示,并保持屏幕常亮 +- 接管项目卡的网页全屏请求,返回键退出全屏 +- 允许叫号音频无需额外手势播放 +- 支持上述两个 HTTPS 页面作为 Android 深链 +- 仅允许 `queue.nianxx.cn` 的公开公示路径,阻止后台、员工端、外域和明文 HTTP 导航 +- 网络或证书异常时显示原生重试页 + +APK 仍依赖线上服务和 Android System WebView,不会把实时队列数据离线复制进安装包。 + +## 构建 + +需要 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。 + +## 安装与深链验证 + +```powershell +& "$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' +``` diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..7098563 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,38 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "cn.nianxx.queue.display" + compileSdk = 36 + buildToolsVersion = "36.1.0" + + defaultConfig { + applicationId = "cn.nianxx.queue.display" + minSdk = 23 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + minifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + returnDefaultValues = true + } + } +} + +dependencies { + testImplementation("junit:junit:4.13.2") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a6fab64 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..66e4405 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java @@ -0,0 +1,403 @@ +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.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 FrameLayout root; + private WebView webView; + 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; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + configureWindow(); + buildContentView(); + setContentView(root); + registerPredictiveBackCallback(); + + String initialUrl = NavigationPolicy.initialUrlOrStart(getIntent().getDataString()); + lastAllowedUrl = initialUrl; + webView.loadUrl(initialUrl); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + String target = NavigationPolicy.initialUrlOrStart(intent.getDataString()); + exitWebFullscreen(); + if (!target.equals(webView.getUrl())) { + webView.loadUrl(target); + } + } + + @Override + protected void onResume() { + super.onResume(); + webView.onResume(); + enterImmersiveMode(); + } + + @Override + protected void onPause() { + webView.onPause(); + super.onPause(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + enterImmersiveMode(); + } + } + + @Override + @SuppressLint("GestureBackNavigation") + @SuppressWarnings("deprecation") + public void onBackPressed() { + handleBackNavigation(); + } + + private void handleBackNavigation() { + if (customView != null) { + exitWebFullscreen(); + return; + } + if (webView.canGoBack()) { + webView.goBack(); + return; + } + if (NavigationPolicy.isProjectDisplay(lastAllowedUrl)) { + webView.loadUrl(NavigationPolicy.START_URL); + return; + } + finishAfterTransition(); + } + + private void registerPredictiveBackCallback() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT, + this::handleBackNavigation); + } + } + + @Override + protected void onDestroy() { + if (customView != null) { + root.removeView(customView); + customView = null; + if (customViewCallback != null) { + customViewCallback.onCustomViewHidden(); + customViewCallback = null; + } + } + root.removeView(webView); + webView.stopLoading(); + webView.setWebChromeClient(null); + webView.setWebViewClient(null); + webView.destroy(); + super.onDestroy(); + } + + private void configureWindow() { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + enterImmersiveMode(); + } + + @SuppressWarnings("deprecation") + private void enterImmersiveMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getWindow().setDecorFitsSystemWindows(false); + WindowInsetsController controller = getWindow().getInsetsController(); + if (controller != null) { + controller.hide(WindowInsets.Type.systemBars()); + controller.setSystemBarsBehavior( + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + return; + } + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | 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.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() { + 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 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; + } + showLoading(); + } + + @Override + public void onPageFinished(WebView view, String url) { + 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/NavigationPolicy.java b/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java new file mode 100644 index 0000000..463dde0 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java @@ -0,0 +1,61 @@ +package cn.nianxx.queue.display; + +import java.net.URI; +import java.net.URISyntaxException; +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 DISPLAY_PREFIX = "/display/"; + private static final Pattern DISPLAY_IDENTIFIER = Pattern.compile("[A-Za-z0-9_-]{2,128}"); + + private NavigationPolicy() { + } + + static String initialUrlOrStart(String candidate) { + return isAllowed(candidate) ? candidate : START_URL; + } + + static boolean isAllowed(String candidate) { + URI uri = parse(candidate); + if (uri == null + || !"https".equalsIgnoreCase(uri.getScheme()) + || !HOST.equalsIgnoreCase(uri.getHost()) + || uri.getRawUserInfo() != null + || (uri.getPort() != -1 && uri.getPort() != 443)) { + return false; + } + + String path = uri.getRawPath(); + if ("/admin/display".equals(path)) { + return true; + } + 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); + } + + private static URI parse(String candidate) { + if (candidate == null || candidate.isBlank()) { + return null; + } + try { + return new URI(candidate); + } catch (URISyntaxException ignored) { + return null; + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..689de06 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3510872 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + 景区排队叫号大屏 + 页面暂时无法连接 + 请检查网络连接后重试 + 重新加载 + 已阻止离开公示页面 + 安全连接校验失败,请联系管理员 + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cc7c3dc --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..9780658 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..4437632 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..683208f --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + 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 new file mode 100644 index 0000000..33a1ae4 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java @@ -0,0 +1,50 @@ +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; + +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")); + + 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 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)); + } + + @Test + public void fallsBackToTheProjectListForInvalidDeepLinks() { + assertEquals( + NavigationPolicy.START_URL, + NavigationPolicy.initialUrlOrStart("https://example.com/display/YYHHC")); + assertEquals( + "https://queue.nianxx.cn/display/YYHHC", + NavigationPolicy.initialUrlOrStart("https://queue.nianxx.cn/display/YYHHC")); + } + + @Test + public void identifiesDirectProjectDisplaysForBackNavigation() { + assertTrue(NavigationPolicy.isProjectDisplay("https://queue.nianxx.cn/display/YYHHC")); + assertFalse(NavigationPolicy.isProjectDisplay(NavigationPolicy.START_URL)); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..e4bb369 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,3 @@ +plugins { + id "com.android.application" version "9.2.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..7d05e58 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c82ad3f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..cb518ae --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "XQKQueueDisplay" +include(":app") diff --git a/deliverables/android/README.md b/deliverables/android/README.md new file mode 100644 index 0000000..76fe7b4 --- /dev/null +++ b/deliverables/android/README.md @@ -0,0 +1,8 @@ +# Android APK 交付物 + +- `XQKQueue-Display-1.0.0-debug.apk`:已使用 Android Debug 证书签名,可直接安装验证。 +- `SHA256SUMS.txt`:安装包完整性校验值。 + +该 APK 默认打开 `https://queue.nianxx.cn/admin/display`,也可显式唤起 `https://queue.nianxx.cn/display/YYHHC` 等单项目地址。 + +正式分发前,请改用单位长期保管的 Android 签名证书构建 Release APK,以便后续安全升级。 diff --git a/deliverables/android/SHA256SUMS.txt b/deliverables/android/SHA256SUMS.txt new file mode 100644 index 0000000..b43382a --- /dev/null +++ b/deliverables/android/SHA256SUMS.txt @@ -0,0 +1 @@ +5504D25C1FDB1310FF85DADCB779BA5C07F48706514B23F8265B492CE21CAB30 XQKQueue-Display-1.0.0-debug.apk diff --git a/deliverables/android/XQKQueue-Display-1.0.0-debug.apk b/deliverables/android/XQKQueue-Display-1.0.0-debug.apk new file mode 100644 index 0000000..4ea6dd4 Binary files /dev/null and b/deliverables/android/XQKQueue-Display-1.0.0-debug.apk differ diff --git a/findings.md b/findings.md index 93893ca..62a8205 100644 --- a/findings.md +++ b/findings.md @@ -631,3 +631,28 @@ - 1280×720 下四个统计卡各约 233px 宽,内容 `scrollWidth=231px`,没有裁切;整页宽高仍与视口一致。 - 390×844 下使用两列统计卡和较小的号段字号,号段与等待时间保留 20px 间距;页面 `scrollWidth=390`、`scrollHeight=844`,无横向或纵向溢出。 - 浏览器日志只有 Vite 连接与热更新调试信息,无 warning 或 error。 + +# 2026-08-01 Android 公示屏 APK + +- 用户要将公开项目页面做成 Android APK,明确只有“项目列表”和“具体项目展示”两类页面。 +- 现有仓库已提供公开项目列表入口 `/admin/display` 和单项目入口 `/display/{项目编码或 Token}`;`YYHHC` 是现成项目编码示例。 +- 初始方案是原生 WebView 薄壳:线上页面保持单一事实来源,APK 负责沉浸式显示、屏幕常亮、允许无需手势的媒体播放和受控页面导航。 +- 仓库当前没有 Android 工程;需要先确认本机 Android SDK/JDK/Gradle 能力,再选择兼容的最小构建配置。 +- 公开项目列表入口实际是 `/admin/display`;每个项目卡的“全屏展示”调用 DOM `requestFullscreen()`,并不跳转 URL。Android 壳必须实现 `WebChromeClient.onShowCustomView`,否则仍会出现“浏览器未允许全屏”。 +- 单项目展示入口是 `/display/:token`,同时支持项目编码(如 `YYHHC`)和既有公示 Token;可作为 HTTPS 深链直接进入 APK。 +- 本机有 Amazon Corretto JDK 21、Android SDK platform 35/36/36.1、Build Tools 35.0.0/36.1.0/37.0.0;未把 Gradle、ADB、sdkmanager 加入 PATH,仓库也没有 Gradle Wrapper。 +- Android SDK 实际位于 `C:\Users\7brot\AppData\Local\Android\Sdk`,可由工程 `local.properties` 或构建环境变量显式引用。 +- 普通网页读取器对 `queue.nianxx.cn` 返回不可重试的安全校验拒绝,未获得线上页面内容;不能把该失败解读为线上服务不可用。 +- 应用内浏览器打开 `/admin/display` 时也在资源加载阶段超时;这与仓库 2026-07-31 的线上访问记录一致,当前尚无证据表明页面业务接口本身故障。 +- 有界重试成功打开 `https://queue.nianxx.cn/display/YYHHC`,页面标题为“景区排队叫号系统”,项目名为“鸳鸯湖下湖自助手划船”,单项目展示结构与源码一致。 +- 浏览器侧出现的是宿主 Statsig 初始化超时,目标页面本身已完成导航和 DOM 渲染;不将该外部统计错误计入产品故障。 +- `/admin/display` 第二次只读导航仍超时,按有界重试原则停止;其路由、项目卡、全屏按钮和公开数据接口已由本地源码及 2026-07-31 的既有验收充分确认。 +- Android 官方 AGP 9.2.0 兼容表要求 Gradle 9.4.1、最低 JDK 17、最低 Build Tools 36.0.0;本机 JDK 21 与 Build Tools 36.1.0 满足基线,因此采用 AGP 9.2.0 + Gradle 9.4.1。 +- Android 官方文档明确:WebView 若不覆盖 `onShowCustomView/onHideCustomView` 会向网页报告不支持全屏;`setMediaPlaybackRequiresUserGesture(false)` 可取消默认的媒体播放手势要求。这两项正好对应项目列表全屏和叫号播报需求。 +- 本机 Gradle 缓存已包含完整的 Gradle 9.4.1 发行包,可直接生成 Wrapper,无需另行下载启动器。 +- Android 工程采用纯 Java 和系统 WebView,不引入 AndroidX、UI 框架或运行时第三方依赖;唯一测试依赖是 JUnit 4.13.2。 +- Android 单测、Lint 和 Debug APK 构建已同时通过;APK 包名 `cn.nianxx.queue.display`、版本 `1.0.0`、minSdk 23、target/compileSdk 36,仅声明网络权限。 +- 生成的 Debug APK 为 902,508 字节,已通过 v1/v2 签名校验;当前电脑未连接 Android 真机或模拟器,因此无法在本轮执行设备级 WebView/音频/全屏交互验收。 +- Lint 第二轮为 0 错误、4 个版本/配置警告;确认 `autoVerify=false` 曾误加到 Launcher Filter,现已移到 HTTPS VIEW Filter,剩余三个是本机未安装 API 37 和采用 AGP 官方默认 Gradle 9.4.1 的已知版本提示。 +- 最终干净构建后 Lint 为 0 错误、3 个已知工具链版本提示;WebView 危险 API 定向扫描无匹配。 +- 最终交付 APK 位于 `deliverables/android/XQKQueue-Display-1.0.0-debug.apk`,大小 895,292 字节,SHA-256 为 `5504D25C1FDB1310FF85DADCB779BA5C07F48706514B23F8265B492CE21CAB30`。 diff --git a/progress.md b/progress.md index b2b2641..7e6185e 100644 --- a/progress.md +++ b/progress.md @@ -719,3 +719,32 @@ - 聚焦验证通过:单项目页 3 项、公开大屏中心 2 项、管理端 10 项测试及 TypeScript 检查全部通过。 - 已使用真实 `YYHHC` 数据完成 2048×1152、1280×720、390×844 视觉验收;三种尺寸均无页面溢出,窄屏统计文字完整可见。 - 全量验证通过:前端 18 个测试文件共 61 项、TypeScript 双配置检查、Vite 生产构建;后端 `go test ./... -count=1`、`go vet ./...`、`go build ./...`;`git diff --check` 无格式错误。 + +# Session: 2026-08-01(Android 公示屏 APK) + +- 已读取 `karpathy-guidelines` 与 `planning-with-files-zh`,恢复现有项目上下文。 +- 已确认仓库当前只有 Web/Go 服务端,没有 Android 工程。 +- 已建立 Phase 49;目标是交付仅承载公开项目列表页和单项目展示页的可安装 APK。 +- 环境恢复脚本因本机无 `python` 命令未运行,已改为直接读取台账与 Git 状态。 +- 首次并行探测确认 JDK 21 与 Android SDK 存在,但因末尾 `rg` 未匹配 Gradle Wrapper 而整体返回非零;已记录并改为分开探测。 +- 已核对 React 路由和公示组件:列表页通过网页 Fullscreen API 展开项目,单项目页使用 `/display/:token`;Android 侧需同时支持网页全屏和受控深链。 +- 已确认本机具备 JDK 21、Android SDK platform 35/36/36.1 与 Build Tools,下一步补齐可复现的 Gradle 构建入口。 +- 普通网页读取器无法通过域名安全校验,下一步改用只读应用内浏览器核对线上页面,不重复同一失败路径。 +- 应用内浏览器首次打开线上项目列表超时;将按故障指引仅重试一次,避免线上核对阻塞本地 APK 实现。 +- 有界重试已成功核对线上 `/display/YYHHC`:标题、项目名、当前叫号与四项统计均正常渲染。 +- 线上项目列表第二次仍在资源加载阶段超时,已停止重试;Phase 49 的页面与导航契约核对完成,后续不依赖该在线检查。 +- 已依据 Android 官方兼容表确定构建基线:AGP 9.2.0、Gradle 9.4.1、compileSdk 36、Build Tools 36.1.0、minSdk 23。 +- 已新增 `android/` 原生工程:沉浸式 WebView、屏幕常亮、网页全屏接管、免手势音频、受控深链、HTTPS/路径白名单、原生错误重试页及导航策略单元测试。 +- 已更新 `.gitignore` 排除 Android 构建缓存、`local.properties` 与模块构建目录。 +- 已使用本机缓存的 Gradle 9.4.1 生成标准 Wrapper;工程文件清单完整,首次 `git diff --check` 通过。 +- 首次单测在主代码编译阶段发现 `PermissionRequest` 包名错误;已按编译器定位修正,并顺手移除同次构建报告的过时 Gradle 属性,等待重新验证。 +- 导航白名单单元测试已通过:覆盖两类允许路径、HTTPS/主机/端口约束、伪造域名、用户信息、编码斜杠、无效深链回退与返回导航判定。 +- `assembleDebug` 已实际产出 APK,但同次 `lintDebug` 报告 2 个错误、8 个警告;首项是 Android 16 预测性返回未走旧 `onBackPressed`,正在按完整报告修正后重跑。 +- 已逐项处理完整 Lint 报告:接入 Android 13+ 平台返回回调、移除低版本不兼容样式属性、明确深链不做域名自动验证、补齐禁止备份规则,并把 Gradle DSL 改为新版赋值语法。 +- 第二轮 `testDebugUnitTest lintDebug assembleDebug` 全部成功;Lint 已降为 0 错误、4 个非阻断警告,APK 已通过包信息和签名静态核验。 +- `adb devices` 未发现连接设备,本轮设备级安装验收暂不可执行;继续完成清单修正、源码审查与交付物复制。 +- 已复核 Manifest 与完整 `MainActivity`,修正深链 Filter 的 `autoVerify=false` 位置;未发现需要扩大范围的业务改动。 +- 干净构建再次通过;已将 895,292 字节的安装包复制到 `deliverables/android`,并生成 SHA-256 校验文件和交付说明。 +- 最终一致性检查通过:源码 APK 与交付 APK 哈希一致、Checksum 文件一致、Lint 0 错误、WebView 危险 API 扫描无命中、`git diff --check` 通过。 +- Phase 49 已完成;剩余外部事项仅为连接 Android 设备做现场验收,以及正式发布时换用单位长期签名证书。 +- 规划技能完成检查显示 47/48:唯一未完成项是历史遗留的 Phase 13“等待用户选定视觉候选图”,与本次 Phase 49 无关,未擅自改写其状态。 diff --git a/task_plan.md b/task_plan.md index 61cf4d8..3763d7b 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 47(单项目公示屏排版修复) +Phase 49(Android 公示屏 APK) ## Phases @@ -632,3 +632,40 @@ Phase 47(单项目公示屏排版修复) | 视觉基准 | 以用户截图和仓库内 `ProjectScreenTile` 全屏态为准 | 截图对应现有管理端项目卡全屏结构,可保持两个入口一致 | | 改动边界 | 只复用展示组件与公开统计字段,不改轮询和语音播报 | 降低视觉重做对实时叫号行为的回归风险 | | 适配策略 | 独立路由使用全屏样式修饰类,管理端预览仍保持缩略卡片 | `/display/YYHHC` 无需点击全屏即可获得参考布局 | + +### Phase 49: Android 公示屏 APK(2026-08-01) +- [x] 核对项目列表页、单项目展示页及两者导航契约 +- [x] 确认本机 Android 构建工具并确定最小壳应用方案 +- [x] 实现仅承载公开公示页面的沉浸式 WebView 应用 +- [x] 覆盖返回导航、外链隔离、音频自动播放、常亮与错误恢复 +- [x] 构建可安装 APK 并完成静态、单元与构建验证 +- **Status:** complete + +#### Initial Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 页面范围 | 默认打开公开项目列表,只允许应用内进入同域项目展示页 | 对齐“项目列表 + 具体项目展示”两页需求,避免把后台或任意网页装进壳内 | +| 实现方向 | 使用原生 Android WebView 薄壳,不复制现有 React 页面 | 页面数据和样式继续由线上系统统一维护,APK 只解除浏览器地址栏、休眠和媒体播放等现场约束 | +| 构建基线 | AGP 9.2.0、Gradle 9.4.1、compileSdk 36、minSdk 23、Java 17 字节码 | 与本机 JDK 21、Android 36 平台和 Build Tools 36.1.0 对齐,同时覆盖常见 Android 6+ 设备 | + +#### Verification Evidence +| Check | Result | +|---|---| +| 干净构建 | `clean testDebugUnitTest lintDebug assembleDebug` 通过 | +| Android Lint | 0 errors;3 个已知工具链版本提示 | +| APK 包信息 | `cn.nianxx.queue.display` / `1.0.0` / minSdk 23 / targetSdk 36 / 仅 INTERNET 权限 | +| APK 签名 | v1、v2 校验通过;Android Debug 证书 | +| 交付校验 | SHA-256 `5504D25C1FDB1310FF85DADCB779BA5C07F48706514B23F8265B492CE21CAB30` | +| 设备验收 | 本机 `adb devices` 无设备;未执行真机/模拟器交互验收 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---:|---| +| `planning-with-files-zh` 会话恢复脚本找不到本机 `python` 命令 | 1 | 不重复执行;直接读取三份现有台账并核对 Git 状态恢复上下文 | +| 并行环境探测以无匹配的 `rg` 结束,导致整组工具调用返回失败 | 1 | 后续把源码与环境探测拆开,并让只读探测显式正常退出 | +| 普通网页读取器因域名安全校验拒绝两个线上页面 | 1 | 不绕过校验;改用只读应用内浏览器核对实际页面 | +| 应用内浏览器打开线上项目列表时资源加载超时并重置连接 | 2 | 已按故障指引有界重试;详情页可用但列表仍超时,停止重试并以仓库源码与既有页面验收记录为准 | +| 更新台账时同一文件的补丁片段顺序与文件顺序相反,校验失败 | 1 | 按文件中的实际先后顺序重新排列补丁片段,未产生部分修改 | +| 更新台账的补丁在切换文件前残留空 hunk 标记,语法校验失败 | 1 | 删除多余 hunk 标记后重新应用,未产生部分修改 | +| 首次 Android 编译把 `PermissionRequest` 误从 `android.view` 导入 | 1 | 改为正确的 `android.webkit.PermissionRequest`;同时删除 AGP 9.2 已弃用的 `android.useAndroidX=false` 配置 | +| 首次 Lint 报告 2 个错误、8 个警告,其中返回键实现不覆盖 Android 16 预测性返回 | 1 | 读取完整报告,迁移到平台 `OnBackInvokedDispatcher` 并逐项关闭其余问题,不建立 Lint 基线掩盖错误 |