main #1

Closed
sookie wants to merge 5 commits from sookie/SMS-Receive:main into main
51 changed files with 1358 additions and 1128 deletions

View File

@ -1,22 +0,0 @@
package com.smsreceive.app;
import android.content.Context;
import android.util.Log;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
public final class SmsProviderInstrumentedTest {
private static final String TAG = "[SMS]SmsReceive";
@Test
public void testLogRecentThirtyMessages() {
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);
}
}

View File

@ -0,0 +1,23 @@
package com.smsreceive.app.sms;
import android.content.Context;
import android.util.Log;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
public final class SmsProviderInstrumentedTest {
private static final String TAG = "[SMS]SmsReceive";
@Test
public void testReadLatestInboxQueryCompletes() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
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);
}
}

View File

@ -15,7 +15,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity <activity
android:name=".MainActivity" android:name=".ui.MainActivity"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -25,7 +25,7 @@
</activity> </activity>
<receiver <receiver
android:name=".SmsReceiver" android:name=".sms.SmsReceiver"
android:enabled="true" android:enabled="true"
android:exported="true" android:exported="true"
android:permission="android.permission.BROADCAST_SMS"> android:permission="android.permission.BROADCAST_SMS">
@ -35,7 +35,7 @@
</receiver> </receiver>
<receiver <receiver
android:name=".BootReceiver" android:name=".keepalive.BootReceiver"
android:enabled="true" android:enabled="true"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@ -46,12 +46,12 @@
</receiver> </receiver>
<service <service
android:name=".SmsKeepAliveService" android:name=".keepalive.SmsKeepAliveService"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false" />
<service <service
android:name=".SmsPollingService" android:name=".keepalive.SmsPollingService"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false" />
</application> </application>

View File

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

View File

@ -1,175 +0,0 @@
package com.smsreceive.app;
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("(?<![0-9A-Za-z])([A-Za-z0-9]{4,8}|[0-9](?:[\\s-]?[0-9]){3,7})(?![0-9A-Za-z])");
private static final Pattern PHONE_PATTERN = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
private static final Pattern MONEY_PATTERN = Pattern.compile("\\d+(?:\\.\\d{1,2})?\\s*(元|RMB|CNY|¥|¥)", Pattern.CASE_INSENSITIVE);
private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}[-/.年]\\d{1,2}[-/.月]\\d{1,2}");
private VerificationCodeParser() {
}
public static ParseResult parse(String body) {
if (body == null || body.trim().isEmpty()) {
return ParseResult.failure("短信正文为空");
}
ParseResult keywordNearby = findKeywordNearbyCode(body);
if (keywordNearby.success) {
return keywordNearby;
}
List<String> 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);
}
}
}

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.feishu;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
@ -11,6 +11,8 @@ import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import com.smsreceive.app.sms.CaptureResult;
import java.io.IOException; import java.io.IOException;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -29,7 +31,7 @@ import okhttp3.Request;
import okhttp3.RequestBody; import okhttp3.RequestBody;
import okhttp3.Response; import okhttp3.Response;
final class FeishuWebhookClient { public final class FeishuWebhookClient {
private static final String TAG = "[SMS]SmsReceive"; 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 String WEBHOOK_URL_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/";
private static final char[] BASE64_TABLE = private static final char[] BASE64_TABLE =
@ -47,17 +49,16 @@ final class FeishuWebhookClient {
private FeishuWebhookClient() { private FeishuWebhookClient() {
} }
static void pushCaptureResultAsync(Context context, CaptureResult result) { public static void pushCaptureResultAsync(Context context, CaptureResult result) {
if (context == null || result == null) { if (context == null || result == null) {
return; return;
} }
if (isEmpty(result.body)) {
Log.d(TAG, "Feishu push skipped: empty body source=" + result.source);
return;
}
Context appContext = context.getApplicationContext(); Context appContext = context.getApplicationContext();
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext); 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);
return;
}
if (FeishuWebhookConfigStore.wasSmsPushed(appContext, result)) { if (FeishuWebhookConfigStore.wasSmsPushed(appContext, result)) {
Log.d(TAG, "Feishu push skipped: duplicate sms receivedSecond=" Log.d(TAG, "Feishu push skipped: duplicate sms receivedSecond="
+ (result.receivedAtMillis / 1000L) + (result.receivedAtMillis / 1000L)
@ -72,11 +73,11 @@ final class FeishuWebhookClient {
"远端推送未开启")); "远端推送未开启"));
return; return;
} }
String markdown = buildMarkdownFromCapture(result, config); String markdown = buildMarkdownFromCapture(result);
pushMarkdownAsync(appContext, markdown, result); pushMarkdownAsync(appContext, markdown, result);
} }
static void pushMarkdownAsync(Context context, String markdownContent) { public static void pushMarkdownAsync(Context context, String markdownContent) {
pushMarkdownAsync(context, markdownContent, null); pushMarkdownAsync(context, markdownContent, null);
} }
@ -98,7 +99,7 @@ final class FeishuWebhookClient {
}); });
} }
static FeishuWebhookPushResult pushMarkdown( public static FeishuWebhookPushResult pushMarkdown(
FeishuWebhookConfigStore.Config config, FeishuWebhookConfigStore.Config config,
String markdownContent, String markdownContent,
long timestampSeconds) { long timestampSeconds) {
@ -157,14 +158,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); String stringToSign = timestampSeconds + "\n" + (secret == null ? "" : secret);
Mac mac = Mac.getInstance("HmacSHA256"); Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return base64EncodeNoWrap(mac.doFinal(new byte[0])); 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() JSONObject markdown = new JSONObject()
.put("tag", "markdown") .put("tag", "markdown")
.put("content", markdownContent == null ? "" : markdownContent); .put("content", markdownContent == null ? "" : markdownContent);
@ -178,7 +179,7 @@ final class FeishuWebhookClient {
.toString(); .toString();
} }
static FeishuWebhookPushResult parseResponse(String responseBody) { public static FeishuWebhookPushResult parseResponse(String responseBody) {
try { try {
JSONObject json = new JSONObject(responseBody == null ? "" : responseBody); JSONObject json = new JSONObject(responseBody == null ? "" : responseBody);
int code = json.optInt("code", Integer.MIN_VALUE); int code = json.optInt("code", Integer.MIN_VALUE);
@ -198,43 +199,27 @@ final class FeishuWebhookClient {
} }
} }
static String buildWebhookUrl(String webhookId) { public static String buildWebhookUrl(String webhookId) {
return WEBHOOK_URL_PREFIX + (webhookId == null ? "" : webhookId.trim()); 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) {
boolean filterCode = config != null && config.filterVerificationCode;
boolean includeFullBody = config == null || !filterCode || config.sendFullBodyDebug;
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
if (filterCode) { builder.append("**短信内容**").append(emptyAsDash(result.body)).append('\n');
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.source)).append('\n'); builder.append("**来源**").append(emptyAsDash(result.source)).append('\n');
builder.append("**发送方**").append(maskSender(result.sender)).append('\n'); builder.append("**发送方**").append(maskSender(result.sender)).append('\n');
builder.append("**时间**").append(result.receivedAtMillis).append('\n'); builder.append("**时间**").append(formatTime(result.receivedAtMillis));
if (result.parseResult.success) { if (!isEmpty(result.failureReason)) {
builder.append("**解析**") builder.append('\n').append("**读取异常**").append(emptyAsDash(result.failureReason));
.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));
} }
return builder.toString(); 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) { private static void saveAndNotify(Context context, FeishuWebhookPushResult result) {
Log.d(TAG, String.format(Locale.US, Log.d(TAG, String.format(Locale.US,
"Feishu push result success=%s status=%s http=%d api=%d message=%s", "Feishu push result success=%s status=%s http=%d api=%d message=%s",

View File

@ -1,9 +1,11 @@
package com.smsreceive.app; package com.smsreceive.app.feishu;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.util.Log; import android.util.Log;
import com.smsreceive.app.sms.CaptureResult;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
@ -16,8 +18,8 @@ import java.io.InputStreamReader;
import java.io.OutputStreamWriter; import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
final class FeishuWebhookConfigStore { public final class FeishuWebhookConfigStore {
static final String ACTION_PUSH_UPDATED = "com.smsreceive.app.ACTION_FEISHU_PUSH_UPDATED"; 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 TAG = "[SMS]SmsReceive";
private static final String PREFS = "feishu_webhook"; private static final String PREFS = "feishu_webhook";
@ -37,13 +39,11 @@ final class FeishuWebhookConfigStore {
private static final String JSON_ENABLED = "enabled"; private static final String JSON_ENABLED = "enabled";
private static final String JSON_WEBHOOK_ID = "webhook_id"; private static final String JSON_WEBHOOK_ID = "webhook_id";
private static final String JSON_SECRET = "secret"; 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() { private FeishuWebhookConfigStore() {
} }
static Config loadConfig(Context context) { public static Config loadConfig(Context context) {
ensureDefaultConfigFile(context); ensureDefaultConfigFile(context);
File file = configFile(context); File file = configFile(context);
if (!file.exists()) { if (!file.exists()) {
@ -59,30 +59,26 @@ final class FeishuWebhookConfigStore {
} }
} }
static void saveConfig( public static void saveConfig(
Context context, Context context,
boolean enabled, boolean enabled,
String webhookId, String webhookId,
String secret, String secret) {
boolean sendFullBodyDebug, Config config = new Config(enabled, webhookId, secret);
boolean filterVerificationCode) {
Config config = new Config(enabled, webhookId, secret, sendFullBodyDebug, filterVerificationCode);
File file = configFile(context); File file = configFile(context);
try { try {
writeFile(file, configToJson(config).toString(2)); writeFile(file, configToJson(config).toString(2));
Log.d(TAG, "save feishu config path=" + file.getAbsolutePath() Log.d(TAG, "save feishu config path=" + file.getAbsolutePath()
+ ", enabled=" + enabled + ", enabled=" + enabled
+ ", webhookConfigured=" + config.hasWebhookId() + ", webhookConfigured=" + config.hasWebhookId()
+ ", secretConfigured=" + config.hasSecret() + ", secretConfigured=" + config.hasSecret());
+ ", debugBody=" + sendFullBodyDebug
+ ", filterCode=" + filterVerificationCode);
} catch (IOException | JSONException e) { } catch (IOException | JSONException e) {
Log.w(TAG, "save feishu config failed path=" + file.getAbsolutePath() Log.w(TAG, "save feishu config failed path=" + file.getAbsolutePath()
+ ", reason=" + e.getClass().getSimpleName(), e); + ", reason=" + e.getClass().getSimpleName(), e);
} }
} }
static void saveLastResult(Context context, FeishuWebhookPushResult result) { public static void saveLastResult(Context context, FeishuWebhookPushResult result) {
preferences(context).edit() preferences(context).edit()
.putLong(KEY_LAST_TIME, result.timeMillis) .putLong(KEY_LAST_TIME, result.timeMillis)
.putBoolean(KEY_LAST_SUCCESS, result.success) .putBoolean(KEY_LAST_SUCCESS, result.success)
@ -136,7 +132,7 @@ final class FeishuWebhookConfigStore {
+ ", smsKey=" + smsKey); + ", smsKey=" + smsKey);
} }
static LastResult loadLastResult(Context context) { public static LastResult loadLastResult(Context context) {
SharedPreferences prefs = preferences(context); SharedPreferences prefs = preferences(context);
return new LastResult( return new LastResult(
prefs.getLong(KEY_LAST_TIME, 0L), prefs.getLong(KEY_LAST_TIME, 0L),
@ -147,14 +143,14 @@ final class FeishuWebhookConfigStore {
prefs.getInt(KEY_LAST_API_CODE, 0)); prefs.getInt(KEY_LAST_API_CODE, 0));
} }
static LastPushedSms loadLastPushedSms(Context context) { public static LastPushedSms loadLastPushedSms(Context context) {
SharedPreferences prefs = preferences(context); SharedPreferences prefs = preferences(context);
return new LastPushedSms( return new LastPushedSms(
prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L), prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L),
prefs.getString(KEY_LAST_PUSHED_SMS_KEY, "")); prefs.getString(KEY_LAST_PUSHED_SMS_KEY, ""));
} }
static String maskSecret(String secret) { public static String maskSecret(String secret) {
if (isEmpty(secret)) { if (isEmpty(secret)) {
return ""; return "";
} }
@ -164,19 +160,19 @@ final class FeishuWebhookConfigStore {
return secret.substring(0, 3) + "***" + secret.substring(secret.length() - 3); 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(); return configFile(context).getAbsolutePath();
} }
static String defaultConfigPath(Context context) { public static String defaultConfigPath(Context context) {
return defaultConfigFile(context).getAbsolutePath(); return defaultConfigFile(context).getAbsolutePath();
} }
static String configTemplate() { public static String configTemplate() {
try { try {
return configToJson(defaultConfig()).toString(2); return configToJson(defaultConfig()).toString(2);
} catch (JSONException e) { } catch (JSONException e) {
return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\",\"send_full_body_debug\":false,\"filter_verification_code\":false}"; return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\"}";
} }
} }
@ -215,25 +211,21 @@ final class FeishuWebhookConfigStore {
} }
private static Config defaultConfig() { private static Config defaultConfig() {
return new Config(false, "", "", false, false); return new Config(false, "", "");
} }
private static Config configFromJson(JSONObject json) { private static Config configFromJson(JSONObject json) {
return new Config( return new Config(
json.optBoolean(JSON_ENABLED, false), json.optBoolean(JSON_ENABLED, false),
json.optString(JSON_WEBHOOK_ID, ""), json.optString(JSON_WEBHOOK_ID, ""),
json.optString(JSON_SECRET, ""), json.optString(JSON_SECRET, ""));
json.optBoolean(JSON_SEND_FULL_BODY_DEBUG, false),
json.optBoolean(JSON_FILTER_VERIFICATION_CODE, false));
} }
private static JSONObject configToJson(Config config) throws JSONException { private static JSONObject configToJson(Config config) throws JSONException {
return new JSONObject() return new JSONObject()
.put(JSON_ENABLED, config.enabled) .put(JSON_ENABLED, config.enabled)
.put(JSON_WEBHOOK_ID, config.webhookId) .put(JSON_WEBHOOK_ID, config.webhookId)
.put(JSON_SECRET, config.secret) .put(JSON_SECRET, config.secret);
.put(JSON_SEND_FULL_BODY_DEBUG, config.sendFullBodyDebug)
.put(JSON_FILTER_VERIFICATION_CODE, config.filterVerificationCode);
} }
private static String readFile(File file) throws IOException { private static String readFile(File file) throws IOException {
@ -289,42 +281,36 @@ final class FeishuWebhookConfigStore {
+ Integer.toHexString((result.body == null ? "" : result.body).hashCode()); + Integer.toHexString((result.body == null ? "" : result.body).hashCode());
} }
static final class Config { public static final class Config {
final boolean enabled; public final boolean enabled;
final String webhookId; public final String webhookId;
final String secret; public final String secret;
final boolean sendFullBodyDebug;
final boolean filterVerificationCode;
Config( public Config(
boolean enabled, boolean enabled,
String webhookId, String webhookId,
String secret, String secret) {
boolean sendFullBodyDebug,
boolean filterVerificationCode) {
this.enabled = enabled; this.enabled = enabled;
this.webhookId = normalize(webhookId); this.webhookId = normalize(webhookId);
this.secret = normalize(secret); this.secret = normalize(secret);
this.sendFullBodyDebug = sendFullBodyDebug;
this.filterVerificationCode = filterVerificationCode;
} }
boolean hasWebhookId() { public boolean hasWebhookId() {
return !isEmpty(webhookId); return !isEmpty(webhookId);
} }
boolean hasSecret() { public boolean hasSecret() {
return !isEmpty(secret); return !isEmpty(secret);
} }
} }
static final class LastResult { public static final class LastResult {
final long timeMillis; public final long timeMillis;
final boolean success; public final boolean success;
final String status; public final String status;
final String message; public final String message;
final int httpStatus; public final int httpStatus;
final int apiCode; public final int apiCode;
LastResult(long timeMillis, boolean success, String status, String message, int httpStatus, int apiCode) { LastResult(long timeMillis, boolean success, String status, String message, int httpStatus, int apiCode) {
this.timeMillis = timeMillis; this.timeMillis = timeMillis;
@ -336,9 +322,9 @@ final class FeishuWebhookConfigStore {
} }
} }
static final class LastPushedSms { public static final class LastPushedSms {
final long receivedSecond; public final long receivedSecond;
final String smsKey; public final String smsKey;
LastPushedSms(long receivedSecond, String smsKey) { LastPushedSms(long receivedSecond, String smsKey) {
this.receivedSecond = receivedSecond; this.receivedSecond = receivedSecond;

View File

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

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.keepalive;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.keepalive;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
@ -11,7 +11,7 @@ import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.Locale; import java.util.Locale;
final class KeepAliveDatabase { public final class KeepAliveDatabase {
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
private static final String DATABASE_NAME = "sms_keep_alive.db"; private static final String DATABASE_NAME = "sms_keep_alive.db";
private static final int DATABASE_VERSION = 1; private static final int DATABASE_VERSION = 1;
@ -23,7 +23,7 @@ final class KeepAliveDatabase {
private KeepAliveDatabase() { private KeepAliveDatabase() {
} }
static long writeLastActiveTime(Context context) { public static long writeLastActiveTime(Context context) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
SQLiteDatabase database = helper(context).getWritableDatabase(); SQLiteDatabase database = helper(context).getWritableDatabase();
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
@ -35,7 +35,7 @@ final class KeepAliveDatabase {
return now; return now;
} }
static long readLastActiveTime(Context context) { public static long readLastActiveTime(Context context) {
SQLiteDatabase database = helper(context).getReadableDatabase(); SQLiteDatabase database = helper(context).getReadableDatabase();
try (Cursor cursor = database.query( try (Cursor cursor = database.query(
TABLE_META, TABLE_META,

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.keepalive;
import android.app.Notification; import android.app.Notification;
import android.app.NotificationChannel; import android.app.NotificationChannel;
@ -9,6 +9,8 @@ import android.content.Intent;
import android.os.Build; import android.os.Build;
import android.util.Log; import android.util.Log;
import com.smsreceive.app.ui.MainActivity;
final class KeepAliveNotification { final class KeepAliveNotification {
static final int NOTIFICATION_ID = 2101; static final int NOTIFICATION_ID = 2101;
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
@ -34,7 +36,7 @@ final class KeepAliveNotification {
: new Notification.Builder(context); : new Notification.Builder(context);
return builder return builder
.setSmallIcon(android.R.drawable.stat_notify_sync) .setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("短信验证码监听运行中") .setContentTitle("短信监听运行中")
.setContentText(contentText) .setContentText(contentText)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(true) .setOngoing(true)

View File

@ -1,11 +1,11 @@
package com.smsreceive.app; package com.smsreceive.app.keepalive;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
final class KeepAliveStateStore { public final class KeepAliveStateStore {
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
private static final String PREFS = "sms_keep_alive"; private static final String PREFS = "sms_keep_alive";
@ -23,14 +23,14 @@ final class KeepAliveStateStore {
private 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); Log.d(TAG, "KeepAliveStateStore.setEnabledByUser enabled=" + enabled);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_ENABLED_BY_USER, enabled) .putBoolean(KEY_ENABLED_BY_USER, enabled)
.apply(); .apply();
} }
static void recordServiceStarted(Context context) { public static void recordServiceStarted(Context context) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
Log.d(TAG, "KeepAliveStateStore.recordServiceStarted time=" + now); Log.d(TAG, "KeepAliveStateStore.recordServiceStarted time=" + now);
preferences(context).edit() preferences(context).edit()
@ -40,7 +40,7 @@ final class KeepAliveStateStore {
.apply(); .apply();
} }
static void recordServiceStopped(Context context, String reason) { public static void recordServiceStopped(Context context, String reason) {
Log.d(TAG, "KeepAliveStateStore.recordServiceStopped reason=" + reason); Log.d(TAG, "KeepAliveStateStore.recordServiceStopped reason=" + reason);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_SERVICE_RUNNING, false) .putBoolean(KEY_SERVICE_RUNNING, false)
@ -48,7 +48,7 @@ final class KeepAliveStateStore {
.apply(); .apply();
} }
static void recordHeartbeat(Context context) { public static void recordHeartbeat(Context context) {
Log.d(TAG, "KeepAliveStateStore.recordHeartbeat"); Log.d(TAG, "KeepAliveStateStore.recordHeartbeat");
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_SERVICE_RUNNING, true) .putBoolean(KEY_SERVICE_RUNNING, true)
@ -56,7 +56,7 @@ final class KeepAliveStateStore {
.apply(); .apply();
} }
static void recordBootEvent(Context context, String action) { public static void recordBootEvent(Context context, String action) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
Log.d(TAG, "KeepAliveStateStore.recordBootEvent action=" + action + ", time=" + now); Log.d(TAG, "KeepAliveStateStore.recordBootEvent action=" + action + ", time=" + now);
preferences(context).edit() preferences(context).edit()
@ -65,7 +65,7 @@ final class KeepAliveStateStore {
.apply(); .apply();
} }
static void recordServiceStartFailure(Context context, String reason) { public static void recordServiceStartFailure(Context context, String reason) {
Log.w(TAG, "KeepAliveStateStore.recordServiceStartFailure reason=" + reason); Log.w(TAG, "KeepAliveStateStore.recordServiceStartFailure reason=" + reason);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_SERVICE_RUNNING, false) .putBoolean(KEY_SERVICE_RUNNING, false)
@ -73,39 +73,39 @@ final class KeepAliveStateStore {
.apply(); .apply();
} }
static void setManualAutostartConfirmed(Context context, boolean confirmed) { public static void setManualAutostartConfirmed(Context context, boolean confirmed) {
Log.d(TAG, "KeepAliveStateStore.setManualAutostartConfirmed confirmed=" + confirmed); Log.d(TAG, "KeepAliveStateStore.setManualAutostartConfirmed confirmed=" + confirmed);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_MANUAL_AUTOSTART_CONFIRMED, confirmed) .putBoolean(KEY_MANUAL_AUTOSTART_CONFIRMED, confirmed)
.apply(); .apply();
} }
static void setManualBatteryUnrestrictedConfirmed(Context context, boolean confirmed) { public static void setManualBatteryUnrestrictedConfirmed(Context context, boolean confirmed) {
Log.d(TAG, "KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed confirmed=" + confirmed); Log.d(TAG, "KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed confirmed=" + confirmed);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED, confirmed) .putBoolean(KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED, confirmed)
.apply(); .apply();
} }
static void setBatteryOptimizationIgnored(Context context, boolean ignored) { public static void setBatteryOptimizationIgnored(Context context, boolean ignored) {
Log.d(TAG, "KeepAliveStateStore.setBatteryOptimizationIgnored ignored=" + ignored); Log.d(TAG, "KeepAliveStateStore.setBatteryOptimizationIgnored ignored=" + ignored);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_BATTERY_OPTIMIZATION_IGNORED, ignored) .putBoolean(KEY_BATTERY_OPTIMIZATION_IGNORED, ignored)
.apply(); .apply();
} }
static void setToastOnDatabaseWrite(Context context, boolean enabled) { public static void setToastOnDatabaseWrite(Context context, boolean enabled) {
Log.d(TAG, "KeepAliveStateStore.setToastOnDatabaseWrite enabled=" + enabled); Log.d(TAG, "KeepAliveStateStore.setToastOnDatabaseWrite enabled=" + enabled);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_TOAST_ON_DATABASE_WRITE, enabled) .putBoolean(KEY_TOAST_ON_DATABASE_WRITE, enabled)
.apply(); .apply();
} }
static boolean isToastOnDatabaseWriteEnabled(Context context) { public static boolean isToastOnDatabaseWriteEnabled(Context context) {
return preferences(context).getBoolean(KEY_TOAST_ON_DATABASE_WRITE, false); 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); SharedPreferences prefs = preferences(context);
return new State( return new State(
prefs.getBoolean(KEY_ENABLED_BY_USER, false), prefs.getBoolean(KEY_ENABLED_BY_USER, false),
@ -128,17 +128,17 @@ final class KeepAliveStateStore {
return TextUtils.isEmpty(value) ? "" : value; return TextUtils.isEmpty(value) ? "" : value;
} }
static final class State { public static final class State {
final boolean enabledByUser; public final boolean enabledByUser;
final boolean serviceRunning; public final boolean serviceRunning;
final long lastHeartbeatMillis; public final long lastHeartbeatMillis;
final String lastBootEvent; public final String lastBootEvent;
final long lastBootTimeMillis; public final long lastBootTimeMillis;
final String lastServiceStartFailure; public final String lastServiceStartFailure;
final boolean manualAutostartConfirmed; public final boolean manualAutostartConfirmed;
final boolean manualBatteryUnrestrictedConfirmed; public final boolean manualBatteryUnrestrictedConfirmed;
final boolean batteryOptimizationIgnored; public final boolean batteryOptimizationIgnored;
final boolean toastOnDatabaseWrite; public final boolean toastOnDatabaseWrite;
State( State(
boolean enabledByUser, boolean enabledByUser,

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.keepalive;
import android.app.Service; import android.app.Service;
import android.content.Context; import android.content.Context;
@ -10,6 +10,8 @@ import android.os.Looper;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
import com.smsreceive.app.sms.SmsCaptureStore;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.Locale; 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); Log.d(TAG, "SmsKeepAliveService.start requested sdk=" + Build.VERSION.SDK_INT);
Intent intent = new Intent(context, SmsKeepAliveService.class); Intent intent = new Intent(context, SmsKeepAliveService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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"); Log.d(TAG, "SmsKeepAliveService.stop requested");
context.stopService(new Intent(context, SmsKeepAliveService.class)); context.stopService(new Intent(context, SmsKeepAliveService.class));
} }

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.keepalive;
import android.Manifest; import android.Manifest;
import android.app.Service; import android.app.Service;
@ -12,6 +12,11 @@ import android.os.Looper;
import android.util.Log; import android.util.Log;
import android.widget.Toast; 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 { public final class SmsPollingService extends Service {
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
private static final String SOURCE_INBOX_POLLING = "sms_inbox_polling"; private static final String SOURCE_INBOX_POLLING = "sms_inbox_polling";
@ -22,14 +27,14 @@ public final class SmsPollingService extends Service {
private final Runnable pollingRunnable = new Runnable() { private final Runnable pollingRunnable = new Runnable() {
@Override @Override
public void run() { public void run() {
pollRecentSmsForCode(); pollRecentSms();
long intervalMillis = SmsPollingStateStore.getIntervalSeconds(SmsPollingService.this) * 1000L; long intervalMillis = SmsPollingStateStore.getIntervalSeconds(SmsPollingService.this) * 1000L;
Log.d(TAG, "SmsPollingService.schedule next intervalMs=" + intervalMillis); Log.d(TAG, "SmsPollingService.schedule next intervalMs=" + intervalMillis);
handler.postDelayed(this, intervalMillis); handler.postDelayed(this, intervalMillis);
} }
}; };
static void start(Context context) { public static void start(Context context) {
Log.d(TAG, "SmsPollingService.start requested sdk=" + Build.VERSION.SDK_INT); Log.d(TAG, "SmsPollingService.start requested sdk=" + Build.VERSION.SDK_INT);
long startTimeMillis = System.currentTimeMillis() - 2_000L; long startTimeMillis = System.currentTimeMillis() - 2_000L;
SmsPollingStateStore.recordStarted(context, startTimeMillis); 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"); Log.d(TAG, "SmsPollingService.stop requested");
SmsPollingStateStore.recordStopped(context, "用户停止轮询"); SmsPollingStateStore.recordStopped(context, "用户停止轮询");
context.stopService(new Intent(context, SmsPollingService.class)); context.stopService(new Intent(context, SmsPollingService.class));
@ -87,7 +92,7 @@ public final class SmsPollingService extends Service {
return null; return null;
} }
private void pollRecentSmsForCode() { private void pollRecentSms() {
if (!hasReadSmsPermission()) { if (!hasReadSmsPermission()) {
Log.w(TAG, "SmsPollingService.poll stop: READ_SMS not granted"); Log.w(TAG, "SmsPollingService.poll stop: READ_SMS not granted");
Toast.makeText(this, "READ_SMS 未授权,已停止短信轮询", Toast.LENGTH_LONG).show(); Toast.makeText(this, "READ_SMS 未授权,已停止短信轮询", Toast.LENGTH_LONG).show();
@ -96,9 +101,9 @@ public final class SmsPollingService extends Service {
return; return;
} }
SmsInboxReader.RecentCodeResult result = SmsInboxReader.findLatestVerificationCode(this, 3, pollingStartMillis); SmsInboxReader.RecentSmsResult result = SmsInboxReader.findLatestSms(this, 3, pollingStartMillis);
if (!result.success) { 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); + ", reason=" + result.failureReason);
return; return;
} }
@ -109,15 +114,13 @@ public final class SmsPollingService extends Service {
lastHitSmsId = result.id; lastHitSmsId = result.id;
SmsPollingStateStore.recordHit(this, result.id, result.dateMillis); SmsPollingStateStore.recordHit(this, result.id, result.dateMillis);
Log.d(TAG, "SmsPollingService.poll hit id=" + result.id Log.d(TAG, "SmsPollingService.poll hit id=" + result.id
+ ", code=" + result.parseResult.code + ", sender=" + result.sender
+ ", strategy=" + result.parseResult.strategy + ", bodyLength=" + result.body.length());
+ ", confidence=" + result.parseResult.confidence);
CaptureResult captureResult = CaptureResult.success( CaptureResult captureResult = CaptureResult.success(
result.dateMillis, result.dateMillis,
result.id, result.id,
result.sender, result.sender,
result.body, result.body,
result.parseResult,
SOURCE_INBOX_POLLING); SOURCE_INBOX_POLLING);
SmsCaptureStore.save(this, captureResult); SmsCaptureStore.save(this, captureResult);
FeishuWebhookClient.pushCaptureResultAsync(this, captureResult); FeishuWebhookClient.pushCaptureResultAsync(this, captureResult);
@ -125,7 +128,7 @@ public final class SmsPollingService extends Service {
Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED); Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED);
updateIntent.setPackage(getPackageName()); updateIntent.setPackage(getPackageName());
sendBroadcast(updateIntent); sendBroadcast(updateIntent);
Toast.makeText(this, "轮询提取验证码:" + result.parseResult.code, Toast.LENGTH_LONG).show(); Toast.makeText(this, "轮询读取到新短信", Toast.LENGTH_LONG).show();
} }
private boolean hasReadSmsPermission() { private boolean hasReadSmsPermission() {

View File

@ -1,11 +1,11 @@
package com.smsreceive.app; package com.smsreceive.app.keepalive;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
final class SmsPollingStateStore { public final class SmsPollingStateStore {
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
private static final String PREFS = "sms_polling"; private static final String PREFS = "sms_polling";
private static final String KEY_ENABLED_BY_USER = "enabled_by_user"; private static final String KEY_ENABLED_BY_USER = "enabled_by_user";
@ -22,7 +22,7 @@ final class SmsPollingStateStore {
private 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); Log.d(TAG, "SmsPollingStateStore.recordStarted startTime=" + startTimeMillis);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_ENABLED_BY_USER, true) .putBoolean(KEY_ENABLED_BY_USER, true)
@ -32,7 +32,7 @@ final class SmsPollingStateStore {
.apply(); .apply();
} }
static void recordStopped(Context context, String reason) { public static void recordStopped(Context context, String reason) {
Log.d(TAG, "SmsPollingStateStore.recordStopped reason=" + reason); Log.d(TAG, "SmsPollingStateStore.recordStopped reason=" + reason);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_ENABLED_BY_USER, false) .putBoolean(KEY_ENABLED_BY_USER, false)
@ -41,7 +41,7 @@ final class SmsPollingStateStore {
.apply(); .apply();
} }
static void recordServiceStopped(Context context, String reason) { public static void recordServiceStopped(Context context, String reason) {
Log.d(TAG, "SmsPollingStateStore.recordServiceStopped reason=" + reason); Log.d(TAG, "SmsPollingStateStore.recordServiceStopped reason=" + reason);
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_RUNNING, false) .putBoolean(KEY_RUNNING, false)
@ -49,13 +49,13 @@ final class SmsPollingStateStore {
.apply(); .apply();
} }
static void recordServiceRunning(Context context) { public static void recordServiceRunning(Context context) {
preferences(context).edit() preferences(context).edit()
.putBoolean(KEY_RUNNING, true) .putBoolean(KEY_RUNNING, true)
.apply(); .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); Log.d(TAG, "SmsPollingStateStore.recordHit id=" + smsId + ", time=" + hitTimeMillis);
preferences(context).edit() preferences(context).edit()
.putLong(KEY_LAST_HIT_ID, smsId) .putLong(KEY_LAST_HIT_ID, smsId)
@ -64,7 +64,7 @@ final class SmsPollingStateStore {
.apply(); .apply();
} }
static void setIntervalSeconds(Context context, int seconds) { public static void setIntervalSeconds(Context context, int seconds) {
int safeSeconds = clampIntervalSeconds(seconds); int safeSeconds = clampIntervalSeconds(seconds);
Log.d(TAG, "SmsPollingStateStore.setIntervalSeconds seconds=" + safeSeconds); Log.d(TAG, "SmsPollingStateStore.setIntervalSeconds seconds=" + safeSeconds);
preferences(context).edit() preferences(context).edit()
@ -72,11 +72,11 @@ final class SmsPollingStateStore {
.apply(); .apply();
} }
static int getIntervalSeconds(Context context) { public static int getIntervalSeconds(Context context) {
return clampIntervalSeconds(preferences(context).getInt(KEY_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS)); 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); SharedPreferences prefs = preferences(context);
return new State( return new State(
prefs.getBoolean(KEY_ENABLED_BY_USER, false), 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)); return Math.max(MIN_INTERVAL_SECONDS, Math.min(seconds, MAX_INTERVAL_SECONDS));
} }
static final class State { public static final class State {
final boolean enabledByUser; public final boolean enabledByUser;
final boolean running; public final boolean running;
final long startTimeMillis; public final long startTimeMillis;
final long lastHitId; public final long lastHitId;
final long lastHitTimeMillis; public final long lastHitTimeMillis;
final String lastFailure; public final String lastFailure;
final int intervalSeconds; public final int intervalSeconds;
State( State(
boolean enabledByUser, boolean enabledByUser,

View File

@ -1,53 +1,50 @@
package com.smsreceive.app; package com.smsreceive.app.sms;
final class CaptureResult { public final class CaptureResult {
static final long UNKNOWN_SMS_PROVIDER_ID = -1L; public static final long UNKNOWN_SMS_PROVIDER_ID = -1L;
final long receivedAtMillis; public final boolean success;
final long smsProviderId; public final long receivedAtMillis;
final String sender; public final long smsProviderId;
final String body; public final String sender;
final VerificationCodeParser.ParseResult parseResult; public final String body;
final String source; public final String source;
final String failureReason; public final String failureReason;
private CaptureResult( private CaptureResult(
long receivedAtMillis, long receivedAtMillis,
long smsProviderId, long smsProviderId,
String sender, String sender,
String body, String body,
VerificationCodeParser.ParseResult parseResult,
String source, String source,
String failureReason) { String failureReason) {
this.success = failureReason == null || failureReason.length() == 0;
this.receivedAtMillis = receivedAtMillis; this.receivedAtMillis = receivedAtMillis;
this.smsProviderId = smsProviderId; this.smsProviderId = smsProviderId;
this.sender = sender == null ? "" : sender; this.sender = sender == null ? "" : sender;
this.body = body == null ? "" : body; this.body = body == null ? "" : body;
this.parseResult = parseResult;
this.source = source == null ? "unknown" : source; this.source = source == null ? "unknown" : source;
this.failureReason = failureReason == null ? "" : failureReason; this.failureReason = failureReason == null ? "" : failureReason;
} }
static CaptureResult success( public static CaptureResult success(
long receivedAtMillis, long receivedAtMillis,
String sender, String sender,
String body, String body,
VerificationCodeParser.ParseResult parseResult,
String source) { String source) {
return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, parseResult, source); return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source);
} }
static CaptureResult success( public static CaptureResult success(
long receivedAtMillis, long receivedAtMillis,
long smsProviderId, long smsProviderId,
String sender, String sender,
String body, String body,
VerificationCodeParser.ParseResult parseResult,
String source) { String source) {
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, ""); return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, source, "");
} }
static CaptureResult failure( public static CaptureResult failure(
long receivedAtMillis, long receivedAtMillis,
String sender, String sender,
String body, String body,
@ -56,14 +53,13 @@ final class CaptureResult {
return failure(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source, failureReason); return failure(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source, failureReason);
} }
static CaptureResult failure( public static CaptureResult failure(
long receivedAtMillis, long receivedAtMillis,
long smsProviderId, long smsProviderId,
String sender, String sender,
String body, String body,
String source, String source,
String failureReason) { String failureReason) {
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.ParseResult.failure(failureReason); return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, source, failureReason);
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, failureReason);
} }
} }

View File

@ -1,22 +1,21 @@
package com.smsreceive.app; package com.smsreceive.app.sms;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
final class SmsCaptureStore { public final class SmsCaptureStore {
static final String ACTION_CAPTURE_UPDATED = "com.smsreceive.app.ACTION_CAPTURE_UPDATED"; public static final String ACTION_CAPTURE_UPDATED = "com.smsreceive.app.ACTION_CAPTURE_UPDATED";
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
private static final String PREFS = "sms_capture"; 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_TIME = "time";
private static final String KEY_SENDER = "sender"; 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_SOURCE = "source";
private static final String KEY_FAILURE = "failure"; 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_BODY_PREVIEW = "body_preview";
private static final String KEY_LAST_BROADCAST_TIME = "last_broadcast_time"; private static final String KEY_LAST_BROADCAST_TIME = "last_broadcast_time";
private static final String KEY_LAST_INBOX_TIME = "last_inbox_time"; private static final String KEY_LAST_INBOX_TIME = "last_inbox_time";
@ -25,20 +24,17 @@ final class SmsCaptureStore {
private 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 Log.d(TAG, "SmsCaptureStore.save source=" + result.source
+ ", success=" + parse.success + ", success=" + result.success
+ ", code=" + parse.code + ", failure=" + result.failureReason);
+ ", failure=" + (TextUtils.isEmpty(result.failureReason) ? parse.failureReason : result.failureReason));
SharedPreferences.Editor editor = preferences(context).edit() SharedPreferences.Editor editor = preferences(context).edit()
.putBoolean(KEY_SUCCESS, result.success)
.putLong(KEY_TIME, result.receivedAtMillis) .putLong(KEY_TIME, result.receivedAtMillis)
.putString(KEY_SENDER, summarizeSender(result.sender)) .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_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)); .putString(KEY_BODY_PREVIEW, previewBody(result.body));
if ("system_sms_broadcast".equals(result.source)) { if ("system_sms_broadcast".equals(result.source)) {
Log.d(TAG, "SmsCaptureStore.save delivery source=system_sms_broadcast time=" Log.d(TAG, "SmsCaptureStore.save delivery source=system_sms_broadcast time="
@ -53,25 +49,26 @@ final class SmsCaptureStore {
editor.apply(); editor.apply();
} }
static StoredCapture load(Context context) { public static StoredCapture load(Context context) {
SharedPreferences prefs = preferences(context); SharedPreferences prefs = preferences(context);
String bodyPreview = prefs.getString(KEY_BODY_PREVIEW, "");
String body = prefs.getString(KEY_BODY, "");
return new StoredCapture( return new StoredCapture(
prefs.getBoolean(KEY_SUCCESS, TextUtils.isEmpty(prefs.getString(KEY_FAILURE, ""))),
prefs.getLong(KEY_TIME, 0L), prefs.getLong(KEY_TIME, 0L),
prefs.getString(KEY_SENDER, ""), prefs.getString(KEY_SENDER, ""),
prefs.getString(KEY_CODE, ""),
prefs.getString(KEY_STRATEGY, ""),
prefs.getInt(KEY_CONFIDENCE, 0),
prefs.getString(KEY_SOURCE, ""), prefs.getString(KEY_SOURCE, ""),
prefs.getString(KEY_FAILURE, ""), prefs.getString(KEY_FAILURE, ""),
prefs.getString(KEY_BODY_PREVIEW, "")); TextUtils.isEmpty(body) ? bodyPreview : body,
bodyPreview);
} }
static void clear(Context context) { public static void clear(Context context) {
Log.d(TAG, "SmsCaptureStore.clear"); Log.d(TAG, "SmsCaptureStore.clear");
preferences(context).edit().clear().apply(); preferences(context).edit().clear().apply();
} }
static DeliveryDiagnostics loadDeliveryDiagnostics(Context context) { public static DeliveryDiagnostics loadDeliveryDiagnostics(Context context) {
SharedPreferences prefs = preferences(context); SharedPreferences prefs = preferences(context);
return new DeliveryDiagnostics( return new DeliveryDiagnostics(
prefs.getLong(KEY_LAST_BROADCAST_TIME, 0L), prefs.getLong(KEY_LAST_BROADCAST_TIME, 0L),
@ -101,40 +98,37 @@ final class SmsCaptureStore {
return normalized.length() <= 48 ? normalized : normalized.substring(0, 48) + "..."; return normalized.length() <= 48 ? normalized : normalized.substring(0, 48) + "...";
} }
static final class StoredCapture { public static final class StoredCapture {
final long timeMillis; public final boolean success;
final String sender; public final long timeMillis;
final String code; public final String sender;
final String strategy; public final String source;
final int confidence; public final String failure;
final String source; public final String body;
final String failure; public final String bodyPreview;
final String bodyPreview;
StoredCapture( StoredCapture(
boolean success,
long timeMillis, long timeMillis,
String sender, String sender,
String code,
String strategy,
int confidence,
String source, String source,
String failure, String failure,
String body,
String bodyPreview) { String bodyPreview) {
this.success = success;
this.timeMillis = timeMillis; this.timeMillis = timeMillis;
this.sender = sender; this.sender = sender;
this.code = code;
this.strategy = strategy;
this.confidence = confidence;
this.source = source; this.source = source;
this.failure = failure; this.failure = failure;
this.body = body;
this.bodyPreview = bodyPreview; this.bodyPreview = bodyPreview;
} }
} }
static final class DeliveryDiagnostics { public static final class DeliveryDiagnostics {
final long lastBroadcastTimeMillis; public final long lastBroadcastTimeMillis;
final long lastInboxTimeMillis; public final long lastInboxTimeMillis;
final String lastInboxSource; public final String lastInboxSource;
DeliveryDiagnostics(long lastBroadcastTimeMillis, long lastInboxTimeMillis, String lastInboxSource) { DeliveryDiagnostics(long lastBroadcastTimeMillis, long lastInboxTimeMillis, String lastInboxSource) {
this.lastBroadcastTimeMillis = lastBroadcastTimeMillis; this.lastBroadcastTimeMillis = lastBroadcastTimeMillis;
@ -142,7 +136,7 @@ final class SmsCaptureStore {
this.lastInboxSource = lastInboxSource == null ? "" : lastInboxSource; this.lastInboxSource = lastInboxSource == null ? "" : lastInboxSource;
} }
boolean inboxNewerThanBroadcast() { public boolean inboxNewerThanBroadcast() {
return lastInboxTimeMillis > 0L && lastInboxTimeMillis > lastBroadcastTimeMillis; return lastInboxTimeMillis > 0L && lastInboxTimeMillis > lastBroadcastTimeMillis;
} }
} }

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.sms;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -11,14 +11,14 @@ import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.Locale; import java.util.Locale;
final class SmsInboxReader { public final class SmsInboxReader {
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
private static final Uri SMS_INBOX_URI = Uri.parse("content://sms/inbox"); private static final Uri SMS_INBOX_URI = Uri.parse("content://sms/inbox");
private SmsInboxReader() { private SmsInboxReader() {
} }
static InboxResult readLatest(Context context) { public static InboxResult readLatest(Context context) {
String[] projection = { String[] projection = {
Telephony.Sms._ID, Telephony.Sms._ID,
Telephony.Sms.ADDRESS, Telephony.Sms.ADDRESS,
@ -64,63 +64,11 @@ final class SmsInboxReader {
} }
} }
static int logRecentMessages(Context context, int limit) { public static RecentSmsResult findLatestSms(Context context, int limit) {
Uri uri = Telephony.Sms.CONTENT_URI; return findLatestSms(context, limit, 0L);
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;
}
} }
static RecentCodeResult findLatestVerificationCode(Context context, int limit) { public static RecentSmsResult findLatestSms(Context context, int limit, long minDateMillis) {
return findLatestVerificationCode(context, limit, 0L);
}
static RecentCodeResult findLatestVerificationCode(Context context, int limit, long minDateMillis) {
Uri uri = Telephony.Sms.CONTENT_URI; Uri uri = Telephony.Sms.CONTENT_URI;
String[] projection = { String[] projection = {
Telephony.Sms._ID, Telephony.Sms._ID,
@ -133,7 +81,7 @@ final class SmsInboxReader {
int safeLimit = Math.max(1, Math.min(limit, 100)); int safeLimit = Math.max(1, Math.min(limit, 100));
String selection = minDateMillis > 0L ? Telephony.Sms.DATE + ">=?" : null; String selection = minDateMillis > 0L ? Telephony.Sms.DATE + ">=?" : null;
String[] selectionArgs = minDateMillis > 0L ? new String[]{String.valueOf(minDateMillis)} : 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")); + ", minDate=" + (minDateMillis > 0L ? formatDate(minDateMillis) : "none"));
try (Cursor cursor = context.getContentResolver().query( try (Cursor cursor = context.getContentResolver().query(
uri, uri,
@ -142,54 +90,39 @@ final class SmsInboxReader {
selectionArgs, selectionArgs,
Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) { Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) {
if (cursor == null) { if (cursor == null) {
Log.w(TAG, "SmsInboxReader.findLatestVerificationCode cursor is null"); Log.w(TAG, "SmsInboxReader.findLatestSms cursor is null");
return RecentCodeResult.failure("短信库查询 cursor 为空"); return RecentSmsResult.failure("短信库查询 cursor 为空");
} }
int scanned = 0; int scanned = 0;
RecentCodeResult latest = null;
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID)); long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID));
String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS)); String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS));
String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY)); String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY));
long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE)); long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE));
int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE)); int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE));
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(body); scanned++;
Log.d(TAG, "poll scan SMS[" + scanned + "] id=" + id Log.d(TAG, "poll scan SMS[" + (scanned - 1) + "] id=" + id
+ ", type=" + smsTypeName(type) + ", type=" + smsTypeName(type)
+ ", date=" + formatDate(date) + ", date=" + formatDate(date)
+ ", sender=" + maskSender(sender) + ", sender=" + maskSender(sender)
+ ", parseSuccess=" + parseResult.success
+ ", code=" + parseResult.code
+ ", strategy=" + parseResult.strategy
+ ", bodyPreview=" + previewBody(body)); + ", bodyPreview=" + previewBody(body));
scanned++; if (TextUtils.isEmpty(body)) {
if (parseResult.success) { continue;
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);
} }
} Log.d(TAG, "SmsInboxReader.findLatestSms hit latest id=" + id
if (latest != null) { + ", date=" + formatDate(date)
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode hit latest id=" + latest.id
+ ", code=" + latest.parseResult.code
+ ", date=" + formatDate(latest.dateMillis)
+ ", scanned=" + scanned); + ", scanned=" + scanned);
return latest.withScannedCount(scanned); return RecentSmsResult.success(id, sender, body, date, scanned);
} }
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode no code scanned=" + scanned); Log.d(TAG, "SmsInboxReader.findLatestSms no sms scanned=" + scanned);
return RecentCodeResult.noCode(scanned); return RecentSmsResult.noSms(scanned);
} catch (SecurityException e) { } catch (SecurityException e) {
Log.w(TAG, "SmsInboxReader.findLatestVerificationCode failed: READ_SMS denied", e); Log.w(TAG, "SmsInboxReader.findLatestSms failed: READ_SMS denied", e);
return RecentCodeResult.failure("READ_SMS 未授权"); return RecentSmsResult.failure("READ_SMS 未授权");
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "SmsInboxReader.findLatestVerificationCode failed", e); Log.w(TAG, "SmsInboxReader.findLatestSms failed", e);
return RecentCodeResult.failure("短信库查询失败:" + e.getClass().getSimpleName()); return RecentSmsResult.failure("短信库查询失败:" + e.getClass().getSimpleName());
} }
} }
@ -231,13 +164,13 @@ final class SmsInboxReader {
} }
} }
static final class InboxResult { public static final class InboxResult {
final boolean success; public final boolean success;
final long id; public final long id;
final String sender; public final String sender;
final String body; public final String body;
final long dateMillis; public final long dateMillis;
final String failureReason; public final String failureReason;
private InboxResult(boolean success, long id, String sender, String body, long dateMillis, String failureReason) { private InboxResult(boolean success, long id, String sender, String body, long dateMillis, String failureReason) {
this.success = success; this.success = success;
@ -257,23 +190,21 @@ final class SmsInboxReader {
} }
} }
static final class RecentCodeResult { public static final class RecentSmsResult {
final boolean success; public final boolean success;
final long id; public final long id;
final String sender; public final String sender;
final String body; public final String body;
final long dateMillis; public final long dateMillis;
final VerificationCodeParser.ParseResult parseResult; public final int scannedCount;
final int scannedCount; public final String failureReason;
final String failureReason;
private RecentCodeResult( private RecentSmsResult(
boolean success, boolean success,
long id, long id,
String sender, String sender,
String body, String body,
long dateMillis, long dateMillis,
VerificationCodeParser.ParseResult parseResult,
int scannedCount, int scannedCount,
String failureReason) { String failureReason) {
this.success = success; this.success = success;
@ -281,33 +212,25 @@ final class SmsInboxReader {
this.sender = sender == null ? "" : sender; this.sender = sender == null ? "" : sender;
this.body = body == null ? "" : body; this.body = body == null ? "" : body;
this.dateMillis = dateMillis; this.dateMillis = dateMillis;
this.parseResult = parseResult;
this.scannedCount = scannedCount; this.scannedCount = scannedCount;
this.failureReason = failureReason == null ? "" : failureReason; this.failureReason = failureReason == null ? "" : failureReason;
} }
static RecentCodeResult success( static RecentSmsResult success(
long id, long id,
String sender, String sender,
String body, String body,
long dateMillis, long dateMillis,
VerificationCodeParser.ParseResult parseResult,
int scannedCount) { 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) { static RecentSmsResult noSms(int scannedCount) {
return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(), return new RecentSmsResult(false, -1L, "", "", System.currentTimeMillis(), scannedCount, "最近短信未找到新短信");
VerificationCodeParser.ParseResult.failure("最近短信未找到验证码"), scannedCount, "最近短信未找到验证码");
} }
static RecentCodeResult failure(String reason) { static RecentSmsResult failure(String reason) {
return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(), return new RecentSmsResult(false, -1L, "", "", System.currentTimeMillis(), 0, reason);
VerificationCodeParser.ParseResult.failure(reason), 0, reason);
}
RecentCodeResult withScannedCount(int scannedCount) {
return new RecentCodeResult(success, id, sender, body, dateMillis, parseResult, scannedCount, failureReason);
} }
} }
} }

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.sms;
import android.content.Intent; import android.content.Intent;
import android.provider.Telephony; import android.provider.Telephony;

View File

@ -1,4 +1,4 @@
package com.smsreceive.app; package com.smsreceive.app.sms;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
@ -7,6 +7,8 @@ import android.provider.Telephony;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
import com.smsreceive.app.feishu.FeishuWebhookClient;
public final class SmsReceiver extends BroadcastReceiver { public final class SmsReceiver extends BroadcastReceiver {
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
private static final String SOURCE_SYSTEM_BROADCAST = "system_sms_broadcast"; private static final String SOURCE_SYSTEM_BROADCAST = "system_sms_broadcast";
@ -23,35 +25,19 @@ public final class SmsReceiver extends BroadcastReceiver {
Log.d(TAG, "SmsReceiver.onReceive ignore non SMS action"); Log.d(TAG, "SmsReceiver.onReceive ignore non SMS action");
return; return;
} }
Toast.makeText(context, "收到短信广播,开始解析", Toast.LENGTH_SHORT).show(); Toast.makeText(context, "收到短信广播,开始处理", Toast.LENGTH_SHORT).show();
SmsMessageReader.ReadResult readResult = SmsMessageReader.read(intent); SmsMessageReader.ReadResult readResult = SmsMessageReader.read(intent);
CaptureResult captureResult; CaptureResult captureResult;
if (readResult.success) { if (readResult.success) {
Log.d(TAG, "SMS read success sender=" + maskSender(readResult.sender) Log.d(TAG, "SMS read success sender=" + maskSender(readResult.sender)
+ ", bodyPreview=" + preview(readResult.body)); + ", bodyPreview=" + preview(readResult.body));
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(readResult.body); Toast.makeText(context, "短信已收到", Toast.LENGTH_LONG).show();
if (parseResult.success) { captureResult = CaptureResult.success(
Log.d(TAG, "verification code parse success code=" + parseResult.code readResult.timestampMillis,
+ ", strategy=" + parseResult.strategy readResult.sender,
+ ", confidence=" + parseResult.confidence); readResult.body,
Toast.makeText(context, "验证码:" + parseResult.code, Toast.LENGTH_LONG).show(); SOURCE_SYSTEM_BROADCAST);
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);
}
} else { } else {
Log.w(TAG, "SMS read failed reason=" + readResult.failureReason); Log.w(TAG, "SMS read failed reason=" + readResult.failureReason);
Toast.makeText(context, "短信读取失败:" + readResult.failureReason, Toast.LENGTH_LONG).show(); Toast.makeText(context, "短信读取失败:" + readResult.failureReason, Toast.LENGTH_LONG).show();
@ -69,7 +55,7 @@ public final class SmsReceiver extends BroadcastReceiver {
updateIntent.setPackage(context.getPackageName()); updateIntent.setPackage(context.getPackageName());
context.sendBroadcast(updateIntent); context.sendBroadcast(updateIntent);
Log.d(TAG, "SmsReceiver.onReceive end source=" + SOURCE_SYSTEM_BROADCAST Log.d(TAG, "SmsReceiver.onReceive end source=" + SOURCE_SYSTEM_BROADCAST
+ ", success=" + captureResult.parseResult.success); + ", success=" + captureResult.success);
} }
private static String maskSender(String sender) { private static String maskSender(String sender) {

View File

@ -1,7 +1,8 @@
package com.smsreceive.app; package com.smsreceive.app.ui;
import android.Manifest; import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
@ -19,22 +20,30 @@ import android.os.Looper;
import android.os.PowerManager; import android.os.PowerManager;
import android.provider.Settings; import android.provider.Settings;
import android.provider.Telephony; import android.provider.Telephony;
import android.text.InputType;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.view.Gravity; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.widget.Button; import android.widget.Button;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.EditText; import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton; import android.widget.RadioButton;
import android.widget.ScrollView;
import android.widget.Switch;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; 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;
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 java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.Locale; import java.util.Locale;
@ -48,24 +57,30 @@ public final class MainActivity extends Activity {
private static final long DATABASE_HEARTBEAT_STALE_MILLIS = 30_000L; private static final long DATABASE_HEARTBEAT_STALE_MILLIS = 30_000L;
private TextView permissionText; private TextView permissionText;
private TextView latestText;
private TextView googlePlayText; private TextView googlePlayText;
private TextView keepAliveText; private TextView keepAliveText;
private TextView databaseHeartbeatText; private TextView databaseHeartbeatText;
private TextView deliveryDiagnosticsText; private TextView deliveryDiagnosticsText;
private TextView feishuPushText; private TextView feishuPushText;
private TextView latestText;
private Button keepAliveButton; private Button keepAliveButton;
private Button autostartConfirmButton;
private Button batteryConfirmButton;
private Button pollingButton; private Button pollingButton;
private RadioButton toastOnDatabaseWriteRadio; private RadioButton toastOnDatabaseWriteRadio;
private CheckBox feishuPushEnabledCheckBox; private CheckBox feishuPushEnabledCheckBox;
private CheckBox feishuDebugBodyCheckBox; private CheckBox autostartConfirmCheckBox;
private Switch feishuFilterCodeSwitch; private CheckBox batteryConfirmCheckBox;
private EditText feishuWebhookIdEdit; private EditText feishuWebhookIdEdit;
private EditText feishuSecretEdit; private EditText feishuSecretEdit;
private EditText pollingIntervalEdit; private EditText pollingIntervalEdit;
private AlertDialog debugInfoDialog;
private long lastInboxSmsId = -1L; 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 Handler mainHandler = new Handler(Looper.getMainLooper());
private final BroadcastReceiver updateReceiver = new BroadcastReceiver() { private final BroadcastReceiver updateReceiver = new BroadcastReceiver() {
@ -82,11 +97,15 @@ public final class MainActivity extends Activity {
readLatestInboxSms(SOURCE_INBOX_OBSERVER, true); readLatestInboxSms(SOURCE_INBOX_OBSERVER, true);
} }
}; };
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
Log.d(TAG, "MainActivity.onCreate"); Log.d(TAG, "MainActivity.onCreate");
setContentView(createContentView()); setContentView(R.layout.activity_main);
bindMainViews();
bindMainActions();
refreshUi();
} }
@Override @Override
@ -130,214 +149,99 @@ public final class MainActivity extends Activity {
} }
} }
private View createContentView() { private void bindMainViews() {
ScrollView scrollView = new ScrollView(this); permissionText = findViewById(R.id.permission_text);
scrollView.setFillViewport(true); 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); private void bindMainActions() {
root.setOrientation(LinearLayout.VERTICAL); findViewById(R.id.request_permission_button).setOnClickListener(v -> requestSmsPermission());
root.setPadding(dp(20), dp(24), dp(20), dp(24)); findViewById(R.id.debug_info_button).setOnClickListener(v -> showDebugInfoDialog());
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("开启常驻保活");
keepAliveButton.setOnClickListener(v -> toggleKeepAlive()); 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()); toastOnDatabaseWriteRadio.setOnClickListener(v -> toggleToastOnDatabaseWrite());
actions.addView(toastOnDatabaseWriteRadio, matchWrap()); findViewById(R.id.read_inbox_button).setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true));
Button readInboxButton = button("读取最新短信");
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.setOnClickListener(v -> togglePolling()); pollingButton.setOnClickListener(v -> togglePolling());
actions.addView(pollingButton, matchWrap()); findViewById(R.id.save_feishu_button).setOnClickListener(v -> saveFeishuConfigFromUi());
findViewById(R.id.test_feishu_button).setOnClickListener(v -> testFeishuPush());
pollingIntervalEdit = new EditText(this); findViewById(R.id.settings_button).setOnClickListener(v -> openAppSettings());
pollingIntervalEdit.setHint("轮询间隔秒数,默认 1"); findViewById(R.id.battery_settings_button).setOnClickListener(v -> openBatteryOptimizationSettings());
pollingIntervalEdit.setSingleLine(true); findViewById(R.id.request_battery_button).setOnClickListener(v -> requestIgnoreBatteryOptimizations());
pollingIntervalEdit.setInputType(InputType.TYPE_CLASS_NUMBER); findViewById(R.id.xiaomi_autostart_button).setOnClickListener(v -> openXiaomiAutostartSettings());
actions.addView(pollingIntervalEdit, matchWrap()); autostartConfirmCheckBox.setOnClickListener(v -> toggleManualAutostartConfirmed());
batteryConfirmCheckBox.setOnClickListener(v -> toggleManualBatteryConfirmed());
Button savePollingIntervalButton = button("保存轮询间隔"); findViewById(R.id.clear_button).setOnClickListener(v -> {
savePollingIntervalButton.setOnClickListener(v -> savePollingIntervalFromUi());
actions.addView(savePollingIntervalButton, 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());
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());
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 -> {
SmsCaptureStore.clear(this); SmsCaptureStore.clear(this);
Toast.makeText(this, "已清空最近结果", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "已清空最近结果", Toast.LENGTH_SHORT).show();
refreshUi(); refreshUi();
}); });
actions.addView(clearButton, matchWrap()); findViewById(R.id.refresh_button).setOnClickListener(v -> refreshUi());
Button refreshButton = button("刷新状态");
refreshButton.setOnClickListener(v -> refreshUi());
actions.addView(refreshButton, matchWrap());
return scrollView;
} }
private TextView section(LinearLayout root, String label) { private void showDebugInfoDialog() {
TextView title = new TextView(this); if (debugInfoDialog == null) {
title.setText(label); View debugInfoView = LayoutInflater.from(this).inflate(R.layout.dialog_debug_info, null);
title.setTextSize(16); googlePlayText = debugInfoView.findViewById(R.id.google_play_text);
title.setTextColor(0xFF17202A); keepAliveText = debugInfoView.findViewById(R.id.keep_alive_text);
title.setPadding(0, dp(12), 0, dp(4)); databaseHeartbeatText = debugInfoView.findViewById(R.id.database_heartbeat_text);
root.addView(title, matchWrap()); deliveryDiagnosticsText = debugInfoView.findViewById(R.id.delivery_diagnostics_text);
feishuPushText = debugInfoView.findViewById(R.id.feishu_push_text);
TextView value = new TextView(this); debugInfoDialog = new AlertDialog.Builder(this)
value.setTextSize(14); .setTitle("调试信息")
value.setTextColor(0xFF27313F); .setView(debugInfoView)
value.setLineSpacing(dp(2), 1.0f); .setPositiveButton("关闭", null)
value.setPadding(dp(12), dp(10), dp(12), dp(10)); .create();
value.setBackgroundColor(0xFFFFFFFF); }
root.addView(value, matchWrap()); applyDebugInfoState();
return value; debugInfoDialog.show();
} }
private Button button(String text) { private void applyDebugInfoState() {
Button button = new Button(this); if (googlePlayText == null) {
button.setText(text); return;
button.setAllCaps(false); }
return button; googlePlayText.setText(googlePlayTextValue);
} keepAliveText.setText(keepAliveTextValue);
databaseHeartbeatText.setText(databaseHeartbeatTextValue);
private LinearLayout.LayoutParams matchWrap() { databaseHeartbeatText.setBackgroundColor(databaseHeartbeatBackgroundColor);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( deliveryDiagnosticsText.setText(deliveryDiagnosticsTextValue);
ViewGroup.LayoutParams.MATCH_PARENT, feishuPushText.setText(feishuPushTextValue);
ViewGroup.LayoutParams.WRAP_CONTENT); feishuPushText.setBackgroundColor(feishuPushBackgroundColor);
params.setMargins(0, dp(4), 0, dp(4));
return params;
} }
private void refreshUi() { private void refreshUi() {
boolean receiveGranted = hasReceiveSmsPermission(); boolean receiveGranted = hasReceiveSmsPermission();
boolean readGranted = hasReadSmsPermission(); boolean readGranted = hasReadSmsPermission();
boolean googlePlayInstalled = isGooglePlayServicesInstalled();
Log.d(TAG, "refreshUi receiveSmsPermissionGranted=" + receiveGranted Log.d(TAG, "refreshUi receiveSmsPermissionGranted=" + receiveGranted
+ ", readSmsPermissionGranted=" + readGranted + ", readSmsPermissionGranted=" + readGranted
+ ", googlePlayInstalled=" + isGooglePlayServicesInstalled()); + ", googlePlayInstalled=" + googlePlayInstalled);
permissionText.setText("RECEIVE_SMS" + (receiveGranted ? "已授权" : "未授权") permissionText.setText("RECEIVE_SMS" + (receiveGranted ? "已授权" : "未授权")
+ "\nREAD_SMS" + (readGranted ? "已授权" : "未授权") + "\nREAD_SMS" + (readGranted ? "已授权" : "未授权")
+ "\n说明如果 receiver 收不到广播,前台会用 READ_SMS 读取最新收件箱作为兜底。"); + "\n说明如果 receiver 收不到广播,前台会用 READ_SMS 读取最新收件箱作为兜底。");
googlePlayText.setText(isGooglePlayServicesInstalled() googlePlayTextValue = googlePlayInstalled
? "已检测到 com.google.android.gms。SMS User Consent / Retriever 可作为后续备选路径验证。" ? "已检测到 com.google.android.gms。SMS User Consent / Retriever 可作为后续备选路径验证。"
: "未检测到 com.google.android.gms。当前实现不依赖 Google API主路径仍是系统短信广播。"); : "未检测到 com.google.android.gms。当前实现不依赖 Google API主路径仍是系统短信广播。";
refreshKeepAliveUi(); refreshKeepAliveUi();
refreshDatabaseHeartbeatUi(); refreshDatabaseHeartbeatUi();
refreshDeliveryDiagnosticsUi(); refreshDeliveryDiagnosticsUi();
refreshPollingUi(); refreshPollingUi();
refreshFeishuPushUi(); refreshFeishuPushUi();
applyDebugInfoState();
SmsCaptureStore.StoredCapture capture = SmsCaptureStore.load(this); SmsCaptureStore.StoredCapture capture = SmsCaptureStore.load(this);
if (capture.timeMillis <= 0L) { if (capture.timeMillis <= 0L) {
latestText.setText("暂无短信接收记录。可以先授权,再从另一台手机发送:验证码 1234565 分钟内有效"); latestText.setText("暂无短信接收记录。可以先授权,再从另一台手机发送一条测试短信。");
return; return;
} }
@ -345,14 +249,10 @@ public final class MainActivity extends Activity {
builder.append("时间:").append(formatTime(capture.timeMillis)).append('\n'); builder.append("时间:").append(formatTime(capture.timeMillis)).append('\n');
builder.append("来源:").append(emptyAsDash(capture.source)).append('\n'); builder.append("来源:").append(emptyAsDash(capture.source)).append('\n');
builder.append("发送方:").append(emptyAsDash(capture.sender)).append('\n'); builder.append("发送方:").append(emptyAsDash(capture.sender)).append('\n');
if (!TextUtils.isEmpty(capture.code)) { if (!capture.success) {
builder.append("验证码:").append(capture.code).append('\n');
builder.append("策略:").append(capture.strategy).append(" / ").append(capture.confidence).append('\n');
} else {
builder.append("验证码:-").append('\n');
builder.append("失败原因:").append(emptyAsDash(capture.failure)).append('\n'); builder.append("失败原因:").append(emptyAsDash(capture.failure)).append('\n');
} }
builder.append("正文摘要").append(emptyAsDash(capture.bodyPreview)); builder.append("短信原文:").append(emptyAsDash(capture.body));
latestText.setText(builder.toString()); latestText.setText(builder.toString());
} }
@ -368,15 +268,9 @@ public final class MainActivity extends Activity {
+ ", notificationsEnabled=" + areNotificationsEnabled() + ", notificationsEnabled=" + areNotificationsEnabled()
+ ", manualAutostart=" + state.manualAutostartConfirmed + ", manualAutostart=" + state.manualAutostartConfirmed
+ ", manualBatteryUnrestricted=" + state.manualBatteryUnrestrictedConfirmed); + ", manualBatteryUnrestricted=" + state.manualBatteryUnrestrictedConfirmed);
if (keepAliveButton != null) { keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活");
keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活"); autostartConfirmCheckBox.setChecked(state.manualAutostartConfirmed);
} batteryConfirmCheckBox.setChecked(state.manualBatteryUnrestrictedConfirmed);
if (autostartConfirmButton != null) {
autostartConfirmButton.setText(state.manualAutostartConfirmed ? "取消自启动确认" : "确认已开启小米自启动");
}
if (batteryConfirmButton != null) {
batteryConfirmButton.setText(state.manualBatteryUnrestrictedConfirmed ? "取消省电无限制确认" : "确认省电策略已设为无限制");
}
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
builder.append("用户开关:").append(state.enabledByUser ? "已开启" : "未开启").append('\n'); builder.append("用户开关:").append(state.enabledByUser ? "已开启" : "未开启").append('\n');
@ -392,14 +286,12 @@ public final class MainActivity extends Activity {
builder.append("通知可见性:").append(areNotificationsEnabled() ? "系统允许通知" : "通知可能被关闭").append('\n'); builder.append("通知可见性:").append(areNotificationsEnabled() ? "系统允许通知" : "通知可能被关闭").append('\n');
builder.append("小米自启动:").append(state.manualAutostartConfirmed ? "已人工确认" : "未确认").append('\n'); builder.append("小米自启动:").append(state.manualAutostartConfirmed ? "已人工确认" : "未确认").append('\n');
builder.append("省电无限制:").append(state.manualBatteryUnrestrictedConfirmed ? "已人工确认" : "未确认"); builder.append("省电无限制:").append(state.manualBatteryUnrestrictedConfirmed ? "已人工确认" : "未确认");
keepAliveText.setText(builder.toString()); keepAliveTextValue = builder.toString();
} }
private void refreshDatabaseHeartbeatUi() { private void refreshDatabaseHeartbeatUi() {
KeepAliveStateStore.State state = KeepAliveStateStore.load(this); KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
if (toastOnDatabaseWriteRadio != null) { toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite);
toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite);
}
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long lastActiveTime = KeepAliveDatabase.readLastActiveTime(this); long lastActiveTime = KeepAliveDatabase.readLastActiveTime(this);
@ -418,7 +310,7 @@ public final class MainActivity extends Activity {
if (lastActiveTime <= 0L) { if (lastActiveTime <= 0L) {
builder.append("最后写入:-").append('\n'); builder.append("最后写入:-").append('\n');
builder.append("判断:数据库还没有 lastActiveTime。开启常驻保活后会开始写入。"); builder.append("判断:数据库还没有 lastActiveTime。开启常驻保活后会开始写入。");
databaseHeartbeatText.setBackgroundColor(0xFFFFFFFF); databaseHeartbeatBackgroundColor = 0xFFFFFFFF;
} else { } else {
builder.append("最后写入:").append(formatTimeWithMillis(lastActiveTime)).append('\n'); builder.append("最后写入:").append(formatTimeWithMillis(lastActiveTime)).append('\n');
builder.append("距离现在:").append(gapMillis).append(" ms").append('\n'); builder.append("距离现在:").append(gapMillis).append(" ms").append('\n');
@ -428,13 +320,13 @@ public final class MainActivity extends Activity {
.append(",大约在此后 ") .append(",大约在此后 ")
.append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L) .append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L)
.append(" 秒内停止写入。"); .append(" 秒内停止写入。");
databaseHeartbeatText.setBackgroundColor(0xFFFFE0E0); databaseHeartbeatBackgroundColor = 0xFFFFE0E0;
} else { } else {
builder.append("判断:数据库心跳仍在正常窗口内。"); builder.append("判断:数据库心跳仍在正常窗口内。");
databaseHeartbeatText.setBackgroundColor(0xFFE8F5E9); databaseHeartbeatBackgroundColor = 0xFFE8F5E9;
} }
} }
databaseHeartbeatText.setText(builder.toString()); databaseHeartbeatTextValue = builder.toString();
} }
private void refreshDeliveryDiagnosticsUi() { private void refreshDeliveryDiagnosticsUi() {
@ -452,15 +344,13 @@ public final class MainActivity extends Activity {
} else { } else {
builder.append("判断:暂无收件箱新于广播的异常记录。"); builder.append("判断:暂无收件箱新于广播的异常记录。");
} }
deliveryDiagnosticsText.setText(builder.toString()); deliveryDiagnosticsTextValue = builder.toString();
} }
private void refreshPollingUi() { private void refreshPollingUi() {
SmsPollingStateStore.State state = SmsPollingStateStore.load(this); SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
if (pollingButton != null) { pollingButton.setText(state.enabledByUser ? "停止短信轮询" : "开始短信轮询");
pollingButton.setText(state.enabledByUser ? "停止1秒轮询验证码" : "开始1秒轮询验证码"); if (!pollingIntervalEdit.hasFocus()) {
}
if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) {
pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds)); pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds));
} }
Log.d(TAG, "refreshPollingUi enabled=" + state.enabledByUser Log.d(TAG, "refreshPollingUi enabled=" + state.enabledByUser
@ -476,19 +366,11 @@ public final class MainActivity extends Activity {
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(this); FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(this);
FeishuWebhookConfigStore.LastResult lastResult = FeishuWebhookConfigStore.loadLastResult(this); FeishuWebhookConfigStore.LastResult lastResult = FeishuWebhookConfigStore.loadLastResult(this);
FeishuWebhookConfigStore.LastPushedSms lastPushedSms = FeishuWebhookConfigStore.loadLastPushedSms(this); FeishuWebhookConfigStore.LastPushedSms lastPushedSms = FeishuWebhookConfigStore.loadLastPushedSms(this);
if (feishuPushEnabledCheckBox != null) { feishuPushEnabledCheckBox.setChecked(config.enabled);
feishuPushEnabledCheckBox.setChecked(config.enabled); if (!feishuWebhookIdEdit.hasFocus()) {
}
if (feishuDebugBodyCheckBox != null) {
feishuDebugBodyCheckBox.setChecked(config.sendFullBodyDebug);
}
if (feishuFilterCodeSwitch != null) {
feishuFilterCodeSwitch.setChecked(config.filterVerificationCode);
}
if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) {
feishuWebhookIdEdit.setText(config.webhookId); feishuWebhookIdEdit.setText(config.webhookId);
} }
if (feishuSecretEdit != null && !feishuSecretEdit.hasFocus()) { if (!feishuSecretEdit.hasFocus()) {
feishuSecretEdit.setText(config.secret); feishuSecretEdit.setText(config.secret);
} }
@ -496,12 +378,11 @@ public final class MainActivity extends Activity {
builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n'); builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n');
builder.append("默认模板:").append(FeishuWebhookConfigStore.defaultConfigPath(this)).append('\n'); builder.append("默认模板:").append(FeishuWebhookConfigStore.defaultConfigPath(this)).append('\n');
builder.append("推送开关:").append(config.enabled ? "已开启" : "未开启").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("Webhook ID").append(config.hasWebhookId() ? "已配置" : "未配置").append('\n');
builder.append("Secret").append(config.hasSecret() builder.append("Secret").append(config.hasSecret()
? FeishuWebhookConfigStore.maskSecret(config.secret) ? FeishuWebhookConfigStore.maskSecret(config.secret)
: "未配置").append('\n'); : "未配置").append('\n');
builder.append("完整正文上传:").append(config.sendFullBodyDebug ? "已开启" : "未开启").append('\n');
builder.append("已推送短信时间秒:") builder.append("已推送短信时间秒:")
.append(lastPushedSms.receivedSecond > 0L ? String.valueOf(lastPushedSms.receivedSecond) : "-") .append(lastPushedSms.receivedSecond > 0L ? String.valueOf(lastPushedSms.receivedSecond) : "-")
.append('\n'); .append('\n');
@ -522,8 +403,8 @@ public final class MainActivity extends Activity {
boolean configIssue = !config.enabled || !config.hasWebhookId() || !config.hasSecret() boolean configIssue = !config.enabled || !config.hasWebhookId() || !config.hasSecret()
|| FeishuWebhookPushResult.STATUS_DISABLED.equals(lastResult.status) || FeishuWebhookPushResult.STATUS_DISABLED.equals(lastResult.status)
|| FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(lastResult.status); || FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(lastResult.status);
feishuPushText.setBackgroundColor(configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF); feishuPushBackgroundColor = configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF;
feishuPushText.setText(builder.toString()); feishuPushTextValue = builder.toString();
} }
private void requestSmsPermission() { private void requestSmsPermission() {
@ -585,24 +466,18 @@ public final class MainActivity extends Activity {
boolean enabled = !state.toastOnDatabaseWrite; boolean enabled = !state.toastOnDatabaseWrite;
Log.d(TAG, "toggleToastOnDatabaseWrite enabled=" + enabled); Log.d(TAG, "toggleToastOnDatabaseWrite enabled=" + enabled);
KeepAliveStateStore.setToastOnDatabaseWrite(this, enabled); KeepAliveStateStore.setToastOnDatabaseWrite(this, enabled);
if (toastOnDatabaseWriteRadio != null) { toastOnDatabaseWriteRadio.setChecked(enabled);
toastOnDatabaseWriteRadio.setChecked(enabled);
}
refreshUi(); refreshUi();
} }
private void saveFeishuConfigFromUi() { private void saveFeishuConfigFromUi() {
boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked(); boolean enabled = feishuPushEnabledCheckBox.isChecked();
boolean debugBody = feishuDebugBodyCheckBox != null && feishuDebugBodyCheckBox.isChecked(); String webhookId = feishuWebhookIdEdit.getText().toString();
boolean filterCode = feishuFilterCodeSwitch != null && feishuFilterCodeSwitch.isChecked(); String secret = feishuSecretEdit.getText().toString();
String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString();
String secret = feishuSecretEdit == null ? "" : feishuSecretEdit.getText().toString();
Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled
+ ", webhookConfigured=" + !TextUtils.isEmpty(webhookId) + ", webhookConfigured=" + !TextUtils.isEmpty(webhookId)
+ ", secretConfigured=" + !TextUtils.isEmpty(secret) + ", secretConfigured=" + !TextUtils.isEmpty(secret));
+ ", debugBody=" + debugBody FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret);
+ ", filterCode=" + filterCode);
FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret, debugBody, filterCode);
Toast.makeText(this, "已保存飞书推送配置", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "已保存飞书推送配置", Toast.LENGTH_SHORT).show();
refreshUi(); refreshUi();
} }
@ -627,21 +502,11 @@ public final class MainActivity extends Activity {
} }
private String buildLatestSmsTestMarkdown(SmsInboxReader.InboxResult inboxResult) { private String buildLatestSmsTestMarkdown(SmsInboxReader.InboxResult inboxResult) {
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body); return "**SmsReceive 最近短信测试推送**" + '\n' +
StringBuilder builder = new StringBuilder(); "时间:" + formatTime(inboxResult.dateMillis) + '\n' +
builder.append("**SmsReceive 最近短信测试推送**").append('\n'); "发送方:" + maskSender(inboxResult.sender) + '\n' +
builder.append("时间:").append(formatTime(inboxResult.dateMillis)).append('\n'); "短信ID" + inboxResult.id + '\n' +
builder.append("发送方:").append(maskSender(inboxResult.sender)).append('\n'); "正文:" + emptyAsDash(inboxResult.body);
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();
} }
private boolean hasReceiveSmsPermission() { private boolean hasReceiveSmsPermission() {
@ -679,70 +544,40 @@ public final class MainActivity extends Activity {
} }
lastInboxSmsId = inboxResult.id; lastInboxSmsId = inboxResult.id;
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body); Log.d(TAG, "readLatestInboxSms success source=" + source
CaptureResult captureResult; + ", id=" + inboxResult.id
if (parseResult.success) { + ", sender=" + inboxResult.sender);
Log.d(TAG, "readLatestInboxSms parse success source=" + source CaptureResult captureResult = CaptureResult.success(
+ ", id=" + inboxResult.id inboxResult.dateMillis,
+ ", code=" + parseResult.code inboxResult.id,
+ ", strategy=" + parseResult.strategy); inboxResult.sender,
captureResult = CaptureResult.success( inboxResult.body,
inboxResult.dateMillis, source);
inboxResult.id, if (showToast) {
inboxResult.sender, Toast.makeText(this, "已读取最新短信", Toast.LENGTH_LONG).show();
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();
}
} }
SmsCaptureStore.save(this, captureResult); SmsCaptureStore.save(this, captureResult);
FeishuWebhookClient.pushCaptureResultAsync(this, captureResult); FeishuWebhookClient.pushCaptureResultAsync(this, captureResult);
refreshUi(); refreshUi();
} }
private void dumpRecentMessages() { private int savePollingIntervalFromUi() {
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() {
int intervalSeconds = parsePollingIntervalSeconds(); int intervalSeconds = parsePollingIntervalSeconds();
SmsPollingStateStore.setIntervalSeconds(this, intervalSeconds); SmsPollingStateStore.setIntervalSeconds(this, intervalSeconds);
Toast.makeText(this, "已保存轮询间隔:" + intervalSeconds + "", Toast.LENGTH_SHORT).show(); pollingIntervalEdit.setText(String.valueOf(intervalSeconds));
refreshUi(); return intervalSeconds;
} }
private int parsePollingIntervalSeconds() { private int parsePollingIntervalSeconds() {
String raw = pollingIntervalEdit == null ? "" : pollingIntervalEdit.getText().toString().trim(); String raw = pollingIntervalEdit.getText().toString().trim();
if (TextUtils.isEmpty(raw)) { if (TextUtils.isEmpty(raw)) {
return SmsPollingStateStore.getIntervalSeconds(this); return 1;
} }
try { try {
return Integer.parseInt(raw); return Integer.parseInt(raw);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
Log.w(TAG, "parsePollingIntervalSeconds invalid raw=" + raw, e); Log.w(TAG, "parsePollingIntervalSeconds invalid raw=" + raw, e);
return SmsPollingStateStore.getIntervalSeconds(this); return 1;
} }
} }
@ -761,11 +596,11 @@ public final class MainActivity extends Activity {
Toast.makeText(this, "READ_SMS 未授权,无法轮询短信库", Toast.LENGTH_LONG).show(); Toast.makeText(this, "READ_SMS 未授权,无法轮询短信库", Toast.LENGTH_LONG).show();
return; return;
} }
savePollingIntervalFromUi(); int intervalSeconds = savePollingIntervalFromUi();
Log.d(TAG, "startPolling via SmsPollingService"); Log.d(TAG, "startPolling via SmsPollingService");
SmsPollingService.start(this); SmsPollingService.start(this);
Toast.makeText(this, Toast.makeText(this,
"已启动后台轮询验证码,间隔 " + SmsPollingStateStore.getIntervalSeconds(this) + "", "已启动后台短信轮询,间隔 " + intervalSeconds + "",
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();
refreshUi(); refreshUi();
} }
@ -855,9 +690,6 @@ public final class MainActivity extends Activity {
} }
private boolean isIgnoringBatteryOptimizations() { private boolean isIgnoringBatteryOptimizations() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true;
}
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
return powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName()); return powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName());
} }
@ -895,8 +727,4 @@ public final class MainActivity extends Activity {
} }
return "***" + sender.substring(sender.length() - 4); return "***" + sender.substring(sender.length() - 4);
} }
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
}
} }

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="@color/accent_dark" />
<corners android:radius="16dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/accent" />
<corners android:radius="16dp" />
</shape>
</item>
</selector>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="@color/accent_soft" />
<stroke
android:width="1dp"
android:color="@color/accent" />
<corners android:radius="16dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/surface" />
<stroke
android:width="1dp"
android:color="@color/card_border" />
<corners android:radius="16dp" />
</shape>
</item>
</selector>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="@color/accent_soft_pressed" />
<corners android:radius="16dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/accent_soft" />
<corners android:radius="16dp" />
</shape>
</item>
</selector>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/surface" />
<stroke
android:width="1dp"
android:color="@color/card_border" />
<corners android:radius="24dp" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="315"
android:endColor="@color/hero_end"
android:startColor="@color/hero_start" />
<corners android:radius="28dp" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/panel_bg" />
<stroke
android:width="1dp"
android:color="@color/card_border" />
<corners android:radius="18dp" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape>
<solid android:color="@color/surface" />
<stroke
android:width="2dp"
android:color="@color/accent" />
<corners android:radius="16dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/surface" />
<stroke
android:width="1dp"
android:color="@color/card_border" />
<corners android:radius="16dp" />
</shape>
</item>
</selector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="@color/surface_alt"
android:startColor="@color/screen_bg" />
</shape>

View File

@ -0,0 +1,386 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_screen_gradient"
android:clipToPadding="false"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="18dp"
android:paddingTop="22dp"
android:paddingRight="18dp"
android:paddingBottom="28dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_hero_panel"
android:elevation="4dp"
android:orientation="vertical"
android:padding="22dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SmsReceive"
style="@style/HeroEyebrow" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="短信接收"
style="@style/ScreenTitle" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="主路径RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后保存短信原文和诊断摘要。"
style="@style/HeroSubtitle" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_card_surface"
android:elevation="2dp"
android:orientation="vertical"
android:padding="18dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="概览"
style="@style/SectionHeading" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="先看权限和最近一次短信结果,调试明细放到单独面板里。"
style="@style/SectionCaption" />
</LinearLayout>
<Button
android:id="@+id/debug_info_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:text="调试信息"
style="@style/TonalActionButton" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="权限状态"
style="@style/CardLabel" />
<TextView
android:id="@+id/permission_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
style="@style/InfoPanelText" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="最近结果"
style="@style/CardLabel" />
<TextView
android:id="@+id/latest_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
style="@style/InfoPanelText" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_card_surface"
android:elevation="2dp"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="快捷操作"
style="@style/SectionHeading" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="把高频操作和配置集中在一个面板里,减少整页的默认控件感。"
style="@style/SectionCaption" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="基础操作"
style="@style/CardLabel" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:baselineAligned="false"
android:orientation="horizontal">
<Button
android:id="@+id/request_permission_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="申请短信权限"
style="@style/PrimaryActionButton" />
<Button
android:id="@+id/keep_alive_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_weight="1"
android:text="开启常驻保活"
style="@style/SecondaryActionButton" />
</LinearLayout>
<RadioButton
android:id="@+id/toast_database_write_radio"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="每次写入数据库时弹 Toast"
style="@style/ToggleControl" />
<Button
android:id="@+id/read_inbox_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="读取最新短信"
style="@style/SecondaryActionButton" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="轮询设置"
style="@style/CardLabel" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/polling_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="开始短信轮询"
style="@style/PrimaryActionButton" />
<EditText
android:id="@+id/polling_interval_edit"
android:layout_width="102dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:gravity="center"
android:hint="默认1"
android:inputType="number"
android:maxLines="1"
style="@style/InputField" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="飞书推送"
style="@style/CardLabel" />
<CheckBox
android:id="@+id/feishu_push_enabled_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="开启飞书远端推送"
style="@style/ToggleControl" />
<EditText
android:id="@+id/feishu_webhook_id_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:hint="飞书 webhook id"
android:maxLines="1"
style="@style/InputField" />
<EditText
android:id="@+id/feishu_secret_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:hint="飞书 webhook secret"
android:inputType="textPassword"
android:maxLines="1"
style="@style/InputField" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:baselineAligned="false"
android:orientation="horizontal">
<Button
android:id="@+id/save_feishu_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="保存飞书配置"
style="@style/SecondaryActionButton" />
<Button
android:id="@+id/test_feishu_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_weight="1"
android:text="测试飞书推送"
style="@style/PrimaryActionButton" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="系统设置"
style="@style/CardLabel" />
<Button
android:id="@+id/settings_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="打开应用权限设置"
style="@style/SecondaryActionButton" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/battery_settings_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.1"
android:text="打开电池优化设置"
style="@style/SecondaryActionButton" />
<CheckBox
android:id="@+id/battery_confirm_checkbox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_weight="0.9"
android:text="确认省电策略无限制"
style="@style/ToggleControl" />
</LinearLayout>
<Button
android:id="@+id/request_battery_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="请求忽略电池优化"
style="@style/SecondaryActionButton" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/xiaomi_autostart_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.1"
android:text="打开小米自启动设置"
style="@style/SecondaryActionButton" />
<CheckBox
android:id="@+id/autostart_confirm_checkbox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_weight="0.9"
android:text="确认自启动开启"
style="@style/ToggleControl" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:baselineAligned="false"
android:orientation="horizontal">
<Button
android:id="@+id/clear_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="清空最近结果"
style="@style/SecondaryActionButton" />
<Button
android:id="@+id/refresh_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_weight="1"
android:text="刷新状态"
style="@style/TonalActionButton" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_screen_gradient"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="4dp"
android:paddingTop="6dp"
android:paddingRight="4dp"
android:paddingBottom="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_card_surface"
android:elevation="2dp"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Google API 诊断"
style="@style/CardLabel" />
<TextView
android:id="@+id/google_play_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/DialogInfoText" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_card_surface"
android:elevation="2dp"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="后台保活状态"
style="@style/CardLabel" />
<TextView
android:id="@+id/keep_alive_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/DialogInfoText" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_card_surface"
android:elevation="2dp"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="数据库心跳诊断"
style="@style/CardLabel" />
<TextView
android:id="@+id/database_heartbeat_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="2dp"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:lineSpacingExtra="3dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_card_surface"
android:elevation="2dp"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="短信广播诊断"
style="@style/CardLabel" />
<TextView
android:id="@+id/delivery_diagnostics_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/DialogInfoText" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_card_surface"
android:elevation="2dp"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="飞书推送状态"
style="@style/CardLabel" />
<TextView
android:id="@+id/feishu_push_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="2dp"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:lineSpacingExtra="3dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@ -1,6 +1,19 @@
<resources> <resources>
<color name="screen_bg">#F6F7F9</color> <color name="screen_bg">#F2F6F3</color>
<color name="text_primary">#17202A</color> <color name="surface">#FCFEFD</color>
<color name="text_secondary">#5F6B7A</color> <color name="surface_alt">#F7FBF8</color>
<color name="accent">#1E7A5F</color> <color name="panel_bg">#EEF5F1</color>
<color name="card_border">#D6E4DD</color>
<color name="text_primary">#17322A</color>
<color name="text_secondary">#5E756A</color>
<color name="text_hint">#8B9B93</color>
<color name="accent">#247A5C</color>
<color name="accent_dark">#1A5B45</color>
<color name="accent_soft">#DDEFE6</color>
<color name="accent_soft_pressed">#CCE6DA</color>
<color name="hero_start">#234E40</color>
<color name="hero_end">#2F8265</color>
<color name="hero_text">#FFFFFF</color>
<color name="hero_text_secondary">#D8F2E7</color>
<color name="button_text_light">#FFFFFF</color>
</resources> </resources>

View File

@ -1,7 +1,107 @@
<resources> <resources>
<style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar"> <style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item> <item name="android:fontFamily">sans-serif-medium</item>
<item name="android:windowBackground">@color/screen_bg</item> <item name="android:windowBackground">@color/screen_bg</item>
<item name="android:colorAccent">@color/accent</item> <item name="android:colorAccent">@color/accent</item>
<item name="android:statusBarColor">@color/accent_dark</item>
<item name="android:navigationBarColor">@color/screen_bg</item>
</style>
<style name="HeroEyebrow">
<item name="android:textColor">@color/hero_text_secondary</item>
<item name="android:textSize">12sp</item>
<item name="android:textStyle">bold</item>
<item name="android:letterSpacing">0.08</item>
<item name="android:textAllCaps">true</item>
</style>
<style name="ScreenTitle">
<item name="android:textColor">@color/hero_text</item>
<item name="android:textSize">30sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="HeroSubtitle">
<item name="android:textColor">@color/hero_text_secondary</item>
<item name="android:textSize">14sp</item>
<item name="android:lineSpacingExtra">2dp</item>
</style>
<style name="SectionHeading">
<item name="android:textColor">@color/text_primary</item>
<item name="android:textSize">19sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="SectionCaption">
<item name="android:textColor">@color/text_secondary</item>
<item name="android:textSize">13sp</item>
<item name="android:lineSpacingExtra">2dp</item>
</style>
<style name="CardLabel">
<item name="android:textColor">@color/text_secondary</item>
<item name="android:textSize">13sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="InfoPanelText">
<item name="android:background">@drawable/bg_info_panel</item>
<item name="android:padding">14dp</item>
<item name="android:textColor">@color/text_primary</item>
<item name="android:textSize">14sp</item>
<item name="android:lineSpacingExtra">3dp</item>
</style>
<style name="DialogInfoText">
<item name="android:paddingTop">8dp</item>
<item name="android:textColor">@color/text_primary</item>
<item name="android:textSize">14sp</item>
<item name="android:lineSpacingExtra">3dp</item>
</style>
<style name="BaseActionButton" parent="@android:style/Widget.Material.Button">
<item name="android:minHeight">48dp</item>
<item name="android:paddingLeft">16dp</item>
<item name="android:paddingTop">12dp</item>
<item name="android:paddingRight">16dp</item>
<item name="android:paddingBottom">12dp</item>
<item name="android:textSize">15sp</item>
<item name="android:textStyle">bold</item>
<item name="android:textAllCaps">false</item>
</style>
<style name="PrimaryActionButton" parent="@style/BaseActionButton">
<item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/button_text_light</item>
</style>
<style name="SecondaryActionButton" parent="@style/BaseActionButton">
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:textColor">@color/text_primary</item>
</style>
<style name="TonalActionButton" parent="@style/BaseActionButton">
<item name="android:background">@drawable/bg_button_tonal</item>
<item name="android:textColor">@color/accent_dark</item>
</style>
<style name="InputField" parent="@android:style/Widget.Material.EditText">
<item name="android:background">@drawable/bg_input</item>
<item name="android:minHeight">48dp</item>
<item name="android:paddingLeft">14dp</item>
<item name="android:paddingTop">12dp</item>
<item name="android:paddingRight">14dp</item>
<item name="android:paddingBottom">12dp</item>
<item name="android:textColor">@color/text_primary</item>
<item name="android:textColorHint">@color/text_hint</item>
<item name="android:textSize">14sp</item>
</style>
<style name="ToggleControl">
<item name="android:buttonTint">@color/accent</item>
<item name="android:textColor">@color/text_primary</item>
<item name="android:textSize">14sp</item>
<item name="android:minHeight">40dp</item>
</style> </style>
</resources> </resources>

View File

@ -1,47 +0,0 @@
package com.smsreceive.app;
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("【测试】验证码 1234565 分钟内有效。");
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);
}
}

View File

@ -1,4 +1,6 @@
package com.smsreceive.app; package com.smsreceive.app.feishu;
import com.smsreceive.app.sms.CaptureResult;
import org.json.JSONObject; import org.json.JSONObject;
import org.junit.Test; import org.junit.Test;
@ -56,11 +58,28 @@ public final class FeishuWebhookClientTest {
@Test @Test
public void pushMarkdownRejectsMissingConfigBeforeNetwork() { 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); FeishuWebhookPushResult result = FeishuWebhookClient.pushMarkdown(config, "test", 1717020800L);
assertFalse(result.success); assertFalse(result.success);
assertEquals(FeishuWebhookPushResult.STATUS_MISSING_CONFIG, result.status); 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"));
}
} }

View File

@ -18,12 +18,12 @@ allprojects {
} }
ext { ext {
minSdkVersion = 21 minSdkVersion = 23
compileSdkVersion = 30 compileSdkVersion = 30
targetSdkVersion = 30 targetSdkVersion = 30
buildToolsVersion = '30.0.3' buildToolsVersion = '30.0.3'
} }
task clean(type: Delete) { tasks.register('clean', Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@ -2,4 +2,3 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=false android.enableJetifier=false
android.injected.testOnly=false android.injected.testOnly=false
android.aapt2FromMavenOverride=/Users/zouchao/Library/Android/sdk/build-tools/30.0.3/aapt2

View File

@ -86,7 +86,7 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串
`BroadcastReceiver.onReceive` 生命周期短,不能同步执行网络请求。后续实现应采用: `BroadcastReceiver.onReceive` 生命周期短,不能同步执行网络请求。后续实现应采用:
- `FeishuWebhookClient.pushMarkdownAsync(...)` 提交到后台 `ExecutorService` - `FeishuWebhookClient.pushMarkdownAsync(...)` 提交到后台 `ExecutorService`
- `SmsReceiver`验证码解析成功、保存本地结果后,只触发异步推送,不等待网络结果。 - `SmsReceiver`短信原文读取成功、保存本地结果后,只触发异步推送,不等待网络结果。
- 推送结果写入 `SharedPreferences` 或轻量状态 store并通过本地广播刷新 UI。 - 推送结果写入 `SharedPreferences` 或轻量状态 store并通过本地广播刷新 UI。
- 若未来需要保证进程退出后仍发送,可另起 change 引入 WorkManager本轮不做。 - 若未来需要保证进程退出后仍发送,可另起 change 引入 WorkManager本轮不做。
@ -97,7 +97,6 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串
- `webhook_id` - `webhook_id`
- `secret` - `secret`
- `enabled` - `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 代码。 飞书 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. 推送内容策略 ### 6. 推送内容策略
验证码解析成功时,默认 markdown 内容建议包含: 短信原文读取成功时,默认 markdown 内容建议包含:
- 验证码:解析出的 code - 短信原文
- 来源:`system_sms_broadcast` / `sms_inbox_*` - 来源:`system_sms_broadcast` / `sms_inbox_*`
- 发送方:掩码后的 sender。 - 发送方:掩码后的 sender。
- 时间:本地格式化时间。 - 时间:本地格式化时间。
- 解析策略和置信度:便于诊断 - 如存在读取异常,附带失败摘要
默认包含完整短信原文,便于远端调试确认。仍不建议在 logcat 打印完整正文。 默认包含完整短信原文,便于远端调试确认。仍不建议在 logcat 打印完整正文。
@ -143,11 +142,11 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串
## Privacy Boundaries ## Privacy Boundaries
- 默认仅上传验证码和必要诊断摘要。 - 默认上传短信原文和必要诊断摘要。
- 飞书推送默认包含完整短信正文;开启推送即表示允许验证码短信离开设备。 - 飞书推送默认包含完整短信正文;开启推送即表示允许短信内容离开设备。
- 不上传联系人通讯录、短信历史或设备标识。 - 不上传联系人通讯录、短信历史或设备标识。
- 本地保存 secret 时使用 `SharedPreferences` 只满足个人调试场景;如需更严格保护,应另起 change 使用 Android Keystore 加密。 - 本地保存 secret 时使用 `SharedPreferences` 只满足个人调试场景;如需更严格保护,应另起 change 使用 Android Keystore 加密。
- 推送到飞书意味着验证码会离开设备UI 应让用户明确知道远端推送已开启。 - 推送到飞书意味着短信内容会离开设备UI 应让用户明确知道远端推送已开启。
## Test Strategy ## Test Strategy
@ -168,7 +167,7 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串
- 使用真实飞书机器人确认收到 markdown 卡片。 - 使用真实飞书机器人确认收到 markdown 卡片。
- 故意填错 secret确认 UI 显示飞书业务错误而不是短信接收失败。 - 故意填错 secret确认 UI 显示飞书业务错误而不是短信接收失败。
- 断网或飞行模式下测试 `network_error`/`timeout` - 断网或飞行模式下测试 `network_error`/`timeout`
- 收到真实验证码短信后,确认本地结果先保存,远端推送状态独立更新。 - 收到真实短信后,确认本地结果先保存,远端推送状态独立更新。
## Rollout Plan ## Rollout Plan
@ -177,12 +176,12 @@ Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串
3. 实现签名、请求体构造、响应解析和异步发送模块。 3. 实现签名、请求体构造、响应解析和异步发送模块。
4. 增加 JSON 配置 store 和最近推送状态。 4. 增加 JSON 配置 store 和最近推送状态。
5. 在 `MainActivity` 增加配置、测试发送和状态展示。 5. 在 `MainActivity` 增加配置、测试发送和状态展示。
6. 将验证码解析成功后的推送接入异步链路。 6. 将短信原文读取成功后的推送接入异步链路。
7. 增加聚焦单元测试,不要求本轮编译。 7. 增加聚焦单元测试,不要求本轮编译。
## Open Questions ## Open Questions
- webhook id 和 secret 是否只用于本机调试,还是需要后续提供导入/导出配置。 - webhook id 和 secret 是否只用于本机调试,还是需要后续提供导入/导出配置。
- 默认推送内容是否只发验证码,还是需要包含发送方掩码和解析策略 - 默认推送内容是否只发短信原文,还是需要包含发送方掩码和来源等诊断摘要
- 是否需要推送所有收到的字符串,还是只在验证码解析成功时推送。 - 是否需要推送所有收到的字符串,还是只在短信原文读取成功时推送。
- 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。 - 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。

View File

@ -1,6 +1,6 @@
## Why ## 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`、手动测试按钮或其它业务入口调用。 这次变更的重点不是重新设计服务端协议,而是把 Python 中已经验证过的请求协议等价迁移到 Android并与当前短信接收链路解耦。Android 侧应提供一个可测试、可诊断、可复用的发送模块,后续可以由 `SmsReceiver`、手动测试按钮或其它业务入口调用。
@ -19,8 +19,8 @@
- 实际配置从 `/sdcard/Android/data/{applicationId}/config/feishu.json` 读取,便于动态调试。 - 实际配置从 `/sdcard/Android/data/{applicationId}/config/feishu.json` 读取,便于动态调试。
- 如果 `feishu.json` 不存在,则生成默认模板 `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json` - 如果 `feishu.json` 不存在,则生成默认模板 `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json`
- 后续实现可接入当前短信结果: - 后续实现可接入当前短信结果:
- 成功解析验证码后,可以把验证码摘要、来源、时间和发送方掩码作为 markdown 内容推送。 - 成功读取短信原文后,可以把短信内容、来源、时间和发送方掩码作为 markdown 内容推送。
- 按调试需求推送完整短信原文,便于确认服务端收到的内容与本机短信一致。 - 默认推送完整短信原文,便于确认服务端收到的内容与本机短信一致。
## Capabilities ## Capabilities
@ -30,7 +30,7 @@
### Modified Capabilities ### Modified Capabilities
- `sms-code-capture`: 后续实现可以在验证码解析成功后触发推送,但短信捕获本身不依赖网络成功。 - `sms-code-capture`: 后续实现可以在短信原文读取成功后触发推送,但短信捕获本身不依赖网络成功。
- `sms-receiver-delivery-diagnostics`: 后续诊断应区分“短信接收/解析成功”和“远端推送成功/失败”,避免把网络失败误判为短信接收失败。 - `sms-receiver-delivery-diagnostics`: 后续诊断应区分“短信接收/解析成功”和“远端推送成功/失败”,避免把网络失败误判为短信接收失败。
## Impact ## Impact
@ -40,14 +40,14 @@
- `app/src/main/AndroidManifest.xml`:新增 `android.permission.INTERNET` - `app/src/main/AndroidManifest.xml`:新增 `android.permission.INTERNET`
- `app/src/main/java/com/smsreceive/app/`:新增飞书推送相关 Java 类,例如 `FeishuWebhookClient``FeishuWebhookConfigStore``FeishuWebhookPushResult` - `app/src/main/java/com/smsreceive/app/`:新增飞书推送相关 Java 类,例如 `FeishuWebhookClient``FeishuWebhookConfigStore``FeishuWebhookPushResult`
- `MainActivity`:新增 JSON 配置路径展示、配置输入、测试发送和最近推送结果展示。 - `MainActivity`:新增 JSON 配置路径展示、配置输入、测试发送和最近推送结果展示。
- `SmsReceiver` 或统一结果处理层:在验证码解析成功后触发异步推送。 - `SmsReceiver` 或统一结果处理层:在短信原文读取成功后触发异步推送。
- 推荐技术选择: - 推荐技术选择:
- 网络库OkHttp。 - 网络库OkHttp。
- JSON 构造/解析:优先使用 Android 自带 `org.json`,降低依赖数量。 - JSON 构造/解析:优先使用 Android 自带 `org.json`,降低依赖数量。
- 签名:`javax.crypto.Mac` + `SecretKeySpec` + `android.util.Base64.NO_WRAP` - 签名:`javax.crypto.Mac` + `SecretKeySpec` + `android.util.Base64.NO_WRAP`
- 异步执行:小型 `ExecutorService` 或现有轻量后台执行器,不引入协程/RxJava。 - 异步执行:小型 `ExecutorService` 或现有轻量后台执行器,不引入协程/RxJava。
- 隐私影响: - 隐私影响:
- 默认推送验证码摘要、来源、发送方掩码、时间和短信原文 - 默认推送短信原文、来源、发送方掩码和时间
- 不在 logcat 打印 secret、sign、完整 webhook URL 或完整短信正文。 - 不在 logcat 打印 secret、sign、完整 webhook URL 或完整短信正文。
## Validation ## Validation

View File

@ -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. The app SHALL avoid blocking UI and SMS broadcast handling while sending webhook requests.
#### Scenario: SMS receiver triggers a push #### 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 - **THEN** the app MUST save the local capture result first
- **AND** it MUST dispatch the webhook push asynchronously - **AND** it MUST dispatch the webhook push asynchronously
- **AND** it MUST NOT wait for the network request before returning from broadcast handling - **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 - **THEN** it MUST mask the secret
- **AND** it MUST NOT log the generated signature or full webhook URL - **AND** it MUST NOT log the generated signature or full webhook URL
#### Scenario: Verification code push content is built #### Scenario: SMS push content is built
- **WHEN** the app builds markdown content from a parsed SMS result - **WHEN** the app builds markdown content from an SMS capture result
- **THEN** it MUST include the verification code and minimal diagnostics such as source, masked sender, time, strategy, and confidence - **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
- **AND** it MUST include the full original SMS body
#### Scenario: Webhook push is disabled #### 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 - **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 - **AND** it MUST record or report that remote push was skipped because it is disabled

View File

@ -30,7 +30,7 @@
## 5. Config And State ## 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.2 新增最近推送状态,包含时间、成功状态、错误类型和错误摘要
- [x] 5.3 UI 展示 secret 时使用掩码 - [x] 5.3 UI 展示 secret 时使用掩码
- [x] 5.4 默认推送完整短信原文,便于远端调试 - [x] 5.4 默认推送完整短信原文,便于远端调试
@ -46,10 +46,10 @@
## 7. SMS Flow Integration ## 7. SMS Flow Integration
- [x] 7.1 验证码解析成功后构造默认 markdown 摘要 - [x] 7.1 短信原文读取成功后构造默认 markdown 摘要
- [x] 7.2 `SmsReceiver` 保存本地结果后触发异步推送 - [x] 7.2 `SmsReceiver` 保存本地结果后触发异步推送
- [x] 7.3 推送失败不影响本地短信捕获、解析和 UI 刷新 - [x] 7.3 推送失败不影响本地短信捕获、解析和 UI 刷新
- [x] 7.4 默认推送内容包含验证码、来源、发送方掩码、时间、解析摘要和短信原文 - [x] 7.4 默认推送内容包含短信原文、来源、发送方掩码和时间
## 8. Tests ## 8. Tests

View File

@ -1,6 +1,6 @@
## Context ## 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。 目标设备是小米 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. 系统短信广播仍是主路径 ### 1. 系统短信广播仍是主路径
短信进入设备时,主路径仍是 `SMS_RECEIVED_ACTION`。这是最直接的验证码捕获路径,但它依赖: 短信进入设备时,主路径仍是 `SMS_RECEIVED_ACTION`。这是最直接的短信原文捕获路径,但它依赖:
- 应用真正持有 `RECEIVE_SMS` - 应用真正持有 `RECEIVE_SMS`
- 应用未被用户 force-stop。 - 应用未被用户 force-stop。
@ -129,7 +129,7 @@ Android 标准能力:
2. 发送短信时抓 logcat `SmsReceive` 2. 发送短信时抓 logcat `SmsReceive`
3. 如果 receiver 无日志,点“读取最新短信”确认短信是否入库。 3. 如果 receiver 无日志,点“读取最新短信”确认短信是否入库。
4. 如果短信已入库但 receiver 无日志重点排查权限、force-stop、HyperOS 自启动和省电。 4. 如果短信已入库但 receiver 无日志重点排查权限、force-stop、HyperOS 自启动和省电。
5. 如果 receiver 有日志但无验证码,排查 PDU/body/parser 5. 如果 receiver 有日志但无短信结果,排查 PDU/body/读取链路
## Data Model ## Data Model

View File

@ -1,6 +1,6 @@
## Why ## 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 人工设置、短信广播兜底路径和可验证诊断组合起来。用户也会手动在小米设置中开启“自启动”和“省电策略-无限制”,因此方案应显式利用这个前提。 这次需求的重点不是规避系统限制,而是在个人自用 sideload/debug app 的边界内,把 Android 官方后台机制、HyperOS 人工设置、短信广播兜底路径和可验证诊断组合起来。用户也会手动在小米设置中开启“自启动”和“省电策略-无限制”,因此方案应显式利用这个前提。

View File

@ -46,7 +46,7 @@
- [x] 6.1 记录最近一次 `system_sms_broadcast` 到达时间 - [x] 6.1 记录最近一次 `system_sms_broadcast` 到达时间
- [x] 6.2 记录最近一次 `sms_inbox_observer``sms_inbox_manual``sms_inbox_polling` 命中时间 - [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.4 在 UI 中列出 `onReceive` 不触发的排查清单
- [x] 6.5 增加 logcat 输出区分权限缺失、body 为空、parser 失败、广播未到达 - [x] 6.5 增加 logcat 输出区分权限缺失、body 为空、parser 失败、广播未到达
@ -62,15 +62,15 @@
- [ ] 8.1 为 `KeepAliveStateStore` 增加状态读写测试 - [ ] 8.1 为 `KeepAliveStateStore` 增加状态读写测试
- [ ] 8.2 为 Boot action 处理逻辑增加单元测试 - [ ] 8.2 为 Boot action 处理逻辑增加单元测试
- [ ] 8.3 为设置 intent fallback 增加测试或可验证日志 - [ ] 8.3 为设置 intent fallback 增加测试或可验证日志
- [x] 8.4 保持现有验证码解析测试通过 - [x] 8.4 保持现有短信接收与结果展示测试通过
- [x] 8.5 不要求本轮编译;代码完成后再按用户要求通知 - [x] 8.5 不要求本轮编译;代码完成后再按用户要求通知
## 9. Xiaomi 12S / HyperOS 3 Device Validation ## 9. Xiaomi 12S / HyperOS 3 Device Validation
- [ ] 9.1 手动开启小米自启动 - [ ] 9.1 手动开启小米自启动
- [ ] 9.2 手动设置省电策略为无限制 - [ ] 9.2 手动设置省电策略为无限制
- [ ] 9.3 开启常驻通知,后台 30 分钟后发送验证码短信 - [ ] 9.3 开启常驻通知,后台 30 分钟后发送测试短信
- [ ] 9.4 锁屏 30 分钟后发送验证码短信 - [ ] 9.4 锁屏 30 分钟后发送测试短信
- [ ] 9.5 重启手机,确认保活服务是否自动恢复 - [ ] 9.5 重启手机,确认保活服务是否自动恢复
- [ ] 9.6 重启后未打开 app 直接发送第一条短信,记录广播是否到达 - [ ] 9.6 重启后未打开 app 直接发送第一条短信,记录广播是否到达
- [ ] 9.7 手动 force-stop 后发送短信,确认不承诺接收,并记录诊断表现 - [ ] 9.7 手动 force-stop 后发送短信,确认不承诺接收,并记录诊断表现

View File

@ -2,23 +2,23 @@
当前 `SmsReceive` 目录几乎为空,只有 macOS 生成的 `.DS_Store`,没有 Android 工程和既有 OpenSpec 目录。用户要求先生成完整 spec 方案,再开始编码;同时明确 Android Studio、Gradle、JDK 等环境不在本次方案范围内,后续实现要参考 `Weather reference project` 的构建环境。 当前 `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 判断如下: 官方 API 判断如下:
- Android `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` 是收到文本短信的系统广播,需要 `RECEIVE_SMS` 权限。它是读取任意短信验证码最直接的路径。 - Android `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` 是收到文本短信的系统广播,需要 `RECEIVE_SMS` 权限。它是读取任意短信原文最直接的路径。
- Google `SMS Retriever API` 不需要 `READ_SMS``RECEIVE_SMS`,但短信必须包含 app hash适合服务端短信模板可控的手机号验证,不适合读取所有第三方验证码 - Google `SMS Retriever API` 不需要 `READ_SMS``RECEIVE_SMS`,但短信必须包含 app hash适合服务端短信模板可控的场景,不适合读取所有第三方短信
- Google `SMS User Consent API` 可以请求用户授权读取单条包含验证码的短信,不要求 app hash但需要弹出用户确认适合作为受限权限或广播异常时的对比验证路径。 - Google `SMS User Consent API` 可以请求用户授权读取单条短信,不要求 app hash但需要弹出用户确认适合作为受限权限或广播异常时的对比验证路径。
## Goals / Non-Goals ## Goals / Non-Goals
**Goals:** **Goals:**
- 先形成可执行 spec不直接写业务代码。 - 先形成可执行 spec不直接写业务代码。
- 建立 Android 15 上读取验证码短信的主路径和备选路径。 - 建立 Android 15 上读取短信原文的主路径和备选路径。
- 明确验证码解析、权限状态、诊断状态和真机验证标准。 - 明确短信原文展示、权限状态、诊断状态和真机验证标准。
- 后续实现应保持最小化:一个主界面、一个接收链路、一组诊断信息,不做复杂产品化。 - 后续实现应保持最小化:一个主界面、一个接收链路、一组诊断信息,不做复杂产品化。
- 保持短信内容本地处理,默认只展示验证码、来源、时间和短诊断摘要。 - 保持短信内容本地处理,默认展示短信原文、来源、时间和短诊断摘要。
**Non-Goals:** **Non-Goals:**
@ -26,13 +26,13 @@
- 不实现发送短信、删除短信、读取历史短信库或同步短信到云端。 - 不实现发送短信、删除短信、读取历史短信库或同步短信到云端。
- 不处理 Android Studio、Gradle、JDK 的重新安装和环境拉取。 - 不处理 Android Studio、Gradle、JDK 的重新安装和环境拉取。
- 不以 Google Play 上架合规作为约束目标。 - 不以 Google Play 上架合规作为约束目标。
- 不保证所有银行、平台、运营商验证码都能被无条件读取;必须通过真机验证确认。 - 不保证所有银行、平台、运营商短信都能被无条件读取;必须通过真机验证确认。
## Decisions ## Decisions
### Decision 1: 主路径使用 `SMS_RECEIVED_ACTION` + `RECEIVE_SMS` ### 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 6.0+ 执行运行时权限申请。
- 注册接收 `android.provider.Telephony.SMS_RECEIVED` 的 receiver。 - 注册接收 `android.provider.Telephony.SMS_RECEIVED` 的 receiver。
- receiver 内只做轻量解析和状态分发,避免长耗时。 - receiver 内只做轻量解析和状态分发,避免长耗时。
- 记录最近一次接收时间、sender、body 摘要、取结果和失败原因。 - 记录最近一次接收时间、sender、body 摘要、取结果和失败原因。
备选方案: 备选方案:
- `READ_SMS` 可读取短信数据库,但需求是监听新验证码,不需要读取历史短信;默认不纳入主路径。 - `READ_SMS` 可读取短信数据库,但需求是监听新短信,不需要读取历史短信;默认不纳入主路径。
- 默认短信应用角色权限更强,但目标不是做短信客户端;不作为一期要求。 - 默认短信应用角色权限更强,但目标不是做短信客户端;不作为一期要求。
### Decision 2: 备选验证路径引入 `SMS User Consent API` ### Decision 2: 备选验证路径引入 `SMS User Consent API`
@ -52,29 +52,28 @@
`SMS User Consent API` 用于验证两类问题: `SMS User Consent API` 用于验证两类问题:
- 当系统广播路径在 HyperOS 上被后台策略影响时,前台触发 consent flow 是否能拿到单条短信。 - 当系统广播路径在 HyperOS 上被后台策略影响时,前台触发 consent flow 是否能拿到单条短信。
- 当用户不愿或系统不允许直接授予短信权限时,是否仍能通过一次性确认读取验证码 - 当用户不愿或系统不允许直接授予短信权限时,是否仍能通过一次性确认读取短信原文
限制: 限制:
- 它不是静默读取,需要用户确认。 - 它不是静默读取,需要用户确认。
- 它适合前台验证流程,不适合后台长期监听所有验证码 - 它适合前台验证流程,不适合后台长期监听所有短信
- 它依赖 Google Play services国内 ROM 环境下需要确认设备实际可用性。 - 它依赖 Google Play services国内 ROM 环境下需要确认设备实际可用性。
### Decision 3: `SMS Retriever API` 只作为受控短信模板能力 ### 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 位数字或字母数字。 1. 优先保证 PDU 合并后的短信正文完整。
2. 次级匹配短信中独立出现的 4-8 位数字,排除明显日期、手机号片段、金额和订单号。 2. 记录 sender、timestamp、source 和 body 摘要。
3. 对带空格或短横线的验证码做归一化,例如 `12 34 56``123-456` 3. 读取失败时保留失败原因,例如无权限、未收到广播、正文为空。
4. 若多个候选值并存,选择距离关键词最近、长度在 4-6 位优先、出现位置更靠前的候选。 4. 默认展示最近一条短信原文,不额外推断正文中的业务字段。
5. 解析失败时保留失败原因,不展示完整正文。
### Decision 5: UI 首版只做诊断型工具界面 ### Decision 5: UI 首版只做诊断型工具界面
@ -83,17 +82,16 @@
- 当前短信权限状态。 - 当前短信权限状态。
- 主路径 receiver 状态。 - 主路径 receiver 状态。
- Google Play services / SMS User Consent 可用性。 - Google Play services / SMS User Consent 可用性。
- 最近一次收到短信的时间、发送方、验证码、解析策略命中类型 - 最近一次收到短信的时间、发送方、来源和短信原文
- 最近失败原因,例如无权限、未收到广播、正文为空、未找到验证码、API timeout。 - 最近失败原因例如无权限、未收到广播、正文为空、API timeout。
- 手动清空最近结果按钮。 - 手动清空最近结果按钮。
### Decision 6: 本地隐私边界 ### Decision 6: 本地隐私边界
即使是自用 app不应默认保存完整短信正文。建议: 即使是自用 app应尽量控制短信内容扩散。建议:
- 内存中可短暂保留最近一条完整正文用于调试开关。 - 默认只持久化最近一条短信原文、时间、sender 摘要和读取状态。
- 默认持久化只保存验证码、时间、sender 摘要和解析状态。 - 核心能力不以网络上传为前提。
- 不做网络上传。
- 日志避免输出完整短信正文debug 模式如需输出,必须集中开关控制。 - 日志避免输出完整短信正文debug 模式如需输出,必须集中开关控制。
## Android 15 And HyperOS Risk Analysis ## Android 15 And HyperOS Risk Analysis
@ -117,7 +115,7 @@
2. 参考 Weather 项目创建或复制最小 Android 构建骨架。 2. 参考 Weather 项目创建或复制最小 Android 构建骨架。
3. 实现权限和诊断 UI。 3. 实现权限和诊断 UI。
4. 实现系统短信广播主路径。 4. 实现系统短信广播主路径。
5. 实现验证码解析器和单元测试 5. 实现短信读取结果存储和展示逻辑
6. 在小米 12S 上跑真机验证。 6. 在小米 12S 上跑真机验证。
7. 根据真机结果决定是否补 `SMS User Consent API` 或后台稳定性处理。 7. 根据真机结果决定是否补 `SMS User Consent API` 或后台稳定性处理。
@ -136,14 +134,14 @@ Rollback 策略:
代码验证: 代码验证:
- 验证码解析器单元测试覆盖中文、英文、空格、短横线、多候选、无验证码样本。 - 短信读取与结果存储测试覆盖多段短信、正文为空、权限缺失和最近结果刷新样本。
- receiver 解析逻辑可通过构造 Intent/PDU 或抽象 message input 测试核心逻辑。 - receiver 解析逻辑可通过构造 Intent/PDU 或抽象 message input 测试核心逻辑。
- 权限状态和诊断状态可通过 ViewModel/unit test 验证。 - 权限状态和诊断状态可通过 ViewModel/unit test 验证。
真机验证: 真机验证:
- 前台打开应用后,向目标号码发送测试短信:`【测试】验证码 1234565 分钟内有效。` - 前台打开应用后,向目标号码发送测试短信:`【测试】这是一条短信原文样本。`
- 应用退到后台后,重复发送不同验证码 - 应用退到后台后,重复发送不同短信正文
- 锁屏状态下发送短信,解锁后检查最近结果。 - 锁屏状态下发送短信,解锁后检查最近结果。
- 若可以控制短信格式,发送带 app hash 的 SMS Retriever 测试短信。 - 若可以控制短信格式,发送带 app hash 的 SMS Retriever 测试短信。
- 若广播路径失败,打开前台 consent flow 再发送短信,观察是否弹出授权并读取正文。 - 若广播路径失败,打开前台 consent flow 再发送短信,观察是否弹出授权并读取正文。
@ -152,5 +150,5 @@ Rollback 策略:
- 目标小米 12S 当前是否安装并启用了 Google Play services。 - 目标小米 12S 当前是否安装并启用了 Google Play services。
- 用户是否接受为了后台稳定性关闭 HyperOS 对该 app 的省电限制。 - 用户是否接受为了后台稳定性关闭 HyperOS 对该 app 的省电限制。
- 首版是否需要常驻通知显示最近验证码,还是只在 app 内展示。 - 首版是否需要常驻通知显示最近短信结果,还是只在 app 内展示。
- 是否需要支持验证码自动复制到剪贴板;这会带来额外隐私和系统提示问题,建议先不做。 - 是否需要支持短信原文快捷复制;这会带来额外隐私和系统提示问题,建议先不做。

View File

@ -1,32 +1,32 @@
## Why ## Why
你想做的不是完整短信客户端,而是一个只服务自己手机的验证码接收工具:在小米 12S、澎湃 OS 3、Android 15 上尽可能可靠地读取新收到短信里的验证码,并把关键结果快速展示出来。 你想做的不是完整短信客户端,而是一个只服务自己手机的短信接收工具:在小米 12S、澎湃 OS 3、Android 15 上尽可能可靠地读取新收到短信的原文,并把关键结果快速展示出来。
这类需求的关键不在 UI而在 Android 15 与厂商系统对短信权限、广播分发、后台限制和验证码短信格式的真实行为。因此必须先把可用 API、兜底路径和真机验证方案写清楚再进入编码。 这类需求的关键不在 UI而在 Android 15 与厂商系统对短信权限、广播分发、后台限制和短信原文读取链路的真实行为。因此必须先把可用 API、兜底路径和真机验证方案写清楚再进入编码。
## What Changes ## What Changes
- 新建一个 Android App 规格方案,目标能力限定为“接收短信验证码、解析验证码、展示最近结果、输出诊断状态”。 - 新建一个 Android App 规格方案,目标能力限定为“接收短信原文、展示最近结果、输出诊断状态”。
- 明确三条可用 SMS 获取路径,并按优先级落地: - 明确三条可用 SMS 获取路径,并按优先级落地:
- `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` + `RECEIVE_SMS`:主路径,适合个人自用、非 Play 上架场景,直接读取收到短信内容。 - `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` + `RECEIVE_SMS`:主路径,适合个人自用、非 Play 上架场景,直接读取收到短信内容。
- `SMS User Consent API`:备选路径,不要求短信带 app hash但需要用户对单条短信授权适合验证系统广播被限制时的可行性。 - `SMS User Consent API`:备选路径,不要求短信带 app hash但需要用户对单条短信授权适合验证系统广播被限制时的可行性。
- `SMS Retriever API`:受控格式路径,不需要短信权限,但要求验证码短信包含当前 app 的 hash更适合服务端可控验证码不适合作为读取任意平台验证码的主路径。 - `SMS Retriever API`:受控格式路径,不需要短信权限,但要求测试短信包含当前 app 的 hash更适合服务端可控短信模板不适合作为读取任意平台短信的主路径。
- 明确不把 Android Studio、Gradle、JDK 环境初始化纳入本次工作,后续实现时参考已有 `Weather` 项目的构建环境。 - 明确不把 Android Studio、Gradle、JDK 环境初始化纳入本次工作,后续实现时参考已有 `Weather` 项目的构建环境。
- 设计验证码解析策略,支持常见 4-8 位数字码、中文验证码文案、带空格/短横线的验证码,以及短信多段 PDU 合并后的正文 - 设计短信原文处理和展示策略,支持短信多段 PDU 合并后的完整正文展示、最近结果存储和诊断摘要
- 设计真机验证路径,重点验证 Android 15 + HyperOS 3 上: - 设计真机验证路径,重点验证 Android 15 + HyperOS 3 上:
- 运行时申请 `RECEIVE_SMS` 是否成功。 - 运行时申请 `RECEIVE_SMS` 是否成功。
- 应用在前台/后台时 `SMS_RECEIVED_ACTION` 是否触发。 - 应用在前台/后台时 `SMS_RECEIVED_ACTION` 是否触发。
- 短信正文是否能被解析为完整 message body。 - 短信正文是否能被解析为完整 message body。
- 常见短信验证码是否能稳定提取。 - 常见短信原文是否能稳定读取。
- 建立诊断能力暴露权限状态、API 路径状态、最近一次广播时间、最近一次解析结果和失败原因。 - 建立诊断能力暴露权限状态、API 路径状态、最近一次广播时间、最近一次读取结果和失败原因。
## Capabilities ## Capabilities
### New Capabilities ### New Capabilities
- `sms-code-capture`: 定义应用通过系统短信广播、Google SMS 验证 API 备选路径接收短信正文并抽取验证码的行为要求。 - `sms-code-capture`: 定义应用通过系统短信广播、Google SMS 验证 API 备选路径接收短信正文并保留原始内容的行为要求。
- `sms-permission-diagnostics`: 定义应用对短信权限、接收状态、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 ### Modified Capabilities
@ -35,14 +35,14 @@
## Impact ## Impact
- 预期后续会创建一个最小 Android 工程或复用 Weather 工程环境生成同类 Android 工程配置。 - 预期后续会创建一个最小 Android 工程或复用 Weather 工程环境生成同类 Android 工程配置。
- 预期会影响 Android Manifest、运行时权限申请、BroadcastReceiver、短信 PDU 解析、验证码正则解析、前台诊断 UI 和测试用例。 - 预期会影响 Android Manifest、运行时权限申请、BroadcastReceiver、短信 PDU 解析、最近结果展示、前台诊断 UI 和测试用例。
- 需要引入或使用的主要 Android/Google API - 需要引入或使用的主要 Android/Google API
- `android.provider.Telephony.Sms.Intents.SMS_RECEIVED_ACTION` - `android.provider.Telephony.Sms.Intents.SMS_RECEIVED_ACTION`
- `android.permission.RECEIVE_SMS` - `android.permission.RECEIVE_SMS`
- `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` - `Telephony.Sms.Intents.getMessagesFromIntent(Intent)`
- `com.google.android.gms.auth.api.phone.SmsRetriever` - `com.google.android.gms.auth.api.phone.SmsRetriever`
- `SMS User Consent API` - `SMS User Consent API`
- 不以 Play Store 上架合规为目标,因此可以使用短信权限;但实现仍必须本地化处理短信内容,不上传、不持久化完整短信正文,减少隐私风险 - 不以 Play Store 上架合规为目标,因此可以使用短信权限;但实现仍必须以本地读取、展示和诊断为主线,减少不必要的短信内容扩散
## Validation ## Validation
@ -50,4 +50,4 @@
- API 方案必须明确主路径、备选路径、适用条件和限制,不写成泛泛而谈的短信读取方案。 - API 方案必须明确主路径、备选路径、适用条件和限制,不写成泛泛而谈的短信读取方案。
- 设计必须说明 Android 15、HyperOS 3、个人自用 sideload/debug 场景下的权限与后台行为风险。 - 设计必须说明 Android 15、HyperOS 3、个人自用 sideload/debug 场景下的权限与后台行为风险。
- 后续实现前必须能从任务列表直接进入编码,不再需要重新讨论核心架构。 - 后续实现前必须能从任务列表直接进入编码,不再需要重新讨论核心架构。
- 后续实现完成后,必须在目标真机上完成至少一轮短信接收、解析和诊断验证。 - 后续实现完成后,必须在目标真机上完成至少一轮短信接收、展示和诊断验证。

View File

@ -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 - **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 - **THEN** the app MUST report that the primary SMS capture path is blocked by missing permission
### Requirement: Parse complete SMS message bodies ### Requirement: Preserve complete SMS message bodies
The app SHALL parse SMS bodies using Android SMS message APIs rather than ad hoc PDU handling in business logic. 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 #### Scenario: Multi-part SMS is received
- **WHEN** the received intent contains multiple SMS message segments - **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 #### Scenario: Sender and timestamp are available
- **WHEN** Android exposes sender address or timestamp for the SMS message - **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 - **THEN** the app MUST attach those values to the capture result for diagnostics and display
### Requirement: Extract verification code candidates ### Requirement: Surface the original SMS content as the primary result
The app SHALL extract verification code candidates from SMS bodies with a conservative parser optimized for common Chinese and English verification messages. The app SHALL use the original SMS body as the primary display and diagnostic result.
#### Scenario: Chinese verification keyword is present #### Scenario: SMS body is available
- **WHEN** the SMS body contains a keyword such as `验证码``校验码` or `动态码` near a 4-8 character code - **WHEN** the app reads an SMS body successfully
- **THEN** the app MUST extract the nearby code as the preferred verification code candidate - **THEN** the app MUST retain the original SMS body for local display and diagnostics
#### Scenario: English verification keyword is present #### Scenario: SMS body is empty
- **WHEN** the SMS body contains a keyword such as `code`, `verification` or `OTP` near a 4-8 character code - **WHEN** the app reads an SMS message but the body is empty
- **THEN** the app MUST extract the nearby code as the preferred verification code candidate - **THEN** the app MUST return a structured failure instead of inventing a display value
#### Scenario: Code contains spaces or hyphens #### Scenario: Capture result is displayed
- **WHEN** the SMS body contains a verification code formatted with spaces or hyphens - **WHEN** the app shows the latest SMS capture result
- **THEN** the app MUST normalize the code before displaying it - **THEN** it MUST display the original SMS content, sender summary, source, timestamp, and any structured failure reason
#### 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
### Requirement: Support optional Google SMS verification APIs ### 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. 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 #### Scenario: SMS User Consent path is available
- **WHEN** Google Play services supports SMS User Consent and the user authorizes reading a single SMS - **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 #### Scenario: SMS Retriever path is used with app hash
- **WHEN** a controlled test SMS includes the app hash required by SMS Retriever - **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 #### Scenario: Google SMS API is unavailable
- **WHEN** Google Play services is missing, disabled, incompatible, times out, or the user declines consent - **WHEN** Google Play services is missing, disabled, incompatible, times out, or the user declines consent

View File

@ -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. 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 #### Scenario: Foreground validation
- **WHEN** the app is open in the foreground and a test SMS containing `验证码 123456` is received - **WHEN** the app is open in the foreground and a test SMS containing `这是一条短信原文样本` is received
- **THEN** the app MUST show `123456` as the latest parsed verification code - **THEN** the app MUST show that SMS body as the latest received SMS content
#### Scenario: Background validation #### Scenario: Background validation
- **WHEN** the app has been moved to the background and a test SMS containing a new verification code is received - **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 the new code after returning to the app or report that background delivery was blocked - **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 #### Scenario: Lock screen validation
- **WHEN** the device is locked and a test SMS is received - **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 - **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 ### Requirement: Validate SMS body handling with representative samples
The verification parser SHALL be validated with representative verification SMS examples and negative examples. The SMS body handling logic SHALL be validated with representative SMS examples and failure examples.
#### Scenario: Common valid samples #### Scenario: Common valid samples
- **WHEN** parser tests include Chinese verification codes, English OTP messages, space-separated codes, hyphen-separated codes, and multiple candidates - **WHEN** tests include Chinese notices, English notification-like messages, multi-part messages, and regular promotional text
- **THEN** all expected verification codes MUST be extracted with the correct normalized value - **THEN** all expected SMS bodies MUST be preserved with the correct original content
#### Scenario: Negative samples #### Scenario: Failure samples
- **WHEN** parser tests include messages with phone numbers, dates, money amounts, tracking numbers, or no verification code - **WHEN** tests include empty bodies, unreadable message input, or missing permission states
- **THEN** the parser MUST avoid returning a false verification code unless a stronger keyword-nearby rule applies - **THEN** the app MUST return a structured failure instead of a fabricated SMS result
### Requirement: Validate optional API assumptions separately ### Requirement: Validate optional API assumptions separately
The app SHALL validate Google SMS APIs independently from the primary system broadcast path. The app SHALL validate Google SMS APIs independently from the primary system broadcast path.

View File

@ -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. The app SHALL expose diagnostic state for each supported SMS capture path.
#### Scenario: Primary path receives an SMS #### Scenario: Primary path receives an SMS
- **WHEN** the system broadcast path receives and parses an SMS - **WHEN** the system broadcast path receives and processes an SMS
- **THEN** the app MUST show the latest receive time, source path, sender summary, parsed code, and parse strategy - **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 #### Scenario: Primary path fails before body capture completes
- **WHEN** the app cannot receive or parse an SMS through the primary path - **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 parser failure - **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 #### Scenario: Optional Google API path fails
- **WHEN** SMS User Consent or SMS Retriever cannot complete - **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 ### Requirement: Avoid unnecessary SMS content retention
The app SHALL minimize retention and logging of full SMS content. The app SHALL minimize retention and logging of full SMS content.
#### Scenario: Verification code is parsed successfully #### Scenario: Recent SMS result is stored for display
- **WHEN** the app extracts a verification code from an SMS - **WHEN** the app stores the latest SMS capture result
- **THEN** the app MUST display or retain the code, sender summary, timestamp, and parse metadata without requiring persistent storage of the full SMS body - **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 #### Scenario: Full body visibility is shown in diagnostics
- **WHEN** a debug-only setting enables full body visibility - **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 normal display state - **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 ### Requirement: Provide recovery actions for permission problems
The app SHALL provide a clear recovery path when Android or HyperOS blocks SMS capture permissions. The app SHALL provide a clear recovery path when Android or HyperOS blocks SMS capture permissions.

View File

@ -10,7 +10,7 @@
- [x] 2.1 基于 Weather 项目环境建立最小 Android app 工程骨架 - [x] 2.1 基于 Weather 项目环境建立最小 Android app 工程骨架
- [x] 2.2 设置包名、minSdk、targetSdk、compileSdk并保持与现有可用环境兼容 - [x] 2.2 设置包名、minSdk、targetSdk、compileSdk并保持与现有可用环境兼容
- [x] 2.3 创建单 Activity 工具型界面,用于权限申请、状态展示和最近验证码展示 - [x] 2.3 创建单 Activity 工具型界面,用于权限申请、状态展示和最近短信原文展示
- [x] 2.4 建立基础日志标签和 debug 开关 - [x] 2.4 建立基础日志标签和 debug 开关
## 3. Permission And Diagnostics ## 3. Permission And Diagnostics
@ -27,15 +27,15 @@
- [x] 4.2 使用 `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` 解析短信消息 - [x] 4.2 使用 `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` 解析短信消息
- [x] 4.3 处理多段短信 body 合并、sender、timestamp 和 subscription id - [x] 4.3 处理多段短信 body 合并、sender、timestamp 和 subscription id
- [x] 4.4 将 receiver 结果分发到应用状态层,避免在 receiver 内执行重任务 - [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.1 保存最近一次短信原文、时间、sender 摘要和来源
- [x] 5.2 实现 4-8 位数字或字母数字候选提取 - [x] 5.2 支持多段短信 body 合并后的完整结果展示
- [x] 5.3 支持空格和短横线归一化,例如 `12 34 56``123-456` - [x] 5.3 对最近结果提供失败原因和正文摘要
- [x] 5.4 增加误判排除规则,降低手机号、日期、金额、订单号误识别概率 - [x] 5.4 保持手动读取、收件箱兜底和系统广播路径的结果结构一致
- [x] 5.5 为多候选短信输出命中策略和置信度 - [x] 5.5 不对短信原文额外做业务字段推断
## 6. Optional Google SMS APIs ## 6. Optional Google SMS APIs
@ -47,10 +47,10 @@
## 7. Tests ## 7. Tests
- [x] 7.1 为验证码解析器添加中文验证码样本测试 - [x] 7.1 为短信读取状态层添加最近结果存储测试
- [x] 7.2 为验证码解析器添加英文 OTP/code 样本测试 - [x] 7.2 为短信广播读取链路添加多段正文样本测试
- [x] 7.3 为验证码解析器添加空格、短横线、多候选样本测试 - [x] 7.3 为收件箱路径添加最近短信读取样本测试
- [x] 7.4 为验证码解析器添加无验证码、手机号、日期、金额误判样本测试 - [x] 7.4 为正文为空、权限缺失和失败原因添加样本测试
- [ ] 7.5 为短信接收状态层添加权限状态和失败原因测试 - [ ] 7.5 为短信接收状态层添加权限状态和失败原因测试
## 8. Xiaomi 12S / HyperOS 3 Device Validation ## 8. Xiaomi 12S / HyperOS 3 Device Validation
@ -67,5 +67,5 @@
- [x] 9.1 相关上下文逻辑分析通过 - [x] 9.1 相关上下文逻辑分析通过
- [x] 9.2 OpenSpec validate 通过 - [x] 9.2 OpenSpec validate 通过
- [x] 9.3 单元测试通过 - [x] 9.3 单元测试通过
- [ ] 9.4 目标真机至少完成一条验证码短信接收和解析 - [ ] 9.4 目标真机至少完成一条短信原文接收和展示
- [x] 9.5 诊断 UI 能解释无权限、未收到广播、未解析到验证码三类失败 - [x] 9.5 诊断 UI 能解释无权限、未收到广播、未读取到短信正文三类失败

View File

@ -5,7 +5,7 @@ context: |
Primary platform: Android Primary platform: Android
Reference build environment: Weather reference project Reference build environment: Weather reference project
Target device: Xiaomi 12S, HyperOS 3, Android 15 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. Audience: an experienced Android engineer who wants a practical Android implementation plan before coding.
rules: rules:
@ -16,7 +16,7 @@ rules:
- Keep Android Studio, Gradle, and JDK setup out of scope; reuse the Weather project build environment as reference - Keep Android Studio, Gradle, and JDK setup out of scope; reuse the Weather project build environment as reference
design: design:
- Write in Chinese - 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 - Treat implementation as a personal sideload/debug app, not a Play Store compliance exercise
tasks: tasks:
- Write in Chinese - Write in Chinese