main #1

Closed
sookie wants to merge 5 commits from sookie/SMS-Receive:main into main
27 changed files with 273 additions and 697 deletions
Showing only changes of commit c5ef726134 - Show all commits

View File

@ -13,10 +13,11 @@ public final class SmsProviderInstrumentedTest {
private static final String TAG = "[SMS]SmsReceive"; private static final String TAG = "[SMS]SmsReceive";
@Test @Test
public void testLogRecentThirtyMessages() { public void testReadLatestInboxQueryCompletes() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
int count = SmsInboxReader.logRecentMessages(context, 30); SmsInboxReader.InboxResult result = SmsInboxReader.readLatest(context);
Log.d(TAG, "SmsProviderInstrumentedTest.testLogRecentThirtyMessages count=" + count); Log.d(TAG, "SmsProviderInstrumentedTest.testReadLatestInboxQueryCompletes success=" + result.success
assertTrue("Expected query to complete. Count can be 0 if SMS provider is empty.", count >= 0); + ", reason=" + result.failureReason);
assertTrue("Expected inbox query to return a structured result.", result.success || result.failureReason.length() > 0);
} }
} }

View File

@ -53,13 +53,12 @@ public final class FeishuWebhookClient {
if (context == null || result == null) { if (context == null || result == null) {
return; return;
} }
Context appContext = context.getApplicationContext(); if (isEmpty(result.body)) {
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext); Log.d(TAG, "Feishu push skipped: empty body source=" + result.source);
if (config.filterVerificationCode && !result.parseResult.success) {
Log.d(TAG, "Feishu push skipped: filter code enabled and parse failed, reason="
+ result.parseResult.failureReason);
return; return;
} }
Context appContext = context.getApplicationContext();
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext);
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)
@ -74,7 +73,7 @@ public final class FeishuWebhookClient {
"远端推送未开启")); "远端推送未开启"));
return; return;
} }
String markdown = buildMarkdownFromCapture(result, config); String markdown = buildMarkdownFromCapture(result);
pushMarkdownAsync(appContext, markdown, result); pushMarkdownAsync(appContext, markdown, result);
} }
@ -205,38 +204,22 @@ public final class FeishuWebhookClient {
} }
public static String buildMarkdownFromCapture(CaptureResult result) { public static String buildMarkdownFromCapture(CaptureResult result) {
return buildMarkdownFromCapture(result, new FeishuWebhookConfigStore.Config(false, "", "", false, false));
}
public static String buildMarkdownFromCapture(CaptureResult result, FeishuWebhookConfigStore.Config config) {
boolean filterCode = config != null && config.filterVerificationCode;
boolean includeFullBody = config == null || !filterCode || config.sendFullBodyDebug;
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
if (filterCode) {
builder.append("**短信验证码**").append(emptyAsDash(result.parseResult.code)).append('\n');
} else {
builder.append("**短信内容**").append(emptyAsDash(result.body)).append('\n'); 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

@ -39,8 +39,6 @@ public 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() {
} }
@ -65,19 +63,15 @@ public final class FeishuWebhookConfigStore {
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);
@ -178,7 +172,7 @@ public final class FeishuWebhookConfigStore {
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\":\"\"}";
} }
} }
@ -217,25 +211,21 @@ public 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 {
@ -295,20 +285,14 @@ public final class FeishuWebhookConfigStore {
public final boolean enabled; public final boolean enabled;
public final String webhookId; public final String webhookId;
public final String secret; public final String secret;
public final boolean sendFullBodyDebug;
public final boolean filterVerificationCode;
public 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;
} }
public boolean hasWebhookId() { public boolean hasWebhookId() {

View File

@ -36,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

@ -27,7 +27,7 @@ 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);
@ -92,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();
@ -101,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;
} }
@ -114,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);
@ -130,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

@ -3,11 +3,11 @@ package com.smsreceive.app.sms;
public final class CaptureResult { public final class CaptureResult {
public static final long UNKNOWN_SMS_PROVIDER_ID = -1L; public static final long UNKNOWN_SMS_PROVIDER_ID = -1L;
public final boolean success;
public final long receivedAtMillis; public final long receivedAtMillis;
public final long smsProviderId; public final long smsProviderId;
public final String sender; public final String sender;
public final String body; public final String body;
public final VerificationCodeParser.ParseResult parseResult;
public final String source; public final String source;
public final String failureReason; public final String failureReason;
@ -16,14 +16,13 @@ public final class CaptureResult {
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;
} }
@ -32,9 +31,8 @@ public final class CaptureResult {
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);
} }
public static CaptureResult success( public static CaptureResult success(
@ -42,9 +40,8 @@ public final class CaptureResult {
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, "");
} }
public static CaptureResult failure( public static CaptureResult failure(
@ -63,7 +60,6 @@ public final class CaptureResult {
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

@ -10,13 +10,12 @@ public final class SmsCaptureStore {
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";
@ -26,19 +25,16 @@ public final class SmsCaptureStore {
} }
public 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="
@ -55,15 +51,16 @@ public final class SmsCaptureStore {
public 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);
} }
public static void clear(Context context) { public static void clear(Context context) {
@ -102,31 +99,28 @@ public final class SmsCaptureStore {
} }
public static final class StoredCapture { public static final class StoredCapture {
public final boolean success;
public final long timeMillis; public final long timeMillis;
public final String sender; public final String sender;
public final String code;
public final String strategy;
public final int confidence;
public final String source; public final String source;
public final String failure; public final String failure;
public final String body;
public final String bodyPreview; public 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;
} }
} }

View File

@ -64,63 +64,11 @@ public final class SmsInboxReader {
} }
} }
public 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; public static RecentSmsResult findLatestSms(Context context, int limit, long minDateMillis) {
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID));
String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS));
String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY));
long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE));
int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE));
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(body);
Log.d(TAG, "SMS[" + count + "] id=" + id
+ ", type=" + smsTypeName(type)
+ ", date=" + formatDate(date)
+ ", sender=" + maskSender(sender)
+ ", parseSuccess=" + parseResult.success
+ ", code=" + parseResult.code
+ ", strategy=" + parseResult.strategy
+ ", bodyPreview=" + previewBody(body));
count++;
}
Log.d(TAG, "SmsInboxReader.logRecentMessages end count=" + count);
return count;
} catch (SecurityException e) {
Log.w(TAG, "SmsInboxReader.logRecentMessages failed: READ_SMS denied", e);
return 0;
} catch (Exception e) {
Log.w(TAG, "SmsInboxReader.logRecentMessages failed", e);
return 0;
}
}
public static RecentCodeResult findLatestVerificationCode(Context context, int limit) {
return findLatestVerificationCode(context, limit, 0L);
}
public 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 @@ public 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 @@ public 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 Log.d(TAG, "SmsInboxReader.findLatestSms hit latest id=" + id
+ ", code=" + parseResult.code
+ ", date=" + formatDate(date) + ", date=" + formatDate(date)
+ ", scanned=" + scanned); + ", scanned=" + scanned);
return RecentSmsResult.success(id, sender, body, date, scanned);
} }
} Log.d(TAG, "SmsInboxReader.findLatestSms no sms scanned=" + scanned);
if (latest != null) { return RecentSmsResult.noSms(scanned);
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode hit latest id=" + latest.id
+ ", code=" + latest.parseResult.code
+ ", date=" + formatDate(latest.dateMillis)
+ ", scanned=" + scanned);
return latest.withScannedCount(scanned);
}
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode no code scanned=" + scanned);
return RecentCodeResult.noCode(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());
} }
} }
@ -257,23 +190,21 @@ public final class SmsInboxReader {
} }
} }
public static final class RecentCodeResult { public static final class RecentSmsResult {
public final boolean success; public final boolean success;
public final long id; public final long id;
public final String sender; public final String sender;
public final String body; public final String body;
public final long dateMillis; public final long dateMillis;
public final VerificationCodeParser.ParseResult parseResult;
public final int scannedCount; public final int scannedCount;
public final String failureReason; public 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 @@ public 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

@ -25,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) {
Log.d(TAG, "verification code parse success code=" + parseResult.code
+ ", strategy=" + parseResult.strategy
+ ", confidence=" + parseResult.confidence);
Toast.makeText(context, "验证码:" + parseResult.code, Toast.LENGTH_LONG).show();
captureResult = CaptureResult.success( captureResult = CaptureResult.success(
readResult.timestampMillis, readResult.timestampMillis,
readResult.sender, readResult.sender,
readResult.body, readResult.body,
parseResult,
SOURCE_SYSTEM_BROADCAST); 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();
@ -71,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,175 +0,0 @@
package com.smsreceive.app.sms;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class VerificationCodeParser {
private static final Pattern KEYWORD_PATTERN = Pattern.compile("(?i)(验证码|校验码|动态码|验证代码|verification|otp|code)");
private static final Pattern CANDIDATE_PATTERN = Pattern.compile("(?<![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

@ -31,7 +31,6 @@ import android.widget.EditText;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.RadioButton; import android.widget.RadioButton;
import android.widget.ScrollView; import android.widget.ScrollView;
import android.widget.Switch;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@ -46,7 +45,6 @@ import com.smsreceive.app.keepalive.SmsPollingStateStore;
import com.smsreceive.app.sms.CaptureResult; import com.smsreceive.app.sms.CaptureResult;
import com.smsreceive.app.sms.SmsCaptureStore; import com.smsreceive.app.sms.SmsCaptureStore;
import com.smsreceive.app.sms.SmsInboxReader; import com.smsreceive.app.sms.SmsInboxReader;
import com.smsreceive.app.sms.VerificationCodeParser;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
@ -73,8 +71,6 @@ public final class MainActivity extends Activity {
private Button pollingButton; private Button pollingButton;
private RadioButton toastOnDatabaseWriteRadio; private RadioButton toastOnDatabaseWriteRadio;
private CheckBox feishuPushEnabledCheckBox; private CheckBox feishuPushEnabledCheckBox;
private CheckBox feishuDebugBodyCheckBox;
private Switch feishuFilterCodeSwitch;
private EditText feishuWebhookIdEdit; private EditText feishuWebhookIdEdit;
private EditText feishuSecretEdit; private EditText feishuSecretEdit;
private EditText pollingIntervalEdit; private EditText pollingIntervalEdit;
@ -155,14 +151,14 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.WRAP_CONTENT)); ViewGroup.LayoutParams.WRAP_CONTENT));
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText("短信验证码接收"); title.setText("短信接收");
title.setTextSize(24); title.setTextSize(24);
title.setTextColor(0xFF17202A); title.setTextColor(0xFF17202A);
title.setGravity(Gravity.START); title.setGravity(Gravity.START);
root.addView(title, matchWrap()); root.addView(title, matchWrap());
TextView subtitle = new TextView(this); TextView subtitle = new TextView(this);
subtitle.setText("主路径RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后只保存验证码和诊断摘要。"); subtitle.setText("主路径RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后保存短信原文和诊断摘要。");
subtitle.setTextSize(14); subtitle.setTextSize(14);
subtitle.setTextColor(0xFF5F6B7A); subtitle.setTextColor(0xFF5F6B7A);
subtitle.setPadding(0, dp(6), 0, dp(16)); subtitle.setPadding(0, dp(6), 0, dp(16));
@ -200,11 +196,7 @@ public final class MainActivity extends Activity {
readInboxButton.setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true)); readInboxButton.setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true));
actions.addView(readInboxButton, matchWrap()); actions.addView(readInboxButton, matchWrap());
Button dumpRecentButton = button("打印最近30条短信"); pollingButton = button("开始短信轮询");
dumpRecentButton.setOnClickListener(v -> dumpRecentMessages());
actions.addView(dumpRecentButton, matchWrap());
pollingButton = button("开始1秒轮询验证码");
pollingButton.setOnClickListener(v -> togglePolling()); pollingButton.setOnClickListener(v -> togglePolling());
actions.addView(pollingButton, matchWrap()); actions.addView(pollingButton, matchWrap());
@ -214,10 +206,6 @@ public final class MainActivity extends Activity {
pollingIntervalEdit.setInputType(InputType.TYPE_CLASS_NUMBER); pollingIntervalEdit.setInputType(InputType.TYPE_CLASS_NUMBER);
actions.addView(pollingIntervalEdit, matchWrap()); actions.addView(pollingIntervalEdit, matchWrap());
Button savePollingIntervalButton = button("保存轮询间隔");
savePollingIntervalButton.setOnClickListener(v -> savePollingIntervalFromUi());
actions.addView(savePollingIntervalButton, matchWrap());
feishuPushEnabledCheckBox = new CheckBox(this); feishuPushEnabledCheckBox = new CheckBox(this);
feishuPushEnabledCheckBox.setText("开启飞书远端推送"); feishuPushEnabledCheckBox.setText("开启飞书远端推送");
feishuPushEnabledCheckBox.setTextSize(14); feishuPushEnabledCheckBox.setTextSize(14);
@ -235,19 +223,6 @@ public final class MainActivity extends Activity {
feishuSecretEdit.setSingleLine(true); feishuSecretEdit.setSingleLine(true);
actions.addView(feishuSecretEdit, matchWrap()); 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("保存飞书配置"); Button saveFeishuButton = button("保存飞书配置");
saveFeishuButton.setOnClickListener(v -> saveFeishuConfigFromUi()); saveFeishuButton.setOnClickListener(v -> saveFeishuConfigFromUi());
actions.addView(saveFeishuButton, matchWrap()); actions.addView(saveFeishuButton, matchWrap());
@ -350,7 +325,7 @@ public final class MainActivity extends Activity {
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;
} }
@ -358,14 +333,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());
} }
@ -471,7 +442,7 @@ public final class MainActivity extends Activity {
private void refreshPollingUi() { private void refreshPollingUi() {
SmsPollingStateStore.State state = SmsPollingStateStore.load(this); SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
if (pollingButton != null) { if (pollingButton != null) {
pollingButton.setText(state.enabledByUser ? "停止1秒轮询验证码" : "开始1秒轮询验证码"); pollingButton.setText(state.enabledByUser ? "停止短信轮询" : "开始短信轮询");
} }
if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) { if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) {
pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds)); pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds));
@ -492,12 +463,6 @@ public final class MainActivity extends Activity {
if (feishuPushEnabledCheckBox != null) { if (feishuPushEnabledCheckBox != null) {
feishuPushEnabledCheckBox.setChecked(config.enabled); feishuPushEnabledCheckBox.setChecked(config.enabled);
} }
if (feishuDebugBodyCheckBox != null) {
feishuDebugBodyCheckBox.setChecked(config.sendFullBodyDebug);
}
if (feishuFilterCodeSwitch != null) {
feishuFilterCodeSwitch.setChecked(config.filterVerificationCode);
}
if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) { if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) {
feishuWebhookIdEdit.setText(config.webhookId); feishuWebhookIdEdit.setText(config.webhookId);
} }
@ -509,12 +474,11 @@ public final class MainActivity extends Activity {
builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n'); builder.append("配置文件:").append(FeishuWebhookConfigStore.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');
@ -606,16 +570,12 @@ public final class MainActivity extends Activity {
private void saveFeishuConfigFromUi() { private void saveFeishuConfigFromUi() {
boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked(); boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked();
boolean debugBody = feishuDebugBodyCheckBox != null && feishuDebugBodyCheckBox.isChecked();
boolean filterCode = feishuFilterCodeSwitch != null && feishuFilterCodeSwitch.isChecked();
String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString(); String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString();
String secret = feishuSecretEdit == null ? "" : feishuSecretEdit.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();
} }
@ -640,19 +600,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);
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
builder.append("**SmsReceive 最近短信测试推送**").append('\n'); builder.append("**SmsReceive 最近短信测试推送**").append('\n');
builder.append("时间:").append(formatTime(inboxResult.dateMillis)).append('\n'); builder.append("时间:").append(formatTime(inboxResult.dateMillis)).append('\n');
builder.append("发送方:").append(maskSender(inboxResult.sender)).append('\n'); builder.append("发送方:").append(maskSender(inboxResult.sender)).append('\n');
builder.append("短信ID").append(inboxResult.id).append('\n'); builder.append("短信ID").append(inboxResult.id).append('\n');
if (parseResult.success) {
builder.append("验证码:").append(parseResult.code).append('\n');
builder.append("解析:").append(parseResult.strategy).append(" / ").append(parseResult.confidence).append('\n');
} else {
builder.append("验证码:-").append('\n');
builder.append("解析失败:").append(emptyAsDash(parseResult.failureReason)).append('\n');
}
builder.append("正文:").append(emptyAsDash(inboxResult.body)); builder.append("正文:").append(emptyAsDash(inboxResult.body));
return builder.toString(); return builder.toString();
} }
@ -692,70 +644,42 @@ 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;
if (parseResult.success) {
Log.d(TAG, "readLatestInboxSms parse success source=" + source
+ ", id=" + inboxResult.id + ", id=" + inboxResult.id
+ ", code=" + parseResult.code + ", sender=" + inboxResult.sender);
+ ", strategy=" + parseResult.strategy); CaptureResult captureResult = CaptureResult.success(
captureResult = CaptureResult.success(
inboxResult.dateMillis, inboxResult.dateMillis,
inboxResult.id, inboxResult.id,
inboxResult.sender, inboxResult.sender,
inboxResult.body, inboxResult.body,
parseResult,
source); source);
if (showToast) { if (showToast) {
Toast.makeText(this, "最新短信验证码:" + parseResult.code, Toast.LENGTH_LONG).show(); Toast.makeText(this, "已读取最新短信", 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(); if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) {
refreshUi(); pollingIntervalEdit.setText(String.valueOf(intervalSeconds));
}
return intervalSeconds;
} }
private int parsePollingIntervalSeconds() { private int parsePollingIntervalSeconds() {
String raw = pollingIntervalEdit == null ? "" : pollingIntervalEdit.getText().toString().trim(); String raw = pollingIntervalEdit == null ? "" : 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;
} }
} }
@ -774,11 +698,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();
} }

View File

@ -1,5 +1,7 @@
package com.smsreceive.app.feishu; 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

@ -1,47 +0,0 @@
package com.smsreceive.app.sms;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class VerificationCodeParserTest {
@Test
public void parsesChineseKeywordCode() {
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("【测试】验证码 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

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