From 95a3c6d8c4ec67cb0a56f7e51977e06ba414f42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=8C=E7=B3=96=E5=8C=85=E5=AD=90?= Date: Mon, 18 May 2026 22:08:04 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=8C=85=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SmsProviderInstrumentedTest.java | 2 +- app/src/main/AndroidManifest.xml | 10 +-- .../app/FeishuWebhookPushResult.java | 47 -------------- .../app/{ => feishu}/FeishuWebhookClient.java | 24 +++---- .../FeishuWebhookConfigStore.java | 64 ++++++++++--------- .../app/feishu/FeishuWebhookPushResult.java | 47 ++++++++++++++ .../app/{ => keepalive}/BootReceiver.java | 2 +- .../{ => keepalive}/KeepAliveDatabase.java | 8 +-- .../KeepAliveNotification.java | 4 +- .../{ => keepalive}/KeepAliveStateStore.java | 50 +++++++-------- .../{ => keepalive}/SmsKeepAliveService.java | 8 ++- .../{ => keepalive}/SmsPollingService.java | 11 +++- .../{ => keepalive}/SmsPollingStateStore.java | 36 +++++------ .../app/{ => sms}/CaptureResult.java | 28 ++++---- .../app/{ => sms}/SmsCaptureStore.java | 42 ++++++------ .../app/{ => sms}/SmsInboxReader.java | 44 ++++++------- .../app/{ => sms}/SmsMessageReader.java | 2 +- .../smsreceive/app/{ => sms}/SmsReceiver.java | 4 +- .../app/{ => sms}/VerificationCodeParser.java | 2 +- .../smsreceive/app/{ => ui}/MainActivity.java | 15 ++++- .../{ => feishu}/FeishuWebhookClientTest.java | 2 +- .../{ => sms}/VerificationCodeParserTest.java | 2 +- gradle.properties | 1 - 23 files changed, 241 insertions(+), 214 deletions(-) rename app/src/androidTest/java/com/smsreceive/app/{ => sms}/SmsProviderInstrumentedTest.java (95%) delete mode 100644 app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java rename app/src/main/java/com/smsreceive/app/{ => feishu}/FeishuWebhookClient.java (93%) rename app/src/main/java/com/smsreceive/app/{ => feishu}/FeishuWebhookConfigStore.java (89%) create mode 100644 app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookPushResult.java rename app/src/main/java/com/smsreceive/app/{ => keepalive}/BootReceiver.java (98%) rename app/src/main/java/com/smsreceive/app/{ => keepalive}/KeepAliveDatabase.java (94%) rename app/src/main/java/com/smsreceive/app/{ => keepalive}/KeepAliveNotification.java (96%) rename app/src/main/java/com/smsreceive/app/{ => keepalive}/KeepAliveStateStore.java (79%) rename app/src/main/java/com/smsreceive/app/{ => keepalive}/SmsKeepAliveService.java (95%) rename app/src/main/java/com/smsreceive/app/{ => keepalive}/SmsPollingService.java (94%) rename app/src/main/java/com/smsreceive/app/{ => keepalive}/SmsPollingStateStore.java (81%) rename app/src/main/java/com/smsreceive/app/{ => sms}/CaptureResult.java (77%) rename app/src/main/java/com/smsreceive/app/{ => sms}/SmsCaptureStore.java (83%) rename app/src/main/java/com/smsreceive/app/{ => sms}/SmsInboxReader.java (92%) rename app/src/main/java/com/smsreceive/app/{ => sms}/SmsMessageReader.java (98%) rename app/src/main/java/com/smsreceive/app/{ => sms}/SmsReceiver.java (97%) rename app/src/main/java/com/smsreceive/app/{ => sms}/VerificationCodeParser.java (99%) rename app/src/main/java/com/smsreceive/app/{ => ui}/MainActivity.java (98%) rename app/src/test/java/com/smsreceive/app/{ => feishu}/FeishuWebhookClientTest.java (98%) rename app/src/test/java/com/smsreceive/app/{ => sms}/VerificationCodeParserTest.java (98%) diff --git a/app/src/androidTest/java/com/smsreceive/app/SmsProviderInstrumentedTest.java b/app/src/androidTest/java/com/smsreceive/app/sms/SmsProviderInstrumentedTest.java similarity index 95% rename from app/src/androidTest/java/com/smsreceive/app/SmsProviderInstrumentedTest.java rename to app/src/androidTest/java/com/smsreceive/app/sms/SmsProviderInstrumentedTest.java index 0d9761d..b460c18 100644 --- a/app/src/androidTest/java/com/smsreceive/app/SmsProviderInstrumentedTest.java +++ b/app/src/androidTest/java/com/smsreceive/app/sms/SmsProviderInstrumentedTest.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; import android.content.Context; import android.util.Log; diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3a4ad5..1300a6f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:supportsRtl="true" android:theme="@style/AppTheme"> @@ -25,7 +25,7 @@ @@ -35,7 +35,7 @@ @@ -46,12 +46,12 @@ diff --git a/app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java b/app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java deleted file mode 100644 index 0d66fdf..0000000 --- a/app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.smsreceive.app; - -final class FeishuWebhookPushResult { - static final String STATUS_SUCCESS = "success"; - static final String STATUS_DISABLED = "disabled"; - static final String STATUS_MISSING_CONFIG = "missing_config"; - static final String STATUS_SIGN_ERROR = "sign_error"; - static final String STATUS_NETWORK_ERROR = "network_error"; - static final String STATUS_TIMEOUT = "timeout"; - static final String STATUS_HTTP_ERROR = "http_error"; - static final String STATUS_INVALID_JSON = "invalid_json"; - static final String STATUS_API_ERROR = "api_error"; - - final boolean success; - final String status; - final String message; - final int httpStatus; - final int apiCode; - final long timeMillis; - - private FeishuWebhookPushResult( - boolean success, - String status, - String message, - int httpStatus, - int apiCode, - long timeMillis) { - this.success = success; - this.status = status == null ? "" : status; - this.message = message == null ? "" : message; - this.httpStatus = httpStatus; - this.apiCode = apiCode; - this.timeMillis = timeMillis; - } - - static FeishuWebhookPushResult success(String message) { - return new FeishuWebhookPushResult(true, STATUS_SUCCESS, message, 200, 0, System.currentTimeMillis()); - } - - static FeishuWebhookPushResult failure(String status, String message) { - return failure(status, message, 0, 0); - } - - static FeishuWebhookPushResult failure(String status, String message, int httpStatus, int apiCode) { - return new FeishuWebhookPushResult(false, status, message, httpStatus, apiCode, System.currentTimeMillis()); - } -} diff --git a/app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookClient.java similarity index 93% rename from app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java rename to app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookClient.java index ab29327..7282305 100644 --- a/app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java +++ b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookClient.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.feishu; import android.content.Context; import android.content.Intent; @@ -11,6 +11,8 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import com.smsreceive.app.sms.CaptureResult; + import java.io.IOException; import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; @@ -29,7 +31,7 @@ import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; -final class FeishuWebhookClient { +public final class FeishuWebhookClient { private static final String TAG = "[SMS]SmsReceive"; private static final String WEBHOOK_URL_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/"; private static final char[] BASE64_TABLE = @@ -47,7 +49,7 @@ final class FeishuWebhookClient { private FeishuWebhookClient() { } - static void pushCaptureResultAsync(Context context, CaptureResult result) { + public static void pushCaptureResultAsync(Context context, CaptureResult result) { if (context == null || result == null) { return; } @@ -76,7 +78,7 @@ final class FeishuWebhookClient { pushMarkdownAsync(appContext, markdown, result); } - static void pushMarkdownAsync(Context context, String markdownContent) { + public static void pushMarkdownAsync(Context context, String markdownContent) { pushMarkdownAsync(context, markdownContent, null); } @@ -98,7 +100,7 @@ final class FeishuWebhookClient { }); } - static FeishuWebhookPushResult pushMarkdown( + public static FeishuWebhookPushResult pushMarkdown( FeishuWebhookConfigStore.Config config, String markdownContent, long timestampSeconds) { @@ -157,14 +159,14 @@ final class FeishuWebhookClient { } } - static String generateSign(String secret, long timestampSeconds) throws GeneralSecurityException { + public static String generateSign(String secret, long timestampSeconds) throws GeneralSecurityException { String stringToSign = timestampSeconds + "\n" + (secret == null ? "" : secret); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return base64EncodeNoWrap(mac.doFinal(new byte[0])); } - static String buildRequestJson(String markdownContent, long timestampSeconds, String sign) throws JSONException { + public static String buildRequestJson(String markdownContent, long timestampSeconds, String sign) throws JSONException { JSONObject markdown = new JSONObject() .put("tag", "markdown") .put("content", markdownContent == null ? "" : markdownContent); @@ -178,7 +180,7 @@ final class FeishuWebhookClient { .toString(); } - static FeishuWebhookPushResult parseResponse(String responseBody) { + public static FeishuWebhookPushResult parseResponse(String responseBody) { try { JSONObject json = new JSONObject(responseBody == null ? "" : responseBody); int code = json.optInt("code", Integer.MIN_VALUE); @@ -198,15 +200,15 @@ final class FeishuWebhookClient { } } - static String buildWebhookUrl(String webhookId) { + public static String buildWebhookUrl(String webhookId) { return WEBHOOK_URL_PREFIX + (webhookId == null ? "" : webhookId.trim()); } - static String buildMarkdownFromCapture(CaptureResult result) { + public static String buildMarkdownFromCapture(CaptureResult result) { return buildMarkdownFromCapture(result, new FeishuWebhookConfigStore.Config(false, "", "", false, false)); } - static String buildMarkdownFromCapture(CaptureResult result, FeishuWebhookConfigStore.Config config) { + public static String buildMarkdownFromCapture(CaptureResult result, FeishuWebhookConfigStore.Config config) { boolean filterCode = config != null && config.filterVerificationCode; boolean includeFullBody = config == null || !filterCode || config.sendFullBodyDebug; StringBuilder builder = new StringBuilder(); diff --git a/app/src/main/java/com/smsreceive/app/FeishuWebhookConfigStore.java b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookConfigStore.java similarity index 89% rename from app/src/main/java/com/smsreceive/app/FeishuWebhookConfigStore.java rename to app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookConfigStore.java index 2774e1f..fc1975d 100644 --- a/app/src/main/java/com/smsreceive/app/FeishuWebhookConfigStore.java +++ b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookConfigStore.java @@ -1,9 +1,11 @@ -package com.smsreceive.app; +package com.smsreceive.app.feishu; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; +import com.smsreceive.app.sms.CaptureResult; + import org.json.JSONException; import org.json.JSONObject; @@ -16,8 +18,8 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; -final class FeishuWebhookConfigStore { - static final String ACTION_PUSH_UPDATED = "com.smsreceive.app.ACTION_FEISHU_PUSH_UPDATED"; +public final class FeishuWebhookConfigStore { + public static final String ACTION_PUSH_UPDATED = "com.smsreceive.app.ACTION_FEISHU_PUSH_UPDATED"; private static final String TAG = "[SMS]SmsReceive"; private static final String PREFS = "feishu_webhook"; @@ -43,7 +45,7 @@ final class FeishuWebhookConfigStore { private FeishuWebhookConfigStore() { } - static Config loadConfig(Context context) { + public static Config loadConfig(Context context) { ensureDefaultConfigFile(context); File file = configFile(context); if (!file.exists()) { @@ -59,7 +61,7 @@ final class FeishuWebhookConfigStore { } } - static void saveConfig( + public static void saveConfig( Context context, boolean enabled, String webhookId, @@ -82,7 +84,7 @@ final class FeishuWebhookConfigStore { } } - static void saveLastResult(Context context, FeishuWebhookPushResult result) { + public static void saveLastResult(Context context, FeishuWebhookPushResult result) { preferences(context).edit() .putLong(KEY_LAST_TIME, result.timeMillis) .putBoolean(KEY_LAST_SUCCESS, result.success) @@ -136,7 +138,7 @@ final class FeishuWebhookConfigStore { + ", smsKey=" + smsKey); } - static LastResult loadLastResult(Context context) { + public static LastResult loadLastResult(Context context) { SharedPreferences prefs = preferences(context); return new LastResult( prefs.getLong(KEY_LAST_TIME, 0L), @@ -147,14 +149,14 @@ final class FeishuWebhookConfigStore { prefs.getInt(KEY_LAST_API_CODE, 0)); } - static LastPushedSms loadLastPushedSms(Context context) { + public static LastPushedSms loadLastPushedSms(Context context) { SharedPreferences prefs = preferences(context); return new LastPushedSms( prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L), prefs.getString(KEY_LAST_PUSHED_SMS_KEY, "")); } - static String maskSecret(String secret) { + public static String maskSecret(String secret) { if (isEmpty(secret)) { return ""; } @@ -164,15 +166,15 @@ final class FeishuWebhookConfigStore { return secret.substring(0, 3) + "***" + secret.substring(secret.length() - 3); } - static String configPath(Context context) { + public static String configPath(Context context) { return configFile(context).getAbsolutePath(); } - static String defaultConfigPath(Context context) { + public static String defaultConfigPath(Context context) { return defaultConfigFile(context).getAbsolutePath(); } - static String configTemplate() { + public static String configTemplate() { try { return configToJson(defaultConfig()).toString(2); } catch (JSONException e) { @@ -289,14 +291,14 @@ final class FeishuWebhookConfigStore { + Integer.toHexString((result.body == null ? "" : result.body).hashCode()); } - static final class Config { - final boolean enabled; - final String webhookId; - final String secret; - final boolean sendFullBodyDebug; - final boolean filterVerificationCode; + public static final class Config { + public final boolean enabled; + public final String webhookId; + public final String secret; + public final boolean sendFullBodyDebug; + public final boolean filterVerificationCode; - Config( + public Config( boolean enabled, String webhookId, String secret, @@ -309,22 +311,22 @@ final class FeishuWebhookConfigStore { this.filterVerificationCode = filterVerificationCode; } - boolean hasWebhookId() { + public boolean hasWebhookId() { return !isEmpty(webhookId); } - boolean hasSecret() { + public boolean hasSecret() { return !isEmpty(secret); } } - static final class LastResult { - final long timeMillis; - final boolean success; - final String status; - final String message; - final int httpStatus; - final int apiCode; + public static final class LastResult { + public final long timeMillis; + public final boolean success; + public final String status; + public final String message; + public final int httpStatus; + public final int apiCode; LastResult(long timeMillis, boolean success, String status, String message, int httpStatus, int apiCode) { this.timeMillis = timeMillis; @@ -336,9 +338,9 @@ final class FeishuWebhookConfigStore { } } - static final class LastPushedSms { - final long receivedSecond; - final String smsKey; + public static final class LastPushedSms { + public final long receivedSecond; + public final String smsKey; LastPushedSms(long receivedSecond, String smsKey) { this.receivedSecond = receivedSecond; diff --git a/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookPushResult.java b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookPushResult.java new file mode 100644 index 0000000..232685a --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookPushResult.java @@ -0,0 +1,47 @@ +package com.smsreceive.app.feishu; + +public final class FeishuWebhookPushResult { + public static final String STATUS_SUCCESS = "success"; + public static final String STATUS_DISABLED = "disabled"; + public static final String STATUS_MISSING_CONFIG = "missing_config"; + public static final String STATUS_SIGN_ERROR = "sign_error"; + public static final String STATUS_NETWORK_ERROR = "network_error"; + public static final String STATUS_TIMEOUT = "timeout"; + public static final String STATUS_HTTP_ERROR = "http_error"; + public static final String STATUS_INVALID_JSON = "invalid_json"; + public static final String STATUS_API_ERROR = "api_error"; + + public final boolean success; + public final String status; + public final String message; + public final int httpStatus; + public final int apiCode; + public final long timeMillis; + + private FeishuWebhookPushResult( + boolean success, + String status, + String message, + int httpStatus, + int apiCode, + long timeMillis) { + this.success = success; + this.status = status == null ? "" : status; + this.message = message == null ? "" : message; + this.httpStatus = httpStatus; + this.apiCode = apiCode; + this.timeMillis = timeMillis; + } + + public static FeishuWebhookPushResult success(String message) { + return new FeishuWebhookPushResult(true, STATUS_SUCCESS, message, 200, 0, System.currentTimeMillis()); + } + + public static FeishuWebhookPushResult failure(String status, String message) { + return failure(status, message, 0, 0); + } + + public static FeishuWebhookPushResult failure(String status, String message, int httpStatus, int apiCode) { + return new FeishuWebhookPushResult(false, status, message, httpStatus, apiCode, System.currentTimeMillis()); + } +} diff --git a/app/src/main/java/com/smsreceive/app/BootReceiver.java b/app/src/main/java/com/smsreceive/app/keepalive/BootReceiver.java similarity index 98% rename from app/src/main/java/com/smsreceive/app/BootReceiver.java rename to app/src/main/java/com/smsreceive/app/keepalive/BootReceiver.java index 39108b7..db9d459 100644 --- a/app/src/main/java/com/smsreceive/app/BootReceiver.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/BootReceiver.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.keepalive; import android.content.BroadcastReceiver; import android.content.Context; diff --git a/app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveDatabase.java similarity index 94% rename from app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java rename to app/src/main/java/com/smsreceive/app/keepalive/KeepAliveDatabase.java index d4fab6e..3ed49a2 100644 --- a/app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveDatabase.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.keepalive; import android.content.ContentValues; import android.content.Context; @@ -11,7 +11,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; -final class KeepAliveDatabase { +public final class KeepAliveDatabase { private static final String TAG = "[SMS]SmsReceive"; private static final String DATABASE_NAME = "sms_keep_alive.db"; private static final int DATABASE_VERSION = 1; @@ -23,7 +23,7 @@ final class KeepAliveDatabase { private KeepAliveDatabase() { } - static long writeLastActiveTime(Context context) { + public static long writeLastActiveTime(Context context) { long now = System.currentTimeMillis(); SQLiteDatabase database = helper(context).getWritableDatabase(); ContentValues values = new ContentValues(); @@ -35,7 +35,7 @@ final class KeepAliveDatabase { return now; } - static long readLastActiveTime(Context context) { + public static long readLastActiveTime(Context context) { SQLiteDatabase database = helper(context).getReadableDatabase(); try (Cursor cursor = database.query( TABLE_META, diff --git a/app/src/main/java/com/smsreceive/app/KeepAliveNotification.java b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveNotification.java similarity index 96% rename from app/src/main/java/com/smsreceive/app/KeepAliveNotification.java rename to app/src/main/java/com/smsreceive/app/keepalive/KeepAliveNotification.java index 4d44b08..7e5dedd 100644 --- a/app/src/main/java/com/smsreceive/app/KeepAliveNotification.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveNotification.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.keepalive; import android.app.Notification; import android.app.NotificationChannel; @@ -9,6 +9,8 @@ import android.content.Intent; import android.os.Build; import android.util.Log; +import com.smsreceive.app.ui.MainActivity; + final class KeepAliveNotification { static final int NOTIFICATION_ID = 2101; private static final String TAG = "[SMS]SmsReceive"; diff --git a/app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveStateStore.java similarity index 79% rename from app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java rename to app/src/main/java/com/smsreceive/app/keepalive/KeepAliveStateStore.java index a1d75c0..74de526 100644 --- a/app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveStateStore.java @@ -1,11 +1,11 @@ -package com.smsreceive.app; +package com.smsreceive.app.keepalive; import android.content.Context; import android.content.SharedPreferences; import android.text.TextUtils; import android.util.Log; -final class KeepAliveStateStore { +public final class KeepAliveStateStore { private static final String TAG = "[SMS]SmsReceive"; private static final String PREFS = "sms_keep_alive"; @@ -23,14 +23,14 @@ final class KeepAliveStateStore { private KeepAliveStateStore() { } - static void setEnabledByUser(Context context, boolean enabled) { + public static void setEnabledByUser(Context context, boolean enabled) { Log.d(TAG, "KeepAliveStateStore.setEnabledByUser enabled=" + enabled); preferences(context).edit() .putBoolean(KEY_ENABLED_BY_USER, enabled) .apply(); } - static void recordServiceStarted(Context context) { + public static void recordServiceStarted(Context context) { long now = System.currentTimeMillis(); Log.d(TAG, "KeepAliveStateStore.recordServiceStarted time=" + now); preferences(context).edit() @@ -40,7 +40,7 @@ final class KeepAliveStateStore { .apply(); } - static void recordServiceStopped(Context context, String reason) { + public static void recordServiceStopped(Context context, String reason) { Log.d(TAG, "KeepAliveStateStore.recordServiceStopped reason=" + reason); preferences(context).edit() .putBoolean(KEY_SERVICE_RUNNING, false) @@ -48,7 +48,7 @@ final class KeepAliveStateStore { .apply(); } - static void recordHeartbeat(Context context) { + public static void recordHeartbeat(Context context) { Log.d(TAG, "KeepAliveStateStore.recordHeartbeat"); preferences(context).edit() .putBoolean(KEY_SERVICE_RUNNING, true) @@ -56,7 +56,7 @@ final class KeepAliveStateStore { .apply(); } - static void recordBootEvent(Context context, String action) { + public static void recordBootEvent(Context context, String action) { long now = System.currentTimeMillis(); Log.d(TAG, "KeepAliveStateStore.recordBootEvent action=" + action + ", time=" + now); preferences(context).edit() @@ -65,7 +65,7 @@ final class KeepAliveStateStore { .apply(); } - static void recordServiceStartFailure(Context context, String reason) { + public static void recordServiceStartFailure(Context context, String reason) { Log.w(TAG, "KeepAliveStateStore.recordServiceStartFailure reason=" + reason); preferences(context).edit() .putBoolean(KEY_SERVICE_RUNNING, false) @@ -73,39 +73,39 @@ final class KeepAliveStateStore { .apply(); } - static void setManualAutostartConfirmed(Context context, boolean confirmed) { + public static void setManualAutostartConfirmed(Context context, boolean confirmed) { Log.d(TAG, "KeepAliveStateStore.setManualAutostartConfirmed confirmed=" + confirmed); preferences(context).edit() .putBoolean(KEY_MANUAL_AUTOSTART_CONFIRMED, confirmed) .apply(); } - static void setManualBatteryUnrestrictedConfirmed(Context context, boolean confirmed) { + public static void setManualBatteryUnrestrictedConfirmed(Context context, boolean confirmed) { Log.d(TAG, "KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed confirmed=" + confirmed); preferences(context).edit() .putBoolean(KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED, confirmed) .apply(); } - static void setBatteryOptimizationIgnored(Context context, boolean ignored) { + public static void setBatteryOptimizationIgnored(Context context, boolean ignored) { Log.d(TAG, "KeepAliveStateStore.setBatteryOptimizationIgnored ignored=" + ignored); preferences(context).edit() .putBoolean(KEY_BATTERY_OPTIMIZATION_IGNORED, ignored) .apply(); } - static void setToastOnDatabaseWrite(Context context, boolean enabled) { + public static void setToastOnDatabaseWrite(Context context, boolean enabled) { Log.d(TAG, "KeepAliveStateStore.setToastOnDatabaseWrite enabled=" + enabled); preferences(context).edit() .putBoolean(KEY_TOAST_ON_DATABASE_WRITE, enabled) .apply(); } - static boolean isToastOnDatabaseWriteEnabled(Context context) { + public static boolean isToastOnDatabaseWriteEnabled(Context context) { return preferences(context).getBoolean(KEY_TOAST_ON_DATABASE_WRITE, false); } - static State load(Context context) { + public static State load(Context context) { SharedPreferences prefs = preferences(context); return new State( prefs.getBoolean(KEY_ENABLED_BY_USER, false), @@ -128,17 +128,17 @@ final class KeepAliveStateStore { return TextUtils.isEmpty(value) ? "" : value; } - static final class State { - final boolean enabledByUser; - final boolean serviceRunning; - final long lastHeartbeatMillis; - final String lastBootEvent; - final long lastBootTimeMillis; - final String lastServiceStartFailure; - final boolean manualAutostartConfirmed; - final boolean manualBatteryUnrestrictedConfirmed; - final boolean batteryOptimizationIgnored; - final boolean toastOnDatabaseWrite; + public static final class State { + public final boolean enabledByUser; + public final boolean serviceRunning; + public final long lastHeartbeatMillis; + public final String lastBootEvent; + public final long lastBootTimeMillis; + public final String lastServiceStartFailure; + public final boolean manualAutostartConfirmed; + public final boolean manualBatteryUnrestrictedConfirmed; + public final boolean batteryOptimizationIgnored; + public final boolean toastOnDatabaseWrite; State( boolean enabledByUser, diff --git a/app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java b/app/src/main/java/com/smsreceive/app/keepalive/SmsKeepAliveService.java similarity index 95% rename from app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java rename to app/src/main/java/com/smsreceive/app/keepalive/SmsKeepAliveService.java index 8ec1138..0ebaa68 100644 --- a/app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/SmsKeepAliveService.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.keepalive; import android.app.Service; import android.content.Context; @@ -10,6 +10,8 @@ import android.os.Looper; import android.util.Log; import android.widget.Toast; +import com.smsreceive.app.sms.SmsCaptureStore; + import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; @@ -41,7 +43,7 @@ public final class SmsKeepAliveService extends Service { } }; - static void start(Context context) { + public static void start(Context context) { Log.d(TAG, "SmsKeepAliveService.start requested sdk=" + Build.VERSION.SDK_INT); Intent intent = new Intent(context, SmsKeepAliveService.class); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -51,7 +53,7 @@ public final class SmsKeepAliveService extends Service { } } - static void stop(Context context) { + public static void stop(Context context) { Log.d(TAG, "SmsKeepAliveService.stop requested"); context.stopService(new Intent(context, SmsKeepAliveService.class)); } diff --git a/app/src/main/java/com/smsreceive/app/SmsPollingService.java b/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingService.java similarity index 94% rename from app/src/main/java/com/smsreceive/app/SmsPollingService.java rename to app/src/main/java/com/smsreceive/app/keepalive/SmsPollingService.java index 0ec0aa9..d285abb 100644 --- a/app/src/main/java/com/smsreceive/app/SmsPollingService.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingService.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.keepalive; import android.Manifest; import android.app.Service; @@ -12,6 +12,11 @@ import android.os.Looper; import android.util.Log; import android.widget.Toast; +import com.smsreceive.app.feishu.FeishuWebhookClient; +import com.smsreceive.app.sms.CaptureResult; +import com.smsreceive.app.sms.SmsCaptureStore; +import com.smsreceive.app.sms.SmsInboxReader; + public final class SmsPollingService extends Service { private static final String TAG = "[SMS]SmsReceive"; private static final String SOURCE_INBOX_POLLING = "sms_inbox_polling"; @@ -29,7 +34,7 @@ public final class SmsPollingService extends Service { } }; - static void start(Context context) { + public static void start(Context context) { Log.d(TAG, "SmsPollingService.start requested sdk=" + Build.VERSION.SDK_INT); long startTimeMillis = System.currentTimeMillis() - 2_000L; SmsPollingStateStore.recordStarted(context, startTimeMillis); @@ -42,7 +47,7 @@ public final class SmsPollingService extends Service { } } - static void stop(Context context) { + public static void stop(Context context) { Log.d(TAG, "SmsPollingService.stop requested"); SmsPollingStateStore.recordStopped(context, "用户停止轮询"); context.stopService(new Intent(context, SmsPollingService.class)); diff --git a/app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java b/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingStateStore.java similarity index 81% rename from app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java rename to app/src/main/java/com/smsreceive/app/keepalive/SmsPollingStateStore.java index 6cc23db..daa5521 100644 --- a/app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingStateStore.java @@ -1,11 +1,11 @@ -package com.smsreceive.app; +package com.smsreceive.app.keepalive; import android.content.Context; import android.content.SharedPreferences; import android.text.TextUtils; import android.util.Log; -final class SmsPollingStateStore { +public final class SmsPollingStateStore { private static final String TAG = "[SMS]SmsReceive"; private static final String PREFS = "sms_polling"; private static final String KEY_ENABLED_BY_USER = "enabled_by_user"; @@ -22,7 +22,7 @@ final class SmsPollingStateStore { private SmsPollingStateStore() { } - static void recordStarted(Context context, long startTimeMillis) { + public static void recordStarted(Context context, long startTimeMillis) { Log.d(TAG, "SmsPollingStateStore.recordStarted startTime=" + startTimeMillis); preferences(context).edit() .putBoolean(KEY_ENABLED_BY_USER, true) @@ -32,7 +32,7 @@ final class SmsPollingStateStore { .apply(); } - static void recordStopped(Context context, String reason) { + public static void recordStopped(Context context, String reason) { Log.d(TAG, "SmsPollingStateStore.recordStopped reason=" + reason); preferences(context).edit() .putBoolean(KEY_ENABLED_BY_USER, false) @@ -41,7 +41,7 @@ final class SmsPollingStateStore { .apply(); } - static void recordServiceStopped(Context context, String reason) { + public static void recordServiceStopped(Context context, String reason) { Log.d(TAG, "SmsPollingStateStore.recordServiceStopped reason=" + reason); preferences(context).edit() .putBoolean(KEY_RUNNING, false) @@ -49,13 +49,13 @@ final class SmsPollingStateStore { .apply(); } - static void recordServiceRunning(Context context) { + public static void recordServiceRunning(Context context) { preferences(context).edit() .putBoolean(KEY_RUNNING, true) .apply(); } - static void recordHit(Context context, long smsId, long hitTimeMillis) { + public static void recordHit(Context context, long smsId, long hitTimeMillis) { Log.d(TAG, "SmsPollingStateStore.recordHit id=" + smsId + ", time=" + hitTimeMillis); preferences(context).edit() .putLong(KEY_LAST_HIT_ID, smsId) @@ -64,7 +64,7 @@ final class SmsPollingStateStore { .apply(); } - static void setIntervalSeconds(Context context, int seconds) { + public static void setIntervalSeconds(Context context, int seconds) { int safeSeconds = clampIntervalSeconds(seconds); Log.d(TAG, "SmsPollingStateStore.setIntervalSeconds seconds=" + safeSeconds); preferences(context).edit() @@ -72,11 +72,11 @@ final class SmsPollingStateStore { .apply(); } - static int getIntervalSeconds(Context context) { + public static int getIntervalSeconds(Context context) { return clampIntervalSeconds(preferences(context).getInt(KEY_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS)); } - static State load(Context context) { + public static State load(Context context) { SharedPreferences prefs = preferences(context); return new State( prefs.getBoolean(KEY_ENABLED_BY_USER, false), @@ -100,14 +100,14 @@ final class SmsPollingStateStore { return Math.max(MIN_INTERVAL_SECONDS, Math.min(seconds, MAX_INTERVAL_SECONDS)); } - static final class State { - final boolean enabledByUser; - final boolean running; - final long startTimeMillis; - final long lastHitId; - final long lastHitTimeMillis; - final String lastFailure; - final int intervalSeconds; + public static final class State { + public final boolean enabledByUser; + public final boolean running; + public final long startTimeMillis; + public final long lastHitId; + public final long lastHitTimeMillis; + public final String lastFailure; + public final int intervalSeconds; State( boolean enabledByUser, diff --git a/app/src/main/java/com/smsreceive/app/CaptureResult.java b/app/src/main/java/com/smsreceive/app/sms/CaptureResult.java similarity index 77% rename from app/src/main/java/com/smsreceive/app/CaptureResult.java rename to app/src/main/java/com/smsreceive/app/sms/CaptureResult.java index 39eb183..a2f64e8 100644 --- a/app/src/main/java/com/smsreceive/app/CaptureResult.java +++ b/app/src/main/java/com/smsreceive/app/sms/CaptureResult.java @@ -1,15 +1,15 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; -final class CaptureResult { - static final long UNKNOWN_SMS_PROVIDER_ID = -1L; +public final class CaptureResult { + public static final long UNKNOWN_SMS_PROVIDER_ID = -1L; - final long receivedAtMillis; - final long smsProviderId; - final String sender; - final String body; - final VerificationCodeParser.ParseResult parseResult; - final String source; - final String failureReason; + public final long receivedAtMillis; + public final long smsProviderId; + public final String sender; + public final String body; + public final VerificationCodeParser.ParseResult parseResult; + public final String source; + public final String failureReason; private CaptureResult( long receivedAtMillis, @@ -28,7 +28,7 @@ final class CaptureResult { this.failureReason = failureReason == null ? "" : failureReason; } - static CaptureResult success( + public static CaptureResult success( long receivedAtMillis, String sender, String body, @@ -37,7 +37,7 @@ final class CaptureResult { return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, parseResult, source); } - static CaptureResult success( + public static CaptureResult success( long receivedAtMillis, long smsProviderId, String sender, @@ -47,7 +47,7 @@ final class CaptureResult { return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, ""); } - static CaptureResult failure( + public static CaptureResult failure( long receivedAtMillis, String sender, String body, @@ -56,7 +56,7 @@ final class CaptureResult { return failure(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source, failureReason); } - static CaptureResult failure( + public static CaptureResult failure( long receivedAtMillis, long smsProviderId, String sender, diff --git a/app/src/main/java/com/smsreceive/app/SmsCaptureStore.java b/app/src/main/java/com/smsreceive/app/sms/SmsCaptureStore.java similarity index 83% rename from app/src/main/java/com/smsreceive/app/SmsCaptureStore.java rename to app/src/main/java/com/smsreceive/app/sms/SmsCaptureStore.java index 4b2c111..2aaa797 100644 --- a/app/src/main/java/com/smsreceive/app/SmsCaptureStore.java +++ b/app/src/main/java/com/smsreceive/app/sms/SmsCaptureStore.java @@ -1,12 +1,12 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; import android.content.Context; import android.content.SharedPreferences; import android.text.TextUtils; import android.util.Log; -final class SmsCaptureStore { - static final String ACTION_CAPTURE_UPDATED = "com.smsreceive.app.ACTION_CAPTURE_UPDATED"; +public final class SmsCaptureStore { + public static final String ACTION_CAPTURE_UPDATED = "com.smsreceive.app.ACTION_CAPTURE_UPDATED"; private static final String TAG = "[SMS]SmsReceive"; private static final String PREFS = "sms_capture"; @@ -25,7 +25,7 @@ final class SmsCaptureStore { private SmsCaptureStore() { } - static void save(Context context, CaptureResult result) { + public static void save(Context context, CaptureResult result) { VerificationCodeParser.ParseResult parse = result.parseResult; Log.d(TAG, "SmsCaptureStore.save source=" + result.source + ", success=" + parse.success @@ -53,7 +53,7 @@ final class SmsCaptureStore { editor.apply(); } - static StoredCapture load(Context context) { + public static StoredCapture load(Context context) { SharedPreferences prefs = preferences(context); return new StoredCapture( prefs.getLong(KEY_TIME, 0L), @@ -66,12 +66,12 @@ final class SmsCaptureStore { prefs.getString(KEY_BODY_PREVIEW, "")); } - static void clear(Context context) { + public static void clear(Context context) { Log.d(TAG, "SmsCaptureStore.clear"); preferences(context).edit().clear().apply(); } - static DeliveryDiagnostics loadDeliveryDiagnostics(Context context) { + public static DeliveryDiagnostics loadDeliveryDiagnostics(Context context) { SharedPreferences prefs = preferences(context); return new DeliveryDiagnostics( prefs.getLong(KEY_LAST_BROADCAST_TIME, 0L), @@ -101,15 +101,15 @@ final class SmsCaptureStore { return normalized.length() <= 48 ? normalized : normalized.substring(0, 48) + "..."; } - static final class StoredCapture { - final long timeMillis; - final String sender; - final String code; - final String strategy; - final int confidence; - final String source; - final String failure; - final String bodyPreview; + public static final class StoredCapture { + public final long timeMillis; + public final String sender; + public final String code; + public final String strategy; + public final int confidence; + public final String source; + public final String failure; + public final String bodyPreview; StoredCapture( long timeMillis, @@ -131,10 +131,10 @@ final class SmsCaptureStore { } } - static final class DeliveryDiagnostics { - final long lastBroadcastTimeMillis; - final long lastInboxTimeMillis; - final String lastInboxSource; + public static final class DeliveryDiagnostics { + public final long lastBroadcastTimeMillis; + public final long lastInboxTimeMillis; + public final String lastInboxSource; DeliveryDiagnostics(long lastBroadcastTimeMillis, long lastInboxTimeMillis, String lastInboxSource) { this.lastBroadcastTimeMillis = lastBroadcastTimeMillis; @@ -142,7 +142,7 @@ final class SmsCaptureStore { this.lastInboxSource = lastInboxSource == null ? "" : lastInboxSource; } - boolean inboxNewerThanBroadcast() { + public boolean inboxNewerThanBroadcast() { return lastInboxTimeMillis > 0L && lastInboxTimeMillis > lastBroadcastTimeMillis; } } diff --git a/app/src/main/java/com/smsreceive/app/SmsInboxReader.java b/app/src/main/java/com/smsreceive/app/sms/SmsInboxReader.java similarity index 92% rename from app/src/main/java/com/smsreceive/app/SmsInboxReader.java rename to app/src/main/java/com/smsreceive/app/sms/SmsInboxReader.java index 9b12186..9adee69 100644 --- a/app/src/main/java/com/smsreceive/app/SmsInboxReader.java +++ b/app/src/main/java/com/smsreceive/app/sms/SmsInboxReader.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; import android.content.Context; import android.database.Cursor; @@ -11,14 +11,14 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; -final class SmsInboxReader { +public final class SmsInboxReader { private static final String TAG = "[SMS]SmsReceive"; private static final Uri SMS_INBOX_URI = Uri.parse("content://sms/inbox"); private SmsInboxReader() { } - static InboxResult readLatest(Context context) { + public static InboxResult readLatest(Context context) { String[] projection = { Telephony.Sms._ID, Telephony.Sms.ADDRESS, @@ -64,7 +64,7 @@ final class SmsInboxReader { } } - static int logRecentMessages(Context context, int limit) { + public static int logRecentMessages(Context context, int limit) { Uri uri = Telephony.Sms.CONTENT_URI; String[] projection = { Telephony.Sms._ID, @@ -116,11 +116,11 @@ final class SmsInboxReader { } } - static RecentCodeResult findLatestVerificationCode(Context context, int limit) { + public static RecentCodeResult findLatestVerificationCode(Context context, int limit) { return findLatestVerificationCode(context, limit, 0L); } - static RecentCodeResult findLatestVerificationCode(Context context, int limit, long minDateMillis) { + public static RecentCodeResult findLatestVerificationCode(Context context, int limit, long minDateMillis) { Uri uri = Telephony.Sms.CONTENT_URI; String[] projection = { Telephony.Sms._ID, @@ -231,13 +231,13 @@ final class SmsInboxReader { } } - static final class InboxResult { - final boolean success; - final long id; - final String sender; - final String body; - final long dateMillis; - final String failureReason; + public static final class InboxResult { + public final boolean success; + public final long id; + public final String sender; + public final String body; + public final long dateMillis; + public final String failureReason; private InboxResult(boolean success, long id, String sender, String body, long dateMillis, String failureReason) { this.success = success; @@ -257,15 +257,15 @@ final class SmsInboxReader { } } - static final class RecentCodeResult { - final boolean success; - final long id; - final String sender; - final String body; - final long dateMillis; - final VerificationCodeParser.ParseResult parseResult; - final int scannedCount; - final String failureReason; + public static final class RecentCodeResult { + public final boolean success; + public final long id; + public final String sender; + public final String body; + public final long dateMillis; + public final VerificationCodeParser.ParseResult parseResult; + public final int scannedCount; + public final String failureReason; private RecentCodeResult( boolean success, diff --git a/app/src/main/java/com/smsreceive/app/SmsMessageReader.java b/app/src/main/java/com/smsreceive/app/sms/SmsMessageReader.java similarity index 98% rename from app/src/main/java/com/smsreceive/app/SmsMessageReader.java rename to app/src/main/java/com/smsreceive/app/sms/SmsMessageReader.java index e28864a..dcac92e 100644 --- a/app/src/main/java/com/smsreceive/app/SmsMessageReader.java +++ b/app/src/main/java/com/smsreceive/app/sms/SmsMessageReader.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; import android.content.Intent; import android.provider.Telephony; diff --git a/app/src/main/java/com/smsreceive/app/SmsReceiver.java b/app/src/main/java/com/smsreceive/app/sms/SmsReceiver.java similarity index 97% rename from app/src/main/java/com/smsreceive/app/SmsReceiver.java rename to app/src/main/java/com/smsreceive/app/sms/SmsReceiver.java index 78c3540..d1a4b6a 100644 --- a/app/src/main/java/com/smsreceive/app/SmsReceiver.java +++ b/app/src/main/java/com/smsreceive/app/sms/SmsReceiver.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; import android.content.BroadcastReceiver; import android.content.Context; @@ -7,6 +7,8 @@ import android.provider.Telephony; import android.util.Log; import android.widget.Toast; +import com.smsreceive.app.feishu.FeishuWebhookClient; + public final class SmsReceiver extends BroadcastReceiver { private static final String TAG = "[SMS]SmsReceive"; private static final String SOURCE_SYSTEM_BROADCAST = "system_sms_broadcast"; diff --git a/app/src/main/java/com/smsreceive/app/VerificationCodeParser.java b/app/src/main/java/com/smsreceive/app/sms/VerificationCodeParser.java similarity index 99% rename from app/src/main/java/com/smsreceive/app/VerificationCodeParser.java rename to app/src/main/java/com/smsreceive/app/sms/VerificationCodeParser.java index a3f5d55..249c063 100644 --- a/app/src/main/java/com/smsreceive/app/VerificationCodeParser.java +++ b/app/src/main/java/com/smsreceive/app/sms/VerificationCodeParser.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; import java.util.ArrayList; import java.util.List; diff --git a/app/src/main/java/com/smsreceive/app/MainActivity.java b/app/src/main/java/com/smsreceive/app/ui/MainActivity.java similarity index 98% rename from app/src/main/java/com/smsreceive/app/MainActivity.java rename to app/src/main/java/com/smsreceive/app/ui/MainActivity.java index 00c5f30..b504616 100644 --- a/app/src/main/java/com/smsreceive/app/MainActivity.java +++ b/app/src/main/java/com/smsreceive/app/ui/MainActivity.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.ui; import android.Manifest; import android.app.Activity; @@ -35,6 +35,19 @@ import android.widget.Switch; import android.widget.TextView; import android.widget.Toast; +import com.smsreceive.app.feishu.FeishuWebhookClient; +import com.smsreceive.app.feishu.FeishuWebhookConfigStore; +import com.smsreceive.app.feishu.FeishuWebhookPushResult; +import com.smsreceive.app.keepalive.KeepAliveDatabase; +import com.smsreceive.app.keepalive.KeepAliveStateStore; +import com.smsreceive.app.keepalive.SmsKeepAliveService; +import com.smsreceive.app.keepalive.SmsPollingService; +import com.smsreceive.app.keepalive.SmsPollingStateStore; +import com.smsreceive.app.sms.CaptureResult; +import com.smsreceive.app.sms.SmsCaptureStore; +import com.smsreceive.app.sms.SmsInboxReader; +import com.smsreceive.app.sms.VerificationCodeParser; + import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; diff --git a/app/src/test/java/com/smsreceive/app/FeishuWebhookClientTest.java b/app/src/test/java/com/smsreceive/app/feishu/FeishuWebhookClientTest.java similarity index 98% rename from app/src/test/java/com/smsreceive/app/FeishuWebhookClientTest.java rename to app/src/test/java/com/smsreceive/app/feishu/FeishuWebhookClientTest.java index 54270d1..5ddcd12 100644 --- a/app/src/test/java/com/smsreceive/app/FeishuWebhookClientTest.java +++ b/app/src/test/java/com/smsreceive/app/feishu/FeishuWebhookClientTest.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.feishu; import org.json.JSONObject; import org.junit.Test; diff --git a/app/src/test/java/com/smsreceive/app/VerificationCodeParserTest.java b/app/src/test/java/com/smsreceive/app/sms/VerificationCodeParserTest.java similarity index 98% rename from app/src/test/java/com/smsreceive/app/VerificationCodeParserTest.java rename to app/src/test/java/com/smsreceive/app/sms/VerificationCodeParserTest.java index d13e32f..6c2cde4 100644 --- a/app/src/test/java/com/smsreceive/app/VerificationCodeParserTest.java +++ b/app/src/test/java/com/smsreceive/app/sms/VerificationCodeParserTest.java @@ -1,4 +1,4 @@ -package com.smsreceive.app; +package com.smsreceive.app.sms; import org.junit.Test; diff --git a/gradle.properties b/gradle.properties index f86ba92..dfc6ee8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,3 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=false android.injected.testOnly=false -android.aapt2FromMavenOverride=/Users/zouchao/Library/Android/sdk/build-tools/30.0.3/aapt2 -- 2.47.1 From c5ef7261347277c84f807ada6d265feb11f19957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=8C=E7=B3=96=E5=8C=85=E5=AD=90?= Date: Mon, 18 May 2026 22:38:06 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E8=A7=A3=E6=9E=90=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E4=B8=BA=E7=BA=AF=E7=9F=AD=E4=BF=A1=E8=BD=AC=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 VerificationCodeParser 及相关测试,短信捕获和推送不再解析验证码 - 飞书推送改为只发送短信原文,时间戳格式化为可读日期 - 移除主界面"只推送验证码"开关和"调试时上传完整短信正文"选项 - 移除"保存轮询间隔"按钮,开启轮询时自动保存间隔(未输入默认1秒) - 按钮文字从"开始1秒轮询验证码"改为"开始短信轮询" - 删除"打印最近30条短信"功能及相关 SmsInboxReader.logRecentMessages - SmsInboxReader 用 RecentSmsResult 替换 RecentCodeResult - FeishuWebhookConfigStore.Config 移除 filterVerificationCode/sendFullBodyDebug - 修复代码缩进不一致问题 --- .../app/sms/SmsProviderInstrumentedTest.java | 9 +- .../app/feishu/FeishuWebhookClient.java | 45 ++--- .../app/feishu/FeishuWebhookConfigStore.java | 32 +--- .../app/keepalive/KeepAliveNotification.java | 2 +- .../app/keepalive/SmsPollingService.java | 16 +- .../com/smsreceive/app/sms/CaptureResult.java | 14 +- .../smsreceive/app/sms/SmsCaptureStore.java | 42 ++--- .../smsreceive/app/sms/SmsInboxReader.java | 131 +++---------- .../com/smsreceive/app/sms/SmsReceiver.java | 32 +--- .../app/sms/VerificationCodeParser.java | 175 ------------------ .../com/smsreceive/app/ui/MainActivity.java | 136 +++----------- .../app/feishu/FeishuWebhookClientTest.java | 21 ++- .../app/sms/VerificationCodeParserTest.java | 47 ----- .../changes/add-feishu-webhook-push/design.md | 23 ++- .../add-feishu-webhook-push/proposal.md | 12 +- .../specs/feishu-webhook-push/spec.md | 11 +- .../changes/add-feishu-webhook-push/tasks.md | 6 +- .../add-xiaomi-background-keepalive/design.md | 6 +- .../proposal.md | 2 +- .../add-xiaomi-background-keepalive/tasks.md | 8 +- .../build-sms-code-receiver-app/design.md | 64 ++++--- .../build-sms-code-receiver-app/proposal.md | 24 +-- .../specs/sms-code-capture/spec.md | 36 ++-- .../sms-code-validation-workflow/spec.md | 22 +-- .../specs/sms-permission-diagnostics/spec.md | 22 +-- .../build-sms-code-receiver-app/tasks.md | 28 +-- openspec/config.yaml | 4 +- 27 files changed, 273 insertions(+), 697 deletions(-) delete mode 100644 app/src/main/java/com/smsreceive/app/sms/VerificationCodeParser.java delete mode 100644 app/src/test/java/com/smsreceive/app/sms/VerificationCodeParserTest.java diff --git a/app/src/androidTest/java/com/smsreceive/app/sms/SmsProviderInstrumentedTest.java b/app/src/androidTest/java/com/smsreceive/app/sms/SmsProviderInstrumentedTest.java index b460c18..332a608 100644 --- a/app/src/androidTest/java/com/smsreceive/app/sms/SmsProviderInstrumentedTest.java +++ b/app/src/androidTest/java/com/smsreceive/app/sms/SmsProviderInstrumentedTest.java @@ -13,10 +13,11 @@ public final class SmsProviderInstrumentedTest { private static final String TAG = "[SMS]SmsReceive"; @Test - public void testLogRecentThirtyMessages() { + public void testReadLatestInboxQueryCompletes() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - int count = SmsInboxReader.logRecentMessages(context, 30); - Log.d(TAG, "SmsProviderInstrumentedTest.testLogRecentThirtyMessages count=" + count); - assertTrue("Expected query to complete. Count can be 0 if SMS provider is empty.", count >= 0); + SmsInboxReader.InboxResult result = SmsInboxReader.readLatest(context); + Log.d(TAG, "SmsProviderInstrumentedTest.testReadLatestInboxQueryCompletes success=" + result.success + + ", reason=" + result.failureReason); + assertTrue("Expected inbox query to return a structured result.", result.success || result.failureReason.length() > 0); } } diff --git a/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookClient.java b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookClient.java index 7282305..164445d 100644 --- a/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookClient.java +++ b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookClient.java @@ -53,13 +53,12 @@ public final class FeishuWebhookClient { if (context == null || result == null) { return; } - Context appContext = context.getApplicationContext(); - FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext); - if (config.filterVerificationCode && !result.parseResult.success) { - Log.d(TAG, "Feishu push skipped: filter code enabled and parse failed, reason=" - + result.parseResult.failureReason); + if (isEmpty(result.body)) { + Log.d(TAG, "Feishu push skipped: empty body source=" + result.source); return; } + Context appContext = context.getApplicationContext(); + FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext); if (FeishuWebhookConfigStore.wasSmsPushed(appContext, result)) { Log.d(TAG, "Feishu push skipped: duplicate sms receivedSecond=" + (result.receivedAtMillis / 1000L) @@ -74,7 +73,7 @@ public final class FeishuWebhookClient { "远端推送未开启")); return; } - String markdown = buildMarkdownFromCapture(result, config); + String markdown = buildMarkdownFromCapture(result); pushMarkdownAsync(appContext, markdown, result); } @@ -205,38 +204,22 @@ public final class FeishuWebhookClient { } public static String buildMarkdownFromCapture(CaptureResult result) { - return buildMarkdownFromCapture(result, new FeishuWebhookConfigStore.Config(false, "", "", false, false)); - } - - public static String buildMarkdownFromCapture(CaptureResult result, FeishuWebhookConfigStore.Config config) { - boolean filterCode = config != null && config.filterVerificationCode; - boolean includeFullBody = config == null || !filterCode || config.sendFullBodyDebug; StringBuilder builder = new StringBuilder(); - if (filterCode) { - builder.append("**短信验证码**:").append(emptyAsDash(result.parseResult.code)).append('\n'); - } else { - builder.append("**短信内容**:").append(emptyAsDash(result.body)).append('\n'); - if (result.parseResult.success) { - builder.append("**识别验证码**:").append(result.parseResult.code).append('\n'); - } - } + builder.append("**短信内容**:").append(emptyAsDash(result.body)).append('\n'); builder.append("**来源**:").append(emptyAsDash(result.source)).append('\n'); builder.append("**发送方**:").append(maskSender(result.sender)).append('\n'); - builder.append("**时间**:").append(result.receivedAtMillis).append('\n'); - if (result.parseResult.success) { - builder.append("**解析**:") - .append(emptyAsDash(result.parseResult.strategy)) - .append(" / ") - .append(result.parseResult.confidence); - } else { - builder.append("**解析失败**:").append(emptyAsDash(result.parseResult.failureReason)); - } - if (includeFullBody && filterCode) { - builder.append('\n').append("**原文**:").append(emptyAsDash(result.body)); + builder.append("**时间**:").append(formatTime(result.receivedAtMillis)); + if (!isEmpty(result.failureReason)) { + builder.append('\n').append("**读取异常**:").append(emptyAsDash(result.failureReason)); } return builder.toString(); } + private static String formatTime(long timeMillis) { + return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.CHINA) + .format(new java.util.Date(timeMillis)); + } + private static void saveAndNotify(Context context, FeishuWebhookPushResult result) { Log.d(TAG, String.format(Locale.US, "Feishu push result success=%s status=%s http=%d api=%d message=%s", diff --git a/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookConfigStore.java b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookConfigStore.java index fc1975d..34d9c13 100644 --- a/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookConfigStore.java +++ b/app/src/main/java/com/smsreceive/app/feishu/FeishuWebhookConfigStore.java @@ -39,8 +39,6 @@ public final class FeishuWebhookConfigStore { private static final String JSON_ENABLED = "enabled"; private static final String JSON_WEBHOOK_ID = "webhook_id"; private static final String JSON_SECRET = "secret"; - private static final String JSON_SEND_FULL_BODY_DEBUG = "send_full_body_debug"; - private static final String JSON_FILTER_VERIFICATION_CODE = "filter_verification_code"; private FeishuWebhookConfigStore() { } @@ -65,19 +63,15 @@ public final class FeishuWebhookConfigStore { Context context, boolean enabled, String webhookId, - String secret, - boolean sendFullBodyDebug, - boolean filterVerificationCode) { - Config config = new Config(enabled, webhookId, secret, sendFullBodyDebug, filterVerificationCode); + String secret) { + Config config = new Config(enabled, webhookId, secret); File file = configFile(context); try { writeFile(file, configToJson(config).toString(2)); Log.d(TAG, "save feishu config path=" + file.getAbsolutePath() + ", enabled=" + enabled + ", webhookConfigured=" + config.hasWebhookId() - + ", secretConfigured=" + config.hasSecret() - + ", debugBody=" + sendFullBodyDebug - + ", filterCode=" + filterVerificationCode); + + ", secretConfigured=" + config.hasSecret()); } catch (IOException | JSONException e) { Log.w(TAG, "save feishu config failed path=" + file.getAbsolutePath() + ", reason=" + e.getClass().getSimpleName(), e); @@ -178,7 +172,7 @@ public final class FeishuWebhookConfigStore { try { return configToJson(defaultConfig()).toString(2); } catch (JSONException e) { - return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\",\"send_full_body_debug\":false,\"filter_verification_code\":false}"; + return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\"}"; } } @@ -217,25 +211,21 @@ public final class FeishuWebhookConfigStore { } private static Config defaultConfig() { - return new Config(false, "", "", false, false); + return new Config(false, "", ""); } private static Config configFromJson(JSONObject json) { return new Config( json.optBoolean(JSON_ENABLED, false), json.optString(JSON_WEBHOOK_ID, ""), - json.optString(JSON_SECRET, ""), - json.optBoolean(JSON_SEND_FULL_BODY_DEBUG, false), - json.optBoolean(JSON_FILTER_VERIFICATION_CODE, false)); + json.optString(JSON_SECRET, "")); } private static JSONObject configToJson(Config config) throws JSONException { return new JSONObject() .put(JSON_ENABLED, config.enabled) .put(JSON_WEBHOOK_ID, config.webhookId) - .put(JSON_SECRET, config.secret) - .put(JSON_SEND_FULL_BODY_DEBUG, config.sendFullBodyDebug) - .put(JSON_FILTER_VERIFICATION_CODE, config.filterVerificationCode); + .put(JSON_SECRET, config.secret); } private static String readFile(File file) throws IOException { @@ -295,20 +285,14 @@ public final class FeishuWebhookConfigStore { public final boolean enabled; public final String webhookId; public final String secret; - public final boolean sendFullBodyDebug; - public final boolean filterVerificationCode; public Config( boolean enabled, String webhookId, - String secret, - boolean sendFullBodyDebug, - boolean filterVerificationCode) { + String secret) { this.enabled = enabled; this.webhookId = normalize(webhookId); this.secret = normalize(secret); - this.sendFullBodyDebug = sendFullBodyDebug; - this.filterVerificationCode = filterVerificationCode; } public boolean hasWebhookId() { diff --git a/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveNotification.java b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveNotification.java index 7e5dedd..99faadf 100644 --- a/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveNotification.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/KeepAliveNotification.java @@ -36,7 +36,7 @@ final class KeepAliveNotification { : new Notification.Builder(context); return builder .setSmallIcon(android.R.drawable.stat_notify_sync) - .setContentTitle("短信验证码监听运行中") + .setContentTitle("短信监听运行中") .setContentText(contentText) .setContentIntent(pendingIntent) .setOngoing(true) diff --git a/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingService.java b/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingService.java index d285abb..e283a7f 100644 --- a/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingService.java +++ b/app/src/main/java/com/smsreceive/app/keepalive/SmsPollingService.java @@ -27,7 +27,7 @@ public final class SmsPollingService extends Service { private final Runnable pollingRunnable = new Runnable() { @Override public void run() { - pollRecentSmsForCode(); + pollRecentSms(); long intervalMillis = SmsPollingStateStore.getIntervalSeconds(SmsPollingService.this) * 1000L; Log.d(TAG, "SmsPollingService.schedule next intervalMs=" + intervalMillis); handler.postDelayed(this, intervalMillis); @@ -92,7 +92,7 @@ public final class SmsPollingService extends Service { return null; } - private void pollRecentSmsForCode() { + private void pollRecentSms() { if (!hasReadSmsPermission()) { Log.w(TAG, "SmsPollingService.poll stop: READ_SMS not granted"); Toast.makeText(this, "READ_SMS 未授权,已停止短信轮询", Toast.LENGTH_LONG).show(); @@ -101,9 +101,9 @@ public final class SmsPollingService extends Service { return; } - SmsInboxReader.RecentCodeResult result = SmsInboxReader.findLatestVerificationCode(this, 3, pollingStartMillis); + SmsInboxReader.RecentSmsResult result = SmsInboxReader.findLatestSms(this, 3, pollingStartMillis); if (!result.success) { - Log.d(TAG, "SmsPollingService.poll no code scanned=" + result.scannedCount + Log.d(TAG, "SmsPollingService.poll no sms scanned=" + result.scannedCount + ", reason=" + result.failureReason); return; } @@ -114,15 +114,13 @@ public final class SmsPollingService extends Service { lastHitSmsId = result.id; SmsPollingStateStore.recordHit(this, result.id, result.dateMillis); Log.d(TAG, "SmsPollingService.poll hit id=" + result.id - + ", code=" + result.parseResult.code - + ", strategy=" + result.parseResult.strategy - + ", confidence=" + result.parseResult.confidence); + + ", sender=" + result.sender + + ", bodyLength=" + result.body.length()); CaptureResult captureResult = CaptureResult.success( result.dateMillis, result.id, result.sender, result.body, - result.parseResult, SOURCE_INBOX_POLLING); SmsCaptureStore.save(this, captureResult); FeishuWebhookClient.pushCaptureResultAsync(this, captureResult); @@ -130,7 +128,7 @@ public final class SmsPollingService extends Service { Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED); updateIntent.setPackage(getPackageName()); sendBroadcast(updateIntent); - Toast.makeText(this, "轮询提取验证码:" + result.parseResult.code, Toast.LENGTH_LONG).show(); + Toast.makeText(this, "轮询读取到新短信", Toast.LENGTH_LONG).show(); } private boolean hasReadSmsPermission() { diff --git a/app/src/main/java/com/smsreceive/app/sms/CaptureResult.java b/app/src/main/java/com/smsreceive/app/sms/CaptureResult.java index a2f64e8..8e5d2e5 100644 --- a/app/src/main/java/com/smsreceive/app/sms/CaptureResult.java +++ b/app/src/main/java/com/smsreceive/app/sms/CaptureResult.java @@ -3,11 +3,11 @@ package com.smsreceive.app.sms; public final class CaptureResult { public static final long UNKNOWN_SMS_PROVIDER_ID = -1L; + public final boolean success; public final long receivedAtMillis; public final long smsProviderId; public final String sender; public final String body; - public final VerificationCodeParser.ParseResult parseResult; public final String source; public final String failureReason; @@ -16,14 +16,13 @@ public final class CaptureResult { long smsProviderId, String sender, String body, - VerificationCodeParser.ParseResult parseResult, String source, String failureReason) { + this.success = failureReason == null || failureReason.length() == 0; this.receivedAtMillis = receivedAtMillis; this.smsProviderId = smsProviderId; this.sender = sender == null ? "" : sender; this.body = body == null ? "" : body; - this.parseResult = parseResult; this.source = source == null ? "unknown" : source; this.failureReason = failureReason == null ? "" : failureReason; } @@ -32,9 +31,8 @@ public final class CaptureResult { long receivedAtMillis, String sender, String body, - VerificationCodeParser.ParseResult parseResult, String source) { - return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, parseResult, source); + return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source); } public static CaptureResult success( @@ -42,9 +40,8 @@ public final class CaptureResult { long smsProviderId, String sender, String body, - VerificationCodeParser.ParseResult parseResult, String source) { - return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, ""); + return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, source, ""); } public static CaptureResult failure( @@ -63,7 +60,6 @@ public final class CaptureResult { String body, String source, String failureReason) { - VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.ParseResult.failure(failureReason); - return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, failureReason); + return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, source, failureReason); } } diff --git a/app/src/main/java/com/smsreceive/app/sms/SmsCaptureStore.java b/app/src/main/java/com/smsreceive/app/sms/SmsCaptureStore.java index 2aaa797..b8992cd 100644 --- a/app/src/main/java/com/smsreceive/app/sms/SmsCaptureStore.java +++ b/app/src/main/java/com/smsreceive/app/sms/SmsCaptureStore.java @@ -10,13 +10,12 @@ public final class SmsCaptureStore { private static final String TAG = "[SMS]SmsReceive"; private static final String PREFS = "sms_capture"; + private static final String KEY_SUCCESS = "success"; private static final String KEY_TIME = "time"; private static final String KEY_SENDER = "sender"; - private static final String KEY_CODE = "code"; - private static final String KEY_STRATEGY = "strategy"; - private static final String KEY_CONFIDENCE = "confidence"; private static final String KEY_SOURCE = "source"; private static final String KEY_FAILURE = "failure"; + private static final String KEY_BODY = "body"; private static final String KEY_BODY_PREVIEW = "body_preview"; private static final String KEY_LAST_BROADCAST_TIME = "last_broadcast_time"; private static final String KEY_LAST_INBOX_TIME = "last_inbox_time"; @@ -26,19 +25,16 @@ public final class SmsCaptureStore { } public static void save(Context context, CaptureResult result) { - VerificationCodeParser.ParseResult parse = result.parseResult; Log.d(TAG, "SmsCaptureStore.save source=" + result.source - + ", success=" + parse.success - + ", code=" + parse.code - + ", failure=" + (TextUtils.isEmpty(result.failureReason) ? parse.failureReason : result.failureReason)); + + ", success=" + result.success + + ", failure=" + result.failureReason); SharedPreferences.Editor editor = preferences(context).edit() + .putBoolean(KEY_SUCCESS, result.success) .putLong(KEY_TIME, result.receivedAtMillis) .putString(KEY_SENDER, summarizeSender(result.sender)) - .putString(KEY_CODE, parse.code) - .putString(KEY_STRATEGY, parse.strategy) - .putInt(KEY_CONFIDENCE, parse.confidence) .putString(KEY_SOURCE, result.source) - .putString(KEY_FAILURE, TextUtils.isEmpty(result.failureReason) ? parse.failureReason : result.failureReason) + .putString(KEY_FAILURE, result.failureReason) + .putString(KEY_BODY, result.body) .putString(KEY_BODY_PREVIEW, previewBody(result.body)); if ("system_sms_broadcast".equals(result.source)) { Log.d(TAG, "SmsCaptureStore.save delivery source=system_sms_broadcast time=" @@ -55,15 +51,16 @@ public final class SmsCaptureStore { public static StoredCapture load(Context context) { SharedPreferences prefs = preferences(context); + String bodyPreview = prefs.getString(KEY_BODY_PREVIEW, ""); + String body = prefs.getString(KEY_BODY, ""); return new StoredCapture( + prefs.getBoolean(KEY_SUCCESS, TextUtils.isEmpty(prefs.getString(KEY_FAILURE, ""))), prefs.getLong(KEY_TIME, 0L), prefs.getString(KEY_SENDER, ""), - prefs.getString(KEY_CODE, ""), - prefs.getString(KEY_STRATEGY, ""), - prefs.getInt(KEY_CONFIDENCE, 0), prefs.getString(KEY_SOURCE, ""), prefs.getString(KEY_FAILURE, ""), - prefs.getString(KEY_BODY_PREVIEW, "")); + TextUtils.isEmpty(body) ? bodyPreview : body, + bodyPreview); } public static void clear(Context context) { @@ -102,31 +99,28 @@ public final class SmsCaptureStore { } public static final class StoredCapture { + public final boolean success; public final long timeMillis; public final String sender; - public final String code; - public final String strategy; - public final int confidence; public final String source; public final String failure; + public final String body; public final String bodyPreview; StoredCapture( + boolean success, long timeMillis, String sender, - String code, - String strategy, - int confidence, String source, String failure, + String body, String bodyPreview) { + this.success = success; this.timeMillis = timeMillis; this.sender = sender; - this.code = code; - this.strategy = strategy; - this.confidence = confidence; this.source = source; this.failure = failure; + this.body = body; this.bodyPreview = bodyPreview; } } diff --git a/app/src/main/java/com/smsreceive/app/sms/SmsInboxReader.java b/app/src/main/java/com/smsreceive/app/sms/SmsInboxReader.java index 9adee69..71f3c5b 100644 --- a/app/src/main/java/com/smsreceive/app/sms/SmsInboxReader.java +++ b/app/src/main/java/com/smsreceive/app/sms/SmsInboxReader.java @@ -64,63 +64,11 @@ public final class SmsInboxReader { } } - public static int logRecentMessages(Context context, int limit) { - Uri uri = Telephony.Sms.CONTENT_URI; - String[] projection = { - Telephony.Sms._ID, - Telephony.Sms.ADDRESS, - Telephony.Sms.BODY, - Telephony.Sms.DATE, - Telephony.Sms.TYPE - }; - - int safeLimit = Math.max(1, Math.min(limit, 100)); - Log.d(TAG, "SmsInboxReader.logRecentMessages start uri=" + uri + ", limit=" + safeLimit); - try (Cursor cursor = context.getContentResolver().query( - uri, - projection, - null, - null, - Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) { - if (cursor == null) { - Log.w(TAG, "SmsInboxReader.logRecentMessages cursor is null"); - return 0; - } - - int count = 0; - while (cursor.moveToNext()) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID)); - String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY)); - long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE)); - int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE)); - VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(body); - Log.d(TAG, "SMS[" + count + "] id=" + id - + ", type=" + smsTypeName(type) - + ", date=" + formatDate(date) - + ", sender=" + maskSender(sender) - + ", parseSuccess=" + parseResult.success - + ", code=" + parseResult.code - + ", strategy=" + parseResult.strategy - + ", bodyPreview=" + previewBody(body)); - count++; - } - Log.d(TAG, "SmsInboxReader.logRecentMessages end count=" + count); - return count; - } catch (SecurityException e) { - Log.w(TAG, "SmsInboxReader.logRecentMessages failed: READ_SMS denied", e); - return 0; - } catch (Exception e) { - Log.w(TAG, "SmsInboxReader.logRecentMessages failed", e); - return 0; - } + public static RecentSmsResult findLatestSms(Context context, int limit) { + return findLatestSms(context, limit, 0L); } - public static RecentCodeResult findLatestVerificationCode(Context context, int limit) { - return findLatestVerificationCode(context, limit, 0L); - } - - public static RecentCodeResult findLatestVerificationCode(Context context, int limit, long minDateMillis) { + public static RecentSmsResult findLatestSms(Context context, int limit, long minDateMillis) { Uri uri = Telephony.Sms.CONTENT_URI; String[] projection = { Telephony.Sms._ID, @@ -133,7 +81,7 @@ public final class SmsInboxReader { int safeLimit = Math.max(1, Math.min(limit, 100)); String selection = minDateMillis > 0L ? Telephony.Sms.DATE + ">=?" : null; String[] selectionArgs = minDateMillis > 0L ? new String[]{String.valueOf(minDateMillis)} : null; - Log.d(TAG, "SmsInboxReader.findLatestVerificationCode start limit=" + safeLimit + Log.d(TAG, "SmsInboxReader.findLatestSms start limit=" + safeLimit + ", minDate=" + (minDateMillis > 0L ? formatDate(minDateMillis) : "none")); try (Cursor cursor = context.getContentResolver().query( uri, @@ -142,54 +90,39 @@ public final class SmsInboxReader { selectionArgs, Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) { if (cursor == null) { - Log.w(TAG, "SmsInboxReader.findLatestVerificationCode cursor is null"); - return RecentCodeResult.failure("短信库查询 cursor 为空"); + Log.w(TAG, "SmsInboxReader.findLatestSms cursor is null"); + return RecentSmsResult.failure("短信库查询 cursor 为空"); } int scanned = 0; - RecentCodeResult latest = null; while (cursor.moveToNext()) { long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID)); String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS)); String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY)); long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE)); int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE)); - VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(body); - Log.d(TAG, "poll scan SMS[" + scanned + "] id=" + id + scanned++; + Log.d(TAG, "poll scan SMS[" + (scanned - 1) + "] id=" + id + ", type=" + smsTypeName(type) + ", date=" + formatDate(date) + ", sender=" + maskSender(sender) - + ", parseSuccess=" + parseResult.success - + ", code=" + parseResult.code - + ", strategy=" + parseResult.strategy + ", bodyPreview=" + previewBody(body)); - scanned++; - if (parseResult.success) { - RecentCodeResult candidate = RecentCodeResult.success(id, sender, body, date, parseResult, scanned); - if (latest == null || candidate.dateMillis > latest.dateMillis) { - latest = candidate; - } - Log.d(TAG, "SmsInboxReader.findLatestVerificationCode candidate id=" + id - + ", code=" + parseResult.code - + ", date=" + formatDate(date) - + ", scanned=" + scanned); + if (TextUtils.isEmpty(body)) { + continue; } - } - if (latest != null) { - Log.d(TAG, "SmsInboxReader.findLatestVerificationCode hit latest id=" + latest.id - + ", code=" + latest.parseResult.code - + ", date=" + formatDate(latest.dateMillis) + Log.d(TAG, "SmsInboxReader.findLatestSms hit latest id=" + id + + ", date=" + formatDate(date) + ", scanned=" + scanned); - return latest.withScannedCount(scanned); + return RecentSmsResult.success(id, sender, body, date, scanned); } - Log.d(TAG, "SmsInboxReader.findLatestVerificationCode no code scanned=" + scanned); - return RecentCodeResult.noCode(scanned); + Log.d(TAG, "SmsInboxReader.findLatestSms no sms scanned=" + scanned); + return RecentSmsResult.noSms(scanned); } catch (SecurityException e) { - Log.w(TAG, "SmsInboxReader.findLatestVerificationCode failed: READ_SMS denied", e); - return RecentCodeResult.failure("READ_SMS 未授权"); + Log.w(TAG, "SmsInboxReader.findLatestSms failed: READ_SMS denied", e); + return RecentSmsResult.failure("READ_SMS 未授权"); } catch (Exception e) { - Log.w(TAG, "SmsInboxReader.findLatestVerificationCode failed", e); - return RecentCodeResult.failure("短信库查询失败:" + e.getClass().getSimpleName()); + Log.w(TAG, "SmsInboxReader.findLatestSms failed", e); + return RecentSmsResult.failure("短信库查询失败:" + e.getClass().getSimpleName()); } } @@ -257,23 +190,21 @@ public final class SmsInboxReader { } } - public static final class RecentCodeResult { + public static final class RecentSmsResult { public final boolean success; public final long id; public final String sender; public final String body; public final long dateMillis; - public final VerificationCodeParser.ParseResult parseResult; public final int scannedCount; public final String failureReason; - private RecentCodeResult( + private RecentSmsResult( boolean success, long id, String sender, String body, long dateMillis, - VerificationCodeParser.ParseResult parseResult, int scannedCount, String failureReason) { this.success = success; @@ -281,33 +212,25 @@ public final class SmsInboxReader { this.sender = sender == null ? "" : sender; this.body = body == null ? "" : body; this.dateMillis = dateMillis; - this.parseResult = parseResult; this.scannedCount = scannedCount; this.failureReason = failureReason == null ? "" : failureReason; } - static RecentCodeResult success( + static RecentSmsResult success( long id, String sender, String body, long dateMillis, - VerificationCodeParser.ParseResult parseResult, int scannedCount) { - return new RecentCodeResult(true, id, sender, body, dateMillis, parseResult, scannedCount, ""); + return new RecentSmsResult(true, id, sender, body, dateMillis, scannedCount, ""); } - static RecentCodeResult noCode(int scannedCount) { - return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(), - VerificationCodeParser.ParseResult.failure("最近短信未找到验证码"), scannedCount, "最近短信未找到验证码"); + static RecentSmsResult noSms(int scannedCount) { + return new RecentSmsResult(false, -1L, "", "", System.currentTimeMillis(), scannedCount, "最近短信未找到新短信"); } - static RecentCodeResult failure(String reason) { - return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(), - VerificationCodeParser.ParseResult.failure(reason), 0, reason); - } - - RecentCodeResult withScannedCount(int scannedCount) { - return new RecentCodeResult(success, id, sender, body, dateMillis, parseResult, scannedCount, failureReason); + static RecentSmsResult failure(String reason) { + return new RecentSmsResult(false, -1L, "", "", System.currentTimeMillis(), 0, reason); } } } diff --git a/app/src/main/java/com/smsreceive/app/sms/SmsReceiver.java b/app/src/main/java/com/smsreceive/app/sms/SmsReceiver.java index d1a4b6a..453fe45 100644 --- a/app/src/main/java/com/smsreceive/app/sms/SmsReceiver.java +++ b/app/src/main/java/com/smsreceive/app/sms/SmsReceiver.java @@ -25,35 +25,19 @@ public final class SmsReceiver extends BroadcastReceiver { Log.d(TAG, "SmsReceiver.onReceive ignore non SMS action"); return; } - Toast.makeText(context, "收到短信广播,开始解析", Toast.LENGTH_SHORT).show(); + Toast.makeText(context, "收到短信广播,开始处理", Toast.LENGTH_SHORT).show(); SmsMessageReader.ReadResult readResult = SmsMessageReader.read(intent); CaptureResult captureResult; if (readResult.success) { Log.d(TAG, "SMS read success sender=" + maskSender(readResult.sender) + ", bodyPreview=" + preview(readResult.body)); - VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(readResult.body); - if (parseResult.success) { - Log.d(TAG, "verification code parse success code=" + parseResult.code - + ", strategy=" + parseResult.strategy - + ", confidence=" + parseResult.confidence); - Toast.makeText(context, "验证码:" + parseResult.code, Toast.LENGTH_LONG).show(); - captureResult = CaptureResult.success( - readResult.timestampMillis, - readResult.sender, - readResult.body, - parseResult, - SOURCE_SYSTEM_BROADCAST); - } else { - Log.w(TAG, "verification code parse failed reason=" + parseResult.failureReason); - Toast.makeText(context, "短信已收到,未解析到验证码", Toast.LENGTH_LONG).show(); - captureResult = CaptureResult.failure( - readResult.timestampMillis, - readResult.sender, - readResult.body, - SOURCE_SYSTEM_BROADCAST, - parseResult.failureReason); - } + Toast.makeText(context, "短信已收到", Toast.LENGTH_LONG).show(); + captureResult = CaptureResult.success( + readResult.timestampMillis, + readResult.sender, + readResult.body, + SOURCE_SYSTEM_BROADCAST); } else { Log.w(TAG, "SMS read failed reason=" + readResult.failureReason); Toast.makeText(context, "短信读取失败:" + readResult.failureReason, Toast.LENGTH_LONG).show(); @@ -71,7 +55,7 @@ public final class SmsReceiver extends BroadcastReceiver { updateIntent.setPackage(context.getPackageName()); context.sendBroadcast(updateIntent); Log.d(TAG, "SmsReceiver.onReceive end source=" + SOURCE_SYSTEM_BROADCAST - + ", success=" + captureResult.parseResult.success); + + ", success=" + captureResult.success); } private static String maskSender(String sender) { diff --git a/app/src/main/java/com/smsreceive/app/sms/VerificationCodeParser.java b/app/src/main/java/com/smsreceive/app/sms/VerificationCodeParser.java deleted file mode 100644 index 249c063..0000000 --- a/app/src/main/java/com/smsreceive/app/sms/VerificationCodeParser.java +++ /dev/null @@ -1,175 +0,0 @@ -package com.smsreceive.app.sms; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class VerificationCodeParser { - private static final Pattern KEYWORD_PATTERN = Pattern.compile("(?i)(验证码|校验码|动态码|验证代码|verification|otp|code)"); - private static final Pattern CANDIDATE_PATTERN = Pattern.compile("(? candidates = new ArrayList<>(); - Matcher matcher = CANDIDATE_PATTERN.matcher(body); - while (matcher.find()) { - String raw = matcher.group(1); - String normalized = normalizeCode(raw); - if (isPlausibleStandalone(body, matcher.start(1), matcher.end(1), normalized)) { - candidates.add(normalized); - } - } - - if (!candidates.isEmpty()) { - String best = candidates.get(0); - for (String candidate : candidates) { - if (scoreStandalone(candidate) > scoreStandalone(best)) { - best = candidate; - } - } - return ParseResult.success(best, "standalone_numeric", 62); - } - - return ParseResult.failure("未找到可靠验证码"); - } - - private static ParseResult findKeywordNearbyCode(String body) { - Matcher keywordMatcher = KEYWORD_PATTERN.matcher(body); - while (keywordMatcher.find()) { - if (isNegatedKeyword(body, keywordMatcher.start())) { - continue; - } - - int forwardStart = keywordMatcher.end(); - int forwardEnd = Math.min(body.length(), keywordMatcher.end() + 32); - ParseResult forward = findCandidateInWindow(body, forwardStart, forwardEnd, "keyword_before_code", 95); - if (forward.success) { - return forward; - } - - int backwardStart = Math.max(0, keywordMatcher.start() - 24); - int backwardEnd = keywordMatcher.start(); - ParseResult backward = findCandidateInWindow(body, backwardStart, backwardEnd, "code_before_keyword", 88); - if (backward.success) { - return backward; - } - } - return ParseResult.failure("未找到可靠验证码"); - } - - private static ParseResult findCandidateInWindow(String body, int start, int end, String strategy, int confidence) { - Matcher matcher = CANDIDATE_PATTERN.matcher(body.substring(start, end)); - while (matcher.find()) { - int absoluteStart = start + matcher.start(1); - int absoluteEnd = start + matcher.end(1); - String normalized = normalizeCode(matcher.group(1)); - if (isPlausibleStandalone(body, absoluteStart, absoluteEnd, normalized)) { - return ParseResult.success(normalized, strategy, confidence); - } - } - return ParseResult.failure("未找到可靠验证码"); - } - - private static boolean isNegatedKeyword(String body, int keywordStart) { - int prefixStart = Math.max(0, keywordStart - 2); - String prefix = body.substring(prefixStart, keywordStart); - return prefix.contains("无") || prefix.contains("非"); - } - - private static String normalizeCode(String raw) { - if (raw == null) { - return ""; - } - return raw.replaceAll("[\\s-]", "").toUpperCase(Locale.US); - } - - private static boolean isValidCode(String code) { - if (code == null || code.length() < 4 || code.length() > 8) { - return false; - } - boolean hasDigit = false; - for (int i = 0; i < code.length(); i++) { - char c = code.charAt(i); - if (!Character.isLetterOrDigit(c)) { - return false; - } - if (Character.isDigit(c)) { - hasDigit = true; - } - } - return hasDigit; - } - - private static boolean isPlausibleStandalone(String body, int start, int end, String normalized) { - if (!isValidCode(normalized)) { - return false; - } - String window = body.substring(Math.max(0, start - 8), Math.min(body.length(), end + 8)); - if (PHONE_PATTERN.matcher(window).find()) { - return false; - } - if (MONEY_PATTERN.matcher(window).find()) { - return false; - } - if (DATE_PATTERN.matcher(window).find()) { - return false; - } - if (normalized.length() == 8 && body.substring(Math.max(0, start - 2), Math.min(body.length(), end + 2)).contains("-")) { - return false; - } - return true; - } - - private static int scoreStandalone(String code) { - int score = 0; - if (code.length() == 6) { - score += 10; - } else if (code.length() == 4) { - score += 6; - } - if (code.matches("\\d+")) { - score += 4; - } - return score; - } - - public static final class ParseResult { - public final boolean success; - public final String code; - public final String strategy; - public final int confidence; - public final String failureReason; - - private ParseResult(boolean success, String code, String strategy, int confidence, String failureReason) { - this.success = success; - this.code = code == null ? "" : code; - this.strategy = strategy == null ? "" : strategy; - this.confidence = confidence; - this.failureReason = failureReason == null ? "" : failureReason; - } - - public static ParseResult success(String code, String strategy, int confidence) { - return new ParseResult(true, code, strategy, confidence, ""); - } - - public static ParseResult failure(String reason) { - return new ParseResult(false, "", "", 0, reason); - } - } -} diff --git a/app/src/main/java/com/smsreceive/app/ui/MainActivity.java b/app/src/main/java/com/smsreceive/app/ui/MainActivity.java index b504616..f6c83e0 100644 --- a/app/src/main/java/com/smsreceive/app/ui/MainActivity.java +++ b/app/src/main/java/com/smsreceive/app/ui/MainActivity.java @@ -31,7 +31,6 @@ import android.widget.EditText; import android.widget.LinearLayout; import android.widget.RadioButton; import android.widget.ScrollView; -import android.widget.Switch; import android.widget.TextView; import android.widget.Toast; @@ -46,7 +45,6 @@ import com.smsreceive.app.keepalive.SmsPollingStateStore; import com.smsreceive.app.sms.CaptureResult; import com.smsreceive.app.sms.SmsCaptureStore; import com.smsreceive.app.sms.SmsInboxReader; -import com.smsreceive.app.sms.VerificationCodeParser; import java.text.SimpleDateFormat; import java.util.Date; @@ -73,8 +71,6 @@ public final class MainActivity extends Activity { private Button pollingButton; private RadioButton toastOnDatabaseWriteRadio; private CheckBox feishuPushEnabledCheckBox; - private CheckBox feishuDebugBodyCheckBox; - private Switch feishuFilterCodeSwitch; private EditText feishuWebhookIdEdit; private EditText feishuSecretEdit; private EditText pollingIntervalEdit; @@ -155,14 +151,14 @@ public final class MainActivity extends Activity { ViewGroup.LayoutParams.WRAP_CONTENT)); TextView title = new TextView(this); - title.setText("短信验证码接收"); + title.setText("短信接收"); title.setTextSize(24); title.setTextColor(0xFF17202A); title.setGravity(Gravity.START); root.addView(title, matchWrap()); TextView subtitle = new TextView(this); - subtitle.setText("主路径:RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后只保存验证码和诊断摘要。"); + subtitle.setText("主路径:RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后保存短信原文和诊断摘要。"); subtitle.setTextSize(14); subtitle.setTextColor(0xFF5F6B7A); subtitle.setPadding(0, dp(6), 0, dp(16)); @@ -200,11 +196,7 @@ public final class MainActivity extends Activity { readInboxButton.setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true)); actions.addView(readInboxButton, matchWrap()); - Button dumpRecentButton = button("打印最近30条短信"); - dumpRecentButton.setOnClickListener(v -> dumpRecentMessages()); - actions.addView(dumpRecentButton, matchWrap()); - - pollingButton = button("开始1秒轮询验证码"); + pollingButton = button("开始短信轮询"); pollingButton.setOnClickListener(v -> togglePolling()); actions.addView(pollingButton, matchWrap()); @@ -214,10 +206,6 @@ public final class MainActivity extends Activity { pollingIntervalEdit.setInputType(InputType.TYPE_CLASS_NUMBER); actions.addView(pollingIntervalEdit, matchWrap()); - Button savePollingIntervalButton = button("保存轮询间隔"); - savePollingIntervalButton.setOnClickListener(v -> savePollingIntervalFromUi()); - actions.addView(savePollingIntervalButton, matchWrap()); - feishuPushEnabledCheckBox = new CheckBox(this); feishuPushEnabledCheckBox.setText("开启飞书远端推送"); feishuPushEnabledCheckBox.setTextSize(14); @@ -235,19 +223,6 @@ public final class MainActivity extends Activity { feishuSecretEdit.setSingleLine(true); actions.addView(feishuSecretEdit, matchWrap()); - feishuDebugBodyCheckBox = new CheckBox(this); - feishuDebugBodyCheckBox.setText("调试时上传完整短信正文"); - feishuDebugBodyCheckBox.setTextSize(14); - feishuDebugBodyCheckBox.setTextColor(0xFF27313F); - actions.addView(feishuDebugBodyCheckBox, matchWrap()); - - feishuFilterCodeSwitch = new Switch(this); - feishuFilterCodeSwitch.setText("只推送验证码(过滤非验证码短信)"); - feishuFilterCodeSwitch.setTextSize(14); - feishuFilterCodeSwitch.setTextColor(0xFF27313F); - feishuFilterCodeSwitch.setChecked(false); - actions.addView(feishuFilterCodeSwitch, matchWrap()); - Button saveFeishuButton = button("保存飞书配置"); saveFeishuButton.setOnClickListener(v -> saveFeishuConfigFromUi()); actions.addView(saveFeishuButton, matchWrap()); @@ -350,7 +325,7 @@ public final class MainActivity extends Activity { SmsCaptureStore.StoredCapture capture = SmsCaptureStore.load(this); if (capture.timeMillis <= 0L) { - latestText.setText("暂无短信接收记录。可以先授权,再从另一台手机发送:验证码 123456,5 分钟内有效。"); + latestText.setText("暂无短信接收记录。可以先授权,再从另一台手机发送一条测试短信。"); return; } @@ -358,14 +333,10 @@ public final class MainActivity extends Activity { builder.append("时间:").append(formatTime(capture.timeMillis)).append('\n'); builder.append("来源:").append(emptyAsDash(capture.source)).append('\n'); builder.append("发送方:").append(emptyAsDash(capture.sender)).append('\n'); - if (!TextUtils.isEmpty(capture.code)) { - builder.append("验证码:").append(capture.code).append('\n'); - builder.append("策略:").append(capture.strategy).append(" / ").append(capture.confidence).append('\n'); - } else { - builder.append("验证码:-").append('\n'); + if (!capture.success) { builder.append("失败原因:").append(emptyAsDash(capture.failure)).append('\n'); } - builder.append("正文摘要:").append(emptyAsDash(capture.bodyPreview)); + builder.append("短信原文:").append(emptyAsDash(capture.body)); latestText.setText(builder.toString()); } @@ -471,7 +442,7 @@ public final class MainActivity extends Activity { private void refreshPollingUi() { SmsPollingStateStore.State state = SmsPollingStateStore.load(this); if (pollingButton != null) { - pollingButton.setText(state.enabledByUser ? "停止1秒轮询验证码" : "开始1秒轮询验证码"); + pollingButton.setText(state.enabledByUser ? "停止短信轮询" : "开始短信轮询"); } if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) { pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds)); @@ -492,12 +463,6 @@ public final class MainActivity extends Activity { if (feishuPushEnabledCheckBox != null) { feishuPushEnabledCheckBox.setChecked(config.enabled); } - if (feishuDebugBodyCheckBox != null) { - feishuDebugBodyCheckBox.setChecked(config.sendFullBodyDebug); - } - if (feishuFilterCodeSwitch != null) { - feishuFilterCodeSwitch.setChecked(config.filterVerificationCode); - } if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) { feishuWebhookIdEdit.setText(config.webhookId); } @@ -509,12 +474,11 @@ public final class MainActivity extends Activity { builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n'); builder.append("默认模板:").append(FeishuWebhookConfigStore.defaultConfigPath(this)).append('\n'); builder.append("推送开关:").append(config.enabled ? "已开启" : "未开启").append('\n'); - builder.append("验证码过滤:").append(config.filterVerificationCode ? "已开启" : "未开启").append('\n'); + builder.append("推送内容:短信原文").append('\n'); builder.append("Webhook ID:").append(config.hasWebhookId() ? "已配置" : "未配置").append('\n'); builder.append("Secret:").append(config.hasSecret() ? FeishuWebhookConfigStore.maskSecret(config.secret) : "未配置").append('\n'); - builder.append("完整正文上传:").append(config.sendFullBodyDebug ? "已开启" : "未开启").append('\n'); builder.append("已推送短信时间秒:") .append(lastPushedSms.receivedSecond > 0L ? String.valueOf(lastPushedSms.receivedSecond) : "-") .append('\n'); @@ -606,16 +570,12 @@ public final class MainActivity extends Activity { private void saveFeishuConfigFromUi() { boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked(); - boolean debugBody = feishuDebugBodyCheckBox != null && feishuDebugBodyCheckBox.isChecked(); - boolean filterCode = feishuFilterCodeSwitch != null && feishuFilterCodeSwitch.isChecked(); String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString(); String secret = feishuSecretEdit == null ? "" : feishuSecretEdit.getText().toString(); Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled + ", webhookConfigured=" + !TextUtils.isEmpty(webhookId) - + ", secretConfigured=" + !TextUtils.isEmpty(secret) - + ", debugBody=" + debugBody - + ", filterCode=" + filterCode); - FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret, debugBody, filterCode); + + ", secretConfigured=" + !TextUtils.isEmpty(secret)); + FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret); Toast.makeText(this, "已保存飞书推送配置", Toast.LENGTH_SHORT).show(); refreshUi(); } @@ -640,19 +600,11 @@ public final class MainActivity extends Activity { } private String buildLatestSmsTestMarkdown(SmsInboxReader.InboxResult inboxResult) { - VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body); StringBuilder builder = new StringBuilder(); builder.append("**SmsReceive 最近短信测试推送**").append('\n'); builder.append("时间:").append(formatTime(inboxResult.dateMillis)).append('\n'); builder.append("发送方:").append(maskSender(inboxResult.sender)).append('\n'); builder.append("短信ID:").append(inboxResult.id).append('\n'); - if (parseResult.success) { - builder.append("验证码:").append(parseResult.code).append('\n'); - builder.append("解析:").append(parseResult.strategy).append(" / ").append(parseResult.confidence).append('\n'); - } else { - builder.append("验证码:-").append('\n'); - builder.append("解析失败:").append(emptyAsDash(parseResult.failureReason)).append('\n'); - } builder.append("正文:").append(emptyAsDash(inboxResult.body)); return builder.toString(); } @@ -692,70 +644,42 @@ public final class MainActivity extends Activity { } lastInboxSmsId = inboxResult.id; - VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body); - CaptureResult captureResult; - if (parseResult.success) { - Log.d(TAG, "readLatestInboxSms parse success source=" + source - + ", id=" + inboxResult.id - + ", code=" + parseResult.code - + ", strategy=" + parseResult.strategy); - captureResult = CaptureResult.success( - inboxResult.dateMillis, - inboxResult.id, - inboxResult.sender, - inboxResult.body, - parseResult, - source); - if (showToast) { - Toast.makeText(this, "最新短信验证码:" + parseResult.code, Toast.LENGTH_LONG).show(); - } - } else { - Log.w(TAG, "readLatestInboxSms parse failed source=" + source - + ", id=" + inboxResult.id - + ", reason=" + parseResult.failureReason); - captureResult = CaptureResult.failure( - inboxResult.dateMillis, - inboxResult.id, - inboxResult.sender, - inboxResult.body, - source, - parseResult.failureReason); - if (showToast) { - Toast.makeText(this, "最新短信未解析到验证码", Toast.LENGTH_LONG).show(); - } + Log.d(TAG, "readLatestInboxSms success source=" + source + + ", id=" + inboxResult.id + + ", sender=" + inboxResult.sender); + CaptureResult captureResult = CaptureResult.success( + inboxResult.dateMillis, + inboxResult.id, + inboxResult.sender, + inboxResult.body, + source); + if (showToast) { + Toast.makeText(this, "已读取最新短信", Toast.LENGTH_LONG).show(); } SmsCaptureStore.save(this, captureResult); FeishuWebhookClient.pushCaptureResultAsync(this, captureResult); refreshUi(); } - private void dumpRecentMessages() { - if (!hasReadSmsPermission()) { - Log.w(TAG, "dumpRecentMessages skip: READ_SMS not granted"); - Toast.makeText(this, "READ_SMS 未授权,无法打印短信库", Toast.LENGTH_LONG).show(); - return; - } - int count = SmsInboxReader.logRecentMessages(this, 30); - Toast.makeText(this, "已打印最近 " + count + " 条短信到 logcat", Toast.LENGTH_LONG).show(); - } - - private void savePollingIntervalFromUi() { + private int savePollingIntervalFromUi() { int intervalSeconds = parsePollingIntervalSeconds(); SmsPollingStateStore.setIntervalSeconds(this, intervalSeconds); - Toast.makeText(this, "已保存轮询间隔:" + intervalSeconds + " 秒", Toast.LENGTH_SHORT).show(); - refreshUi(); + if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) { + pollingIntervalEdit.setText(String.valueOf(intervalSeconds)); + } + return intervalSeconds; } private int parsePollingIntervalSeconds() { String raw = pollingIntervalEdit == null ? "" : pollingIntervalEdit.getText().toString().trim(); if (TextUtils.isEmpty(raw)) { - return SmsPollingStateStore.getIntervalSeconds(this); + return 1; } try { return Integer.parseInt(raw); } catch (NumberFormatException e) { Log.w(TAG, "parsePollingIntervalSeconds invalid raw=" + raw, e); - return SmsPollingStateStore.getIntervalSeconds(this); + return 1; } } @@ -774,11 +698,11 @@ public final class MainActivity extends Activity { Toast.makeText(this, "READ_SMS 未授权,无法轮询短信库", Toast.LENGTH_LONG).show(); return; } - savePollingIntervalFromUi(); + int intervalSeconds = savePollingIntervalFromUi(); Log.d(TAG, "startPolling via SmsPollingService"); SmsPollingService.start(this); Toast.makeText(this, - "已启动后台轮询验证码,间隔 " + SmsPollingStateStore.getIntervalSeconds(this) + " 秒", + "已启动后台短信轮询,间隔 " + intervalSeconds + " 秒", Toast.LENGTH_SHORT).show(); refreshUi(); } diff --git a/app/src/test/java/com/smsreceive/app/feishu/FeishuWebhookClientTest.java b/app/src/test/java/com/smsreceive/app/feishu/FeishuWebhookClientTest.java index 5ddcd12..e1a3b0e 100644 --- a/app/src/test/java/com/smsreceive/app/feishu/FeishuWebhookClientTest.java +++ b/app/src/test/java/com/smsreceive/app/feishu/FeishuWebhookClientTest.java @@ -1,5 +1,7 @@ package com.smsreceive.app.feishu; +import com.smsreceive.app.sms.CaptureResult; + import org.json.JSONObject; import org.junit.Test; @@ -56,11 +58,28 @@ public final class FeishuWebhookClientTest { @Test public void pushMarkdownRejectsMissingConfigBeforeNetwork() { - FeishuWebhookConfigStore.Config config = new FeishuWebhookConfigStore.Config(true, "", "", false, false); + FeishuWebhookConfigStore.Config config = new FeishuWebhookConfigStore.Config(true, "", ""); FeishuWebhookPushResult result = FeishuWebhookClient.pushMarkdown(config, "test", 1717020800L); assertFalse(result.success); assertEquals(FeishuWebhookPushResult.STATUS_MISSING_CONFIG, result.status); } + + @Test + public void buildMarkdownFromCaptureUsesRawSmsBody() { + CaptureResult result = CaptureResult.success( + 1717020800000L, + "10690001", + "测试短信原文", + "system_sms_broadcast"); + + String markdown = FeishuWebhookClient.buildMarkdownFromCapture(result); + + assertTrue(markdown.contains("测试短信原文")); + assertTrue(markdown.contains("短信内容")); + assertFalse(markdown.contains("验证码")); + assertFalse(markdown.contains("解析")); + assertTrue(markdown.contains("2024")); + } } diff --git a/app/src/test/java/com/smsreceive/app/sms/VerificationCodeParserTest.java b/app/src/test/java/com/smsreceive/app/sms/VerificationCodeParserTest.java deleted file mode 100644 index 6c2cde4..0000000 --- a/app/src/test/java/com/smsreceive/app/sms/VerificationCodeParserTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.smsreceive.app.sms; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public final class VerificationCodeParserTest { - @Test - public void parsesChineseKeywordCode() { - VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("【测试】验证码 123456,5 分钟内有效。"); - - assertTrue(result.success); - assertEquals("123456", result.code); - assertEquals("keyword_before_code", result.strategy); - } - - @Test - public void parsesEnglishOtpCode() { - VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("Your OTP code is 839204. Do not share it."); - - assertTrue(result.success); - assertEquals("839204", result.code); - } - - @Test - public void normalizesSpacesAndHyphens() { - assertEquals("123456", VerificationCodeParser.parse("验证码:12 34 56").code); - assertEquals("123456", VerificationCodeParser.parse("验证码:123-456").code); - } - - @Test - public void prefersKeywordCandidate() { - VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("订单 998877,验证码 246810,请勿泄露。"); - - assertTrue(result.success); - assertEquals("246810", result.code); - } - - @Test - public void rejectsCommonFalsePositives() { - assertFalse(VerificationCodeParser.parse("订单金额 1234 元,手机号 13800138000。").success); - assertFalse(VerificationCodeParser.parse("会议日期 2026-05-16,无验证码。").success); - assertFalse(VerificationCodeParser.parse("这是一条普通通知。").success); - } -} diff --git a/openspec/changes/add-feishu-webhook-push/design.md b/openspec/changes/add-feishu-webhook-push/design.md index 4f6bf17..5e178fd 100644 --- a/openspec/changes/add-feishu-webhook-push/design.md +++ b/openspec/changes/add-feishu-webhook-push/design.md @@ -86,7 +86,7 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串 `BroadcastReceiver.onReceive` 生命周期短,不能同步执行网络请求。后续实现应采用: - `FeishuWebhookClient.pushMarkdownAsync(...)` 提交到后台 `ExecutorService`。 -- `SmsReceiver` 在验证码解析成功、保存本地结果后,只触发异步推送,不等待网络结果。 +- `SmsReceiver` 在短信原文读取成功、保存本地结果后,只触发异步推送,不等待网络结果。 - 推送结果写入 `SharedPreferences` 或轻量状态 store,并通过本地广播刷新 UI。 - 若未来需要保证进程退出后仍发送,可另起 change 引入 WorkManager;本轮不做。 @@ -97,7 +97,6 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串 - `webhook_id` - `secret` - `enabled` -- `sendFullSmsBodyForDebug`,默认 false - 最近一次推送状态、时间、错误类型和错误摘要 飞书 webhook 配置保存到 `/sdcard/Android/data/{applicationId}/config/feishu.json`。如果 `feishu.json` 不存在,app 生成默认模板 `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json`,但不把模板当作生效配置。这样调试时可以直接改 `feishu.json`,不需要重新编译 Android 代码。 @@ -106,13 +105,13 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串 ### 6. 推送内容策略 -验证码解析成功时,默认 markdown 内容建议包含: +短信原文读取成功时,默认 markdown 内容建议包含: -- 验证码:解析出的 code。 +- 短信原文。 - 来源:`system_sms_broadcast` / `sms_inbox_*`。 - 发送方:掩码后的 sender。 - 时间:本地格式化时间。 -- 解析策略和置信度:便于诊断。 +- 如存在读取异常,附带失败摘要。 默认包含完整短信原文,便于远端调试确认。仍不建议在 logcat 打印完整正文。 @@ -143,11 +142,11 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串 ## Privacy Boundaries -- 默认仅上传验证码和必要诊断摘要。 -- 飞书推送默认包含完整短信正文;开启推送即表示允许验证码短信离开设备。 +- 默认上传短信原文和必要诊断摘要。 +- 飞书推送默认包含完整短信正文;开启推送即表示允许短信内容离开设备。 - 不上传联系人通讯录、短信历史或设备标识。 - 本地保存 secret 时使用 `SharedPreferences` 只满足个人调试场景;如需更严格保护,应另起 change 使用 Android Keystore 加密。 -- 推送到飞书意味着验证码会离开设备,UI 应让用户明确知道远端推送已开启。 +- 推送到飞书意味着短信内容会离开设备,UI 应让用户明确知道远端推送已开启。 ## Test Strategy @@ -168,7 +167,7 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串 - 使用真实飞书机器人确认收到 markdown 卡片。 - 故意填错 secret,确认 UI 显示飞书业务错误而不是短信接收失败。 - 断网或飞行模式下测试 `network_error`/`timeout`。 -- 收到真实验证码短信后,确认本地结果先保存,远端推送状态独立更新。 +- 收到真实短信后,确认本地结果先保存,远端推送状态独立更新。 ## Rollout Plan @@ -177,12 +176,12 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串 3. 实现签名、请求体构造、响应解析和异步发送模块。 4. 增加 JSON 配置 store 和最近推送状态。 5. 在 `MainActivity` 增加配置、测试发送和状态展示。 -6. 将验证码解析成功后的推送接入异步链路。 +6. 将短信原文读取成功后的推送接入异步链路。 7. 增加聚焦单元测试,不要求本轮编译。 ## Open Questions - webhook id 和 secret 是否只用于本机调试,还是需要后续提供导入/导出配置。 -- 默认推送内容是否只发验证码,还是需要包含发送方掩码和解析策略。 -- 是否需要推送所有收到的字符串,还是只在验证码解析成功时推送。 +- 默认推送内容是否只发短信原文,还是需要包含发送方掩码和来源等诊断摘要。 +- 是否需要推送所有收到的字符串,还是只在短信原文读取成功时推送。 - 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。 diff --git a/openspec/changes/add-feishu-webhook-push/proposal.md b/openspec/changes/add-feishu-webhook-push/proposal.md index 27c772a..5f44381 100644 --- a/openspec/changes/add-feishu-webhook-push/proposal.md +++ b/openspec/changes/add-feishu-webhook-push/proposal.md @@ -1,6 +1,6 @@ ## Why -当前 `SmsReceive` 已经具备短信验证码接收、解析、存储和本地诊断能力。新需求是在收到字符串内容后,把它发送到服务器。用户已经给出 Python 参考实现,目标服务器实际是飞书机器人 webhook:构造 interactive markdown 卡片,使用 `timestamp + "\n" + secret` 生成 HmacSHA256 + Base64 签名,然后 POST 到 `https://open.feishu.cn/open-apis/bot/v2/hook/{webhook_id}`。 +当前 `SmsReceive` 已经具备短信原文接收、存储和本地诊断能力。新需求是在收到字符串内容后,把它发送到服务器。用户已经给出 Python 参考实现,目标服务器实际是飞书机器人 webhook:构造 interactive markdown 卡片,使用 `timestamp + "\n" + secret` 生成 HmacSHA256 + Base64 签名,然后 POST 到 `https://open.feishu.cn/open-apis/bot/v2/hook/{webhook_id}`。 这次变更的重点不是重新设计服务端协议,而是把 Python 中已经验证过的请求协议等价迁移到 Android,并与当前短信接收链路解耦。Android 侧应提供一个可测试、可诊断、可复用的发送模块,后续可以由 `SmsReceiver`、手动测试按钮或其它业务入口调用。 @@ -19,8 +19,8 @@ - 实际配置从 `/sdcard/Android/data/{applicationId}/config/feishu.json` 读取,便于动态调试。 - 如果 `feishu.json` 不存在,则生成默认模板 `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json`。 - 后续实现可接入当前短信结果: - - 成功解析验证码后,可以把验证码摘要、来源、时间和发送方掩码作为 markdown 内容推送。 - - 按调试需求推送完整短信原文,便于确认服务端收到的内容与本机短信一致。 + - 成功读取短信原文后,可以把短信内容、来源、时间和发送方掩码作为 markdown 内容推送。 + - 默认推送完整短信原文,便于确认服务端收到的内容与本机短信一致。 ## Capabilities @@ -30,7 +30,7 @@ ### Modified Capabilities -- `sms-code-capture`: 后续实现可以在验证码解析成功后触发推送,但短信捕获本身不依赖网络成功。 +- `sms-code-capture`: 后续实现可以在短信原文读取成功后触发推送,但短信捕获本身不依赖网络成功。 - `sms-receiver-delivery-diagnostics`: 后续诊断应区分“短信接收/解析成功”和“远端推送成功/失败”,避免把网络失败误判为短信接收失败。 ## Impact @@ -40,14 +40,14 @@ - `app/src/main/AndroidManifest.xml`:新增 `android.permission.INTERNET`。 - `app/src/main/java/com/smsreceive/app/`:新增飞书推送相关 Java 类,例如 `FeishuWebhookClient`、`FeishuWebhookConfigStore`、`FeishuWebhookPushResult`。 - `MainActivity`:新增 JSON 配置路径展示、配置输入、测试发送和最近推送结果展示。 - - `SmsReceiver` 或统一结果处理层:在验证码解析成功后触发异步推送。 + - `SmsReceiver` 或统一结果处理层:在短信原文读取成功后触发异步推送。 - 推荐技术选择: - 网络库:OkHttp。 - JSON 构造/解析:优先使用 Android 自带 `org.json`,降低依赖数量。 - 签名:`javax.crypto.Mac` + `SecretKeySpec` + `android.util.Base64.NO_WRAP`。 - 异步执行:小型 `ExecutorService` 或现有轻量后台执行器,不引入协程/RxJava。 - 隐私影响: - - 默认推送验证码摘要、来源、发送方掩码、时间和短信原文。 + - 默认推送短信原文、来源、发送方掩码和时间。 - 不在 logcat 打印 secret、sign、完整 webhook URL 或完整短信正文。 ## Validation diff --git a/openspec/changes/add-feishu-webhook-push/specs/feishu-webhook-push/spec.md b/openspec/changes/add-feishu-webhook-push/specs/feishu-webhook-push/spec.md index 1bcf003..97eb41e 100644 --- a/openspec/changes/add-feishu-webhook-push/specs/feishu-webhook-push/spec.md +++ b/openspec/changes/add-feishu-webhook-push/specs/feishu-webhook-push/spec.md @@ -33,7 +33,7 @@ The app SHALL send markdown content to the Feishu bot webhook using the same req The app SHALL avoid blocking UI and SMS broadcast handling while sending webhook requests. #### Scenario: SMS receiver triggers a push -- **WHEN** a verification code is parsed successfully in `SmsReceiver` +- **WHEN** a new SMS capture result is stored successfully in `SmsReceiver` - **THEN** the app MUST save the local capture result first - **AND** it MUST dispatch the webhook push asynchronously - **AND** it MUST NOT wait for the network request before returning from broadcast handling @@ -85,12 +85,11 @@ The app SHALL avoid exposing webhook secrets while sending complete SMS content - **THEN** it MUST mask the secret - **AND** it MUST NOT log the generated signature or full webhook URL -#### Scenario: Verification code push content is built -- **WHEN** the app builds markdown content from a parsed SMS result -- **THEN** it MUST include the verification code and minimal diagnostics such as source, masked sender, time, strategy, and confidence -- **AND** it MUST include the full original SMS body +#### Scenario: SMS push content is built +- **WHEN** the app builds markdown content from an SMS capture result +- **THEN** it MUST include the full original SMS body and minimal diagnostics such as source, masked sender, time, and any structured failure reason when present #### Scenario: Webhook push is disabled -- **WHEN** a verification code is parsed while push is disabled +- **WHEN** an SMS is captured while push is disabled - **THEN** the app MUST keep local SMS capture behavior unchanged - **AND** it MUST record or report that remote push was skipped because it is disabled diff --git a/openspec/changes/add-feishu-webhook-push/tasks.md b/openspec/changes/add-feishu-webhook-push/tasks.md index 3edf1cd..e0923a3 100644 --- a/openspec/changes/add-feishu-webhook-push/tasks.md +++ b/openspec/changes/add-feishu-webhook-push/tasks.md @@ -30,7 +30,7 @@ ## 5. Config And State -- [x] 5.1 新增 `FeishuWebhookConfigStore`,通过 `/sdcard/Android/data/{applicationId}/config/feishu.json` 保存 enabled、webhook id、secret 和 debug 上传开关 +- [x] 5.1 新增 `FeishuWebhookConfigStore`,通过 `/sdcard/Android/data/{applicationId}/config/feishu.json` 保存 enabled、webhook id 和 secret - [x] 5.2 新增最近推送状态,包含时间、成功状态、错误类型和错误摘要 - [x] 5.3 UI 展示 secret 时使用掩码 - [x] 5.4 默认推送完整短信原文,便于远端调试 @@ -46,10 +46,10 @@ ## 7. SMS Flow Integration -- [x] 7.1 验证码解析成功后构造默认 markdown 摘要 +- [x] 7.1 短信原文读取成功后构造默认 markdown 摘要 - [x] 7.2 `SmsReceiver` 保存本地结果后触发异步推送 - [x] 7.3 推送失败不影响本地短信捕获、解析和 UI 刷新 -- [x] 7.4 默认推送内容包含验证码、来源、发送方掩码、时间、解析摘要和短信原文 +- [x] 7.4 默认推送内容包含短信原文、来源、发送方掩码和时间 ## 8. Tests diff --git a/openspec/changes/add-xiaomi-background-keepalive/design.md b/openspec/changes/add-xiaomi-background-keepalive/design.md index 6f1ce35..8a565e7 100644 --- a/openspec/changes/add-xiaomi-background-keepalive/design.md +++ b/openspec/changes/add-xiaomi-background-keepalive/design.md @@ -1,6 +1,6 @@ ## Context -当前 app 已有短信验证码接收实现:Manifest 声明了 `RECEIVE_SMS`、`READ_SMS`,静态 `SmsReceiver` 监听 `android.provider.Telephony.SMS_RECEIVED`,`MainActivity` 提供权限申请、最近结果展示、`READ_SMS` 最新短信读取、ContentObserver 和短时轮询诊断。用户反馈“逻辑试过了能用”,但不确定为什么某些场景 `onReceive` 收不到。 +当前 app 已有短信原文接收实现:Manifest 声明了 `RECEIVE_SMS`、`READ_SMS`,静态 `SmsReceiver` 监听 `android.provider.Telephony.SMS_RECEIVED`,`MainActivity` 提供权限申请、最近结果展示、`READ_SMS` 最新短信读取、ContentObserver 和短时轮询诊断。用户反馈“逻辑试过了能用”,但不确定为什么某些场景 `onReceive` 收不到。 目标设备是小米 12S、澎湃 OS 3、Android 15。根据 Android 官方文档,`RECEIVE_SMS` 允许应用接收 SMS,但它是 dangerous 且 hard restricted 权限,是否能真正持有可能受安装来源/安装器 allowlist 影响。Android 15 又新增了 `BOOT_COMPLETED` 启动部分前台服务类型的限制;Doze/App Standby 白名单也不是无限制后台执行。小米官方支持文档说明 HyperOS/小米系统存在“Background autostart”用户开关,路径为 Settings > Apps > Permissions > Background autostart。 @@ -34,7 +34,7 @@ ### 1. 系统短信广播仍是主路径 -短信进入设备时,主路径仍是 `SMS_RECEIVED_ACTION`。这是最直接的验证码捕获路径,但它依赖: +短信进入设备时,主路径仍是 `SMS_RECEIVED_ACTION`。这是最直接的短信原文捕获路径,但它依赖: - 应用真正持有 `RECEIVE_SMS`。 - 应用未被用户 force-stop。 @@ -129,7 +129,7 @@ Android 标准能力: 2. 发送短信时抓 logcat `SmsReceive`。 3. 如果 receiver 无日志,点“读取最新短信”确认短信是否入库。 4. 如果短信已入库但 receiver 无日志,重点排查权限、force-stop、HyperOS 自启动和省电。 -5. 如果 receiver 有日志但无验证码,排查 PDU/body/parser。 +5. 如果 receiver 有日志但无短信结果,排查 PDU/body/读取链路。 ## Data Model diff --git a/openspec/changes/add-xiaomi-background-keepalive/proposal.md b/openspec/changes/add-xiaomi-background-keepalive/proposal.md index df7adc0..585ce65 100644 --- a/openspec/changes/add-xiaomi-background-keepalive/proposal.md +++ b/openspec/changes/add-xiaomi-background-keepalive/proposal.md @@ -1,6 +1,6 @@ ## Why -当前 `SmsReceive` 已经能在部分场景接收并解析验证码短信,但目标设备是小米 12S、澎湃 OS 3、Android 15,后台策略比标准 Android 更激进。仅靠 `SMS_RECEIVED_ACTION` 静态广播不够,需要补齐“开机后自动恢复监听、后台运行可见、系统设置可引导、失败可诊断”的完整方案。 +当前 `SmsReceive` 已经能在部分场景接收并展示短信原文,但目标设备是小米 12S、澎湃 OS 3、Android 15,后台策略比标准 Android 更激进。仅靠 `SMS_RECEIVED_ACTION` 静态广播不够,需要补齐“开机后自动恢复监听、后台运行可见、系统设置可引导、失败可诊断”的完整方案。 这次需求的重点不是规避系统限制,而是在个人自用 sideload/debug app 的边界内,把 Android 官方后台机制、HyperOS 人工设置、短信广播兜底路径和可验证诊断组合起来。用户也会手动在小米设置中开启“自启动”和“省电策略-无限制”,因此方案应显式利用这个前提。 diff --git a/openspec/changes/add-xiaomi-background-keepalive/tasks.md b/openspec/changes/add-xiaomi-background-keepalive/tasks.md index 25a67d2..96f7640 100644 --- a/openspec/changes/add-xiaomi-background-keepalive/tasks.md +++ b/openspec/changes/add-xiaomi-background-keepalive/tasks.md @@ -46,7 +46,7 @@ - [x] 6.1 记录最近一次 `system_sms_broadcast` 到达时间 - [x] 6.2 记录最近一次 `sms_inbox_observer`、`sms_inbox_manual`、`sms_inbox_polling` 命中时间 -- [x] 6.3 当收件箱兜底发现新验证码但广播未到达时,展示“疑似短信广播未投递” +- [x] 6.3 当收件箱兜底发现新短信但广播未到达时,展示“疑似短信广播未投递” - [x] 6.4 在 UI 中列出 `onReceive` 不触发的排查清单 - [x] 6.5 增加 logcat 输出,区分权限缺失、body 为空、parser 失败、广播未到达 @@ -62,15 +62,15 @@ - [ ] 8.1 为 `KeepAliveStateStore` 增加状态读写测试 - [ ] 8.2 为 Boot action 处理逻辑增加单元测试 - [ ] 8.3 为设置 intent fallback 增加测试或可验证日志 -- [x] 8.4 保持现有验证码解析测试通过 +- [x] 8.4 保持现有短信接收与结果展示测试通过 - [x] 8.5 不要求本轮编译;代码完成后再按用户要求通知 ## 9. Xiaomi 12S / HyperOS 3 Device Validation - [ ] 9.1 手动开启小米自启动 - [ ] 9.2 手动设置省电策略为无限制 -- [ ] 9.3 开启常驻通知,后台 30 分钟后发送验证码短信 -- [ ] 9.4 锁屏 30 分钟后发送验证码短信 +- [ ] 9.3 开启常驻通知,后台 30 分钟后发送测试短信 +- [ ] 9.4 锁屏 30 分钟后发送测试短信 - [ ] 9.5 重启手机,确认保活服务是否自动恢复 - [ ] 9.6 重启后未打开 app 直接发送第一条短信,记录广播是否到达 - [ ] 9.7 手动 force-stop 后发送短信,确认不承诺接收,并记录诊断表现 diff --git a/openspec/changes/build-sms-code-receiver-app/design.md b/openspec/changes/build-sms-code-receiver-app/design.md index 7662f64..85366a5 100644 --- a/openspec/changes/build-sms-code-receiver-app/design.md +++ b/openspec/changes/build-sms-code-receiver-app/design.md @@ -2,23 +2,23 @@ 当前 `SmsReceive` 目录几乎为空,只有 macOS 生成的 `.DS_Store`,没有 Android 工程和既有 OpenSpec 目录。用户要求先生成完整 spec 方案,再开始编码;同时明确 Android Studio、Gradle、JDK 等环境不在本次方案范围内,后续实现要参考 `Weather reference project` 的构建环境。 -目标设备是小米 12S、澎湃 OS 3、Android 15。需求本质是个人自用工具:收到手机短信验证码后,应用读取短信正文、提取验证码并展示。由于不是 Play Store 上架应用,方案可以直接使用短信权限,但仍要面对 Android 运行时权限、Android 15 受限权限策略、厂商后台管理和短信广播分发行为。 +目标设备是小米 12S、澎湃 OS 3、Android 15。需求本质是个人自用工具:收到手机短信后,应用读取短信原文并展示。由于不是 Play Store 上架应用,方案可以直接使用短信权限,但仍要面对 Android 运行时权限、Android 15 受限权限策略、厂商后台管理和短信广播分发行为。 官方 API 判断如下: -- Android `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` 是收到文本短信的系统广播,需要 `RECEIVE_SMS` 权限。它是读取任意短信验证码最直接的路径。 -- Google `SMS Retriever API` 不需要 `READ_SMS` 或 `RECEIVE_SMS`,但短信必须包含 app hash,适合服务端短信模板可控的手机号验证,不适合读取所有第三方验证码。 -- Google `SMS User Consent API` 可以请求用户授权读取单条包含验证码的短信,不要求 app hash,但需要弹出用户确认,适合作为受限权限或广播异常时的对比验证路径。 +- Android `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` 是收到文本短信的系统广播,需要 `RECEIVE_SMS` 权限。它是读取任意短信原文最直接的路径。 +- Google `SMS Retriever API` 不需要 `READ_SMS` 或 `RECEIVE_SMS`,但短信必须包含 app hash,适合服务端短信模板可控的场景,不适合读取所有第三方短信。 +- Google `SMS User Consent API` 可以请求用户授权读取单条短信,不要求 app hash,但需要弹出用户确认,适合作为受限权限或广播异常时的对比验证路径。 ## Goals / Non-Goals **Goals:** - 先形成可执行 spec,不直接写业务代码。 -- 建立 Android 15 上读取验证码短信的主路径和备选路径。 -- 明确验证码解析、权限状态、诊断状态和真机验证标准。 +- 建立 Android 15 上读取短信原文的主路径和备选路径。 +- 明确短信原文展示、权限状态、诊断状态和真机验证标准。 - 后续实现应保持最小化:一个主界面、一个接收链路、一组诊断信息,不做复杂产品化。 -- 保持短信内容本地处理,默认只展示验证码、来源、时间和短诊断摘要。 +- 保持短信内容本地处理,默认展示短信原文、来源、时间和短诊断摘要。 **Non-Goals:** @@ -26,13 +26,13 @@ - 不实现发送短信、删除短信、读取历史短信库或同步短信到云端。 - 不处理 Android Studio、Gradle、JDK 的重新安装和环境拉取。 - 不以 Google Play 上架合规作为约束目标。 -- 不保证所有银行、平台、运营商验证码都能被无条件读取;必须通过真机验证确认。 +- 不保证所有银行、平台、运营商短信都能被无条件读取;必须通过真机验证确认。 ## Decisions ### Decision 1: 主路径使用 `SMS_RECEIVED_ACTION` + `RECEIVE_SMS` -主路径选择系统短信广播。原因是目标是读取“我自己的手机收到的验证码”,短信来源不可控,很多验证码短信不会带当前 app 的 hash,`SMS Retriever API` 无法覆盖任意验证码。系统广播能拿到完整 PDU,再通过 `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` 合并为正文,是最符合目标的能力。 +主路径选择系统短信广播。原因是目标是读取“我自己的手机收到的短信原文”,短信来源不可控,很多短信不会带当前 app 的 hash,`SMS Retriever API` 无法覆盖任意短信。系统广播能拿到完整 PDU,再通过 `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` 合并为正文,是最符合目标的能力。 实现要求: @@ -40,11 +40,11 @@ - 对 Android 6.0+ 执行运行时权限申请。 - 注册接收 `android.provider.Telephony.SMS_RECEIVED` 的 receiver。 - receiver 内只做轻量解析和状态分发,避免长耗时。 -- 记录最近一次接收时间、sender、body 摘要、提取结果和失败原因。 +- 记录最近一次接收时间、sender、body 摘要、读取结果和失败原因。 备选方案: -- `READ_SMS` 可读取短信数据库,但需求是监听新验证码,不需要读取历史短信;默认不纳入主路径。 +- `READ_SMS` 可读取短信数据库,但需求是监听新短信,不需要读取历史短信;默认不纳入主路径。 - 默认短信应用角色权限更强,但目标不是做短信客户端;不作为一期要求。 ### Decision 2: 备选验证路径引入 `SMS User Consent API` @@ -52,29 +52,28 @@ `SMS User Consent API` 用于验证两类问题: - 当系统广播路径在 HyperOS 上被后台策略影响时,前台触发 consent flow 是否能拿到单条短信。 -- 当用户不愿或系统不允许直接授予短信权限时,是否仍能通过一次性确认读取验证码。 +- 当用户不愿或系统不允许直接授予短信权限时,是否仍能通过一次性确认读取短信原文。 限制: - 它不是静默读取,需要用户确认。 -- 它适合前台验证流程,不适合后台长期监听所有验证码。 +- 它适合前台验证流程,不适合后台长期监听所有短信。 - 它依赖 Google Play services;国内 ROM 环境下需要确认设备实际可用性。 ### Decision 3: `SMS Retriever API` 只作为受控短信模板能力 -`SMS Retriever API` 的优点是无需短信权限,体验干净;但它要求短信包含 app hash,且通常需要服务端发送符合格式的短信。对于读取第三方平台验证码,它大概率不适用。因此一期只实现或预留为“自发测试短信/未来自控服务端验证码”的能力,不作为读取任意验证码的主线。 +`SMS Retriever API` 的优点是无需短信权限,体验干净;但它要求短信包含 app hash,且通常需要服务端发送符合格式的短信。对于读取第三方平台短信,它大概率不适用。因此一期只实现或预留为“自发测试短信/未来自控服务端短信”的能力,不作为读取任意短信的主线。 -### Decision 4: 验证码解析采用多阶段规则 +### Decision 4: 最近结果直接以短信原文为主 -解析规则必须保守,避免把手机号、金额、日期误识别为验证码。 +当前目标不再是从短信中提取结构化业务字段,而是直接保留收到的短信原文。因此最近结果与诊断数据应围绕“是否成功读取到完整短信正文”展开,而不是围绕解析命中策略。 建议顺序: -1. 优先匹配包含关键词的模式:`验证码`、`校验码`、`动态码`、`code`、`verification`、`OTP` 附近的 4-8 位数字或字母数字。 -2. 次级匹配短信中独立出现的 4-8 位数字,排除明显日期、手机号片段、金额和订单号。 -3. 对带空格或短横线的验证码做归一化,例如 `12 34 56`、`123-456`。 -4. 若多个候选值并存,选择距离关键词最近、长度在 4-6 位优先、出现位置更靠前的候选。 -5. 解析失败时保留失败原因,不展示完整正文。 +1. 优先保证 PDU 合并后的短信正文完整。 +2. 记录 sender、timestamp、source 和 body 摘要。 +3. 读取失败时保留失败原因,例如无权限、未收到广播、正文为空。 +4. 默认展示最近一条短信原文,不额外推断正文中的业务字段。 ### Decision 5: UI 首版只做诊断型工具界面 @@ -83,17 +82,16 @@ - 当前短信权限状态。 - 主路径 receiver 状态。 - Google Play services / SMS User Consent 可用性。 -- 最近一次收到短信的时间、发送方、验证码、解析策略命中类型。 -- 最近失败原因,例如无权限、未收到广播、正文为空、未找到验证码、API timeout。 +- 最近一次收到短信的时间、发送方、来源和短信原文。 +- 最近失败原因,例如无权限、未收到广播、正文为空、API timeout。 - 手动清空最近结果按钮。 ### Decision 6: 本地隐私边界 -即使是自用 app,也不应默认保存完整短信正文。建议: +即使是自用 app,也应尽量控制短信内容扩散。建议: -- 内存中可短暂保留最近一条完整正文用于调试开关。 -- 默认持久化只保存验证码、时间、sender 摘要和解析状态。 -- 不做网络上传。 +- 默认只持久化最近一条短信原文、时间、sender 摘要和读取状态。 +- 核心能力不以网络上传为前提。 - 日志避免输出完整短信正文;debug 模式如需输出,必须集中开关控制。 ## Android 15 And HyperOS Risk Analysis @@ -117,7 +115,7 @@ 2. 参考 Weather 项目创建或复制最小 Android 构建骨架。 3. 实现权限和诊断 UI。 4. 实现系统短信广播主路径。 -5. 实现验证码解析器和单元测试。 +5. 实现短信读取结果存储和展示逻辑。 6. 在小米 12S 上跑真机验证。 7. 根据真机结果决定是否补 `SMS User Consent API` 或后台稳定性处理。 @@ -136,14 +134,14 @@ Rollback 策略: 代码验证: -- 验证码解析器单元测试覆盖中文、英文、空格、短横线、多候选、无验证码样本。 +- 短信读取与结果存储测试覆盖多段短信、正文为空、权限缺失和最近结果刷新样本。 - receiver 解析逻辑可通过构造 Intent/PDU 或抽象 message input 测试核心逻辑。 - 权限状态和诊断状态可通过 ViewModel/unit test 验证。 真机验证: -- 前台打开应用后,向目标号码发送测试短信:`【测试】验证码 123456,5 分钟内有效。` -- 应用退到后台后,重复发送不同验证码。 +- 前台打开应用后,向目标号码发送测试短信:`【测试】这是一条短信原文样本。` +- 应用退到后台后,重复发送不同短信正文。 - 锁屏状态下发送短信,解锁后检查最近结果。 - 若可以控制短信格式,发送带 app hash 的 SMS Retriever 测试短信。 - 若广播路径失败,打开前台 consent flow 再发送短信,观察是否弹出授权并读取正文。 @@ -152,5 +150,5 @@ Rollback 策略: - 目标小米 12S 当前是否安装并启用了 Google Play services。 - 用户是否接受为了后台稳定性关闭 HyperOS 对该 app 的省电限制。 -- 首版是否需要常驻通知显示最近验证码,还是只在 app 内展示。 -- 是否需要支持验证码自动复制到剪贴板;这会带来额外隐私和系统提示问题,建议先不做。 +- 首版是否需要常驻通知显示最近短信结果,还是只在 app 内展示。 +- 是否需要支持短信原文快捷复制;这会带来额外隐私和系统提示问题,建议先不做。 diff --git a/openspec/changes/build-sms-code-receiver-app/proposal.md b/openspec/changes/build-sms-code-receiver-app/proposal.md index 510351e..ac4bc97 100644 --- a/openspec/changes/build-sms-code-receiver-app/proposal.md +++ b/openspec/changes/build-sms-code-receiver-app/proposal.md @@ -1,32 +1,32 @@ ## Why -你想做的不是完整短信客户端,而是一个只服务自己手机的验证码接收工具:在小米 12S、澎湃 OS 3、Android 15 上尽可能可靠地读取新收到短信里的验证码,并把关键结果快速展示出来。 +你想做的不是完整短信客户端,而是一个只服务自己手机的短信接收工具:在小米 12S、澎湃 OS 3、Android 15 上尽可能可靠地读取新收到短信的原文,并把关键结果快速展示出来。 -这类需求的关键不在 UI,而在 Android 15 与厂商系统对短信权限、广播分发、后台限制和验证码短信格式的真实行为。因此必须先把可用 API、兜底路径和真机验证方案写清楚,再进入编码。 +这类需求的关键不在 UI,而在 Android 15 与厂商系统对短信权限、广播分发、后台限制和短信原文读取链路的真实行为。因此必须先把可用 API、兜底路径和真机验证方案写清楚,再进入编码。 ## What Changes -- 新建一个 Android App 规格方案,目标能力限定为“接收短信验证码、解析验证码、展示最近结果、输出诊断状态”。 +- 新建一个 Android App 规格方案,目标能力限定为“接收短信原文、展示最近结果、输出诊断状态”。 - 明确三条可用 SMS 获取路径,并按优先级落地: - `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` + `RECEIVE_SMS`:主路径,适合个人自用、非 Play 上架场景,直接读取收到短信内容。 - `SMS User Consent API`:备选路径,不要求短信带 app hash,但需要用户对单条短信授权,适合验证系统广播被限制时的可行性。 - - `SMS Retriever API`:受控格式路径,不需要短信权限,但要求验证码短信包含当前 app 的 hash,更适合服务端可控验证码,不适合作为读取任意平台验证码的主路径。 + - `SMS Retriever API`:受控格式路径,不需要短信权限,但要求测试短信包含当前 app 的 hash,更适合服务端可控短信模板,不适合作为读取任意平台短信的主路径。 - 明确不把 Android Studio、Gradle、JDK 环境初始化纳入本次工作,后续实现时参考已有 `Weather` 项目的构建环境。 -- 设计验证码解析策略,支持常见 4-8 位数字码、中文验证码文案、带空格/短横线的验证码,以及短信多段 PDU 合并后的正文。 +- 设计短信原文处理和展示策略,支持短信多段 PDU 合并后的完整正文展示、最近结果存储和诊断摘要。 - 设计真机验证路径,重点验证 Android 15 + HyperOS 3 上: - 运行时申请 `RECEIVE_SMS` 是否成功。 - 应用在前台/后台时 `SMS_RECEIVED_ACTION` 是否触发。 - 短信正文是否能被解析为完整 message body。 - - 常见短信验证码是否能稳定提取。 -- 建立诊断能力,暴露权限状态、API 路径状态、最近一次广播时间、最近一次解析结果和失败原因。 + - 常见短信原文是否能稳定读取。 +- 建立诊断能力,暴露权限状态、API 路径状态、最近一次广播时间、最近一次读取结果和失败原因。 ## Capabilities ### New Capabilities -- `sms-code-capture`: 定义应用通过系统短信广播、Google SMS 验证 API 备选路径接收短信正文并抽取验证码的行为要求。 +- `sms-code-capture`: 定义应用通过系统短信广播、Google SMS 验证 API 备选路径接收短信正文并保留原始内容的行为要求。 - `sms-permission-diagnostics`: 定义应用对短信权限、接收状态、API 可用性和解析失败原因的诊断展示要求。 -- `sms-code-validation-workflow`: 定义在小米 12S、澎湃 OS 3、Android 15 真机上的验证流程和测试通过标准。 +- `sms-code-validation-workflow`: 定义在小米 12S、澎湃 OS 3、Android 15 真机上的短信原文读取验证流程和测试通过标准。 ### Modified Capabilities @@ -35,14 +35,14 @@ ## Impact - 预期后续会创建一个最小 Android 工程或复用 Weather 工程环境生成同类 Android 工程配置。 -- 预期会影响 Android Manifest、运行时权限申请、BroadcastReceiver、短信 PDU 解析、验证码正则解析、前台诊断 UI 和测试用例。 +- 预期会影响 Android Manifest、运行时权限申请、BroadcastReceiver、短信 PDU 解析、最近结果展示、前台诊断 UI 和测试用例。 - 需要引入或使用的主要 Android/Google API: - `android.provider.Telephony.Sms.Intents.SMS_RECEIVED_ACTION` - `android.permission.RECEIVE_SMS` - `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` - `com.google.android.gms.auth.api.phone.SmsRetriever` - `SMS User Consent API` -- 不以 Play Store 上架合规为目标,因此可以使用短信权限;但实现仍必须本地化处理短信内容,不上传、不持久化完整短信正文,减少隐私风险。 +- 不以 Play Store 上架合规为目标,因此可以使用短信权限;但实现仍必须以本地读取、展示和诊断为主线,减少不必要的短信内容扩散。 ## Validation @@ -50,4 +50,4 @@ - API 方案必须明确主路径、备选路径、适用条件和限制,不写成泛泛而谈的短信读取方案。 - 设计必须说明 Android 15、HyperOS 3、个人自用 sideload/debug 场景下的权限与后台行为风险。 - 后续实现前必须能从任务列表直接进入编码,不再需要重新讨论核心架构。 -- 后续实现完成后,必须在目标真机上完成至少一轮短信接收、解析和诊断验证。 +- 后续实现完成后,必须在目标真机上完成至少一轮短信接收、展示和诊断验证。 diff --git a/openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md index 733c584..757ec71 100644 --- a/openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md +++ b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md @@ -11,46 +11,42 @@ The app SHALL use Android's new SMS received broadcast path as the primary mecha - **WHEN** the app does not have `RECEIVE_SMS` permission - **THEN** the app MUST report that the primary SMS capture path is blocked by missing permission -### Requirement: Parse complete SMS message bodies -The app SHALL parse SMS bodies using Android SMS message APIs rather than ad hoc PDU handling in business logic. +### Requirement: Preserve complete SMS message bodies +The app SHALL preserve SMS bodies using Android SMS message APIs rather than ad hoc PDU handling in business logic. #### Scenario: Multi-part SMS is received - **WHEN** the received intent contains multiple SMS message segments -- **THEN** the app MUST combine the message bodies in received order before verification code extraction +- **THEN** the app MUST combine the message bodies in received order before saving or displaying the capture result #### Scenario: Sender and timestamp are available - **WHEN** Android exposes sender address or timestamp for the SMS message - **THEN** the app MUST attach those values to the capture result for diagnostics and display -### Requirement: Extract verification code candidates -The app SHALL extract verification code candidates from SMS bodies with a conservative parser optimized for common Chinese and English verification messages. +### Requirement: Surface the original SMS content as the primary result +The app SHALL use the original SMS body as the primary display and diagnostic result. -#### Scenario: Chinese verification keyword is present -- **WHEN** the SMS body contains a keyword such as `验证码`、`校验码` or `动态码` near a 4-8 character code -- **THEN** the app MUST extract the nearby code as the preferred verification code candidate +#### Scenario: SMS body is available +- **WHEN** the app reads an SMS body successfully +- **THEN** the app MUST retain the original SMS body for local display and diagnostics -#### Scenario: English verification keyword is present -- **WHEN** the SMS body contains a keyword such as `code`, `verification` or `OTP` near a 4-8 character code -- **THEN** the app MUST extract the nearby code as the preferred verification code candidate +#### Scenario: SMS body is empty +- **WHEN** the app reads an SMS message but the body is empty +- **THEN** the app MUST return a structured failure instead of inventing a display value -#### Scenario: Code contains spaces or hyphens -- **WHEN** the SMS body contains a verification code formatted with spaces or hyphens -- **THEN** the app MUST normalize the code before displaying it - -#### Scenario: No reliable code candidate exists -- **WHEN** the SMS body does not contain a reliable verification code candidate -- **THEN** the app MUST return a structured parse failure instead of displaying a guessed code +#### Scenario: Capture result is displayed +- **WHEN** the app shows the latest SMS capture result +- **THEN** it MUST display the original SMS content, sender summary, source, timestamp, and any structured failure reason ### Requirement: Support optional Google SMS verification APIs The app SHALL support Google SMS verification APIs only as optional paths and MUST NOT depend on them for the primary capture behavior. #### Scenario: SMS User Consent path is available - **WHEN** Google Play services supports SMS User Consent and the user authorizes reading a single SMS -- **THEN** the app MUST parse that SMS body through the same verification code parser used by the primary path +- **THEN** the app MUST feed that SMS body through the same body-preservation path used by the primary path #### Scenario: SMS Retriever path is used with app hash - **WHEN** a controlled test SMS includes the app hash required by SMS Retriever -- **THEN** the app MUST accept the retrieved message and parse the verification code +- **THEN** the app MUST accept the retrieved message and preserve the original SMS body #### Scenario: Google SMS API is unavailable - **WHEN** Google Play services is missing, disabled, incompatible, times out, or the user declines consent diff --git a/openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md index cebb4cb..9511dde 100644 --- a/openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md +++ b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md @@ -4,27 +4,27 @@ The app SHALL be validated on the user's Xiaomi 12S running HyperOS 3 and Android 15 before the SMS capture behavior is considered complete. #### Scenario: Foreground validation -- **WHEN** the app is open in the foreground and a test SMS containing `验证码 123456` is received -- **THEN** the app MUST show `123456` as the latest parsed verification code +- **WHEN** the app is open in the foreground and a test SMS containing `这是一条短信原文样本` is received +- **THEN** the app MUST show that SMS body as the latest received SMS content #### Scenario: Background validation -- **WHEN** the app has been moved to the background and a test SMS containing a new verification code is received -- **THEN** the app MUST either show the new code after returning to the app or report that background delivery was blocked +- **WHEN** the app has been moved to the background and a test SMS containing new SMS content is received +- **THEN** the app MUST either show that new SMS body after returning to the app or report that background delivery was blocked #### Scenario: Lock screen validation - **WHEN** the device is locked and a test SMS is received - **THEN** the app MUST show the received result after unlock or report that delivery was blocked under lock screen conditions -### Requirement: Validate parser behavior with representative samples -The verification parser SHALL be validated with representative verification SMS examples and negative examples. +### Requirement: Validate SMS body handling with representative samples +The SMS body handling logic SHALL be validated with representative SMS examples and failure examples. #### Scenario: Common valid samples -- **WHEN** parser tests include Chinese verification codes, English OTP messages, space-separated codes, hyphen-separated codes, and multiple candidates -- **THEN** all expected verification codes MUST be extracted with the correct normalized value +- **WHEN** tests include Chinese notices, English notification-like messages, multi-part messages, and regular promotional text +- **THEN** all expected SMS bodies MUST be preserved with the correct original content -#### Scenario: Negative samples -- **WHEN** parser tests include messages with phone numbers, dates, money amounts, tracking numbers, or no verification code -- **THEN** the parser MUST avoid returning a false verification code unless a stronger keyword-nearby rule applies +#### Scenario: Failure samples +- **WHEN** tests include empty bodies, unreadable message input, or missing permission states +- **THEN** the app MUST return a structured failure instead of a fabricated SMS result ### Requirement: Validate optional API assumptions separately The app SHALL validate Google SMS APIs independently from the primary system broadcast path. diff --git a/openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md b/openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md index 234fd89..d99ad32 100644 --- a/openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md +++ b/openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md @@ -15,12 +15,12 @@ The app SHALL display whether the SMS receive permission is granted, denied, or The app SHALL expose diagnostic state for each supported SMS capture path. #### Scenario: Primary path receives an SMS -- **WHEN** the system broadcast path receives and parses an SMS -- **THEN** the app MUST show the latest receive time, source path, sender summary, parsed code, and parse strategy +- **WHEN** the system broadcast path receives and processes an SMS +- **THEN** the app MUST show the latest receive time, source path, sender summary, and SMS body or body summary -#### Scenario: Primary path fails before parsing -- **WHEN** the app cannot receive or parse an SMS through the primary path -- **THEN** the app MUST show a specific reason such as missing permission, no broadcast received, empty body, or parser failure +#### Scenario: Primary path fails before body capture completes +- **WHEN** the app cannot receive or process an SMS through the primary path +- **THEN** the app MUST show a specific reason such as missing permission, no broadcast received, empty body, or body-read failure #### Scenario: Optional Google API path fails - **WHEN** SMS User Consent or SMS Retriever cannot complete @@ -29,13 +29,13 @@ The app SHALL expose diagnostic state for each supported SMS capture path. ### Requirement: Avoid unnecessary SMS content retention The app SHALL minimize retention and logging of full SMS content. -#### Scenario: Verification code is parsed successfully -- **WHEN** the app extracts a verification code from an SMS -- **THEN** the app MUST display or retain the code, sender summary, timestamp, and parse metadata without requiring persistent storage of the full SMS body +#### Scenario: Recent SMS result is stored for display +- **WHEN** the app stores the latest SMS capture result +- **THEN** the app MUST keep retention limited to the recent result, sender summary, timestamp, source, and the SMS body needed for local display or diagnostics -#### Scenario: Debug body visibility is enabled -- **WHEN** a debug-only setting enables full body visibility -- **THEN** the app MUST keep that behavior local to the device and clearly separate it from normal display state +#### Scenario: Full body visibility is shown in diagnostics +- **WHEN** the app shows full SMS content for local diagnostics +- **THEN** the app MUST keep that behavior local to the device and clearly separate it from broader logging or export behavior ### Requirement: Provide recovery actions for permission problems The app SHALL provide a clear recovery path when Android or HyperOS blocks SMS capture permissions. diff --git a/openspec/changes/build-sms-code-receiver-app/tasks.md b/openspec/changes/build-sms-code-receiver-app/tasks.md index 8eff704..c318a65 100644 --- a/openspec/changes/build-sms-code-receiver-app/tasks.md +++ b/openspec/changes/build-sms-code-receiver-app/tasks.md @@ -10,7 +10,7 @@ - [x] 2.1 基于 Weather 项目环境建立最小 Android app 工程骨架 - [x] 2.2 设置包名、minSdk、targetSdk、compileSdk,并保持与现有可用环境兼容 -- [x] 2.3 创建单 Activity 工具型界面,用于权限申请、状态展示和最近验证码展示 +- [x] 2.3 创建单 Activity 工具型界面,用于权限申请、状态展示和最近短信原文展示 - [x] 2.4 建立基础日志标签和 debug 开关 ## 3. Permission And Diagnostics @@ -27,15 +27,15 @@ - [x] 4.2 使用 `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` 解析短信消息 - [x] 4.3 处理多段短信 body 合并、sender、timestamp 和 subscription id - [x] 4.4 将 receiver 结果分发到应用状态层,避免在 receiver 内执行重任务 -- [x] 4.5 在无权限、body 为空、解析失败时输出结构化失败原因 +- [x] 4.5 在无权限、body 为空、读取失败时输出结构化失败原因 -## 5. Verification Code Parser +## 5. SMS Content Result Handling -- [x] 5.1 实现关键词邻近匹配,支持验证码、校验码、动态码、code、verification、OTP -- [x] 5.2 实现 4-8 位数字或字母数字候选提取 -- [x] 5.3 支持空格和短横线归一化,例如 `12 34 56` 和 `123-456` -- [x] 5.4 增加误判排除规则,降低手机号、日期、金额、订单号误识别概率 -- [x] 5.5 为多候选短信输出命中策略和置信度 +- [x] 5.1 保存最近一次短信原文、时间、sender 摘要和来源 +- [x] 5.2 支持多段短信 body 合并后的完整结果展示 +- [x] 5.3 对最近结果提供失败原因和正文摘要 +- [x] 5.4 保持手动读取、收件箱兜底和系统广播路径的结果结构一致 +- [x] 5.5 不对短信原文额外做业务字段推断 ## 6. Optional Google SMS APIs @@ -47,10 +47,10 @@ ## 7. Tests -- [x] 7.1 为验证码解析器添加中文验证码样本测试 -- [x] 7.2 为验证码解析器添加英文 OTP/code 样本测试 -- [x] 7.3 为验证码解析器添加空格、短横线、多候选样本测试 -- [x] 7.4 为验证码解析器添加无验证码、手机号、日期、金额误判样本测试 +- [x] 7.1 为短信读取状态层添加最近结果存储测试 +- [x] 7.2 为短信广播读取链路添加多段正文样本测试 +- [x] 7.3 为收件箱路径添加最近短信读取样本测试 +- [x] 7.4 为正文为空、权限缺失和失败原因添加样本测试 - [ ] 7.5 为短信接收状态层添加权限状态和失败原因测试 ## 8. Xiaomi 12S / HyperOS 3 Device Validation @@ -67,5 +67,5 @@ - [x] 9.1 相关上下文逻辑分析通过 - [x] 9.2 OpenSpec validate 通过 - [x] 9.3 单元测试通过 -- [ ] 9.4 目标真机至少完成一条验证码短信接收和解析 -- [x] 9.5 诊断 UI 能解释无权限、未收到广播、未解析到验证码三类失败 +- [ ] 9.4 目标真机至少完成一条短信原文接收和展示 +- [x] 9.5 诊断 UI 能解释无权限、未收到广播、未读取到短信正文三类失败 diff --git a/openspec/config.yaml b/openspec/config.yaml index 4500317..8f32205 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -5,7 +5,7 @@ context: | Primary platform: Android Reference build environment: Weather reference project Target device: Xiaomi 12S, HyperOS 3, Android 15 - Primary goal: build a personal Android app that can receive and surface SMS verification codes on the user's own phone. + Primary goal: build a personal Android app that can receive and surface original SMS content on the user's own phone. Audience: an experienced Android engineer who wants a practical Android implementation plan before coding. rules: @@ -16,7 +16,7 @@ rules: - Keep Android Studio, Gradle, and JDK setup out of scope; reuse the Weather project build environment as reference design: - Write in Chinese - - Include API strategy, Android 15 permission constraints, Xiaomi/HyperOS validation risks, parsing strategy, diagnostics, privacy boundaries, and test strategy + - Include API strategy, Android 15 permission constraints, Xiaomi/HyperOS validation risks, message handling strategy, diagnostics, privacy boundaries, and test strategy - Treat implementation as a personal sideload/debug app, not a Play Store compliance exercise tasks: - Write in Chinese -- 2.47.1 From 300632a1e0648746133e98be3d9e21d3ea4875cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=8C=E7=B3=96=E5=8C=85=E5=AD=90?= Date: Mon, 18 May 2026 23:50:38 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=8F=90=E5=8F=96=E5=88=B0xml=E5=BD=93?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/smsreceive/app/ui/MainActivity.java | 309 ++++++------------ app/src/main/res/layout/activity_main.xml | 289 ++++++++++++++++ app/src/main/res/layout/dialog_debug_info.xml | 110 +++++++ 3 files changed, 502 insertions(+), 206 deletions(-) create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/dialog_debug_info.xml diff --git a/app/src/main/java/com/smsreceive/app/ui/MainActivity.java b/app/src/main/java/com/smsreceive/app/ui/MainActivity.java index f6c83e0..12b5b9c 100644 --- a/app/src/main/java/com/smsreceive/app/ui/MainActivity.java +++ b/app/src/main/java/com/smsreceive/app/ui/MainActivity.java @@ -2,6 +2,7 @@ package com.smsreceive.app.ui; import android.Manifest; import android.app.Activity; +import android.app.AlertDialog; import android.app.NotificationManager; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; @@ -19,21 +20,18 @@ import android.os.Looper; import android.os.PowerManager; import android.provider.Settings; import android.provider.Telephony; -import android.text.InputType; import android.text.TextUtils; import android.util.Log; -import android.view.Gravity; +import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; import android.widget.Button; import android.widget.CheckBox; import android.widget.EditText; -import android.widget.LinearLayout; import android.widget.RadioButton; -import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; +import com.smsreceive.app.R; import com.smsreceive.app.feishu.FeishuWebhookClient; import com.smsreceive.app.feishu.FeishuWebhookConfigStore; import com.smsreceive.app.feishu.FeishuWebhookPushResult; @@ -59,22 +57,31 @@ public final class MainActivity extends Activity { private static final long DATABASE_HEARTBEAT_STALE_MILLIS = 30_000L; private TextView permissionText; + private TextView latestText; private TextView googlePlayText; private TextView keepAliveText; private TextView databaseHeartbeatText; private TextView deliveryDiagnosticsText; private TextView feishuPushText; - private TextView latestText; private Button keepAliveButton; - private Button autostartConfirmButton; - private Button batteryConfirmButton; private Button pollingButton; private RadioButton toastOnDatabaseWriteRadio; private CheckBox feishuPushEnabledCheckBox; + private CheckBox autostartConfirmCheckBox; + private CheckBox batteryConfirmCheckBox; private EditText feishuWebhookIdEdit; private EditText feishuSecretEdit; private EditText pollingIntervalEdit; + private AlertDialog debugInfoDialog; + private View debugInfoView; private long lastInboxSmsId = -1L; + private String googlePlayTextValue = ""; + private String keepAliveTextValue = ""; + private String databaseHeartbeatTextValue = ""; + private String deliveryDiagnosticsTextValue = ""; + private String feishuPushTextValue = ""; + private int databaseHeartbeatBackgroundColor = 0xFFFFFFFF; + private int feishuPushBackgroundColor = 0xFFFFFFFF; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final BroadcastReceiver updateReceiver = new BroadcastReceiver() { @@ -91,11 +98,15 @@ public final class MainActivity extends Activity { readLatestInboxSms(SOURCE_INBOX_OBSERVER, true); } }; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "MainActivity.onCreate"); - setContentView(createContentView()); + setContentView(R.layout.activity_main); + bindMainViews(); + bindMainActions(); + refreshUi(); } @Override @@ -139,189 +150,95 @@ public final class MainActivity extends Activity { } } - private View createContentView() { - ScrollView scrollView = new ScrollView(this); - scrollView.setFillViewport(true); + private void bindMainViews() { + permissionText = findViewById(R.id.permission_text); + latestText = findViewById(R.id.latest_text); + keepAliveButton = findViewById(R.id.keep_alive_button); + toastOnDatabaseWriteRadio = findViewById(R.id.toast_database_write_radio); + pollingButton = findViewById(R.id.polling_button); + feishuPushEnabledCheckBox = findViewById(R.id.feishu_push_enabled_checkbox); + autostartConfirmCheckBox = findViewById(R.id.autostart_confirm_checkbox); + batteryConfirmCheckBox = findViewById(R.id.battery_confirm_checkbox); + feishuWebhookIdEdit = findViewById(R.id.feishu_webhook_id_edit); + feishuSecretEdit = findViewById(R.id.feishu_secret_edit); + pollingIntervalEdit = findViewById(R.id.polling_interval_edit); + } - LinearLayout root = new LinearLayout(this); - root.setOrientation(LinearLayout.VERTICAL); - root.setPadding(dp(20), dp(24), dp(20), dp(24)); - scrollView.addView(root, new ScrollView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT)); - - TextView title = new TextView(this); - title.setText("短信接收"); - title.setTextSize(24); - title.setTextColor(0xFF17202A); - title.setGravity(Gravity.START); - root.addView(title, matchWrap()); - - TextView subtitle = new TextView(this); - subtitle.setText("主路径:RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后保存短信原文和诊断摘要。"); - subtitle.setTextSize(14); - subtitle.setTextColor(0xFF5F6B7A); - subtitle.setPadding(0, dp(6), 0, dp(16)); - root.addView(subtitle, matchWrap()); - - permissionText = section(root, "权限状态"); - googlePlayText = section(root, "Google API 诊断"); - keepAliveText = section(root, "后台保活状态"); - databaseHeartbeatText = section(root, "数据库心跳诊断"); - deliveryDiagnosticsText = section(root, "短信广播诊断"); - feishuPushText = section(root, "飞书推送状态"); - latestText = section(root, "最近结果"); - - LinearLayout actions = new LinearLayout(this); - actions.setOrientation(LinearLayout.VERTICAL); - actions.setPadding(0, dp(12), 0, 0); - root.addView(actions, matchWrap()); - - Button requestPermissionButton = button("申请短信权限"); - requestPermissionButton.setOnClickListener(v -> requestSmsPermission()); - actions.addView(requestPermissionButton, matchWrap()); - - keepAliveButton = button("开启常驻保活"); + private void bindMainActions() { + findViewById(R.id.request_permission_button).setOnClickListener(v -> requestSmsPermission()); + findViewById(R.id.debug_info_button).setOnClickListener(v -> showDebugInfoDialog()); keepAliveButton.setOnClickListener(v -> toggleKeepAlive()); - actions.addView(keepAliveButton, matchWrap()); - - toastOnDatabaseWriteRadio = new RadioButton(this); - toastOnDatabaseWriteRadio.setText("每次写入数据库时弹 Toast"); - toastOnDatabaseWriteRadio.setTextSize(14); - toastOnDatabaseWriteRadio.setTextColor(0xFF27313F); toastOnDatabaseWriteRadio.setOnClickListener(v -> toggleToastOnDatabaseWrite()); - actions.addView(toastOnDatabaseWriteRadio, matchWrap()); - - Button readInboxButton = button("读取最新短信"); - readInboxButton.setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true)); - actions.addView(readInboxButton, matchWrap()); - - pollingButton = button("开始短信轮询"); + findViewById(R.id.read_inbox_button).setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true)); pollingButton.setOnClickListener(v -> togglePolling()); - actions.addView(pollingButton, matchWrap()); - - pollingIntervalEdit = new EditText(this); - pollingIntervalEdit.setHint("轮询间隔秒数,默认 1"); - pollingIntervalEdit.setSingleLine(true); - pollingIntervalEdit.setInputType(InputType.TYPE_CLASS_NUMBER); - actions.addView(pollingIntervalEdit, matchWrap()); - - feishuPushEnabledCheckBox = new CheckBox(this); - feishuPushEnabledCheckBox.setText("开启飞书远端推送"); - feishuPushEnabledCheckBox.setTextSize(14); - feishuPushEnabledCheckBox.setTextColor(0xFF27313F); - actions.addView(feishuPushEnabledCheckBox, matchWrap()); - - feishuWebhookIdEdit = new EditText(this); - feishuWebhookIdEdit.setHint("飞书 webhook id"); - feishuWebhookIdEdit.setSingleLine(true); - actions.addView(feishuWebhookIdEdit, matchWrap()); - - feishuSecretEdit = new EditText(this); - feishuSecretEdit.setHint("飞书 webhook secret"); - feishuSecretEdit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); - feishuSecretEdit.setSingleLine(true); - actions.addView(feishuSecretEdit, matchWrap()); - - Button saveFeishuButton = button("保存飞书配置"); - saveFeishuButton.setOnClickListener(v -> saveFeishuConfigFromUi()); - actions.addView(saveFeishuButton, matchWrap()); - - Button testFeishuButton = button("测试飞书推送"); - testFeishuButton.setOnClickListener(v -> testFeishuPush()); - actions.addView(testFeishuButton, matchWrap()); - - Button settingsButton = button("打开应用权限设置"); - settingsButton.setOnClickListener(v -> openAppSettings()); - actions.addView(settingsButton, matchWrap()); - - Button batterySettingsButton = button("打开电池优化设置"); - batterySettingsButton.setOnClickListener(v -> openBatteryOptimizationSettings()); - actions.addView(batterySettingsButton, matchWrap()); - - Button requestBatteryButton = button("请求忽略电池优化"); - requestBatteryButton.setOnClickListener(v -> requestIgnoreBatteryOptimizations()); - actions.addView(requestBatteryButton, matchWrap()); - - Button xiaomiAutostartButton = button("打开小米自启动设置"); - xiaomiAutostartButton.setOnClickListener(v -> openXiaomiAutostartSettings()); - actions.addView(xiaomiAutostartButton, matchWrap()); - - autostartConfirmButton = button("确认已开启小米自启动"); - autostartConfirmButton.setOnClickListener(v -> toggleManualAutostartConfirmed()); - actions.addView(autostartConfirmButton, matchWrap()); - - batteryConfirmButton = button("确认省电策略已设为无限制"); - batteryConfirmButton.setOnClickListener(v -> toggleManualBatteryConfirmed()); - actions.addView(batteryConfirmButton, matchWrap()); - - Button clearButton = button("清空最近结果"); - clearButton.setOnClickListener(v -> { + findViewById(R.id.save_feishu_button).setOnClickListener(v -> saveFeishuConfigFromUi()); + findViewById(R.id.test_feishu_button).setOnClickListener(v -> testFeishuPush()); + findViewById(R.id.settings_button).setOnClickListener(v -> openAppSettings()); + findViewById(R.id.battery_settings_button).setOnClickListener(v -> openBatteryOptimizationSettings()); + findViewById(R.id.request_battery_button).setOnClickListener(v -> requestIgnoreBatteryOptimizations()); + findViewById(R.id.xiaomi_autostart_button).setOnClickListener(v -> openXiaomiAutostartSettings()); + autostartConfirmCheckBox.setOnClickListener(v -> toggleManualAutostartConfirmed()); + batteryConfirmCheckBox.setOnClickListener(v -> toggleManualBatteryConfirmed()); + findViewById(R.id.clear_button).setOnClickListener(v -> { SmsCaptureStore.clear(this); Toast.makeText(this, "已清空最近结果", Toast.LENGTH_SHORT).show(); refreshUi(); }); - actions.addView(clearButton, matchWrap()); - - Button refreshButton = button("刷新状态"); - refreshButton.setOnClickListener(v -> refreshUi()); - actions.addView(refreshButton, matchWrap()); - - return scrollView; + findViewById(R.id.refresh_button).setOnClickListener(v -> refreshUi()); } - private TextView section(LinearLayout root, String label) { - TextView title = new TextView(this); - title.setText(label); - title.setTextSize(16); - title.setTextColor(0xFF17202A); - title.setPadding(0, dp(12), 0, dp(4)); - root.addView(title, matchWrap()); - - TextView value = new TextView(this); - value.setTextSize(14); - value.setTextColor(0xFF27313F); - value.setLineSpacing(dp(2), 1.0f); - value.setPadding(dp(12), dp(10), dp(12), dp(10)); - value.setBackgroundColor(0xFFFFFFFF); - root.addView(value, matchWrap()); - return value; + private void showDebugInfoDialog() { + if (debugInfoDialog == null) { + debugInfoView = LayoutInflater.from(this).inflate(R.layout.dialog_debug_info, null); + googlePlayText = debugInfoView.findViewById(R.id.google_play_text); + keepAliveText = debugInfoView.findViewById(R.id.keep_alive_text); + databaseHeartbeatText = debugInfoView.findViewById(R.id.database_heartbeat_text); + deliveryDiagnosticsText = debugInfoView.findViewById(R.id.delivery_diagnostics_text); + feishuPushText = debugInfoView.findViewById(R.id.feishu_push_text); + debugInfoDialog = new AlertDialog.Builder(this) + .setTitle("调试信息") + .setView(debugInfoView) + .setPositiveButton("关闭", null) + .create(); + } + applyDebugInfoState(); + debugInfoDialog.show(); } - private Button button(String text) { - Button button = new Button(this); - button.setText(text); - button.setAllCaps(false); - return button; - } - - private LinearLayout.LayoutParams matchWrap() { - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMargins(0, dp(4), 0, dp(4)); - return params; + private void applyDebugInfoState() { + if (googlePlayText == null) { + return; + } + googlePlayText.setText(googlePlayTextValue); + keepAliveText.setText(keepAliveTextValue); + databaseHeartbeatText.setText(databaseHeartbeatTextValue); + databaseHeartbeatText.setBackgroundColor(databaseHeartbeatBackgroundColor); + deliveryDiagnosticsText.setText(deliveryDiagnosticsTextValue); + feishuPushText.setText(feishuPushTextValue); + feishuPushText.setBackgroundColor(feishuPushBackgroundColor); } private void refreshUi() { boolean receiveGranted = hasReceiveSmsPermission(); boolean readGranted = hasReadSmsPermission(); + boolean googlePlayInstalled = isGooglePlayServicesInstalled(); Log.d(TAG, "refreshUi receiveSmsPermissionGranted=" + receiveGranted + ", readSmsPermissionGranted=" + readGranted - + ", googlePlayInstalled=" + isGooglePlayServicesInstalled()); + + ", googlePlayInstalled=" + googlePlayInstalled); permissionText.setText("RECEIVE_SMS:" + (receiveGranted ? "已授权" : "未授权") + "\nREAD_SMS:" + (readGranted ? "已授权" : "未授权") + "\n说明:如果 receiver 收不到广播,前台会用 READ_SMS 读取最新收件箱作为兜底。"); - googlePlayText.setText(isGooglePlayServicesInstalled() + googlePlayTextValue = googlePlayInstalled ? "已检测到 com.google.android.gms。SMS User Consent / Retriever 可作为后续备选路径验证。" - : "未检测到 com.google.android.gms。当前实现不依赖 Google API,主路径仍是系统短信广播。"); + : "未检测到 com.google.android.gms。当前实现不依赖 Google API,主路径仍是系统短信广播。"; refreshKeepAliveUi(); refreshDatabaseHeartbeatUi(); refreshDeliveryDiagnosticsUi(); refreshPollingUi(); refreshFeishuPushUi(); + applyDebugInfoState(); SmsCaptureStore.StoredCapture capture = SmsCaptureStore.load(this); if (capture.timeMillis <= 0L) { @@ -352,15 +269,9 @@ public final class MainActivity extends Activity { + ", notificationsEnabled=" + areNotificationsEnabled() + ", manualAutostart=" + state.manualAutostartConfirmed + ", manualBatteryUnrestricted=" + state.manualBatteryUnrestrictedConfirmed); - if (keepAliveButton != null) { - keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活"); - } - if (autostartConfirmButton != null) { - autostartConfirmButton.setText(state.manualAutostartConfirmed ? "取消自启动确认" : "确认已开启小米自启动"); - } - if (batteryConfirmButton != null) { - batteryConfirmButton.setText(state.manualBatteryUnrestrictedConfirmed ? "取消省电无限制确认" : "确认省电策略已设为无限制"); - } + keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活"); + autostartConfirmCheckBox.setChecked(state.manualAutostartConfirmed); + batteryConfirmCheckBox.setChecked(state.manualBatteryUnrestrictedConfirmed); StringBuilder builder = new StringBuilder(); builder.append("用户开关:").append(state.enabledByUser ? "已开启" : "未开启").append('\n'); @@ -376,14 +287,12 @@ public final class MainActivity extends Activity { builder.append("通知可见性:").append(areNotificationsEnabled() ? "系统允许通知" : "通知可能被关闭").append('\n'); builder.append("小米自启动:").append(state.manualAutostartConfirmed ? "已人工确认" : "未确认").append('\n'); builder.append("省电无限制:").append(state.manualBatteryUnrestrictedConfirmed ? "已人工确认" : "未确认"); - keepAliveText.setText(builder.toString()); + keepAliveTextValue = builder.toString(); } private void refreshDatabaseHeartbeatUi() { KeepAliveStateStore.State state = KeepAliveStateStore.load(this); - if (toastOnDatabaseWriteRadio != null) { - toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite); - } + toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite); long now = System.currentTimeMillis(); long lastActiveTime = KeepAliveDatabase.readLastActiveTime(this); @@ -402,7 +311,7 @@ public final class MainActivity extends Activity { if (lastActiveTime <= 0L) { builder.append("最后写入:-").append('\n'); builder.append("判断:数据库还没有 lastActiveTime。开启常驻保活后会开始写入。"); - databaseHeartbeatText.setBackgroundColor(0xFFFFFFFF); + databaseHeartbeatBackgroundColor = 0xFFFFFFFF; } else { builder.append("最后写入:").append(formatTimeWithMillis(lastActiveTime)).append('\n'); builder.append("距离现在:").append(gapMillis).append(" ms").append('\n'); @@ -412,13 +321,13 @@ public final class MainActivity extends Activity { .append(",大约在此后 ") .append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L) .append(" 秒内停止写入。"); - databaseHeartbeatText.setBackgroundColor(0xFFFFE0E0); + databaseHeartbeatBackgroundColor = 0xFFFFE0E0; } else { builder.append("判断:数据库心跳仍在正常窗口内。"); - databaseHeartbeatText.setBackgroundColor(0xFFE8F5E9); + databaseHeartbeatBackgroundColor = 0xFFE8F5E9; } } - databaseHeartbeatText.setText(builder.toString()); + databaseHeartbeatTextValue = builder.toString(); } private void refreshDeliveryDiagnosticsUi() { @@ -436,15 +345,13 @@ public final class MainActivity extends Activity { } else { builder.append("判断:暂无收件箱新于广播的异常记录。"); } - deliveryDiagnosticsText.setText(builder.toString()); + deliveryDiagnosticsTextValue = builder.toString(); } private void refreshPollingUi() { SmsPollingStateStore.State state = SmsPollingStateStore.load(this); - if (pollingButton != null) { - pollingButton.setText(state.enabledByUser ? "停止短信轮询" : "开始短信轮询"); - } - if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) { + pollingButton.setText(state.enabledByUser ? "停止短信轮询" : "开始短信轮询"); + if (!pollingIntervalEdit.hasFocus()) { pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds)); } Log.d(TAG, "refreshPollingUi enabled=" + state.enabledByUser @@ -460,13 +367,11 @@ public final class MainActivity extends Activity { FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(this); FeishuWebhookConfigStore.LastResult lastResult = FeishuWebhookConfigStore.loadLastResult(this); FeishuWebhookConfigStore.LastPushedSms lastPushedSms = FeishuWebhookConfigStore.loadLastPushedSms(this); - if (feishuPushEnabledCheckBox != null) { - feishuPushEnabledCheckBox.setChecked(config.enabled); - } - if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) { + feishuPushEnabledCheckBox.setChecked(config.enabled); + if (!feishuWebhookIdEdit.hasFocus()) { feishuWebhookIdEdit.setText(config.webhookId); } - if (feishuSecretEdit != null && !feishuSecretEdit.hasFocus()) { + if (!feishuSecretEdit.hasFocus()) { feishuSecretEdit.setText(config.secret); } @@ -499,8 +404,8 @@ public final class MainActivity extends Activity { boolean configIssue = !config.enabled || !config.hasWebhookId() || !config.hasSecret() || FeishuWebhookPushResult.STATUS_DISABLED.equals(lastResult.status) || FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(lastResult.status); - feishuPushText.setBackgroundColor(configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF); - feishuPushText.setText(builder.toString()); + feishuPushBackgroundColor = configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF; + feishuPushTextValue = builder.toString(); } private void requestSmsPermission() { @@ -562,16 +467,14 @@ public final class MainActivity extends Activity { boolean enabled = !state.toastOnDatabaseWrite; Log.d(TAG, "toggleToastOnDatabaseWrite enabled=" + enabled); KeepAliveStateStore.setToastOnDatabaseWrite(this, enabled); - if (toastOnDatabaseWriteRadio != null) { - toastOnDatabaseWriteRadio.setChecked(enabled); - } + toastOnDatabaseWriteRadio.setChecked(enabled); refreshUi(); } private void saveFeishuConfigFromUi() { - boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked(); - String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString(); - String secret = feishuSecretEdit == null ? "" : feishuSecretEdit.getText().toString(); + boolean enabled = feishuPushEnabledCheckBox.isChecked(); + String webhookId = feishuWebhookIdEdit.getText().toString(); + String secret = feishuSecretEdit.getText().toString(); Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled + ", webhookConfigured=" + !TextUtils.isEmpty(webhookId) + ", secretConfigured=" + !TextUtils.isEmpty(secret)); @@ -664,14 +567,12 @@ public final class MainActivity extends Activity { private int savePollingIntervalFromUi() { int intervalSeconds = parsePollingIntervalSeconds(); SmsPollingStateStore.setIntervalSeconds(this, intervalSeconds); - if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) { - pollingIntervalEdit.setText(String.valueOf(intervalSeconds)); - } + pollingIntervalEdit.setText(String.valueOf(intervalSeconds)); return intervalSeconds; } private int parsePollingIntervalSeconds() { - String raw = pollingIntervalEdit == null ? "" : pollingIntervalEdit.getText().toString().trim(); + String raw = pollingIntervalEdit.getText().toString().trim(); if (TextUtils.isEmpty(raw)) { return 1; } @@ -702,7 +603,7 @@ public final class MainActivity extends Activity { Log.d(TAG, "startPolling via SmsPollingService"); SmsPollingService.start(this); Toast.makeText(this, - "已启动后台短信轮询,间隔 " + intervalSeconds + " 秒", + "已启动后台短信轮询,间隔 " + intervalSeconds + " 秒", Toast.LENGTH_SHORT).show(); refreshUi(); } @@ -832,8 +733,4 @@ public final class MainActivity extends Activity { } return "***" + sender.substring(sender.length() - 4); } - - private int dp(int value) { - return (int) (value * getResources().getDisplayMetrics().density + 0.5f); - } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..eba8ffa --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + +