main #1
@ -1,22 +0,0 @@
|
|||||||
package com.smsreceive.app;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
import static org.junit.Assert.assertTrue;
|
|
||||||
|
|
||||||
public final class SmsProviderInstrumentedTest {
|
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testLogRecentThirtyMessages() {
|
|
||||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
|
||||||
int count = SmsInboxReader.logRecentMessages(context, 30);
|
|
||||||
Log.d(TAG, "SmsProviderInstrumentedTest.testLogRecentThirtyMessages count=" + count);
|
|
||||||
assertTrue("Expected query to complete. Count can be 0 if SMS provider is empty.", count >= 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.smsreceive.app.sms;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public final class SmsProviderInstrumentedTest {
|
||||||
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testReadLatestInboxQueryCompletes() {
|
||||||
|
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
SmsInboxReader.InboxResult result = SmsInboxReader.readLatest(context);
|
||||||
|
Log.d(TAG, "SmsProviderInstrumentedTest.testReadLatestInboxQueryCompletes success=" + result.success
|
||||||
|
+ ", reason=" + result.failureReason);
|
||||||
|
assertTrue("Expected inbox query to return a structured result.", result.success || result.failureReason.length() > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@ -25,7 +25,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".SmsReceiver"
|
android:name=".sms.SmsReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:permission="android.permission.BROADCAST_SMS">
|
android:permission="android.permission.BROADCAST_SMS">
|
||||||
@ -35,7 +35,7 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".BootReceiver"
|
android:name=".keepalive.BootReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@ -46,12 +46,12 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".SmsKeepAliveService"
|
android:name=".keepalive.SmsKeepAliveService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".SmsPollingService"
|
android:name=".keepalive.SmsPollingService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
package com.smsreceive.app;
|
|
||||||
|
|
||||||
final class FeishuWebhookPushResult {
|
|
||||||
static final String STATUS_SUCCESS = "success";
|
|
||||||
static final String STATUS_DISABLED = "disabled";
|
|
||||||
static final String STATUS_MISSING_CONFIG = "missing_config";
|
|
||||||
static final String STATUS_SIGN_ERROR = "sign_error";
|
|
||||||
static final String STATUS_NETWORK_ERROR = "network_error";
|
|
||||||
static final String STATUS_TIMEOUT = "timeout";
|
|
||||||
static final String STATUS_HTTP_ERROR = "http_error";
|
|
||||||
static final String STATUS_INVALID_JSON = "invalid_json";
|
|
||||||
static final String STATUS_API_ERROR = "api_error";
|
|
||||||
|
|
||||||
final boolean success;
|
|
||||||
final String status;
|
|
||||||
final String message;
|
|
||||||
final int httpStatus;
|
|
||||||
final int apiCode;
|
|
||||||
final long timeMillis;
|
|
||||||
|
|
||||||
private FeishuWebhookPushResult(
|
|
||||||
boolean success,
|
|
||||||
String status,
|
|
||||||
String message,
|
|
||||||
int httpStatus,
|
|
||||||
int apiCode,
|
|
||||||
long timeMillis) {
|
|
||||||
this.success = success;
|
|
||||||
this.status = status == null ? "" : status;
|
|
||||||
this.message = message == null ? "" : message;
|
|
||||||
this.httpStatus = httpStatus;
|
|
||||||
this.apiCode = apiCode;
|
|
||||||
this.timeMillis = timeMillis;
|
|
||||||
}
|
|
||||||
|
|
||||||
static FeishuWebhookPushResult success(String message) {
|
|
||||||
return new FeishuWebhookPushResult(true, STATUS_SUCCESS, message, 200, 0, System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
|
|
||||||
static FeishuWebhookPushResult failure(String status, String message) {
|
|
||||||
return failure(status, message, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
static FeishuWebhookPushResult failure(String status, String message, int httpStatus, int apiCode) {
|
|
||||||
return new FeishuWebhookPushResult(false, status, message, httpStatus, apiCode, System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
package com.smsreceive.app;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
public final class VerificationCodeParser {
|
|
||||||
private static final Pattern KEYWORD_PATTERN = Pattern.compile("(?i)(验证码|校验码|动态码|验证代码|verification|otp|code)");
|
|
||||||
private static final Pattern CANDIDATE_PATTERN = Pattern.compile("(?<![0-9A-Za-z])([A-Za-z0-9]{4,8}|[0-9](?:[\\s-]?[0-9]){3,7})(?![0-9A-Za-z])");
|
|
||||||
private static final Pattern PHONE_PATTERN = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
|
|
||||||
private static final Pattern MONEY_PATTERN = Pattern.compile("\\d+(?:\\.\\d{1,2})?\\s*(元|RMB|CNY|¥|¥)", Pattern.CASE_INSENSITIVE);
|
|
||||||
private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}[-/.年]\\d{1,2}[-/.月]\\d{1,2}");
|
|
||||||
|
|
||||||
private VerificationCodeParser() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ParseResult parse(String body) {
|
|
||||||
if (body == null || body.trim().isEmpty()) {
|
|
||||||
return ParseResult.failure("短信正文为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
ParseResult keywordNearby = findKeywordNearbyCode(body);
|
|
||||||
if (keywordNearby.success) {
|
|
||||||
return keywordNearby;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> candidates = new ArrayList<>();
|
|
||||||
Matcher matcher = CANDIDATE_PATTERN.matcher(body);
|
|
||||||
while (matcher.find()) {
|
|
||||||
String raw = matcher.group(1);
|
|
||||||
String normalized = normalizeCode(raw);
|
|
||||||
if (isPlausibleStandalone(body, matcher.start(1), matcher.end(1), normalized)) {
|
|
||||||
candidates.add(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidates.isEmpty()) {
|
|
||||||
String best = candidates.get(0);
|
|
||||||
for (String candidate : candidates) {
|
|
||||||
if (scoreStandalone(candidate) > scoreStandalone(best)) {
|
|
||||||
best = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ParseResult.success(best, "standalone_numeric", 62);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ParseResult.failure("未找到可靠验证码");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ParseResult findKeywordNearbyCode(String body) {
|
|
||||||
Matcher keywordMatcher = KEYWORD_PATTERN.matcher(body);
|
|
||||||
while (keywordMatcher.find()) {
|
|
||||||
if (isNegatedKeyword(body, keywordMatcher.start())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int forwardStart = keywordMatcher.end();
|
|
||||||
int forwardEnd = Math.min(body.length(), keywordMatcher.end() + 32);
|
|
||||||
ParseResult forward = findCandidateInWindow(body, forwardStart, forwardEnd, "keyword_before_code", 95);
|
|
||||||
if (forward.success) {
|
|
||||||
return forward;
|
|
||||||
}
|
|
||||||
|
|
||||||
int backwardStart = Math.max(0, keywordMatcher.start() - 24);
|
|
||||||
int backwardEnd = keywordMatcher.start();
|
|
||||||
ParseResult backward = findCandidateInWindow(body, backwardStart, backwardEnd, "code_before_keyword", 88);
|
|
||||||
if (backward.success) {
|
|
||||||
return backward;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ParseResult.failure("未找到可靠验证码");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ParseResult findCandidateInWindow(String body, int start, int end, String strategy, int confidence) {
|
|
||||||
Matcher matcher = CANDIDATE_PATTERN.matcher(body.substring(start, end));
|
|
||||||
while (matcher.find()) {
|
|
||||||
int absoluteStart = start + matcher.start(1);
|
|
||||||
int absoluteEnd = start + matcher.end(1);
|
|
||||||
String normalized = normalizeCode(matcher.group(1));
|
|
||||||
if (isPlausibleStandalone(body, absoluteStart, absoluteEnd, normalized)) {
|
|
||||||
return ParseResult.success(normalized, strategy, confidence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ParseResult.failure("未找到可靠验证码");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isNegatedKeyword(String body, int keywordStart) {
|
|
||||||
int prefixStart = Math.max(0, keywordStart - 2);
|
|
||||||
String prefix = body.substring(prefixStart, keywordStart);
|
|
||||||
return prefix.contains("无") || prefix.contains("非");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeCode(String raw) {
|
|
||||||
if (raw == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return raw.replaceAll("[\\s-]", "").toUpperCase(Locale.US);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isValidCode(String code) {
|
|
||||||
if (code == null || code.length() < 4 || code.length() > 8) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
boolean hasDigit = false;
|
|
||||||
for (int i = 0; i < code.length(); i++) {
|
|
||||||
char c = code.charAt(i);
|
|
||||||
if (!Character.isLetterOrDigit(c)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (Character.isDigit(c)) {
|
|
||||||
hasDigit = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasDigit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isPlausibleStandalone(String body, int start, int end, String normalized) {
|
|
||||||
if (!isValidCode(normalized)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String window = body.substring(Math.max(0, start - 8), Math.min(body.length(), end + 8));
|
|
||||||
if (PHONE_PATTERN.matcher(window).find()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (MONEY_PATTERN.matcher(window).find()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (DATE_PATTERN.matcher(window).find()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (normalized.length() == 8 && body.substring(Math.max(0, start - 2), Math.min(body.length(), end + 2)).contains("-")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int scoreStandalone(String code) {
|
|
||||||
int score = 0;
|
|
||||||
if (code.length() == 6) {
|
|
||||||
score += 10;
|
|
||||||
} else if (code.length() == 4) {
|
|
||||||
score += 6;
|
|
||||||
}
|
|
||||||
if (code.matches("\\d+")) {
|
|
||||||
score += 4;
|
|
||||||
}
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class ParseResult {
|
|
||||||
public final boolean success;
|
|
||||||
public final String code;
|
|
||||||
public final String strategy;
|
|
||||||
public final int confidence;
|
|
||||||
public final String failureReason;
|
|
||||||
|
|
||||||
private ParseResult(boolean success, String code, String strategy, int confidence, String failureReason) {
|
|
||||||
this.success = success;
|
|
||||||
this.code = code == null ? "" : code;
|
|
||||||
this.strategy = strategy == null ? "" : strategy;
|
|
||||||
this.confidence = confidence;
|
|
||||||
this.failureReason = failureReason == null ? "" : failureReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ParseResult success(String code, String strategy, int confidence) {
|
|
||||||
return new ParseResult(true, code, strategy, confidence, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ParseResult failure(String reason) {
|
|
||||||
return new ParseResult(false, "", "", 0, reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.feishu;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
@ -11,6 +11,8 @@ import org.json.JSONArray;
|
|||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import com.smsreceive.app.sms.CaptureResult;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.SocketTimeoutException;
|
import java.net.SocketTimeoutException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@ -29,7 +31,7 @@ import okhttp3.Request;
|
|||||||
import okhttp3.RequestBody;
|
import okhttp3.RequestBody;
|
||||||
import okhttp3.Response;
|
import okhttp3.Response;
|
||||||
|
|
||||||
final class FeishuWebhookClient {
|
public final class FeishuWebhookClient {
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final String WEBHOOK_URL_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/";
|
private static final String WEBHOOK_URL_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/";
|
||||||
private static final char[] BASE64_TABLE =
|
private static final char[] BASE64_TABLE =
|
||||||
@ -47,17 +49,16 @@ final class FeishuWebhookClient {
|
|||||||
private FeishuWebhookClient() {
|
private FeishuWebhookClient() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void pushCaptureResultAsync(Context context, CaptureResult result) {
|
public static void pushCaptureResultAsync(Context context, CaptureResult result) {
|
||||||
if (context == null || result == null) {
|
if (context == null || result == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isEmpty(result.body)) {
|
||||||
|
Log.d(TAG, "Feishu push skipped: empty body source=" + result.source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Context appContext = context.getApplicationContext();
|
Context appContext = context.getApplicationContext();
|
||||||
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext);
|
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext);
|
||||||
if (config.filterVerificationCode && !result.parseResult.success) {
|
|
||||||
Log.d(TAG, "Feishu push skipped: filter code enabled and parse failed, reason="
|
|
||||||
+ result.parseResult.failureReason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (FeishuWebhookConfigStore.wasSmsPushed(appContext, result)) {
|
if (FeishuWebhookConfigStore.wasSmsPushed(appContext, result)) {
|
||||||
Log.d(TAG, "Feishu push skipped: duplicate sms receivedSecond="
|
Log.d(TAG, "Feishu push skipped: duplicate sms receivedSecond="
|
||||||
+ (result.receivedAtMillis / 1000L)
|
+ (result.receivedAtMillis / 1000L)
|
||||||
@ -72,11 +73,11 @@ final class FeishuWebhookClient {
|
|||||||
"远端推送未开启"));
|
"远端推送未开启"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String markdown = buildMarkdownFromCapture(result, config);
|
String markdown = buildMarkdownFromCapture(result);
|
||||||
pushMarkdownAsync(appContext, markdown, result);
|
pushMarkdownAsync(appContext, markdown, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void pushMarkdownAsync(Context context, String markdownContent) {
|
public static void pushMarkdownAsync(Context context, String markdownContent) {
|
||||||
pushMarkdownAsync(context, markdownContent, null);
|
pushMarkdownAsync(context, markdownContent, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +99,7 @@ final class FeishuWebhookClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static FeishuWebhookPushResult pushMarkdown(
|
public static FeishuWebhookPushResult pushMarkdown(
|
||||||
FeishuWebhookConfigStore.Config config,
|
FeishuWebhookConfigStore.Config config,
|
||||||
String markdownContent,
|
String markdownContent,
|
||||||
long timestampSeconds) {
|
long timestampSeconds) {
|
||||||
@ -157,14 +158,14 @@ final class FeishuWebhookClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String generateSign(String secret, long timestampSeconds) throws GeneralSecurityException {
|
public static String generateSign(String secret, long timestampSeconds) throws GeneralSecurityException {
|
||||||
String stringToSign = timestampSeconds + "\n" + (secret == null ? "" : secret);
|
String stringToSign = timestampSeconds + "\n" + (secret == null ? "" : secret);
|
||||||
Mac mac = Mac.getInstance("HmacSHA256");
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||||
return base64EncodeNoWrap(mac.doFinal(new byte[0]));
|
return base64EncodeNoWrap(mac.doFinal(new byte[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
static String buildRequestJson(String markdownContent, long timestampSeconds, String sign) throws JSONException {
|
public static String buildRequestJson(String markdownContent, long timestampSeconds, String sign) throws JSONException {
|
||||||
JSONObject markdown = new JSONObject()
|
JSONObject markdown = new JSONObject()
|
||||||
.put("tag", "markdown")
|
.put("tag", "markdown")
|
||||||
.put("content", markdownContent == null ? "" : markdownContent);
|
.put("content", markdownContent == null ? "" : markdownContent);
|
||||||
@ -178,7 +179,7 @@ final class FeishuWebhookClient {
|
|||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
static FeishuWebhookPushResult parseResponse(String responseBody) {
|
public static FeishuWebhookPushResult parseResponse(String responseBody) {
|
||||||
try {
|
try {
|
||||||
JSONObject json = new JSONObject(responseBody == null ? "" : responseBody);
|
JSONObject json = new JSONObject(responseBody == null ? "" : responseBody);
|
||||||
int code = json.optInt("code", Integer.MIN_VALUE);
|
int code = json.optInt("code", Integer.MIN_VALUE);
|
||||||
@ -198,43 +199,27 @@ final class FeishuWebhookClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String buildWebhookUrl(String webhookId) {
|
public static String buildWebhookUrl(String webhookId) {
|
||||||
return WEBHOOK_URL_PREFIX + (webhookId == null ? "" : webhookId.trim());
|
return WEBHOOK_URL_PREFIX + (webhookId == null ? "" : webhookId.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
static String buildMarkdownFromCapture(CaptureResult result) {
|
public static String buildMarkdownFromCapture(CaptureResult result) {
|
||||||
return buildMarkdownFromCapture(result, new FeishuWebhookConfigStore.Config(false, "", "", false, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
static String buildMarkdownFromCapture(CaptureResult result, FeishuWebhookConfigStore.Config config) {
|
|
||||||
boolean filterCode = config != null && config.filterVerificationCode;
|
|
||||||
boolean includeFullBody = config == null || !filterCode || config.sendFullBodyDebug;
|
|
||||||
StringBuilder builder = new StringBuilder();
|
StringBuilder builder = new StringBuilder();
|
||||||
if (filterCode) {
|
|
||||||
builder.append("**短信验证码**:").append(emptyAsDash(result.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",
|
||||||
@ -1,9 +1,11 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.feishu;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.smsreceive.app.sms.CaptureResult;
|
||||||
|
|
||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
@ -16,8 +18,8 @@ import java.io.InputStreamReader;
|
|||||||
import java.io.OutputStreamWriter;
|
import java.io.OutputStreamWriter;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
final class FeishuWebhookConfigStore {
|
public final class FeishuWebhookConfigStore {
|
||||||
static final String ACTION_PUSH_UPDATED = "com.smsreceive.app.ACTION_FEISHU_PUSH_UPDATED";
|
public static final String ACTION_PUSH_UPDATED = "com.smsreceive.app.ACTION_FEISHU_PUSH_UPDATED";
|
||||||
|
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final String PREFS = "feishu_webhook";
|
private static final String PREFS = "feishu_webhook";
|
||||||
@ -37,13 +39,11 @@ final class FeishuWebhookConfigStore {
|
|||||||
private static final String JSON_ENABLED = "enabled";
|
private static final String JSON_ENABLED = "enabled";
|
||||||
private static final String JSON_WEBHOOK_ID = "webhook_id";
|
private static final String JSON_WEBHOOK_ID = "webhook_id";
|
||||||
private static final String JSON_SECRET = "secret";
|
private static final String JSON_SECRET = "secret";
|
||||||
private static final String JSON_SEND_FULL_BODY_DEBUG = "send_full_body_debug";
|
|
||||||
private static final String JSON_FILTER_VERIFICATION_CODE = "filter_verification_code";
|
|
||||||
|
|
||||||
private FeishuWebhookConfigStore() {
|
private FeishuWebhookConfigStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static Config loadConfig(Context context) {
|
public static Config loadConfig(Context context) {
|
||||||
ensureDefaultConfigFile(context);
|
ensureDefaultConfigFile(context);
|
||||||
File file = configFile(context);
|
File file = configFile(context);
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
@ -59,30 +59,26 @@ final class FeishuWebhookConfigStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void saveConfig(
|
public static void saveConfig(
|
||||||
Context context,
|
Context context,
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
String webhookId,
|
String webhookId,
|
||||||
String secret,
|
String secret) {
|
||||||
boolean sendFullBodyDebug,
|
Config config = new Config(enabled, webhookId, secret);
|
||||||
boolean filterVerificationCode) {
|
|
||||||
Config config = new Config(enabled, webhookId, secret, sendFullBodyDebug, filterVerificationCode);
|
|
||||||
File file = configFile(context);
|
File file = configFile(context);
|
||||||
try {
|
try {
|
||||||
writeFile(file, configToJson(config).toString(2));
|
writeFile(file, configToJson(config).toString(2));
|
||||||
Log.d(TAG, "save feishu config path=" + file.getAbsolutePath()
|
Log.d(TAG, "save feishu config path=" + file.getAbsolutePath()
|
||||||
+ ", enabled=" + enabled
|
+ ", enabled=" + enabled
|
||||||
+ ", webhookConfigured=" + config.hasWebhookId()
|
+ ", webhookConfigured=" + config.hasWebhookId()
|
||||||
+ ", secretConfigured=" + config.hasSecret()
|
+ ", secretConfigured=" + config.hasSecret());
|
||||||
+ ", debugBody=" + sendFullBodyDebug
|
|
||||||
+ ", filterCode=" + filterVerificationCode);
|
|
||||||
} catch (IOException | JSONException e) {
|
} catch (IOException | JSONException e) {
|
||||||
Log.w(TAG, "save feishu config failed path=" + file.getAbsolutePath()
|
Log.w(TAG, "save feishu config failed path=" + file.getAbsolutePath()
|
||||||
+ ", reason=" + e.getClass().getSimpleName(), e);
|
+ ", reason=" + e.getClass().getSimpleName(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void saveLastResult(Context context, FeishuWebhookPushResult result) {
|
public static void saveLastResult(Context context, FeishuWebhookPushResult result) {
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putLong(KEY_LAST_TIME, result.timeMillis)
|
.putLong(KEY_LAST_TIME, result.timeMillis)
|
||||||
.putBoolean(KEY_LAST_SUCCESS, result.success)
|
.putBoolean(KEY_LAST_SUCCESS, result.success)
|
||||||
@ -136,7 +132,7 @@ final class FeishuWebhookConfigStore {
|
|||||||
+ ", smsKey=" + smsKey);
|
+ ", smsKey=" + smsKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
static LastResult loadLastResult(Context context) {
|
public static LastResult loadLastResult(Context context) {
|
||||||
SharedPreferences prefs = preferences(context);
|
SharedPreferences prefs = preferences(context);
|
||||||
return new LastResult(
|
return new LastResult(
|
||||||
prefs.getLong(KEY_LAST_TIME, 0L),
|
prefs.getLong(KEY_LAST_TIME, 0L),
|
||||||
@ -147,14 +143,14 @@ final class FeishuWebhookConfigStore {
|
|||||||
prefs.getInt(KEY_LAST_API_CODE, 0));
|
prefs.getInt(KEY_LAST_API_CODE, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
static LastPushedSms loadLastPushedSms(Context context) {
|
public static LastPushedSms loadLastPushedSms(Context context) {
|
||||||
SharedPreferences prefs = preferences(context);
|
SharedPreferences prefs = preferences(context);
|
||||||
return new LastPushedSms(
|
return new LastPushedSms(
|
||||||
prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L),
|
prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L),
|
||||||
prefs.getString(KEY_LAST_PUSHED_SMS_KEY, ""));
|
prefs.getString(KEY_LAST_PUSHED_SMS_KEY, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
static String maskSecret(String secret) {
|
public static String maskSecret(String secret) {
|
||||||
if (isEmpty(secret)) {
|
if (isEmpty(secret)) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@ -164,19 +160,19 @@ final class FeishuWebhookConfigStore {
|
|||||||
return secret.substring(0, 3) + "***" + secret.substring(secret.length() - 3);
|
return secret.substring(0, 3) + "***" + secret.substring(secret.length() - 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String configPath(Context context) {
|
public static String configPath(Context context) {
|
||||||
return configFile(context).getAbsolutePath();
|
return configFile(context).getAbsolutePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String defaultConfigPath(Context context) {
|
public static String defaultConfigPath(Context context) {
|
||||||
return defaultConfigFile(context).getAbsolutePath();
|
return defaultConfigFile(context).getAbsolutePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String configTemplate() {
|
public static String configTemplate() {
|
||||||
try {
|
try {
|
||||||
return configToJson(defaultConfig()).toString(2);
|
return configToJson(defaultConfig()).toString(2);
|
||||||
} catch (JSONException e) {
|
} catch (JSONException e) {
|
||||||
return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\",\"send_full_body_debug\":false,\"filter_verification_code\":false}";
|
return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\"}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,25 +211,21 @@ final class FeishuWebhookConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Config defaultConfig() {
|
private static Config defaultConfig() {
|
||||||
return new Config(false, "", "", false, false);
|
return new Config(false, "", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Config configFromJson(JSONObject json) {
|
private static Config configFromJson(JSONObject json) {
|
||||||
return new Config(
|
return new Config(
|
||||||
json.optBoolean(JSON_ENABLED, false),
|
json.optBoolean(JSON_ENABLED, false),
|
||||||
json.optString(JSON_WEBHOOK_ID, ""),
|
json.optString(JSON_WEBHOOK_ID, ""),
|
||||||
json.optString(JSON_SECRET, ""),
|
json.optString(JSON_SECRET, ""));
|
||||||
json.optBoolean(JSON_SEND_FULL_BODY_DEBUG, false),
|
|
||||||
json.optBoolean(JSON_FILTER_VERIFICATION_CODE, false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JSONObject configToJson(Config config) throws JSONException {
|
private static JSONObject configToJson(Config config) throws JSONException {
|
||||||
return new JSONObject()
|
return new JSONObject()
|
||||||
.put(JSON_ENABLED, config.enabled)
|
.put(JSON_ENABLED, config.enabled)
|
||||||
.put(JSON_WEBHOOK_ID, config.webhookId)
|
.put(JSON_WEBHOOK_ID, config.webhookId)
|
||||||
.put(JSON_SECRET, config.secret)
|
.put(JSON_SECRET, config.secret);
|
||||||
.put(JSON_SEND_FULL_BODY_DEBUG, config.sendFullBodyDebug)
|
|
||||||
.put(JSON_FILTER_VERIFICATION_CODE, config.filterVerificationCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readFile(File file) throws IOException {
|
private static String readFile(File file) throws IOException {
|
||||||
@ -289,42 +281,36 @@ final class FeishuWebhookConfigStore {
|
|||||||
+ Integer.toHexString((result.body == null ? "" : result.body).hashCode());
|
+ Integer.toHexString((result.body == null ? "" : result.body).hashCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class Config {
|
public static final class Config {
|
||||||
final boolean enabled;
|
public final boolean enabled;
|
||||||
final String webhookId;
|
public final String webhookId;
|
||||||
final String secret;
|
public final String secret;
|
||||||
final boolean sendFullBodyDebug;
|
|
||||||
final boolean filterVerificationCode;
|
|
||||||
|
|
||||||
Config(
|
public Config(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
String webhookId,
|
String webhookId,
|
||||||
String secret,
|
String secret) {
|
||||||
boolean sendFullBodyDebug,
|
|
||||||
boolean filterVerificationCode) {
|
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
this.webhookId = normalize(webhookId);
|
this.webhookId = normalize(webhookId);
|
||||||
this.secret = normalize(secret);
|
this.secret = normalize(secret);
|
||||||
this.sendFullBodyDebug = sendFullBodyDebug;
|
|
||||||
this.filterVerificationCode = filterVerificationCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasWebhookId() {
|
public boolean hasWebhookId() {
|
||||||
return !isEmpty(webhookId);
|
return !isEmpty(webhookId);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasSecret() {
|
public boolean hasSecret() {
|
||||||
return !isEmpty(secret);
|
return !isEmpty(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class LastResult {
|
public static final class LastResult {
|
||||||
final long timeMillis;
|
public final long timeMillis;
|
||||||
final boolean success;
|
public final boolean success;
|
||||||
final String status;
|
public final String status;
|
||||||
final String message;
|
public final String message;
|
||||||
final int httpStatus;
|
public final int httpStatus;
|
||||||
final int apiCode;
|
public final int apiCode;
|
||||||
|
|
||||||
LastResult(long timeMillis, boolean success, String status, String message, int httpStatus, int apiCode) {
|
LastResult(long timeMillis, boolean success, String status, String message, int httpStatus, int apiCode) {
|
||||||
this.timeMillis = timeMillis;
|
this.timeMillis = timeMillis;
|
||||||
@ -336,9 +322,9 @@ final class FeishuWebhookConfigStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class LastPushedSms {
|
public static final class LastPushedSms {
|
||||||
final long receivedSecond;
|
public final long receivedSecond;
|
||||||
final String smsKey;
|
public final String smsKey;
|
||||||
|
|
||||||
LastPushedSms(long receivedSecond, String smsKey) {
|
LastPushedSms(long receivedSecond, String smsKey) {
|
||||||
this.receivedSecond = receivedSecond;
|
this.receivedSecond = receivedSecond;
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.smsreceive.app.feishu;
|
||||||
|
|
||||||
|
public final class FeishuWebhookPushResult {
|
||||||
|
public static final String STATUS_SUCCESS = "success";
|
||||||
|
public static final String STATUS_DISABLED = "disabled";
|
||||||
|
public static final String STATUS_MISSING_CONFIG = "missing_config";
|
||||||
|
public static final String STATUS_SIGN_ERROR = "sign_error";
|
||||||
|
public static final String STATUS_NETWORK_ERROR = "network_error";
|
||||||
|
public static final String STATUS_TIMEOUT = "timeout";
|
||||||
|
public static final String STATUS_HTTP_ERROR = "http_error";
|
||||||
|
public static final String STATUS_INVALID_JSON = "invalid_json";
|
||||||
|
public static final String STATUS_API_ERROR = "api_error";
|
||||||
|
|
||||||
|
public final boolean success;
|
||||||
|
public final String status;
|
||||||
|
public final String message;
|
||||||
|
public final int httpStatus;
|
||||||
|
public final int apiCode;
|
||||||
|
public final long timeMillis;
|
||||||
|
|
||||||
|
private FeishuWebhookPushResult(
|
||||||
|
boolean success,
|
||||||
|
String status,
|
||||||
|
String message,
|
||||||
|
int httpStatus,
|
||||||
|
int apiCode,
|
||||||
|
long timeMillis) {
|
||||||
|
this.success = success;
|
||||||
|
this.status = status == null ? "" : status;
|
||||||
|
this.message = message == null ? "" : message;
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
this.apiCode = apiCode;
|
||||||
|
this.timeMillis = timeMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FeishuWebhookPushResult success(String message) {
|
||||||
|
return new FeishuWebhookPushResult(true, STATUS_SUCCESS, message, 200, 0, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FeishuWebhookPushResult failure(String status, String message) {
|
||||||
|
return failure(status, message, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FeishuWebhookPushResult failure(String status, String message, int httpStatus, int apiCode) {
|
||||||
|
return new FeishuWebhookPushResult(false, status, message, httpStatus, apiCode, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.keepalive;
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.keepalive;
|
||||||
|
|
||||||
import android.content.ContentValues;
|
import android.content.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@ -11,7 +11,7 @@ import java.text.SimpleDateFormat;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
final class KeepAliveDatabase {
|
public final class KeepAliveDatabase {
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final String DATABASE_NAME = "sms_keep_alive.db";
|
private static final String DATABASE_NAME = "sms_keep_alive.db";
|
||||||
private static final int DATABASE_VERSION = 1;
|
private static final int DATABASE_VERSION = 1;
|
||||||
@ -23,7 +23,7 @@ final class KeepAliveDatabase {
|
|||||||
private KeepAliveDatabase() {
|
private KeepAliveDatabase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static long writeLastActiveTime(Context context) {
|
public static long writeLastActiveTime(Context context) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
SQLiteDatabase database = helper(context).getWritableDatabase();
|
SQLiteDatabase database = helper(context).getWritableDatabase();
|
||||||
ContentValues values = new ContentValues();
|
ContentValues values = new ContentValues();
|
||||||
@ -35,7 +35,7 @@ final class KeepAliveDatabase {
|
|||||||
return now;
|
return now;
|
||||||
}
|
}
|
||||||
|
|
||||||
static long readLastActiveTime(Context context) {
|
public static long readLastActiveTime(Context context) {
|
||||||
SQLiteDatabase database = helper(context).getReadableDatabase();
|
SQLiteDatabase database = helper(context).getReadableDatabase();
|
||||||
try (Cursor cursor = database.query(
|
try (Cursor cursor = database.query(
|
||||||
TABLE_META,
|
TABLE_META,
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.keepalive;
|
||||||
|
|
||||||
import android.app.Notification;
|
import android.app.Notification;
|
||||||
import android.app.NotificationChannel;
|
import android.app.NotificationChannel;
|
||||||
@ -9,6 +9,8 @@ import android.content.Intent;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.smsreceive.app.ui.MainActivity;
|
||||||
|
|
||||||
final class KeepAliveNotification {
|
final class KeepAliveNotification {
|
||||||
static final int NOTIFICATION_ID = 2101;
|
static final int NOTIFICATION_ID = 2101;
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
@ -34,7 +36,7 @@ final class KeepAliveNotification {
|
|||||||
: new Notification.Builder(context);
|
: new Notification.Builder(context);
|
||||||
return builder
|
return builder
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
.setContentTitle("短信验证码监听运行中")
|
.setContentTitle("短信监听运行中")
|
||||||
.setContentText(contentText)
|
.setContentText(contentText)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
@ -1,11 +1,11 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.keepalive;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
final class KeepAliveStateStore {
|
public final class KeepAliveStateStore {
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final String PREFS = "sms_keep_alive";
|
private static final String PREFS = "sms_keep_alive";
|
||||||
|
|
||||||
@ -23,14 +23,14 @@ final class KeepAliveStateStore {
|
|||||||
private KeepAliveStateStore() {
|
private KeepAliveStateStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setEnabledByUser(Context context, boolean enabled) {
|
public static void setEnabledByUser(Context context, boolean enabled) {
|
||||||
Log.d(TAG, "KeepAliveStateStore.setEnabledByUser enabled=" + enabled);
|
Log.d(TAG, "KeepAliveStateStore.setEnabledByUser enabled=" + enabled);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_ENABLED_BY_USER, enabled)
|
.putBoolean(KEY_ENABLED_BY_USER, enabled)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordServiceStarted(Context context) {
|
public static void recordServiceStarted(Context context) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
Log.d(TAG, "KeepAliveStateStore.recordServiceStarted time=" + now);
|
Log.d(TAG, "KeepAliveStateStore.recordServiceStarted time=" + now);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
@ -40,7 +40,7 @@ final class KeepAliveStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordServiceStopped(Context context, String reason) {
|
public static void recordServiceStopped(Context context, String reason) {
|
||||||
Log.d(TAG, "KeepAliveStateStore.recordServiceStopped reason=" + reason);
|
Log.d(TAG, "KeepAliveStateStore.recordServiceStopped reason=" + reason);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_SERVICE_RUNNING, false)
|
.putBoolean(KEY_SERVICE_RUNNING, false)
|
||||||
@ -48,7 +48,7 @@ final class KeepAliveStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordHeartbeat(Context context) {
|
public static void recordHeartbeat(Context context) {
|
||||||
Log.d(TAG, "KeepAliveStateStore.recordHeartbeat");
|
Log.d(TAG, "KeepAliveStateStore.recordHeartbeat");
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_SERVICE_RUNNING, true)
|
.putBoolean(KEY_SERVICE_RUNNING, true)
|
||||||
@ -56,7 +56,7 @@ final class KeepAliveStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordBootEvent(Context context, String action) {
|
public static void recordBootEvent(Context context, String action) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
Log.d(TAG, "KeepAliveStateStore.recordBootEvent action=" + action + ", time=" + now);
|
Log.d(TAG, "KeepAliveStateStore.recordBootEvent action=" + action + ", time=" + now);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
@ -65,7 +65,7 @@ final class KeepAliveStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordServiceStartFailure(Context context, String reason) {
|
public static void recordServiceStartFailure(Context context, String reason) {
|
||||||
Log.w(TAG, "KeepAliveStateStore.recordServiceStartFailure reason=" + reason);
|
Log.w(TAG, "KeepAliveStateStore.recordServiceStartFailure reason=" + reason);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_SERVICE_RUNNING, false)
|
.putBoolean(KEY_SERVICE_RUNNING, false)
|
||||||
@ -73,39 +73,39 @@ final class KeepAliveStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setManualAutostartConfirmed(Context context, boolean confirmed) {
|
public static void setManualAutostartConfirmed(Context context, boolean confirmed) {
|
||||||
Log.d(TAG, "KeepAliveStateStore.setManualAutostartConfirmed confirmed=" + confirmed);
|
Log.d(TAG, "KeepAliveStateStore.setManualAutostartConfirmed confirmed=" + confirmed);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_MANUAL_AUTOSTART_CONFIRMED, confirmed)
|
.putBoolean(KEY_MANUAL_AUTOSTART_CONFIRMED, confirmed)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setManualBatteryUnrestrictedConfirmed(Context context, boolean confirmed) {
|
public static void setManualBatteryUnrestrictedConfirmed(Context context, boolean confirmed) {
|
||||||
Log.d(TAG, "KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed confirmed=" + confirmed);
|
Log.d(TAG, "KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed confirmed=" + confirmed);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED, confirmed)
|
.putBoolean(KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED, confirmed)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setBatteryOptimizationIgnored(Context context, boolean ignored) {
|
public static void setBatteryOptimizationIgnored(Context context, boolean ignored) {
|
||||||
Log.d(TAG, "KeepAliveStateStore.setBatteryOptimizationIgnored ignored=" + ignored);
|
Log.d(TAG, "KeepAliveStateStore.setBatteryOptimizationIgnored ignored=" + ignored);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_BATTERY_OPTIMIZATION_IGNORED, ignored)
|
.putBoolean(KEY_BATTERY_OPTIMIZATION_IGNORED, ignored)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setToastOnDatabaseWrite(Context context, boolean enabled) {
|
public static void setToastOnDatabaseWrite(Context context, boolean enabled) {
|
||||||
Log.d(TAG, "KeepAliveStateStore.setToastOnDatabaseWrite enabled=" + enabled);
|
Log.d(TAG, "KeepAliveStateStore.setToastOnDatabaseWrite enabled=" + enabled);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_TOAST_ON_DATABASE_WRITE, enabled)
|
.putBoolean(KEY_TOAST_ON_DATABASE_WRITE, enabled)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean isToastOnDatabaseWriteEnabled(Context context) {
|
public static boolean isToastOnDatabaseWriteEnabled(Context context) {
|
||||||
return preferences(context).getBoolean(KEY_TOAST_ON_DATABASE_WRITE, false);
|
return preferences(context).getBoolean(KEY_TOAST_ON_DATABASE_WRITE, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
static State load(Context context) {
|
public static State load(Context context) {
|
||||||
SharedPreferences prefs = preferences(context);
|
SharedPreferences prefs = preferences(context);
|
||||||
return new State(
|
return new State(
|
||||||
prefs.getBoolean(KEY_ENABLED_BY_USER, false),
|
prefs.getBoolean(KEY_ENABLED_BY_USER, false),
|
||||||
@ -128,17 +128,17 @@ final class KeepAliveStateStore {
|
|||||||
return TextUtils.isEmpty(value) ? "" : value;
|
return TextUtils.isEmpty(value) ? "" : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class State {
|
public static final class State {
|
||||||
final boolean enabledByUser;
|
public final boolean enabledByUser;
|
||||||
final boolean serviceRunning;
|
public final boolean serviceRunning;
|
||||||
final long lastHeartbeatMillis;
|
public final long lastHeartbeatMillis;
|
||||||
final String lastBootEvent;
|
public final String lastBootEvent;
|
||||||
final long lastBootTimeMillis;
|
public final long lastBootTimeMillis;
|
||||||
final String lastServiceStartFailure;
|
public final String lastServiceStartFailure;
|
||||||
final boolean manualAutostartConfirmed;
|
public final boolean manualAutostartConfirmed;
|
||||||
final boolean manualBatteryUnrestrictedConfirmed;
|
public final boolean manualBatteryUnrestrictedConfirmed;
|
||||||
final boolean batteryOptimizationIgnored;
|
public final boolean batteryOptimizationIgnored;
|
||||||
final boolean toastOnDatabaseWrite;
|
public final boolean toastOnDatabaseWrite;
|
||||||
|
|
||||||
State(
|
State(
|
||||||
boolean enabledByUser,
|
boolean enabledByUser,
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.keepalive;
|
||||||
|
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@ -10,6 +10,8 @@ import android.os.Looper;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.smsreceive.app.sms.SmsCaptureStore;
|
||||||
|
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@ -41,7 +43,7 @@ public final class SmsKeepAliveService extends Service {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
static void start(Context context) {
|
public static void start(Context context) {
|
||||||
Log.d(TAG, "SmsKeepAliveService.start requested sdk=" + Build.VERSION.SDK_INT);
|
Log.d(TAG, "SmsKeepAliveService.start requested sdk=" + Build.VERSION.SDK_INT);
|
||||||
Intent intent = new Intent(context, SmsKeepAliveService.class);
|
Intent intent = new Intent(context, SmsKeepAliveService.class);
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
@ -51,7 +53,7 @@ public final class SmsKeepAliveService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void stop(Context context) {
|
public static void stop(Context context) {
|
||||||
Log.d(TAG, "SmsKeepAliveService.stop requested");
|
Log.d(TAG, "SmsKeepAliveService.stop requested");
|
||||||
context.stopService(new Intent(context, SmsKeepAliveService.class));
|
context.stopService(new Intent(context, SmsKeepAliveService.class));
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.keepalive;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
@ -12,6 +12,11 @@ import android.os.Looper;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.smsreceive.app.feishu.FeishuWebhookClient;
|
||||||
|
import com.smsreceive.app.sms.CaptureResult;
|
||||||
|
import com.smsreceive.app.sms.SmsCaptureStore;
|
||||||
|
import com.smsreceive.app.sms.SmsInboxReader;
|
||||||
|
|
||||||
public final class SmsPollingService extends Service {
|
public final class SmsPollingService extends Service {
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final String SOURCE_INBOX_POLLING = "sms_inbox_polling";
|
private static final String SOURCE_INBOX_POLLING = "sms_inbox_polling";
|
||||||
@ -22,14 +27,14 @@ public final class SmsPollingService extends Service {
|
|||||||
private final Runnable pollingRunnable = new Runnable() {
|
private final Runnable pollingRunnable = new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
pollRecentSmsForCode();
|
pollRecentSms();
|
||||||
long intervalMillis = SmsPollingStateStore.getIntervalSeconds(SmsPollingService.this) * 1000L;
|
long intervalMillis = SmsPollingStateStore.getIntervalSeconds(SmsPollingService.this) * 1000L;
|
||||||
Log.d(TAG, "SmsPollingService.schedule next intervalMs=" + intervalMillis);
|
Log.d(TAG, "SmsPollingService.schedule next intervalMs=" + intervalMillis);
|
||||||
handler.postDelayed(this, intervalMillis);
|
handler.postDelayed(this, intervalMillis);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
static void start(Context context) {
|
public static void start(Context context) {
|
||||||
Log.d(TAG, "SmsPollingService.start requested sdk=" + Build.VERSION.SDK_INT);
|
Log.d(TAG, "SmsPollingService.start requested sdk=" + Build.VERSION.SDK_INT);
|
||||||
long startTimeMillis = System.currentTimeMillis() - 2_000L;
|
long startTimeMillis = System.currentTimeMillis() - 2_000L;
|
||||||
SmsPollingStateStore.recordStarted(context, startTimeMillis);
|
SmsPollingStateStore.recordStarted(context, startTimeMillis);
|
||||||
@ -42,7 +47,7 @@ public final class SmsPollingService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void stop(Context context) {
|
public static void stop(Context context) {
|
||||||
Log.d(TAG, "SmsPollingService.stop requested");
|
Log.d(TAG, "SmsPollingService.stop requested");
|
||||||
SmsPollingStateStore.recordStopped(context, "用户停止轮询");
|
SmsPollingStateStore.recordStopped(context, "用户停止轮询");
|
||||||
context.stopService(new Intent(context, SmsPollingService.class));
|
context.stopService(new Intent(context, SmsPollingService.class));
|
||||||
@ -87,7 +92,7 @@ public final class SmsPollingService extends Service {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pollRecentSmsForCode() {
|
private void pollRecentSms() {
|
||||||
if (!hasReadSmsPermission()) {
|
if (!hasReadSmsPermission()) {
|
||||||
Log.w(TAG, "SmsPollingService.poll stop: READ_SMS not granted");
|
Log.w(TAG, "SmsPollingService.poll stop: READ_SMS not granted");
|
||||||
Toast.makeText(this, "READ_SMS 未授权,已停止短信轮询", Toast.LENGTH_LONG).show();
|
Toast.makeText(this, "READ_SMS 未授权,已停止短信轮询", Toast.LENGTH_LONG).show();
|
||||||
@ -96,9 +101,9 @@ public final class SmsPollingService extends Service {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SmsInboxReader.RecentCodeResult result = SmsInboxReader.findLatestVerificationCode(this, 3, pollingStartMillis);
|
SmsInboxReader.RecentSmsResult result = SmsInboxReader.findLatestSms(this, 3, pollingStartMillis);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
Log.d(TAG, "SmsPollingService.poll no code scanned=" + result.scannedCount
|
Log.d(TAG, "SmsPollingService.poll no sms scanned=" + result.scannedCount
|
||||||
+ ", reason=" + result.failureReason);
|
+ ", reason=" + result.failureReason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -109,15 +114,13 @@ public final class SmsPollingService extends Service {
|
|||||||
lastHitSmsId = result.id;
|
lastHitSmsId = result.id;
|
||||||
SmsPollingStateStore.recordHit(this, result.id, result.dateMillis);
|
SmsPollingStateStore.recordHit(this, result.id, result.dateMillis);
|
||||||
Log.d(TAG, "SmsPollingService.poll hit id=" + result.id
|
Log.d(TAG, "SmsPollingService.poll hit id=" + result.id
|
||||||
+ ", code=" + result.parseResult.code
|
+ ", sender=" + result.sender
|
||||||
+ ", strategy=" + result.parseResult.strategy
|
+ ", bodyLength=" + result.body.length());
|
||||||
+ ", confidence=" + result.parseResult.confidence);
|
|
||||||
CaptureResult captureResult = CaptureResult.success(
|
CaptureResult captureResult = CaptureResult.success(
|
||||||
result.dateMillis,
|
result.dateMillis,
|
||||||
result.id,
|
result.id,
|
||||||
result.sender,
|
result.sender,
|
||||||
result.body,
|
result.body,
|
||||||
result.parseResult,
|
|
||||||
SOURCE_INBOX_POLLING);
|
SOURCE_INBOX_POLLING);
|
||||||
SmsCaptureStore.save(this, captureResult);
|
SmsCaptureStore.save(this, captureResult);
|
||||||
FeishuWebhookClient.pushCaptureResultAsync(this, captureResult);
|
FeishuWebhookClient.pushCaptureResultAsync(this, captureResult);
|
||||||
@ -125,7 +128,7 @@ public final class SmsPollingService extends Service {
|
|||||||
Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED);
|
Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED);
|
||||||
updateIntent.setPackage(getPackageName());
|
updateIntent.setPackage(getPackageName());
|
||||||
sendBroadcast(updateIntent);
|
sendBroadcast(updateIntent);
|
||||||
Toast.makeText(this, "轮询提取验证码:" + result.parseResult.code, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, "轮询读取到新短信", Toast.LENGTH_LONG).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasReadSmsPermission() {
|
private boolean hasReadSmsPermission() {
|
||||||
@ -1,11 +1,11 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.keepalive;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
final class SmsPollingStateStore {
|
public final class SmsPollingStateStore {
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final String PREFS = "sms_polling";
|
private static final String PREFS = "sms_polling";
|
||||||
private static final String KEY_ENABLED_BY_USER = "enabled_by_user";
|
private static final String KEY_ENABLED_BY_USER = "enabled_by_user";
|
||||||
@ -22,7 +22,7 @@ final class SmsPollingStateStore {
|
|||||||
private SmsPollingStateStore() {
|
private SmsPollingStateStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordStarted(Context context, long startTimeMillis) {
|
public static void recordStarted(Context context, long startTimeMillis) {
|
||||||
Log.d(TAG, "SmsPollingStateStore.recordStarted startTime=" + startTimeMillis);
|
Log.d(TAG, "SmsPollingStateStore.recordStarted startTime=" + startTimeMillis);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_ENABLED_BY_USER, true)
|
.putBoolean(KEY_ENABLED_BY_USER, true)
|
||||||
@ -32,7 +32,7 @@ final class SmsPollingStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordStopped(Context context, String reason) {
|
public static void recordStopped(Context context, String reason) {
|
||||||
Log.d(TAG, "SmsPollingStateStore.recordStopped reason=" + reason);
|
Log.d(TAG, "SmsPollingStateStore.recordStopped reason=" + reason);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_ENABLED_BY_USER, false)
|
.putBoolean(KEY_ENABLED_BY_USER, false)
|
||||||
@ -41,7 +41,7 @@ final class SmsPollingStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordServiceStopped(Context context, String reason) {
|
public static void recordServiceStopped(Context context, String reason) {
|
||||||
Log.d(TAG, "SmsPollingStateStore.recordServiceStopped reason=" + reason);
|
Log.d(TAG, "SmsPollingStateStore.recordServiceStopped reason=" + reason);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_RUNNING, false)
|
.putBoolean(KEY_RUNNING, false)
|
||||||
@ -49,13 +49,13 @@ final class SmsPollingStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordServiceRunning(Context context) {
|
public static void recordServiceRunning(Context context) {
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putBoolean(KEY_RUNNING, true)
|
.putBoolean(KEY_RUNNING, true)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void recordHit(Context context, long smsId, long hitTimeMillis) {
|
public static void recordHit(Context context, long smsId, long hitTimeMillis) {
|
||||||
Log.d(TAG, "SmsPollingStateStore.recordHit id=" + smsId + ", time=" + hitTimeMillis);
|
Log.d(TAG, "SmsPollingStateStore.recordHit id=" + smsId + ", time=" + hitTimeMillis);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
.putLong(KEY_LAST_HIT_ID, smsId)
|
.putLong(KEY_LAST_HIT_ID, smsId)
|
||||||
@ -64,7 +64,7 @@ final class SmsPollingStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setIntervalSeconds(Context context, int seconds) {
|
public static void setIntervalSeconds(Context context, int seconds) {
|
||||||
int safeSeconds = clampIntervalSeconds(seconds);
|
int safeSeconds = clampIntervalSeconds(seconds);
|
||||||
Log.d(TAG, "SmsPollingStateStore.setIntervalSeconds seconds=" + safeSeconds);
|
Log.d(TAG, "SmsPollingStateStore.setIntervalSeconds seconds=" + safeSeconds);
|
||||||
preferences(context).edit()
|
preferences(context).edit()
|
||||||
@ -72,11 +72,11 @@ final class SmsPollingStateStore {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static int getIntervalSeconds(Context context) {
|
public static int getIntervalSeconds(Context context) {
|
||||||
return clampIntervalSeconds(preferences(context).getInt(KEY_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS));
|
return clampIntervalSeconds(preferences(context).getInt(KEY_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS));
|
||||||
}
|
}
|
||||||
|
|
||||||
static State load(Context context) {
|
public static State load(Context context) {
|
||||||
SharedPreferences prefs = preferences(context);
|
SharedPreferences prefs = preferences(context);
|
||||||
return new State(
|
return new State(
|
||||||
prefs.getBoolean(KEY_ENABLED_BY_USER, false),
|
prefs.getBoolean(KEY_ENABLED_BY_USER, false),
|
||||||
@ -100,14 +100,14 @@ final class SmsPollingStateStore {
|
|||||||
return Math.max(MIN_INTERVAL_SECONDS, Math.min(seconds, MAX_INTERVAL_SECONDS));
|
return Math.max(MIN_INTERVAL_SECONDS, Math.min(seconds, MAX_INTERVAL_SECONDS));
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class State {
|
public static final class State {
|
||||||
final boolean enabledByUser;
|
public final boolean enabledByUser;
|
||||||
final boolean running;
|
public final boolean running;
|
||||||
final long startTimeMillis;
|
public final long startTimeMillis;
|
||||||
final long lastHitId;
|
public final long lastHitId;
|
||||||
final long lastHitTimeMillis;
|
public final long lastHitTimeMillis;
|
||||||
final String lastFailure;
|
public final String lastFailure;
|
||||||
final int intervalSeconds;
|
public final int intervalSeconds;
|
||||||
|
|
||||||
State(
|
State(
|
||||||
boolean enabledByUser,
|
boolean enabledByUser,
|
||||||
@ -1,53 +1,50 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.sms;
|
||||||
|
|
||||||
final class CaptureResult {
|
public final class CaptureResult {
|
||||||
static final long UNKNOWN_SMS_PROVIDER_ID = -1L;
|
public static final long UNKNOWN_SMS_PROVIDER_ID = -1L;
|
||||||
|
|
||||||
final long receivedAtMillis;
|
public final boolean success;
|
||||||
final long smsProviderId;
|
public final long receivedAtMillis;
|
||||||
final String sender;
|
public final long smsProviderId;
|
||||||
final String body;
|
public final String sender;
|
||||||
final VerificationCodeParser.ParseResult parseResult;
|
public final String body;
|
||||||
final String source;
|
public final String source;
|
||||||
final String failureReason;
|
public final String failureReason;
|
||||||
|
|
||||||
private CaptureResult(
|
private CaptureResult(
|
||||||
long receivedAtMillis,
|
long receivedAtMillis,
|
||||||
long smsProviderId,
|
long smsProviderId,
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
VerificationCodeParser.ParseResult parseResult,
|
|
||||||
String source,
|
String source,
|
||||||
String failureReason) {
|
String failureReason) {
|
||||||
|
this.success = failureReason == null || failureReason.length() == 0;
|
||||||
this.receivedAtMillis = receivedAtMillis;
|
this.receivedAtMillis = receivedAtMillis;
|
||||||
this.smsProviderId = smsProviderId;
|
this.smsProviderId = smsProviderId;
|
||||||
this.sender = sender == null ? "" : sender;
|
this.sender = sender == null ? "" : sender;
|
||||||
this.body = body == null ? "" : body;
|
this.body = body == null ? "" : body;
|
||||||
this.parseResult = parseResult;
|
|
||||||
this.source = source == null ? "unknown" : source;
|
this.source = source == null ? "unknown" : source;
|
||||||
this.failureReason = failureReason == null ? "" : failureReason;
|
this.failureReason = failureReason == null ? "" : failureReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
static CaptureResult success(
|
public static CaptureResult success(
|
||||||
long receivedAtMillis,
|
long receivedAtMillis,
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
VerificationCodeParser.ParseResult parseResult,
|
|
||||||
String source) {
|
String source) {
|
||||||
return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, parseResult, source);
|
return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
static CaptureResult success(
|
public static CaptureResult success(
|
||||||
long receivedAtMillis,
|
long receivedAtMillis,
|
||||||
long smsProviderId,
|
long smsProviderId,
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
VerificationCodeParser.ParseResult parseResult,
|
|
||||||
String source) {
|
String source) {
|
||||||
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, "");
|
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, source, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
static CaptureResult failure(
|
public static CaptureResult failure(
|
||||||
long receivedAtMillis,
|
long receivedAtMillis,
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
@ -56,14 +53,13 @@ final class CaptureResult {
|
|||||||
return failure(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source, failureReason);
|
return failure(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source, failureReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
static CaptureResult failure(
|
public static CaptureResult failure(
|
||||||
long receivedAtMillis,
|
long receivedAtMillis,
|
||||||
long smsProviderId,
|
long smsProviderId,
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
String source,
|
String source,
|
||||||
String failureReason) {
|
String failureReason) {
|
||||||
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.ParseResult.failure(failureReason);
|
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, source, failureReason);
|
||||||
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, failureReason);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,22 +1,21 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.sms;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
final class SmsCaptureStore {
|
public final class SmsCaptureStore {
|
||||||
static final String ACTION_CAPTURE_UPDATED = "com.smsreceive.app.ACTION_CAPTURE_UPDATED";
|
public static final String ACTION_CAPTURE_UPDATED = "com.smsreceive.app.ACTION_CAPTURE_UPDATED";
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
|
|
||||||
private static final String PREFS = "sms_capture";
|
private static final String PREFS = "sms_capture";
|
||||||
|
private static final String KEY_SUCCESS = "success";
|
||||||
private static final String KEY_TIME = "time";
|
private static final String KEY_TIME = "time";
|
||||||
private static final String KEY_SENDER = "sender";
|
private static final String KEY_SENDER = "sender";
|
||||||
private static final String KEY_CODE = "code";
|
|
||||||
private static final String KEY_STRATEGY = "strategy";
|
|
||||||
private static final String KEY_CONFIDENCE = "confidence";
|
|
||||||
private static final String KEY_SOURCE = "source";
|
private static final String KEY_SOURCE = "source";
|
||||||
private static final String KEY_FAILURE = "failure";
|
private static final String KEY_FAILURE = "failure";
|
||||||
|
private static final String KEY_BODY = "body";
|
||||||
private static final String KEY_BODY_PREVIEW = "body_preview";
|
private static final String KEY_BODY_PREVIEW = "body_preview";
|
||||||
private static final String KEY_LAST_BROADCAST_TIME = "last_broadcast_time";
|
private static final String KEY_LAST_BROADCAST_TIME = "last_broadcast_time";
|
||||||
private static final String KEY_LAST_INBOX_TIME = "last_inbox_time";
|
private static final String KEY_LAST_INBOX_TIME = "last_inbox_time";
|
||||||
@ -25,20 +24,17 @@ final class SmsCaptureStore {
|
|||||||
private SmsCaptureStore() {
|
private SmsCaptureStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void save(Context context, CaptureResult result) {
|
public static void save(Context context, CaptureResult result) {
|
||||||
VerificationCodeParser.ParseResult parse = result.parseResult;
|
|
||||||
Log.d(TAG, "SmsCaptureStore.save source=" + result.source
|
Log.d(TAG, "SmsCaptureStore.save source=" + result.source
|
||||||
+ ", success=" + parse.success
|
+ ", success=" + result.success
|
||||||
+ ", code=" + parse.code
|
+ ", failure=" + result.failureReason);
|
||||||
+ ", failure=" + (TextUtils.isEmpty(result.failureReason) ? parse.failureReason : result.failureReason));
|
|
||||||
SharedPreferences.Editor editor = preferences(context).edit()
|
SharedPreferences.Editor editor = preferences(context).edit()
|
||||||
|
.putBoolean(KEY_SUCCESS, result.success)
|
||||||
.putLong(KEY_TIME, result.receivedAtMillis)
|
.putLong(KEY_TIME, result.receivedAtMillis)
|
||||||
.putString(KEY_SENDER, summarizeSender(result.sender))
|
.putString(KEY_SENDER, summarizeSender(result.sender))
|
||||||
.putString(KEY_CODE, parse.code)
|
|
||||||
.putString(KEY_STRATEGY, parse.strategy)
|
|
||||||
.putInt(KEY_CONFIDENCE, parse.confidence)
|
|
||||||
.putString(KEY_SOURCE, result.source)
|
.putString(KEY_SOURCE, result.source)
|
||||||
.putString(KEY_FAILURE, TextUtils.isEmpty(result.failureReason) ? parse.failureReason : result.failureReason)
|
.putString(KEY_FAILURE, result.failureReason)
|
||||||
|
.putString(KEY_BODY, result.body)
|
||||||
.putString(KEY_BODY_PREVIEW, previewBody(result.body));
|
.putString(KEY_BODY_PREVIEW, previewBody(result.body));
|
||||||
if ("system_sms_broadcast".equals(result.source)) {
|
if ("system_sms_broadcast".equals(result.source)) {
|
||||||
Log.d(TAG, "SmsCaptureStore.save delivery source=system_sms_broadcast time="
|
Log.d(TAG, "SmsCaptureStore.save delivery source=system_sms_broadcast time="
|
||||||
@ -53,25 +49,26 @@ final class SmsCaptureStore {
|
|||||||
editor.apply();
|
editor.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static StoredCapture load(Context context) {
|
public static StoredCapture load(Context context) {
|
||||||
SharedPreferences prefs = preferences(context);
|
SharedPreferences prefs = preferences(context);
|
||||||
|
String bodyPreview = prefs.getString(KEY_BODY_PREVIEW, "");
|
||||||
|
String body = prefs.getString(KEY_BODY, "");
|
||||||
return new StoredCapture(
|
return new StoredCapture(
|
||||||
|
prefs.getBoolean(KEY_SUCCESS, TextUtils.isEmpty(prefs.getString(KEY_FAILURE, ""))),
|
||||||
prefs.getLong(KEY_TIME, 0L),
|
prefs.getLong(KEY_TIME, 0L),
|
||||||
prefs.getString(KEY_SENDER, ""),
|
prefs.getString(KEY_SENDER, ""),
|
||||||
prefs.getString(KEY_CODE, ""),
|
|
||||||
prefs.getString(KEY_STRATEGY, ""),
|
|
||||||
prefs.getInt(KEY_CONFIDENCE, 0),
|
|
||||||
prefs.getString(KEY_SOURCE, ""),
|
prefs.getString(KEY_SOURCE, ""),
|
||||||
prefs.getString(KEY_FAILURE, ""),
|
prefs.getString(KEY_FAILURE, ""),
|
||||||
prefs.getString(KEY_BODY_PREVIEW, ""));
|
TextUtils.isEmpty(body) ? bodyPreview : body,
|
||||||
|
bodyPreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void clear(Context context) {
|
public static void clear(Context context) {
|
||||||
Log.d(TAG, "SmsCaptureStore.clear");
|
Log.d(TAG, "SmsCaptureStore.clear");
|
||||||
preferences(context).edit().clear().apply();
|
preferences(context).edit().clear().apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static DeliveryDiagnostics loadDeliveryDiagnostics(Context context) {
|
public static DeliveryDiagnostics loadDeliveryDiagnostics(Context context) {
|
||||||
SharedPreferences prefs = preferences(context);
|
SharedPreferences prefs = preferences(context);
|
||||||
return new DeliveryDiagnostics(
|
return new DeliveryDiagnostics(
|
||||||
prefs.getLong(KEY_LAST_BROADCAST_TIME, 0L),
|
prefs.getLong(KEY_LAST_BROADCAST_TIME, 0L),
|
||||||
@ -101,40 +98,37 @@ final class SmsCaptureStore {
|
|||||||
return normalized.length() <= 48 ? normalized : normalized.substring(0, 48) + "...";
|
return normalized.length() <= 48 ? normalized : normalized.substring(0, 48) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class StoredCapture {
|
public static final class StoredCapture {
|
||||||
final long timeMillis;
|
public final boolean success;
|
||||||
final String sender;
|
public final long timeMillis;
|
||||||
final String code;
|
public final String sender;
|
||||||
final String strategy;
|
public final String source;
|
||||||
final int confidence;
|
public final String failure;
|
||||||
final String source;
|
public final String body;
|
||||||
final String failure;
|
public final String bodyPreview;
|
||||||
final String bodyPreview;
|
|
||||||
|
|
||||||
StoredCapture(
|
StoredCapture(
|
||||||
|
boolean success,
|
||||||
long timeMillis,
|
long timeMillis,
|
||||||
String sender,
|
String sender,
|
||||||
String code,
|
|
||||||
String strategy,
|
|
||||||
int confidence,
|
|
||||||
String source,
|
String source,
|
||||||
String failure,
|
String failure,
|
||||||
|
String body,
|
||||||
String bodyPreview) {
|
String bodyPreview) {
|
||||||
|
this.success = success;
|
||||||
this.timeMillis = timeMillis;
|
this.timeMillis = timeMillis;
|
||||||
this.sender = sender;
|
this.sender = sender;
|
||||||
this.code = code;
|
|
||||||
this.strategy = strategy;
|
|
||||||
this.confidence = confidence;
|
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this.failure = failure;
|
this.failure = failure;
|
||||||
|
this.body = body;
|
||||||
this.bodyPreview = bodyPreview;
|
this.bodyPreview = bodyPreview;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class DeliveryDiagnostics {
|
public static final class DeliveryDiagnostics {
|
||||||
final long lastBroadcastTimeMillis;
|
public final long lastBroadcastTimeMillis;
|
||||||
final long lastInboxTimeMillis;
|
public final long lastInboxTimeMillis;
|
||||||
final String lastInboxSource;
|
public final String lastInboxSource;
|
||||||
|
|
||||||
DeliveryDiagnostics(long lastBroadcastTimeMillis, long lastInboxTimeMillis, String lastInboxSource) {
|
DeliveryDiagnostics(long lastBroadcastTimeMillis, long lastInboxTimeMillis, String lastInboxSource) {
|
||||||
this.lastBroadcastTimeMillis = lastBroadcastTimeMillis;
|
this.lastBroadcastTimeMillis = lastBroadcastTimeMillis;
|
||||||
@ -142,7 +136,7 @@ final class SmsCaptureStore {
|
|||||||
this.lastInboxSource = lastInboxSource == null ? "" : lastInboxSource;
|
this.lastInboxSource = lastInboxSource == null ? "" : lastInboxSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean inboxNewerThanBroadcast() {
|
public boolean inboxNewerThanBroadcast() {
|
||||||
return lastInboxTimeMillis > 0L && lastInboxTimeMillis > lastBroadcastTimeMillis;
|
return lastInboxTimeMillis > 0L && lastInboxTimeMillis > lastBroadcastTimeMillis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.sms;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
@ -11,14 +11,14 @@ import java.text.SimpleDateFormat;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
final class SmsInboxReader {
|
public final class SmsInboxReader {
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final Uri SMS_INBOX_URI = Uri.parse("content://sms/inbox");
|
private static final Uri SMS_INBOX_URI = Uri.parse("content://sms/inbox");
|
||||||
|
|
||||||
private SmsInboxReader() {
|
private SmsInboxReader() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static InboxResult readLatest(Context context) {
|
public static InboxResult readLatest(Context context) {
|
||||||
String[] projection = {
|
String[] projection = {
|
||||||
Telephony.Sms._ID,
|
Telephony.Sms._ID,
|
||||||
Telephony.Sms.ADDRESS,
|
Telephony.Sms.ADDRESS,
|
||||||
@ -64,63 +64,11 @@ final class SmsInboxReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static int logRecentMessages(Context context, int limit) {
|
public static RecentSmsResult findLatestSms(Context context, int limit) {
|
||||||
Uri uri = Telephony.Sms.CONTENT_URI;
|
return findLatestSms(context, limit, 0L);
|
||||||
String[] projection = {
|
|
||||||
Telephony.Sms._ID,
|
|
||||||
Telephony.Sms.ADDRESS,
|
|
||||||
Telephony.Sms.BODY,
|
|
||||||
Telephony.Sms.DATE,
|
|
||||||
Telephony.Sms.TYPE
|
|
||||||
};
|
|
||||||
|
|
||||||
int safeLimit = Math.max(1, Math.min(limit, 100));
|
|
||||||
Log.d(TAG, "SmsInboxReader.logRecentMessages start uri=" + uri + ", limit=" + safeLimit);
|
|
||||||
try (Cursor cursor = context.getContentResolver().query(
|
|
||||||
uri,
|
|
||||||
projection,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) {
|
|
||||||
if (cursor == null) {
|
|
||||||
Log.w(TAG, "SmsInboxReader.logRecentMessages cursor is null");
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int count = 0;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static RecentCodeResult findLatestVerificationCode(Context context, int limit) {
|
|
||||||
return findLatestVerificationCode(context, limit, 0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
static RecentCodeResult findLatestVerificationCode(Context context, int limit, long minDateMillis) {
|
|
||||||
Uri uri = Telephony.Sms.CONTENT_URI;
|
Uri uri = Telephony.Sms.CONTENT_URI;
|
||||||
String[] projection = {
|
String[] projection = {
|
||||||
Telephony.Sms._ID,
|
Telephony.Sms._ID,
|
||||||
@ -133,7 +81,7 @@ final class SmsInboxReader {
|
|||||||
int safeLimit = Math.max(1, Math.min(limit, 100));
|
int safeLimit = Math.max(1, Math.min(limit, 100));
|
||||||
String selection = minDateMillis > 0L ? Telephony.Sms.DATE + ">=?" : null;
|
String selection = minDateMillis > 0L ? Telephony.Sms.DATE + ">=?" : null;
|
||||||
String[] selectionArgs = minDateMillis > 0L ? new String[]{String.valueOf(minDateMillis)} : null;
|
String[] selectionArgs = minDateMillis > 0L ? new String[]{String.valueOf(minDateMillis)} : null;
|
||||||
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode start limit=" + safeLimit
|
Log.d(TAG, "SmsInboxReader.findLatestSms start limit=" + safeLimit
|
||||||
+ ", minDate=" + (minDateMillis > 0L ? formatDate(minDateMillis) : "none"));
|
+ ", minDate=" + (minDateMillis > 0L ? formatDate(minDateMillis) : "none"));
|
||||||
try (Cursor cursor = context.getContentResolver().query(
|
try (Cursor cursor = context.getContentResolver().query(
|
||||||
uri,
|
uri,
|
||||||
@ -142,54 +90,39 @@ final class SmsInboxReader {
|
|||||||
selectionArgs,
|
selectionArgs,
|
||||||
Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) {
|
Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) {
|
||||||
if (cursor == null) {
|
if (cursor == null) {
|
||||||
Log.w(TAG, "SmsInboxReader.findLatestVerificationCode cursor is null");
|
Log.w(TAG, "SmsInboxReader.findLatestSms cursor is null");
|
||||||
return RecentCodeResult.failure("短信库查询 cursor 为空");
|
return RecentSmsResult.failure("短信库查询 cursor 为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
int scanned = 0;
|
int scanned = 0;
|
||||||
RecentCodeResult latest = null;
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID));
|
long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID));
|
||||||
String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS));
|
String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS));
|
||||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY));
|
String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY));
|
||||||
long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE));
|
long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE));
|
||||||
int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE));
|
int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE));
|
||||||
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(body);
|
scanned++;
|
||||||
Log.d(TAG, "poll scan SMS[" + scanned + "] id=" + id
|
Log.d(TAG, "poll scan SMS[" + (scanned - 1) + "] id=" + id
|
||||||
+ ", type=" + smsTypeName(type)
|
+ ", type=" + smsTypeName(type)
|
||||||
+ ", date=" + formatDate(date)
|
+ ", date=" + formatDate(date)
|
||||||
+ ", sender=" + maskSender(sender)
|
+ ", sender=" + maskSender(sender)
|
||||||
+ ", parseSuccess=" + parseResult.success
|
|
||||||
+ ", code=" + parseResult.code
|
|
||||||
+ ", strategy=" + parseResult.strategy
|
|
||||||
+ ", bodyPreview=" + previewBody(body));
|
+ ", bodyPreview=" + previewBody(body));
|
||||||
scanned++;
|
if (TextUtils.isEmpty(body)) {
|
||||||
if (parseResult.success) {
|
continue;
|
||||||
RecentCodeResult candidate = RecentCodeResult.success(id, sender, body, date, parseResult, scanned);
|
|
||||||
if (latest == null || candidate.dateMillis > latest.dateMillis) {
|
|
||||||
latest = candidate;
|
|
||||||
}
|
}
|
||||||
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode candidate id=" + id
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,13 +164,13 @@ final class SmsInboxReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class InboxResult {
|
public static final class InboxResult {
|
||||||
final boolean success;
|
public final boolean success;
|
||||||
final long id;
|
public final long id;
|
||||||
final String sender;
|
public final String sender;
|
||||||
final String body;
|
public final String body;
|
||||||
final long dateMillis;
|
public final long dateMillis;
|
||||||
final String failureReason;
|
public final String failureReason;
|
||||||
|
|
||||||
private InboxResult(boolean success, long id, String sender, String body, long dateMillis, String failureReason) {
|
private InboxResult(boolean success, long id, String sender, String body, long dateMillis, String failureReason) {
|
||||||
this.success = success;
|
this.success = success;
|
||||||
@ -257,23 +190,21 @@ final class SmsInboxReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class RecentCodeResult {
|
public static final class RecentSmsResult {
|
||||||
final boolean success;
|
public final boolean success;
|
||||||
final long id;
|
public final long id;
|
||||||
final String sender;
|
public final String sender;
|
||||||
final String body;
|
public final String body;
|
||||||
final long dateMillis;
|
public final long dateMillis;
|
||||||
final VerificationCodeParser.ParseResult parseResult;
|
public final int scannedCount;
|
||||||
final int scannedCount;
|
public final String failureReason;
|
||||||
final String failureReason;
|
|
||||||
|
|
||||||
private RecentCodeResult(
|
private RecentSmsResult(
|
||||||
boolean success,
|
boolean success,
|
||||||
long id,
|
long id,
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
long dateMillis,
|
long dateMillis,
|
||||||
VerificationCodeParser.ParseResult parseResult,
|
|
||||||
int scannedCount,
|
int scannedCount,
|
||||||
String failureReason) {
|
String failureReason) {
|
||||||
this.success = success;
|
this.success = success;
|
||||||
@ -281,33 +212,25 @@ final class SmsInboxReader {
|
|||||||
this.sender = sender == null ? "" : sender;
|
this.sender = sender == null ? "" : sender;
|
||||||
this.body = body == null ? "" : body;
|
this.body = body == null ? "" : body;
|
||||||
this.dateMillis = dateMillis;
|
this.dateMillis = dateMillis;
|
||||||
this.parseResult = parseResult;
|
|
||||||
this.scannedCount = scannedCount;
|
this.scannedCount = scannedCount;
|
||||||
this.failureReason = failureReason == null ? "" : failureReason;
|
this.failureReason = failureReason == null ? "" : failureReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
static RecentCodeResult success(
|
static RecentSmsResult success(
|
||||||
long id,
|
long id,
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
long dateMillis,
|
long dateMillis,
|
||||||
VerificationCodeParser.ParseResult parseResult,
|
|
||||||
int scannedCount) {
|
int scannedCount) {
|
||||||
return new RecentCodeResult(true, id, sender, body, dateMillis, parseResult, scannedCount, "");
|
return new RecentSmsResult(true, id, sender, body, dateMillis, scannedCount, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
static RecentCodeResult noCode(int scannedCount) {
|
static RecentSmsResult noSms(int scannedCount) {
|
||||||
return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(),
|
return new RecentSmsResult(false, -1L, "", "", System.currentTimeMillis(), scannedCount, "最近短信未找到新短信");
|
||||||
VerificationCodeParser.ParseResult.failure("最近短信未找到验证码"), scannedCount, "最近短信未找到验证码");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static RecentCodeResult failure(String reason) {
|
static RecentSmsResult failure(String reason) {
|
||||||
return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(),
|
return new RecentSmsResult(false, -1L, "", "", System.currentTimeMillis(), 0, reason);
|
||||||
VerificationCodeParser.ParseResult.failure(reason), 0, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
RecentCodeResult withScannedCount(int scannedCount) {
|
|
||||||
return new RecentCodeResult(success, id, sender, body, dateMillis, parseResult, scannedCount, failureReason);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.sms;
|
||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.provider.Telephony;
|
import android.provider.Telephony;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.sms;
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@ -7,6 +7,8 @@ import android.provider.Telephony;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.smsreceive.app.feishu.FeishuWebhookClient;
|
||||||
|
|
||||||
public final class SmsReceiver extends BroadcastReceiver {
|
public final class SmsReceiver extends BroadcastReceiver {
|
||||||
private static final String TAG = "[SMS]SmsReceive";
|
private static final String TAG = "[SMS]SmsReceive";
|
||||||
private static final String SOURCE_SYSTEM_BROADCAST = "system_sms_broadcast";
|
private static final String SOURCE_SYSTEM_BROADCAST = "system_sms_broadcast";
|
||||||
@ -23,35 +25,19 @@ public final class SmsReceiver extends BroadcastReceiver {
|
|||||||
Log.d(TAG, "SmsReceiver.onReceive ignore non SMS action");
|
Log.d(TAG, "SmsReceiver.onReceive ignore non SMS action");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Toast.makeText(context, "收到短信广播,开始解析", Toast.LENGTH_SHORT).show();
|
Toast.makeText(context, "收到短信广播,开始处理", Toast.LENGTH_SHORT).show();
|
||||||
|
|
||||||
SmsMessageReader.ReadResult readResult = SmsMessageReader.read(intent);
|
SmsMessageReader.ReadResult readResult = SmsMessageReader.read(intent);
|
||||||
CaptureResult captureResult;
|
CaptureResult captureResult;
|
||||||
if (readResult.success) {
|
if (readResult.success) {
|
||||||
Log.d(TAG, "SMS read success sender=" + maskSender(readResult.sender)
|
Log.d(TAG, "SMS read success sender=" + maskSender(readResult.sender)
|
||||||
+ ", bodyPreview=" + preview(readResult.body));
|
+ ", bodyPreview=" + preview(readResult.body));
|
||||||
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(readResult.body);
|
Toast.makeText(context, "短信已收到", Toast.LENGTH_LONG).show();
|
||||||
if (parseResult.success) {
|
|
||||||
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();
|
||||||
@ -69,7 +55,7 @@ public final class SmsReceiver extends BroadcastReceiver {
|
|||||||
updateIntent.setPackage(context.getPackageName());
|
updateIntent.setPackage(context.getPackageName());
|
||||||
context.sendBroadcast(updateIntent);
|
context.sendBroadcast(updateIntent);
|
||||||
Log.d(TAG, "SmsReceiver.onReceive end source=" + SOURCE_SYSTEM_BROADCAST
|
Log.d(TAG, "SmsReceiver.onReceive end source=" + SOURCE_SYSTEM_BROADCAST
|
||||||
+ ", success=" + captureResult.parseResult.success);
|
+ ", success=" + captureResult.success);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String maskSender(String sender) {
|
private static String maskSender(String sender) {
|
||||||
@ -1,7 +1,8 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.ui;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.app.AlertDialog;
|
||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.content.ActivityNotFoundException;
|
import android.content.ActivityNotFoundException;
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
@ -19,22 +20,30 @@ import android.os.Looper;
|
|||||||
import android.os.PowerManager;
|
import android.os.PowerManager;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.provider.Telephony;
|
import android.provider.Telephony;
|
||||||
import android.text.InputType;
|
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Gravity;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.CheckBox;
|
import android.widget.CheckBox;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.LinearLayout;
|
|
||||||
import android.widget.RadioButton;
|
import android.widget.RadioButton;
|
||||||
import android.widget.ScrollView;
|
|
||||||
import android.widget.Switch;
|
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.smsreceive.app.R;
|
||||||
|
import com.smsreceive.app.feishu.FeishuWebhookClient;
|
||||||
|
import com.smsreceive.app.feishu.FeishuWebhookConfigStore;
|
||||||
|
import com.smsreceive.app.feishu.FeishuWebhookPushResult;
|
||||||
|
import com.smsreceive.app.keepalive.KeepAliveDatabase;
|
||||||
|
import com.smsreceive.app.keepalive.KeepAliveStateStore;
|
||||||
|
import com.smsreceive.app.keepalive.SmsKeepAliveService;
|
||||||
|
import com.smsreceive.app.keepalive.SmsPollingService;
|
||||||
|
import com.smsreceive.app.keepalive.SmsPollingStateStore;
|
||||||
|
import com.smsreceive.app.sms.CaptureResult;
|
||||||
|
import com.smsreceive.app.sms.SmsCaptureStore;
|
||||||
|
import com.smsreceive.app.sms.SmsInboxReader;
|
||||||
|
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@ -48,24 +57,30 @@ public final class MainActivity extends Activity {
|
|||||||
private static final long DATABASE_HEARTBEAT_STALE_MILLIS = 30_000L;
|
private static final long DATABASE_HEARTBEAT_STALE_MILLIS = 30_000L;
|
||||||
|
|
||||||
private TextView permissionText;
|
private TextView permissionText;
|
||||||
|
private TextView latestText;
|
||||||
private TextView googlePlayText;
|
private TextView googlePlayText;
|
||||||
private TextView keepAliveText;
|
private TextView keepAliveText;
|
||||||
private TextView databaseHeartbeatText;
|
private TextView databaseHeartbeatText;
|
||||||
private TextView deliveryDiagnosticsText;
|
private TextView deliveryDiagnosticsText;
|
||||||
private TextView feishuPushText;
|
private TextView feishuPushText;
|
||||||
private TextView latestText;
|
|
||||||
private Button keepAliveButton;
|
private Button keepAliveButton;
|
||||||
private Button autostartConfirmButton;
|
|
||||||
private Button batteryConfirmButton;
|
|
||||||
private Button pollingButton;
|
private Button pollingButton;
|
||||||
private RadioButton toastOnDatabaseWriteRadio;
|
private RadioButton toastOnDatabaseWriteRadio;
|
||||||
private CheckBox feishuPushEnabledCheckBox;
|
private CheckBox feishuPushEnabledCheckBox;
|
||||||
private CheckBox feishuDebugBodyCheckBox;
|
private CheckBox autostartConfirmCheckBox;
|
||||||
private Switch feishuFilterCodeSwitch;
|
private CheckBox batteryConfirmCheckBox;
|
||||||
private EditText feishuWebhookIdEdit;
|
private EditText feishuWebhookIdEdit;
|
||||||
private EditText feishuSecretEdit;
|
private EditText feishuSecretEdit;
|
||||||
private EditText pollingIntervalEdit;
|
private EditText pollingIntervalEdit;
|
||||||
|
private AlertDialog debugInfoDialog;
|
||||||
private long lastInboxSmsId = -1L;
|
private long lastInboxSmsId = -1L;
|
||||||
|
private String googlePlayTextValue = "";
|
||||||
|
private String keepAliveTextValue = "";
|
||||||
|
private String databaseHeartbeatTextValue = "";
|
||||||
|
private String deliveryDiagnosticsTextValue = "";
|
||||||
|
private String feishuPushTextValue = "";
|
||||||
|
private int databaseHeartbeatBackgroundColor = 0xFFFFFFFF;
|
||||||
|
private int feishuPushBackgroundColor = 0xFFFFFFFF;
|
||||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
|
||||||
private final BroadcastReceiver updateReceiver = new BroadcastReceiver() {
|
private final BroadcastReceiver updateReceiver = new BroadcastReceiver() {
|
||||||
@ -82,11 +97,15 @@ public final class MainActivity extends Activity {
|
|||||||
readLatestInboxSms(SOURCE_INBOX_OBSERVER, true);
|
readLatestInboxSms(SOURCE_INBOX_OBSERVER, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
Log.d(TAG, "MainActivity.onCreate");
|
Log.d(TAG, "MainActivity.onCreate");
|
||||||
setContentView(createContentView());
|
setContentView(R.layout.activity_main);
|
||||||
|
bindMainViews();
|
||||||
|
bindMainActions();
|
||||||
|
refreshUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -130,214 +149,99 @@ public final class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private View createContentView() {
|
private void bindMainViews() {
|
||||||
ScrollView scrollView = new ScrollView(this);
|
permissionText = findViewById(R.id.permission_text);
|
||||||
scrollView.setFillViewport(true);
|
latestText = findViewById(R.id.latest_text);
|
||||||
|
keepAliveButton = findViewById(R.id.keep_alive_button);
|
||||||
|
toastOnDatabaseWriteRadio = findViewById(R.id.toast_database_write_radio);
|
||||||
|
pollingButton = findViewById(R.id.polling_button);
|
||||||
|
feishuPushEnabledCheckBox = findViewById(R.id.feishu_push_enabled_checkbox);
|
||||||
|
autostartConfirmCheckBox = findViewById(R.id.autostart_confirm_checkbox);
|
||||||
|
batteryConfirmCheckBox = findViewById(R.id.battery_confirm_checkbox);
|
||||||
|
feishuWebhookIdEdit = findViewById(R.id.feishu_webhook_id_edit);
|
||||||
|
feishuSecretEdit = findViewById(R.id.feishu_secret_edit);
|
||||||
|
pollingIntervalEdit = findViewById(R.id.polling_interval_edit);
|
||||||
|
}
|
||||||
|
|
||||||
LinearLayout root = new LinearLayout(this);
|
private void bindMainActions() {
|
||||||
root.setOrientation(LinearLayout.VERTICAL);
|
findViewById(R.id.request_permission_button).setOnClickListener(v -> requestSmsPermission());
|
||||||
root.setPadding(dp(20), dp(24), dp(20), dp(24));
|
findViewById(R.id.debug_info_button).setOnClickListener(v -> showDebugInfoDialog());
|
||||||
scrollView.addView(root, new ScrollView.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
|
||||||
|
|
||||||
TextView title = new TextView(this);
|
|
||||||
title.setText("短信验证码接收");
|
|
||||||
title.setTextSize(24);
|
|
||||||
title.setTextColor(0xFF17202A);
|
|
||||||
title.setGravity(Gravity.START);
|
|
||||||
root.addView(title, matchWrap());
|
|
||||||
|
|
||||||
TextView subtitle = new TextView(this);
|
|
||||||
subtitle.setText("主路径:RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后只保存验证码和诊断摘要。");
|
|
||||||
subtitle.setTextSize(14);
|
|
||||||
subtitle.setTextColor(0xFF5F6B7A);
|
|
||||||
subtitle.setPadding(0, dp(6), 0, dp(16));
|
|
||||||
root.addView(subtitle, matchWrap());
|
|
||||||
|
|
||||||
permissionText = section(root, "权限状态");
|
|
||||||
googlePlayText = section(root, "Google API 诊断");
|
|
||||||
keepAliveText = section(root, "后台保活状态");
|
|
||||||
databaseHeartbeatText = section(root, "数据库心跳诊断");
|
|
||||||
deliveryDiagnosticsText = section(root, "短信广播诊断");
|
|
||||||
feishuPushText = section(root, "飞书推送状态");
|
|
||||||
latestText = section(root, "最近结果");
|
|
||||||
|
|
||||||
LinearLayout actions = new LinearLayout(this);
|
|
||||||
actions.setOrientation(LinearLayout.VERTICAL);
|
|
||||||
actions.setPadding(0, dp(12), 0, 0);
|
|
||||||
root.addView(actions, matchWrap());
|
|
||||||
|
|
||||||
Button requestPermissionButton = button("申请短信权限");
|
|
||||||
requestPermissionButton.setOnClickListener(v -> requestSmsPermission());
|
|
||||||
actions.addView(requestPermissionButton, matchWrap());
|
|
||||||
|
|
||||||
keepAliveButton = button("开启常驻保活");
|
|
||||||
keepAliveButton.setOnClickListener(v -> toggleKeepAlive());
|
keepAliveButton.setOnClickListener(v -> toggleKeepAlive());
|
||||||
actions.addView(keepAliveButton, matchWrap());
|
|
||||||
|
|
||||||
toastOnDatabaseWriteRadio = new RadioButton(this);
|
|
||||||
toastOnDatabaseWriteRadio.setText("每次写入数据库时弹 Toast");
|
|
||||||
toastOnDatabaseWriteRadio.setTextSize(14);
|
|
||||||
toastOnDatabaseWriteRadio.setTextColor(0xFF27313F);
|
|
||||||
toastOnDatabaseWriteRadio.setOnClickListener(v -> toggleToastOnDatabaseWrite());
|
toastOnDatabaseWriteRadio.setOnClickListener(v -> toggleToastOnDatabaseWrite());
|
||||||
actions.addView(toastOnDatabaseWriteRadio, matchWrap());
|
findViewById(R.id.read_inbox_button).setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true));
|
||||||
|
|
||||||
Button readInboxButton = button("读取最新短信");
|
|
||||||
readInboxButton.setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true));
|
|
||||||
actions.addView(readInboxButton, matchWrap());
|
|
||||||
|
|
||||||
Button dumpRecentButton = button("打印最近30条短信");
|
|
||||||
dumpRecentButton.setOnClickListener(v -> dumpRecentMessages());
|
|
||||||
actions.addView(dumpRecentButton, matchWrap());
|
|
||||||
|
|
||||||
pollingButton = button("开始1秒轮询验证码");
|
|
||||||
pollingButton.setOnClickListener(v -> togglePolling());
|
pollingButton.setOnClickListener(v -> togglePolling());
|
||||||
actions.addView(pollingButton, matchWrap());
|
findViewById(R.id.save_feishu_button).setOnClickListener(v -> saveFeishuConfigFromUi());
|
||||||
|
findViewById(R.id.test_feishu_button).setOnClickListener(v -> testFeishuPush());
|
||||||
pollingIntervalEdit = new EditText(this);
|
findViewById(R.id.settings_button).setOnClickListener(v -> openAppSettings());
|
||||||
pollingIntervalEdit.setHint("轮询间隔秒数,默认 1");
|
findViewById(R.id.battery_settings_button).setOnClickListener(v -> openBatteryOptimizationSettings());
|
||||||
pollingIntervalEdit.setSingleLine(true);
|
findViewById(R.id.request_battery_button).setOnClickListener(v -> requestIgnoreBatteryOptimizations());
|
||||||
pollingIntervalEdit.setInputType(InputType.TYPE_CLASS_NUMBER);
|
findViewById(R.id.xiaomi_autostart_button).setOnClickListener(v -> openXiaomiAutostartSettings());
|
||||||
actions.addView(pollingIntervalEdit, matchWrap());
|
autostartConfirmCheckBox.setOnClickListener(v -> toggleManualAutostartConfirmed());
|
||||||
|
batteryConfirmCheckBox.setOnClickListener(v -> toggleManualBatteryConfirmed());
|
||||||
Button savePollingIntervalButton = button("保存轮询间隔");
|
findViewById(R.id.clear_button).setOnClickListener(v -> {
|
||||||
savePollingIntervalButton.setOnClickListener(v -> savePollingIntervalFromUi());
|
|
||||||
actions.addView(savePollingIntervalButton, matchWrap());
|
|
||||||
|
|
||||||
feishuPushEnabledCheckBox = new CheckBox(this);
|
|
||||||
feishuPushEnabledCheckBox.setText("开启飞书远端推送");
|
|
||||||
feishuPushEnabledCheckBox.setTextSize(14);
|
|
||||||
feishuPushEnabledCheckBox.setTextColor(0xFF27313F);
|
|
||||||
actions.addView(feishuPushEnabledCheckBox, matchWrap());
|
|
||||||
|
|
||||||
feishuWebhookIdEdit = new EditText(this);
|
|
||||||
feishuWebhookIdEdit.setHint("飞书 webhook id");
|
|
||||||
feishuWebhookIdEdit.setSingleLine(true);
|
|
||||||
actions.addView(feishuWebhookIdEdit, matchWrap());
|
|
||||||
|
|
||||||
feishuSecretEdit = new EditText(this);
|
|
||||||
feishuSecretEdit.setHint("飞书 webhook secret");
|
|
||||||
feishuSecretEdit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
|
||||||
feishuSecretEdit.setSingleLine(true);
|
|
||||||
actions.addView(feishuSecretEdit, matchWrap());
|
|
||||||
|
|
||||||
feishuDebugBodyCheckBox = new CheckBox(this);
|
|
||||||
feishuDebugBodyCheckBox.setText("调试时上传完整短信正文");
|
|
||||||
feishuDebugBodyCheckBox.setTextSize(14);
|
|
||||||
feishuDebugBodyCheckBox.setTextColor(0xFF27313F);
|
|
||||||
actions.addView(feishuDebugBodyCheckBox, matchWrap());
|
|
||||||
|
|
||||||
feishuFilterCodeSwitch = new Switch(this);
|
|
||||||
feishuFilterCodeSwitch.setText("只推送验证码(过滤非验证码短信)");
|
|
||||||
feishuFilterCodeSwitch.setTextSize(14);
|
|
||||||
feishuFilterCodeSwitch.setTextColor(0xFF27313F);
|
|
||||||
feishuFilterCodeSwitch.setChecked(false);
|
|
||||||
actions.addView(feishuFilterCodeSwitch, matchWrap());
|
|
||||||
|
|
||||||
Button saveFeishuButton = button("保存飞书配置");
|
|
||||||
saveFeishuButton.setOnClickListener(v -> saveFeishuConfigFromUi());
|
|
||||||
actions.addView(saveFeishuButton, matchWrap());
|
|
||||||
|
|
||||||
Button testFeishuButton = button("测试飞书推送");
|
|
||||||
testFeishuButton.setOnClickListener(v -> testFeishuPush());
|
|
||||||
actions.addView(testFeishuButton, matchWrap());
|
|
||||||
|
|
||||||
Button settingsButton = button("打开应用权限设置");
|
|
||||||
settingsButton.setOnClickListener(v -> openAppSettings());
|
|
||||||
actions.addView(settingsButton, matchWrap());
|
|
||||||
|
|
||||||
Button batterySettingsButton = button("打开电池优化设置");
|
|
||||||
batterySettingsButton.setOnClickListener(v -> openBatteryOptimizationSettings());
|
|
||||||
actions.addView(batterySettingsButton, matchWrap());
|
|
||||||
|
|
||||||
Button requestBatteryButton = button("请求忽略电池优化");
|
|
||||||
requestBatteryButton.setOnClickListener(v -> requestIgnoreBatteryOptimizations());
|
|
||||||
actions.addView(requestBatteryButton, matchWrap());
|
|
||||||
|
|
||||||
Button xiaomiAutostartButton = button("打开小米自启动设置");
|
|
||||||
xiaomiAutostartButton.setOnClickListener(v -> openXiaomiAutostartSettings());
|
|
||||||
actions.addView(xiaomiAutostartButton, matchWrap());
|
|
||||||
|
|
||||||
autostartConfirmButton = button("确认已开启小米自启动");
|
|
||||||
autostartConfirmButton.setOnClickListener(v -> toggleManualAutostartConfirmed());
|
|
||||||
actions.addView(autostartConfirmButton, matchWrap());
|
|
||||||
|
|
||||||
batteryConfirmButton = button("确认省电策略已设为无限制");
|
|
||||||
batteryConfirmButton.setOnClickListener(v -> toggleManualBatteryConfirmed());
|
|
||||||
actions.addView(batteryConfirmButton, matchWrap());
|
|
||||||
|
|
||||||
Button clearButton = button("清空最近结果");
|
|
||||||
clearButton.setOnClickListener(v -> {
|
|
||||||
SmsCaptureStore.clear(this);
|
SmsCaptureStore.clear(this);
|
||||||
Toast.makeText(this, "已清空最近结果", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "已清空最近结果", Toast.LENGTH_SHORT).show();
|
||||||
refreshUi();
|
refreshUi();
|
||||||
});
|
});
|
||||||
actions.addView(clearButton, matchWrap());
|
findViewById(R.id.refresh_button).setOnClickListener(v -> refreshUi());
|
||||||
|
|
||||||
Button refreshButton = button("刷新状态");
|
|
||||||
refreshButton.setOnClickListener(v -> refreshUi());
|
|
||||||
actions.addView(refreshButton, matchWrap());
|
|
||||||
|
|
||||||
return scrollView;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TextView section(LinearLayout root, String label) {
|
private void showDebugInfoDialog() {
|
||||||
TextView title = new TextView(this);
|
if (debugInfoDialog == null) {
|
||||||
title.setText(label);
|
View debugInfoView = LayoutInflater.from(this).inflate(R.layout.dialog_debug_info, null);
|
||||||
title.setTextSize(16);
|
googlePlayText = debugInfoView.findViewById(R.id.google_play_text);
|
||||||
title.setTextColor(0xFF17202A);
|
keepAliveText = debugInfoView.findViewById(R.id.keep_alive_text);
|
||||||
title.setPadding(0, dp(12), 0, dp(4));
|
databaseHeartbeatText = debugInfoView.findViewById(R.id.database_heartbeat_text);
|
||||||
root.addView(title, matchWrap());
|
deliveryDiagnosticsText = debugInfoView.findViewById(R.id.delivery_diagnostics_text);
|
||||||
|
feishuPushText = debugInfoView.findViewById(R.id.feishu_push_text);
|
||||||
TextView value = new TextView(this);
|
debugInfoDialog = new AlertDialog.Builder(this)
|
||||||
value.setTextSize(14);
|
.setTitle("调试信息")
|
||||||
value.setTextColor(0xFF27313F);
|
.setView(debugInfoView)
|
||||||
value.setLineSpacing(dp(2), 1.0f);
|
.setPositiveButton("关闭", null)
|
||||||
value.setPadding(dp(12), dp(10), dp(12), dp(10));
|
.create();
|
||||||
value.setBackgroundColor(0xFFFFFFFF);
|
}
|
||||||
root.addView(value, matchWrap());
|
applyDebugInfoState();
|
||||||
return value;
|
debugInfoDialog.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Button button(String text) {
|
private void applyDebugInfoState() {
|
||||||
Button button = new Button(this);
|
if (googlePlayText == null) {
|
||||||
button.setText(text);
|
return;
|
||||||
button.setAllCaps(false);
|
|
||||||
return button;
|
|
||||||
}
|
}
|
||||||
|
googlePlayText.setText(googlePlayTextValue);
|
||||||
private LinearLayout.LayoutParams matchWrap() {
|
keepAliveText.setText(keepAliveTextValue);
|
||||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
databaseHeartbeatText.setText(databaseHeartbeatTextValue);
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
databaseHeartbeatText.setBackgroundColor(databaseHeartbeatBackgroundColor);
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT);
|
deliveryDiagnosticsText.setText(deliveryDiagnosticsTextValue);
|
||||||
params.setMargins(0, dp(4), 0, dp(4));
|
feishuPushText.setText(feishuPushTextValue);
|
||||||
return params;
|
feishuPushText.setBackgroundColor(feishuPushBackgroundColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshUi() {
|
private void refreshUi() {
|
||||||
boolean receiveGranted = hasReceiveSmsPermission();
|
boolean receiveGranted = hasReceiveSmsPermission();
|
||||||
boolean readGranted = hasReadSmsPermission();
|
boolean readGranted = hasReadSmsPermission();
|
||||||
|
boolean googlePlayInstalled = isGooglePlayServicesInstalled();
|
||||||
Log.d(TAG, "refreshUi receiveSmsPermissionGranted=" + receiveGranted
|
Log.d(TAG, "refreshUi receiveSmsPermissionGranted=" + receiveGranted
|
||||||
+ ", readSmsPermissionGranted=" + readGranted
|
+ ", readSmsPermissionGranted=" + readGranted
|
||||||
+ ", googlePlayInstalled=" + isGooglePlayServicesInstalled());
|
+ ", googlePlayInstalled=" + googlePlayInstalled);
|
||||||
permissionText.setText("RECEIVE_SMS:" + (receiveGranted ? "已授权" : "未授权")
|
permissionText.setText("RECEIVE_SMS:" + (receiveGranted ? "已授权" : "未授权")
|
||||||
+ "\nREAD_SMS:" + (readGranted ? "已授权" : "未授权")
|
+ "\nREAD_SMS:" + (readGranted ? "已授权" : "未授权")
|
||||||
+ "\n说明:如果 receiver 收不到广播,前台会用 READ_SMS 读取最新收件箱作为兜底。");
|
+ "\n说明:如果 receiver 收不到广播,前台会用 READ_SMS 读取最新收件箱作为兜底。");
|
||||||
|
|
||||||
googlePlayText.setText(isGooglePlayServicesInstalled()
|
googlePlayTextValue = googlePlayInstalled
|
||||||
? "已检测到 com.google.android.gms。SMS User Consent / Retriever 可作为后续备选路径验证。"
|
? "已检测到 com.google.android.gms。SMS User Consent / Retriever 可作为后续备选路径验证。"
|
||||||
: "未检测到 com.google.android.gms。当前实现不依赖 Google API,主路径仍是系统短信广播。");
|
: "未检测到 com.google.android.gms。当前实现不依赖 Google API,主路径仍是系统短信广播。";
|
||||||
|
|
||||||
refreshKeepAliveUi();
|
refreshKeepAliveUi();
|
||||||
refreshDatabaseHeartbeatUi();
|
refreshDatabaseHeartbeatUi();
|
||||||
refreshDeliveryDiagnosticsUi();
|
refreshDeliveryDiagnosticsUi();
|
||||||
refreshPollingUi();
|
refreshPollingUi();
|
||||||
refreshFeishuPushUi();
|
refreshFeishuPushUi();
|
||||||
|
applyDebugInfoState();
|
||||||
|
|
||||||
SmsCaptureStore.StoredCapture capture = SmsCaptureStore.load(this);
|
SmsCaptureStore.StoredCapture capture = SmsCaptureStore.load(this);
|
||||||
if (capture.timeMillis <= 0L) {
|
if (capture.timeMillis <= 0L) {
|
||||||
latestText.setText("暂无短信接收记录。可以先授权,再从另一台手机发送:验证码 123456,5 分钟内有效。");
|
latestText.setText("暂无短信接收记录。可以先授权,再从另一台手机发送一条测试短信。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,14 +249,10 @@ public final class MainActivity extends Activity {
|
|||||||
builder.append("时间:").append(formatTime(capture.timeMillis)).append('\n');
|
builder.append("时间:").append(formatTime(capture.timeMillis)).append('\n');
|
||||||
builder.append("来源:").append(emptyAsDash(capture.source)).append('\n');
|
builder.append("来源:").append(emptyAsDash(capture.source)).append('\n');
|
||||||
builder.append("发送方:").append(emptyAsDash(capture.sender)).append('\n');
|
builder.append("发送方:").append(emptyAsDash(capture.sender)).append('\n');
|
||||||
if (!TextUtils.isEmpty(capture.code)) {
|
if (!capture.success) {
|
||||||
builder.append("验证码:").append(capture.code).append('\n');
|
|
||||||
builder.append("策略:").append(capture.strategy).append(" / ").append(capture.confidence).append('\n');
|
|
||||||
} else {
|
|
||||||
builder.append("验证码:-").append('\n');
|
|
||||||
builder.append("失败原因:").append(emptyAsDash(capture.failure)).append('\n');
|
builder.append("失败原因:").append(emptyAsDash(capture.failure)).append('\n');
|
||||||
}
|
}
|
||||||
builder.append("正文摘要:").append(emptyAsDash(capture.bodyPreview));
|
builder.append("短信原文:").append(emptyAsDash(capture.body));
|
||||||
latestText.setText(builder.toString());
|
latestText.setText(builder.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,15 +268,9 @@ public final class MainActivity extends Activity {
|
|||||||
+ ", notificationsEnabled=" + areNotificationsEnabled()
|
+ ", notificationsEnabled=" + areNotificationsEnabled()
|
||||||
+ ", manualAutostart=" + state.manualAutostartConfirmed
|
+ ", manualAutostart=" + state.manualAutostartConfirmed
|
||||||
+ ", manualBatteryUnrestricted=" + state.manualBatteryUnrestrictedConfirmed);
|
+ ", manualBatteryUnrestricted=" + state.manualBatteryUnrestrictedConfirmed);
|
||||||
if (keepAliveButton != null) {
|
|
||||||
keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活");
|
keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活");
|
||||||
}
|
autostartConfirmCheckBox.setChecked(state.manualAutostartConfirmed);
|
||||||
if (autostartConfirmButton != null) {
|
batteryConfirmCheckBox.setChecked(state.manualBatteryUnrestrictedConfirmed);
|
||||||
autostartConfirmButton.setText(state.manualAutostartConfirmed ? "取消自启动确认" : "确认已开启小米自启动");
|
|
||||||
}
|
|
||||||
if (batteryConfirmButton != null) {
|
|
||||||
batteryConfirmButton.setText(state.manualBatteryUnrestrictedConfirmed ? "取消省电无限制确认" : "确认省电策略已设为无限制");
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder builder = new StringBuilder();
|
StringBuilder builder = new StringBuilder();
|
||||||
builder.append("用户开关:").append(state.enabledByUser ? "已开启" : "未开启").append('\n');
|
builder.append("用户开关:").append(state.enabledByUser ? "已开启" : "未开启").append('\n');
|
||||||
@ -392,14 +286,12 @@ public final class MainActivity extends Activity {
|
|||||||
builder.append("通知可见性:").append(areNotificationsEnabled() ? "系统允许通知" : "通知可能被关闭").append('\n');
|
builder.append("通知可见性:").append(areNotificationsEnabled() ? "系统允许通知" : "通知可能被关闭").append('\n');
|
||||||
builder.append("小米自启动:").append(state.manualAutostartConfirmed ? "已人工确认" : "未确认").append('\n');
|
builder.append("小米自启动:").append(state.manualAutostartConfirmed ? "已人工确认" : "未确认").append('\n');
|
||||||
builder.append("省电无限制:").append(state.manualBatteryUnrestrictedConfirmed ? "已人工确认" : "未确认");
|
builder.append("省电无限制:").append(state.manualBatteryUnrestrictedConfirmed ? "已人工确认" : "未确认");
|
||||||
keepAliveText.setText(builder.toString());
|
keepAliveTextValue = builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshDatabaseHeartbeatUi() {
|
private void refreshDatabaseHeartbeatUi() {
|
||||||
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
||||||
if (toastOnDatabaseWriteRadio != null) {
|
|
||||||
toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite);
|
toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite);
|
||||||
}
|
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
long lastActiveTime = KeepAliveDatabase.readLastActiveTime(this);
|
long lastActiveTime = KeepAliveDatabase.readLastActiveTime(this);
|
||||||
@ -418,7 +310,7 @@ public final class MainActivity extends Activity {
|
|||||||
if (lastActiveTime <= 0L) {
|
if (lastActiveTime <= 0L) {
|
||||||
builder.append("最后写入:-").append('\n');
|
builder.append("最后写入:-").append('\n');
|
||||||
builder.append("判断:数据库还没有 lastActiveTime。开启常驻保活后会开始写入。");
|
builder.append("判断:数据库还没有 lastActiveTime。开启常驻保活后会开始写入。");
|
||||||
databaseHeartbeatText.setBackgroundColor(0xFFFFFFFF);
|
databaseHeartbeatBackgroundColor = 0xFFFFFFFF;
|
||||||
} else {
|
} else {
|
||||||
builder.append("最后写入:").append(formatTimeWithMillis(lastActiveTime)).append('\n');
|
builder.append("最后写入:").append(formatTimeWithMillis(lastActiveTime)).append('\n');
|
||||||
builder.append("距离现在:").append(gapMillis).append(" ms").append('\n');
|
builder.append("距离现在:").append(gapMillis).append(" ms").append('\n');
|
||||||
@ -428,13 +320,13 @@ public final class MainActivity extends Activity {
|
|||||||
.append(",大约在此后 ")
|
.append(",大约在此后 ")
|
||||||
.append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L)
|
.append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L)
|
||||||
.append(" 秒内停止写入。");
|
.append(" 秒内停止写入。");
|
||||||
databaseHeartbeatText.setBackgroundColor(0xFFFFE0E0);
|
databaseHeartbeatBackgroundColor = 0xFFFFE0E0;
|
||||||
} else {
|
} else {
|
||||||
builder.append("判断:数据库心跳仍在正常窗口内。");
|
builder.append("判断:数据库心跳仍在正常窗口内。");
|
||||||
databaseHeartbeatText.setBackgroundColor(0xFFE8F5E9);
|
databaseHeartbeatBackgroundColor = 0xFFE8F5E9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
databaseHeartbeatText.setText(builder.toString());
|
databaseHeartbeatTextValue = builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshDeliveryDiagnosticsUi() {
|
private void refreshDeliveryDiagnosticsUi() {
|
||||||
@ -452,15 +344,13 @@ public final class MainActivity extends Activity {
|
|||||||
} else {
|
} else {
|
||||||
builder.append("判断:暂无收件箱新于广播的异常记录。");
|
builder.append("判断:暂无收件箱新于广播的异常记录。");
|
||||||
}
|
}
|
||||||
deliveryDiagnosticsText.setText(builder.toString());
|
deliveryDiagnosticsTextValue = builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshPollingUi() {
|
private void refreshPollingUi() {
|
||||||
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
|
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
|
||||||
if (pollingButton != null) {
|
pollingButton.setText(state.enabledByUser ? "停止短信轮询" : "开始短信轮询");
|
||||||
pollingButton.setText(state.enabledByUser ? "停止1秒轮询验证码" : "开始1秒轮询验证码");
|
if (!pollingIntervalEdit.hasFocus()) {
|
||||||
}
|
|
||||||
if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) {
|
|
||||||
pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds));
|
pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds));
|
||||||
}
|
}
|
||||||
Log.d(TAG, "refreshPollingUi enabled=" + state.enabledByUser
|
Log.d(TAG, "refreshPollingUi enabled=" + state.enabledByUser
|
||||||
@ -476,19 +366,11 @@ public final class MainActivity extends Activity {
|
|||||||
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(this);
|
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(this);
|
||||||
FeishuWebhookConfigStore.LastResult lastResult = FeishuWebhookConfigStore.loadLastResult(this);
|
FeishuWebhookConfigStore.LastResult lastResult = FeishuWebhookConfigStore.loadLastResult(this);
|
||||||
FeishuWebhookConfigStore.LastPushedSms lastPushedSms = FeishuWebhookConfigStore.loadLastPushedSms(this);
|
FeishuWebhookConfigStore.LastPushedSms lastPushedSms = FeishuWebhookConfigStore.loadLastPushedSms(this);
|
||||||
if (feishuPushEnabledCheckBox != null) {
|
|
||||||
feishuPushEnabledCheckBox.setChecked(config.enabled);
|
feishuPushEnabledCheckBox.setChecked(config.enabled);
|
||||||
}
|
if (!feishuWebhookIdEdit.hasFocus()) {
|
||||||
if (feishuDebugBodyCheckBox != null) {
|
|
||||||
feishuDebugBodyCheckBox.setChecked(config.sendFullBodyDebug);
|
|
||||||
}
|
|
||||||
if (feishuFilterCodeSwitch != null) {
|
|
||||||
feishuFilterCodeSwitch.setChecked(config.filterVerificationCode);
|
|
||||||
}
|
|
||||||
if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) {
|
|
||||||
feishuWebhookIdEdit.setText(config.webhookId);
|
feishuWebhookIdEdit.setText(config.webhookId);
|
||||||
}
|
}
|
||||||
if (feishuSecretEdit != null && !feishuSecretEdit.hasFocus()) {
|
if (!feishuSecretEdit.hasFocus()) {
|
||||||
feishuSecretEdit.setText(config.secret);
|
feishuSecretEdit.setText(config.secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,12 +378,11 @@ public final class MainActivity extends Activity {
|
|||||||
builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n');
|
builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n');
|
||||||
builder.append("默认模板:").append(FeishuWebhookConfigStore.defaultConfigPath(this)).append('\n');
|
builder.append("默认模板:").append(FeishuWebhookConfigStore.defaultConfigPath(this)).append('\n');
|
||||||
builder.append("推送开关:").append(config.enabled ? "已开启" : "未开启").append('\n');
|
builder.append("推送开关:").append(config.enabled ? "已开启" : "未开启").append('\n');
|
||||||
builder.append("验证码过滤:").append(config.filterVerificationCode ? "已开启" : "未开启").append('\n');
|
builder.append("推送内容:短信原文").append('\n');
|
||||||
builder.append("Webhook ID:").append(config.hasWebhookId() ? "已配置" : "未配置").append('\n');
|
builder.append("Webhook ID:").append(config.hasWebhookId() ? "已配置" : "未配置").append('\n');
|
||||||
builder.append("Secret:").append(config.hasSecret()
|
builder.append("Secret:").append(config.hasSecret()
|
||||||
? FeishuWebhookConfigStore.maskSecret(config.secret)
|
? FeishuWebhookConfigStore.maskSecret(config.secret)
|
||||||
: "未配置").append('\n');
|
: "未配置").append('\n');
|
||||||
builder.append("完整正文上传:").append(config.sendFullBodyDebug ? "已开启" : "未开启").append('\n');
|
|
||||||
builder.append("已推送短信时间秒:")
|
builder.append("已推送短信时间秒:")
|
||||||
.append(lastPushedSms.receivedSecond > 0L ? String.valueOf(lastPushedSms.receivedSecond) : "-")
|
.append(lastPushedSms.receivedSecond > 0L ? String.valueOf(lastPushedSms.receivedSecond) : "-")
|
||||||
.append('\n');
|
.append('\n');
|
||||||
@ -522,8 +403,8 @@ public final class MainActivity extends Activity {
|
|||||||
boolean configIssue = !config.enabled || !config.hasWebhookId() || !config.hasSecret()
|
boolean configIssue = !config.enabled || !config.hasWebhookId() || !config.hasSecret()
|
||||||
|| FeishuWebhookPushResult.STATUS_DISABLED.equals(lastResult.status)
|
|| FeishuWebhookPushResult.STATUS_DISABLED.equals(lastResult.status)
|
||||||
|| FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(lastResult.status);
|
|| FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(lastResult.status);
|
||||||
feishuPushText.setBackgroundColor(configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF);
|
feishuPushBackgroundColor = configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF;
|
||||||
feishuPushText.setText(builder.toString());
|
feishuPushTextValue = builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requestSmsPermission() {
|
private void requestSmsPermission() {
|
||||||
@ -585,24 +466,18 @@ public final class MainActivity extends Activity {
|
|||||||
boolean enabled = !state.toastOnDatabaseWrite;
|
boolean enabled = !state.toastOnDatabaseWrite;
|
||||||
Log.d(TAG, "toggleToastOnDatabaseWrite enabled=" + enabled);
|
Log.d(TAG, "toggleToastOnDatabaseWrite enabled=" + enabled);
|
||||||
KeepAliveStateStore.setToastOnDatabaseWrite(this, enabled);
|
KeepAliveStateStore.setToastOnDatabaseWrite(this, enabled);
|
||||||
if (toastOnDatabaseWriteRadio != null) {
|
|
||||||
toastOnDatabaseWriteRadio.setChecked(enabled);
|
toastOnDatabaseWriteRadio.setChecked(enabled);
|
||||||
}
|
|
||||||
refreshUi();
|
refreshUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveFeishuConfigFromUi() {
|
private void saveFeishuConfigFromUi() {
|
||||||
boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked();
|
boolean enabled = feishuPushEnabledCheckBox.isChecked();
|
||||||
boolean debugBody = feishuDebugBodyCheckBox != null && feishuDebugBodyCheckBox.isChecked();
|
String webhookId = feishuWebhookIdEdit.getText().toString();
|
||||||
boolean filterCode = feishuFilterCodeSwitch != null && feishuFilterCodeSwitch.isChecked();
|
String secret = feishuSecretEdit.getText().toString();
|
||||||
String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString();
|
|
||||||
String secret = feishuSecretEdit == null ? "" : feishuSecretEdit.getText().toString();
|
|
||||||
Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled
|
Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled
|
||||||
+ ", webhookConfigured=" + !TextUtils.isEmpty(webhookId)
|
+ ", webhookConfigured=" + !TextUtils.isEmpty(webhookId)
|
||||||
+ ", secretConfigured=" + !TextUtils.isEmpty(secret)
|
+ ", secretConfigured=" + !TextUtils.isEmpty(secret));
|
||||||
+ ", debugBody=" + debugBody
|
FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret);
|
||||||
+ ", filterCode=" + filterCode);
|
|
||||||
FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret, debugBody, filterCode);
|
|
||||||
Toast.makeText(this, "已保存飞书推送配置", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "已保存飞书推送配置", Toast.LENGTH_SHORT).show();
|
||||||
refreshUi();
|
refreshUi();
|
||||||
}
|
}
|
||||||
@ -627,21 +502,11 @@ public final class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String buildLatestSmsTestMarkdown(SmsInboxReader.InboxResult inboxResult) {
|
private String buildLatestSmsTestMarkdown(SmsInboxReader.InboxResult inboxResult) {
|
||||||
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body);
|
return "**SmsReceive 最近短信测试推送**" + '\n' +
|
||||||
StringBuilder builder = new StringBuilder();
|
"时间:" + formatTime(inboxResult.dateMillis) + '\n' +
|
||||||
builder.append("**SmsReceive 最近短信测试推送**").append('\n');
|
"发送方:" + maskSender(inboxResult.sender) + '\n' +
|
||||||
builder.append("时间:").append(formatTime(inboxResult.dateMillis)).append('\n');
|
"短信ID:" + inboxResult.id + '\n' +
|
||||||
builder.append("发送方:").append(maskSender(inboxResult.sender)).append('\n');
|
"正文:" + emptyAsDash(inboxResult.body);
|
||||||
builder.append("短信ID:").append(inboxResult.id).append('\n');
|
|
||||||
if (parseResult.success) {
|
|
||||||
builder.append("验证码:").append(parseResult.code).append('\n');
|
|
||||||
builder.append("解析:").append(parseResult.strategy).append(" / ").append(parseResult.confidence).append('\n');
|
|
||||||
} else {
|
|
||||||
builder.append("验证码:-").append('\n');
|
|
||||||
builder.append("解析失败:").append(emptyAsDash(parseResult.failureReason)).append('\n');
|
|
||||||
}
|
|
||||||
builder.append("正文:").append(emptyAsDash(inboxResult.body));
|
|
||||||
return builder.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasReceiveSmsPermission() {
|
private boolean hasReceiveSmsPermission() {
|
||||||
@ -679,70 +544,40 @@ public final class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
lastInboxSmsId = inboxResult.id;
|
lastInboxSmsId = inboxResult.id;
|
||||||
|
|
||||||
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body);
|
Log.d(TAG, "readLatestInboxSms success source=" + source
|
||||||
CaptureResult captureResult;
|
|
||||||
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();
|
pollingIntervalEdit.setText(String.valueOf(intervalSeconds));
|
||||||
refreshUi();
|
return intervalSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int parsePollingIntervalSeconds() {
|
private int parsePollingIntervalSeconds() {
|
||||||
String raw = pollingIntervalEdit == null ? "" : pollingIntervalEdit.getText().toString().trim();
|
String raw = pollingIntervalEdit.getText().toString().trim();
|
||||||
if (TextUtils.isEmpty(raw)) {
|
if (TextUtils.isEmpty(raw)) {
|
||||||
return SmsPollingStateStore.getIntervalSeconds(this);
|
return 1;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return Integer.parseInt(raw);
|
return Integer.parseInt(raw);
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
Log.w(TAG, "parsePollingIntervalSeconds invalid raw=" + raw, e);
|
Log.w(TAG, "parsePollingIntervalSeconds invalid raw=" + raw, e);
|
||||||
return SmsPollingStateStore.getIntervalSeconds(this);
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -761,11 +596,11 @@ public final class MainActivity extends Activity {
|
|||||||
Toast.makeText(this, "READ_SMS 未授权,无法轮询短信库", Toast.LENGTH_LONG).show();
|
Toast.makeText(this, "READ_SMS 未授权,无法轮询短信库", Toast.LENGTH_LONG).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
savePollingIntervalFromUi();
|
int intervalSeconds = savePollingIntervalFromUi();
|
||||||
Log.d(TAG, "startPolling via SmsPollingService");
|
Log.d(TAG, "startPolling via SmsPollingService");
|
||||||
SmsPollingService.start(this);
|
SmsPollingService.start(this);
|
||||||
Toast.makeText(this,
|
Toast.makeText(this,
|
||||||
"已启动后台轮询验证码,间隔 " + SmsPollingStateStore.getIntervalSeconds(this) + " 秒",
|
"已启动后台短信轮询,间隔 " + intervalSeconds + " 秒",
|
||||||
Toast.LENGTH_SHORT).show();
|
Toast.LENGTH_SHORT).show();
|
||||||
refreshUi();
|
refreshUi();
|
||||||
}
|
}
|
||||||
@ -855,9 +690,6 @@ public final class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isIgnoringBatteryOptimizations() {
|
private boolean isIgnoringBatteryOptimizations() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||||
return powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName());
|
return powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName());
|
||||||
}
|
}
|
||||||
@ -895,8 +727,4 @@ public final class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
return "***" + sender.substring(sender.length() - 4);
|
return "***" + sender.substring(sender.length() - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int dp(int value) {
|
|
||||||
return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
15
app/src/main/res/drawable/bg_button_primary.xml
Normal file
15
app/src/main/res/drawable/bg_button_primary.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_pressed="true">
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/accent_dark" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/accent" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
21
app/src/main/res/drawable/bg_button_secondary.xml
Normal file
21
app/src/main/res/drawable/bg_button_secondary.xml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_pressed="true">
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/accent_soft" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/accent" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/surface" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/card_border" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
15
app/src/main/res/drawable/bg_button_tonal.xml
Normal file
15
app/src/main/res/drawable/bg_button_tonal.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_pressed="true">
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/accent_soft_pressed" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/accent_soft" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
8
app/src/main/res/drawable/bg_card_surface.xml
Normal file
8
app/src/main/res/drawable/bg_card_surface.xml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="@color/surface" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/card_border" />
|
||||||
|
<corners android:radius="24dp" />
|
||||||
|
</shape>
|
||||||
8
app/src/main/res/drawable/bg_hero_panel.xml
Normal file
8
app/src/main/res/drawable/bg_hero_panel.xml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="315"
|
||||||
|
android:endColor="@color/hero_end"
|
||||||
|
android:startColor="@color/hero_start" />
|
||||||
|
<corners android:radius="28dp" />
|
||||||
|
</shape>
|
||||||
8
app/src/main/res/drawable/bg_info_panel.xml
Normal file
8
app/src/main/res/drawable/bg_info_panel.xml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="@color/panel_bg" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/card_border" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
</shape>
|
||||||
21
app/src/main/res/drawable/bg_input.xml
Normal file
21
app/src/main/res/drawable/bg_input.xml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_focused="true">
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/surface" />
|
||||||
|
<stroke
|
||||||
|
android:width="2dp"
|
||||||
|
android:color="@color/accent" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape>
|
||||||
|
<solid android:color="@color/surface" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/card_border" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
7
app/src/main/res/drawable/bg_screen_gradient.xml
Normal file
7
app/src/main/res/drawable/bg_screen_gradient.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="270"
|
||||||
|
android:endColor="@color/surface_alt"
|
||||||
|
android:startColor="@color/screen_bg" />
|
||||||
|
</shape>
|
||||||
386
app/src/main/res/layout/activity_main.xml
Normal file
386
app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/bg_screen_gradient"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingLeft="18dp"
|
||||||
|
android:paddingTop="22dp"
|
||||||
|
android:paddingRight="18dp"
|
||||||
|
android:paddingBottom="28dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_hero_panel"
|
||||||
|
android:elevation="4dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="22dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="SmsReceive"
|
||||||
|
style="@style/HeroEyebrow" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="短信接收"
|
||||||
|
style="@style/ScreenTitle" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:text="主路径:RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后保存短信原文和诊断摘要。"
|
||||||
|
style="@style/HeroSubtitle" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:background="@drawable/bg_card_surface"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="18dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="概览"
|
||||||
|
style="@style/SectionHeading" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:text="先看权限和最近一次短信结果,调试明细放到单独面板里。"
|
||||||
|
style="@style/SectionCaption" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/debug_info_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="调试信息"
|
||||||
|
style="@style/TonalActionButton" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:text="权限状态"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/permission_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
style="@style/InfoPanelText" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="最近结果"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/latest_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
style="@style/InfoPanelText" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:background="@drawable/bg_card_surface"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="18dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="快捷操作"
|
||||||
|
style="@style/SectionHeading" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:text="把高频操作和配置集中在一个面板里,减少整页的默认控件感。"
|
||||||
|
style="@style/SectionCaption" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:text="基础操作"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/request_permission_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="申请短信权限"
|
||||||
|
style="@style/PrimaryActionButton" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/keep_alive_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="开启常驻保活"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/toast_database_write_radio"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:text="每次写入数据库时弹 Toast"
|
||||||
|
style="@style/ToggleControl" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/read_inbox_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:text="读取最新短信"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:text="轮询设置"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/polling_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="开始短信轮询"
|
||||||
|
style="@style/PrimaryActionButton" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/polling_interval_edit"
|
||||||
|
android:layout_width="102dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:hint="默认1"
|
||||||
|
android:inputType="number"
|
||||||
|
android:maxLines="1"
|
||||||
|
style="@style/InputField" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:text="飞书推送"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/feishu_push_enabled_checkbox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:text="开启飞书远端推送"
|
||||||
|
style="@style/ToggleControl" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/feishu_webhook_id_edit"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:hint="飞书 webhook id"
|
||||||
|
android:maxLines="1"
|
||||||
|
style="@style/InputField" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/feishu_secret_edit"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:hint="飞书 webhook secret"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:maxLines="1"
|
||||||
|
style="@style/InputField" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/save_feishu_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="保存飞书配置"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/test_feishu_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="测试飞书推送"
|
||||||
|
style="@style/PrimaryActionButton" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:text="系统设置"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/settings_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:text="打开应用权限设置"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/battery_settings_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1.1"
|
||||||
|
android:text="打开电池优化设置"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/battery_confirm_checkbox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:layout_weight="0.9"
|
||||||
|
android:text="确认省电策略无限制"
|
||||||
|
style="@style/ToggleControl" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/request_battery_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:text="请求忽略电池优化"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/xiaomi_autostart_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1.1"
|
||||||
|
android:text="打开小米自启动设置"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/autostart_confirm_checkbox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:layout_weight="0.9"
|
||||||
|
android:text="确认自启动开启"
|
||||||
|
style="@style/ToggleControl" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/clear_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="清空最近结果"
|
||||||
|
style="@style/SecondaryActionButton" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/refresh_button"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="刷新状态"
|
||||||
|
style="@style/TonalActionButton" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
134
app/src/main/res/layout/dialog_debug_info.xml
Normal file
134
app/src/main/res/layout/dialog_debug_info.xml
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_screen_gradient"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingLeft="4dp"
|
||||||
|
android:paddingTop="6dp"
|
||||||
|
android:paddingRight="4dp"
|
||||||
|
android:paddingBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_card_surface"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Google API 诊断"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/google_play_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
style="@style/DialogInfoText" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_card_surface"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="后台保活状态"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/keep_alive_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
style="@style/DialogInfoText" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_card_surface"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="数据库心跳诊断"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/database_heartbeat_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_card_surface"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="短信广播诊断"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/delivery_diagnostics_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
style="@style/DialogInfoText" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_card_surface"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="飞书推送状态"
|
||||||
|
style="@style/CardLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/feishu_push_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@ -1,6 +1,19 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="screen_bg">#F6F7F9</color>
|
<color name="screen_bg">#F2F6F3</color>
|
||||||
<color name="text_primary">#17202A</color>
|
<color name="surface">#FCFEFD</color>
|
||||||
<color name="text_secondary">#5F6B7A</color>
|
<color name="surface_alt">#F7FBF8</color>
|
||||||
<color name="accent">#1E7A5F</color>
|
<color name="panel_bg">#EEF5F1</color>
|
||||||
|
<color name="card_border">#D6E4DD</color>
|
||||||
|
<color name="text_primary">#17322A</color>
|
||||||
|
<color name="text_secondary">#5E756A</color>
|
||||||
|
<color name="text_hint">#8B9B93</color>
|
||||||
|
<color name="accent">#247A5C</color>
|
||||||
|
<color name="accent_dark">#1A5B45</color>
|
||||||
|
<color name="accent_soft">#DDEFE6</color>
|
||||||
|
<color name="accent_soft_pressed">#CCE6DA</color>
|
||||||
|
<color name="hero_start">#234E40</color>
|
||||||
|
<color name="hero_end">#2F8265</color>
|
||||||
|
<color name="hero_text">#FFFFFF</color>
|
||||||
|
<color name="hero_text_secondary">#D8F2E7</color>
|
||||||
|
<color name="button_text_light">#FFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,7 +1,107 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
|
<style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
<item name="android:fontFamily">sans</item>
|
<item name="android:fontFamily">sans-serif-medium</item>
|
||||||
<item name="android:windowBackground">@color/screen_bg</item>
|
<item name="android:windowBackground">@color/screen_bg</item>
|
||||||
<item name="android:colorAccent">@color/accent</item>
|
<item name="android:colorAccent">@color/accent</item>
|
||||||
|
<item name="android:statusBarColor">@color/accent_dark</item>
|
||||||
|
<item name="android:navigationBarColor">@color/screen_bg</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="HeroEyebrow">
|
||||||
|
<item name="android:textColor">@color/hero_text_secondary</item>
|
||||||
|
<item name="android:textSize">12sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
<item name="android:letterSpacing">0.08</item>
|
||||||
|
<item name="android:textAllCaps">true</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="ScreenTitle">
|
||||||
|
<item name="android:textColor">@color/hero_text</item>
|
||||||
|
<item name="android:textSize">30sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="HeroSubtitle">
|
||||||
|
<item name="android:textColor">@color/hero_text_secondary</item>
|
||||||
|
<item name="android:textSize">14sp</item>
|
||||||
|
<item name="android:lineSpacingExtra">2dp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="SectionHeading">
|
||||||
|
<item name="android:textColor">@color/text_primary</item>
|
||||||
|
<item name="android:textSize">19sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="SectionCaption">
|
||||||
|
<item name="android:textColor">@color/text_secondary</item>
|
||||||
|
<item name="android:textSize">13sp</item>
|
||||||
|
<item name="android:lineSpacingExtra">2dp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="CardLabel">
|
||||||
|
<item name="android:textColor">@color/text_secondary</item>
|
||||||
|
<item name="android:textSize">13sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="InfoPanelText">
|
||||||
|
<item name="android:background">@drawable/bg_info_panel</item>
|
||||||
|
<item name="android:padding">14dp</item>
|
||||||
|
<item name="android:textColor">@color/text_primary</item>
|
||||||
|
<item name="android:textSize">14sp</item>
|
||||||
|
<item name="android:lineSpacingExtra">3dp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="DialogInfoText">
|
||||||
|
<item name="android:paddingTop">8dp</item>
|
||||||
|
<item name="android:textColor">@color/text_primary</item>
|
||||||
|
<item name="android:textSize">14sp</item>
|
||||||
|
<item name="android:lineSpacingExtra">3dp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="BaseActionButton" parent="@android:style/Widget.Material.Button">
|
||||||
|
<item name="android:minHeight">48dp</item>
|
||||||
|
<item name="android:paddingLeft">16dp</item>
|
||||||
|
<item name="android:paddingTop">12dp</item>
|
||||||
|
<item name="android:paddingRight">16dp</item>
|
||||||
|
<item name="android:paddingBottom">12dp</item>
|
||||||
|
<item name="android:textSize">15sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
<item name="android:textAllCaps">false</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="PrimaryActionButton" parent="@style/BaseActionButton">
|
||||||
|
<item name="android:background">@drawable/bg_button_primary</item>
|
||||||
|
<item name="android:textColor">@color/button_text_light</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="SecondaryActionButton" parent="@style/BaseActionButton">
|
||||||
|
<item name="android:background">@drawable/bg_button_secondary</item>
|
||||||
|
<item name="android:textColor">@color/text_primary</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="TonalActionButton" parent="@style/BaseActionButton">
|
||||||
|
<item name="android:background">@drawable/bg_button_tonal</item>
|
||||||
|
<item name="android:textColor">@color/accent_dark</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="InputField" parent="@android:style/Widget.Material.EditText">
|
||||||
|
<item name="android:background">@drawable/bg_input</item>
|
||||||
|
<item name="android:minHeight">48dp</item>
|
||||||
|
<item name="android:paddingLeft">14dp</item>
|
||||||
|
<item name="android:paddingTop">12dp</item>
|
||||||
|
<item name="android:paddingRight">14dp</item>
|
||||||
|
<item name="android:paddingBottom">12dp</item>
|
||||||
|
<item name="android:textColor">@color/text_primary</item>
|
||||||
|
<item name="android:textColorHint">@color/text_hint</item>
|
||||||
|
<item name="android:textSize">14sp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="ToggleControl">
|
||||||
|
<item name="android:buttonTint">@color/accent</item>
|
||||||
|
<item name="android:textColor">@color/text_primary</item>
|
||||||
|
<item name="android:textSize">14sp</item>
|
||||||
|
<item name="android:minHeight">40dp</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
package com.smsreceive.app;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
|
||||||
import static org.junit.Assert.assertFalse;
|
|
||||||
import static org.junit.Assert.assertTrue;
|
|
||||||
|
|
||||||
public final class VerificationCodeParserTest {
|
|
||||||
@Test
|
|
||||||
public void parsesChineseKeywordCode() {
|
|
||||||
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("【测试】验证码 123456,5 分钟内有效。");
|
|
||||||
|
|
||||||
assertTrue(result.success);
|
|
||||||
assertEquals("123456", result.code);
|
|
||||||
assertEquals("keyword_before_code", result.strategy);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void parsesEnglishOtpCode() {
|
|
||||||
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("Your OTP code is 839204. Do not share it.");
|
|
||||||
|
|
||||||
assertTrue(result.success);
|
|
||||||
assertEquals("839204", result.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void normalizesSpacesAndHyphens() {
|
|
||||||
assertEquals("123456", VerificationCodeParser.parse("验证码:12 34 56").code);
|
|
||||||
assertEquals("123456", VerificationCodeParser.parse("验证码:123-456").code);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void prefersKeywordCandidate() {
|
|
||||||
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("订单 998877,验证码 246810,请勿泄露。");
|
|
||||||
|
|
||||||
assertTrue(result.success);
|
|
||||||
assertEquals("246810", result.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void rejectsCommonFalsePositives() {
|
|
||||||
assertFalse(VerificationCodeParser.parse("订单金额 1234 元,手机号 13800138000。").success);
|
|
||||||
assertFalse(VerificationCodeParser.parse("会议日期 2026-05-16,无验证码。").success);
|
|
||||||
assertFalse(VerificationCodeParser.parse("这是一条普通通知。").success);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,6 @@
|
|||||||
package com.smsreceive.app;
|
package com.smsreceive.app.feishu;
|
||||||
|
|
||||||
|
import com.smsreceive.app.sms.CaptureResult;
|
||||||
|
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@ -56,11 +58,28 @@ public final class FeishuWebhookClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void pushMarkdownRejectsMissingConfigBeforeNetwork() {
|
public void pushMarkdownRejectsMissingConfigBeforeNetwork() {
|
||||||
FeishuWebhookConfigStore.Config config = new FeishuWebhookConfigStore.Config(true, "", "", false, false);
|
FeishuWebhookConfigStore.Config config = new FeishuWebhookConfigStore.Config(true, "", "");
|
||||||
|
|
||||||
FeishuWebhookPushResult result = FeishuWebhookClient.pushMarkdown(config, "test", 1717020800L);
|
FeishuWebhookPushResult result = FeishuWebhookClient.pushMarkdown(config, "test", 1717020800L);
|
||||||
|
|
||||||
assertFalse(result.success);
|
assertFalse(result.success);
|
||||||
assertEquals(FeishuWebhookPushResult.STATUS_MISSING_CONFIG, result.status);
|
assertEquals(FeishuWebhookPushResult.STATUS_MISSING_CONFIG, result.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void buildMarkdownFromCaptureUsesRawSmsBody() {
|
||||||
|
CaptureResult result = CaptureResult.success(
|
||||||
|
1717020800000L,
|
||||||
|
"10690001",
|
||||||
|
"测试短信原文",
|
||||||
|
"system_sms_broadcast");
|
||||||
|
|
||||||
|
String markdown = FeishuWebhookClient.buildMarkdownFromCapture(result);
|
||||||
|
|
||||||
|
assertTrue(markdown.contains("测试短信原文"));
|
||||||
|
assertTrue(markdown.contains("短信内容"));
|
||||||
|
assertFalse(markdown.contains("验证码"));
|
||||||
|
assertFalse(markdown.contains("解析"));
|
||||||
|
assertTrue(markdown.contains("2024"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -18,12 +18,12 @@ allprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
minSdkVersion = 21
|
minSdkVersion = 23
|
||||||
compileSdkVersion = 30
|
compileSdkVersion = 30
|
||||||
targetSdkVersion = 30
|
targetSdkVersion = 30
|
||||||
buildToolsVersion = '30.0.3'
|
buildToolsVersion = '30.0.3'
|
||||||
}
|
}
|
||||||
|
|
||||||
task clean(type: Delete) {
|
tasks.register('clean', Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,4 +2,3 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.injected.testOnly=false
|
android.injected.testOnly=false
|
||||||
android.aapt2FromMavenOverride=/Users/zouchao/Library/Android/sdk/build-tools/30.0.3/aapt2
|
|
||||||
|
|||||||
@ -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 是否只用于本机调试,还是需要后续提供导入/导出配置。
|
||||||
- 默认推送内容是否只发验证码,还是需要包含发送方掩码和解析策略。
|
- 默认推送内容是否只发短信原文,还是需要包含发送方掩码和来源等诊断摘要。
|
||||||
- 是否需要推送所有收到的字符串,还是只在验证码解析成功时推送。
|
- 是否需要推送所有收到的字符串,还是只在短信原文读取成功时推送。
|
||||||
- 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。
|
- 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 人工设置、短信广播兜底路径和可验证诊断组合起来。用户也会手动在小米设置中开启“自启动”和“省电策略-无限制”,因此方案应显式利用这个前提。
|
||||||
|
|
||||||
|
|||||||
@ -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 后发送短信,确认不承诺接收,并记录诊断表现
|
||||||
|
|||||||
@ -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 验证。
|
||||||
|
|
||||||
真机验证:
|
真机验证:
|
||||||
|
|
||||||
- 前台打开应用后,向目标号码发送测试短信:`【测试】验证码 123456,5 分钟内有效。`
|
- 前台打开应用后,向目标号码发送测试短信:`【测试】这是一条短信原文样本。`
|
||||||
- 应用退到后台后,重复发送不同验证码。
|
- 应用退到后台后,重复发送不同短信正文。
|
||||||
- 锁屏状态下发送短信,解锁后检查最近结果。
|
- 锁屏状态下发送短信,解锁后检查最近结果。
|
||||||
- 若可以控制短信格式,发送带 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 内展示。
|
||||||
- 是否需要支持验证码自动复制到剪贴板;这会带来额外隐私和系统提示问题,建议先不做。
|
- 是否需要支持短信原文快捷复制;这会带来额外隐私和系统提示问题,建议先不做。
|
||||||
|
|||||||
@ -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 场景下的权限与后台行为风险。
|
||||||
- 后续实现前必须能从任务列表直接进入编码,不再需要重新讨论核心架构。
|
- 后续实现前必须能从任务列表直接进入编码,不再需要重新讨论核心架构。
|
||||||
- 后续实现完成后,必须在目标真机上完成至少一轮短信接收、解析和诊断验证。
|
- 后续实现完成后,必须在目标真机上完成至少一轮短信接收、展示和诊断验证。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 能解释无权限、未收到广播、未读取到短信正文三类失败
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user