[update] init

This commit is contained in:
邹超
2026-05-18 11:10:52 +08:00
commit 790afd679e
54 changed files with 5015 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
defaultConfig {
applicationId "com.smsreceive.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
debuggable true
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'com.squareup.okhttp3:okhttp:3.14.9'
testImplementation 'junit:junit:4.12'
testImplementation 'org.json:json:20210307'
androidTestImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:core:1.3.0'
androidTestImplementation 'androidx.test:runner:1.3.0'
}
+1
View File
@@ -0,0 +1 @@
# Keep default debug build simple; release minification is disabled.
@@ -0,0 +1,22 @@
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);
}
}
+58
View File
@@ -0,0 +1,58 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.smsreceive.app">
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:allowBackup="false"
android:hardwareAccelerated="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".SmsReceiver"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="1000">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<service
android:name=".SmsKeepAliveService"
android:enabled="true"
android:exported="false" />
<service
android:name=".SmsPollingService"
android:enabled="true"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,56 @@
package com.smsreceive.app;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.text.TextUtils;
import android.util.Log;
public final class BootReceiver extends BroadcastReceiver {
private static final String TAG = "[SMS]SmsReceive";
@Override
public void onReceive(Context context, Intent intent) {
if (context == null || intent == null) {
return;
}
String action = intent.getAction();
Log.d(TAG, "BootReceiver.onReceive action=" + action);
if (!isSupportedAction(action)) {
Log.d(TAG, "BootReceiver ignore action=" + action);
return;
}
KeepAliveStateStore.recordBootEvent(context, action);
KeepAliveStateStore.State state = KeepAliveStateStore.load(context);
if (state.enabledByUser) {
try {
SmsKeepAliveService.start(context);
Log.d(TAG, "BootReceiver started keepalive service");
} catch (RuntimeException e) {
String reason = "开机恢复服务失败:" + e.getClass().getSimpleName();
Log.w(TAG, reason, e);
KeepAliveStateStore.recordServiceStartFailure(context, reason);
}
} else {
Log.d(TAG, "BootReceiver skip keepalive restore: disabled by user");
}
SmsPollingStateStore.State pollingState = SmsPollingStateStore.load(context);
if (pollingState.enabledByUser) {
try {
SmsPollingService.start(context);
Log.d(TAG, "BootReceiver started polling service");
} catch (RuntimeException e) {
Log.w(TAG, "开机恢复短信轮询失败:" + e.getClass().getSimpleName(), e);
SmsPollingStateStore.recordServiceStopped(context, "开机恢复短信轮询失败:" + e.getClass().getSimpleName());
}
}
}
private static boolean isSupportedAction(String action) {
return TextUtils.equals(Intent.ACTION_BOOT_COMPLETED, action)
|| TextUtils.equals(Intent.ACTION_LOCKED_BOOT_COMPLETED, action)
|| TextUtils.equals(Intent.ACTION_MY_PACKAGE_REPLACED, action);
}
}
@@ -0,0 +1,69 @@
package com.smsreceive.app;
final class CaptureResult {
static final long UNKNOWN_SMS_PROVIDER_ID = -1L;
final long receivedAtMillis;
final long smsProviderId;
final String sender;
final String body;
final VerificationCodeParser.ParseResult parseResult;
final String source;
final String failureReason;
private CaptureResult(
long receivedAtMillis,
long smsProviderId,
String sender,
String body,
VerificationCodeParser.ParseResult parseResult,
String source,
String failureReason) {
this.receivedAtMillis = receivedAtMillis;
this.smsProviderId = smsProviderId;
this.sender = sender == null ? "" : sender;
this.body = body == null ? "" : body;
this.parseResult = parseResult;
this.source = source == null ? "unknown" : source;
this.failureReason = failureReason == null ? "" : failureReason;
}
static CaptureResult success(
long receivedAtMillis,
String sender,
String body,
VerificationCodeParser.ParseResult parseResult,
String source) {
return success(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, parseResult, source);
}
static CaptureResult success(
long receivedAtMillis,
long smsProviderId,
String sender,
String body,
VerificationCodeParser.ParseResult parseResult,
String source) {
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, "");
}
static CaptureResult failure(
long receivedAtMillis,
String sender,
String body,
String source,
String failureReason) {
return failure(receivedAtMillis, UNKNOWN_SMS_PROVIDER_ID, sender, body, source, failureReason);
}
static CaptureResult failure(
long receivedAtMillis,
long smsProviderId,
String sender,
String body,
String source,
String failureReason) {
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.ParseResult.failure(failureReason);
return new CaptureResult(receivedAtMillis, smsProviderId, sender, body, parseResult, source, failureReason);
}
}
@@ -0,0 +1,299 @@
package com.smsreceive.app;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
final class FeishuWebhookClient {
private static final String TAG = "[SMS]SmsReceive";
private static final String WEBHOOK_URL_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/";
private static final char[] BASE64_TABLE =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.callTimeout(10, TimeUnit.SECONDS)
.build();
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
private FeishuWebhookClient() {
}
static void pushCaptureResultAsync(Context context, CaptureResult result) {
if (context == null || result == null) {
return;
}
Context appContext = context.getApplicationContext();
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext);
if (config.filterVerificationCode && !result.parseResult.success) {
Log.d(TAG, "Feishu push skipped: filter code enabled and parse failed, reason="
+ result.parseResult.failureReason);
return;
}
if (FeishuWebhookConfigStore.wasSmsPushed(appContext, result)) {
Log.d(TAG, "Feishu push skipped: duplicate sms receivedSecond="
+ (result.receivedAtMillis / 1000L)
+ ", source=" + result.source);
return;
}
if (!config.enabled) {
Log.w(TAG, "Feishu push blocked: config disabled, path="
+ FeishuWebhookConfigStore.configPath(appContext));
saveAndNotify(appContext, FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_DISABLED,
"远端推送未开启"));
return;
}
String markdown = buildMarkdownFromCapture(result, config);
pushMarkdownAsync(appContext, markdown, result);
}
static void pushMarkdownAsync(Context context, String markdownContent) {
pushMarkdownAsync(context, markdownContent, null);
}
private static void pushMarkdownAsync(Context context, String markdownContent, CaptureResult captureResult) {
Context appContext = context.getApplicationContext();
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(appContext);
EXECUTOR.execute(() -> {
if (captureResult != null && FeishuWebhookConfigStore.wasSmsPushed(appContext, captureResult)) {
Log.d(TAG, "Feishu queued push skipped: duplicate sms receivedSecond="
+ (captureResult.receivedAtMillis / 1000L)
+ ", source=" + captureResult.source);
return;
}
FeishuWebhookPushResult result = pushMarkdown(config, markdownContent, System.currentTimeMillis() / 1000L);
if (result.success && captureResult != null) {
FeishuWebhookConfigStore.saveLastPushedSms(appContext, captureResult);
}
saveAndNotify(appContext, result);
});
}
static FeishuWebhookPushResult pushMarkdown(
FeishuWebhookConfigStore.Config config,
String markdownContent,
long timestampSeconds) {
if (config == null || !config.enabled) {
return FeishuWebhookPushResult.failure(FeishuWebhookPushResult.STATUS_DISABLED, "远端推送未开启");
}
if (!config.hasWebhookId() || !config.hasSecret()) {
return FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_MISSING_CONFIG,
"缺少 webhook id 或 secret");
}
String sign;
try {
sign = generateSign(config.secret, timestampSeconds);
} catch (GeneralSecurityException e) {
Log.w(TAG, "Feishu sign failed: " + e.getClass().getSimpleName(), e);
return FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_SIGN_ERROR,
"签名失败:" + e.getClass().getSimpleName());
}
String requestJson;
try {
requestJson = buildRequestJson(markdownContent, timestampSeconds, sign);
} catch (JSONException e) {
return FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_INVALID_JSON,
"请求 JSON 构造失败:" + e.getClass().getSimpleName());
}
Request request = new Request.Builder()
.url(buildWebhookUrl(config.webhookId))
.post(RequestBody.create(JSON, requestJson))
.build();
try (Response response = HTTP_CLIENT.newCall(request).execute()) {
int status = response.code();
String body = response.body() == null ? "" : response.body().string();
if (status != 200) {
Log.w(TAG, "Feishu push HTTP error status=" + status);
return FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_HTTP_ERROR,
"HTTP 状态码 " + status,
status,
0);
}
return parseResponse(body);
} catch (SocketTimeoutException e) {
Log.w(TAG, "Feishu push timeout", e);
return FeishuWebhookPushResult.failure(FeishuWebhookPushResult.STATUS_TIMEOUT, "请求超时");
} catch (IOException e) {
Log.w(TAG, "Feishu push network error: " + e.getClass().getSimpleName(), e);
return FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_NETWORK_ERROR,
"网络异常:" + e.getClass().getSimpleName());
}
}
static String generateSign(String secret, long timestampSeconds) throws GeneralSecurityException {
String stringToSign = timestampSeconds + "\n" + (secret == null ? "" : secret);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return base64EncodeNoWrap(mac.doFinal(new byte[0]));
}
static String buildRequestJson(String markdownContent, long timestampSeconds, String sign) throws JSONException {
JSONObject markdown = new JSONObject()
.put("tag", "markdown")
.put("content", markdownContent == null ? "" : markdownContent);
JSONArray elements = new JSONArray().put(markdown);
JSONObject card = new JSONObject().put("elements", elements);
return new JSONObject()
.put("msg_type", "interactive")
.put("card", card)
.put("timestamp", String.valueOf(timestampSeconds))
.put("sign", sign == null ? "" : sign)
.toString();
}
static FeishuWebhookPushResult parseResponse(String responseBody) {
try {
JSONObject json = new JSONObject(responseBody == null ? "" : responseBody);
int code = json.optInt("code", Integer.MIN_VALUE);
String msg = json.optString("msg", "");
if (code == 0) {
return FeishuWebhookPushResult.success(isEmpty(msg) ? "推送成功" : msg);
}
return FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_API_ERROR,
"飞书错误:" + (isEmpty(msg) ? "code=" + code : msg),
200,
code);
} catch (JSONException e) {
return FeishuWebhookPushResult.failure(
FeishuWebhookPushResult.STATUS_INVALID_JSON,
"响应 JSON 解析失败");
}
}
static String buildWebhookUrl(String webhookId) {
return WEBHOOK_URL_PREFIX + (webhookId == null ? "" : webhookId.trim());
}
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();
if (filterCode) {
builder.append("**短信验证码**").append(emptyAsDash(result.parseResult.code)).append('\n');
} else {
builder.append("**短信内容**").append(emptyAsDash(result.body)).append('\n');
if (result.parseResult.success) {
builder.append("**识别验证码**").append(result.parseResult.code).append('\n');
}
}
builder.append("**来源**").append(emptyAsDash(result.source)).append('\n');
builder.append("**发送方**").append(maskSender(result.sender)).append('\n');
builder.append("**时间**").append(result.receivedAtMillis).append('\n');
if (result.parseResult.success) {
builder.append("**解析**")
.append(emptyAsDash(result.parseResult.strategy))
.append(" / ")
.append(result.parseResult.confidence);
} else {
builder.append("**解析失败**").append(emptyAsDash(result.parseResult.failureReason));
}
if (includeFullBody && filterCode) {
builder.append('\n').append("**原文**").append(emptyAsDash(result.body));
}
return builder.toString();
}
private static void saveAndNotify(Context context, FeishuWebhookPushResult result) {
Log.d(TAG, String.format(Locale.US,
"Feishu push result success=%s status=%s http=%d api=%d message=%s",
result.success,
result.status,
result.httpStatus,
result.apiCode,
result.message));
FeishuWebhookConfigStore.saveLastResult(context, result);
Intent intent = new Intent(FeishuWebhookConfigStore.ACTION_PUSH_UPDATED);
intent.setPackage(context.getPackageName());
context.sendBroadcast(intent);
if (isConfigIssue(result)) {
showToast(context, "飞书配置异常:" + result.message);
}
}
private static boolean isConfigIssue(FeishuWebhookPushResult result) {
return FeishuWebhookPushResult.STATUS_DISABLED.equals(result.status)
|| FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(result.status);
}
private static void showToast(Context context, String message) {
new Handler(Looper.getMainLooper()).post(() ->
Toast.makeText(context.getApplicationContext(), message, Toast.LENGTH_LONG).show());
}
private static String maskSender(String sender) {
if (isEmpty(sender)) {
return "-";
}
if (sender.length() <= 4) {
return sender;
}
return "***" + sender.substring(sender.length() - 4);
}
private static String emptyAsDash(String value) {
return isEmpty(value) ? "-" : value;
}
private static boolean isEmpty(String value) {
return value == null || value.length() == 0;
}
private static String base64EncodeNoWrap(byte[] data) {
if (data == null || data.length == 0) {
return "";
}
StringBuilder builder = new StringBuilder(((data.length + 2) / 3) * 4);
for (int i = 0; i < data.length; i += 3) {
int b0 = data[i] & 0xFF;
int b1 = i + 1 < data.length ? data[i + 1] & 0xFF : 0;
int b2 = i + 2 < data.length ? data[i + 2] & 0xFF : 0;
builder.append(BASE64_TABLE[b0 >>> 2]);
builder.append(BASE64_TABLE[((b0 & 0x03) << 4) | (b1 >>> 4)]);
builder.append(i + 1 < data.length ? BASE64_TABLE[((b1 & 0x0F) << 2) | (b2 >>> 6)] : '=');
builder.append(i + 2 < data.length ? BASE64_TABLE[b2 & 0x3F] : '=');
}
return builder.toString();
}
}
@@ -0,0 +1,352 @@
package com.smsreceive.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
final class FeishuWebhookConfigStore {
static final String ACTION_PUSH_UPDATED = "com.smsreceive.app.ACTION_FEISHU_PUSH_UPDATED";
private static final String TAG = "[SMS]SmsReceive";
private static final String PREFS = "feishu_webhook";
private static final String KEY_LAST_TIME = "last_time";
private static final String KEY_LAST_SUCCESS = "last_success";
private static final String KEY_LAST_STATUS = "last_status";
private static final String KEY_LAST_MESSAGE = "last_message";
private static final String KEY_LAST_HTTP_STATUS = "last_http_status";
private static final String KEY_LAST_API_CODE = "last_api_code";
private static final String KEY_LAST_PUSHED_SMS_RECEIVED_SECOND = "last_pushed_sms_received_second";
private static final String KEY_LAST_PUSHED_SMS_KEY = "last_pushed_sms_key";
private static final String KEY_LAST_PUSHED_SMS_CONTENT_KEY = "last_pushed_sms_content_key";
private static final long DUPLICATE_SMS_TIME_TOLERANCE_SECONDS = 5L;
private static final String CONFIG_DIR = "config";
private static final String CONFIG_FILE = "feishu.json";
private static final String DEFAULT_CONFIG_FILE = "def_config_feishu.json";
private static final String JSON_ENABLED = "enabled";
private static final String JSON_WEBHOOK_ID = "webhook_id";
private static final String JSON_SECRET = "secret";
private static final String JSON_SEND_FULL_BODY_DEBUG = "send_full_body_debug";
private static final String JSON_FILTER_VERIFICATION_CODE = "filter_verification_code";
private FeishuWebhookConfigStore() {
}
static Config loadConfig(Context context) {
ensureDefaultConfigFile(context);
File file = configFile(context);
if (!file.exists()) {
return defaultConfig();
}
try {
JSONObject json = new JSONObject(readFile(file));
return configFromJson(json);
} catch (IOException | JSONException e) {
Log.w(TAG, "load feishu config failed path=" + file.getAbsolutePath()
+ ", reason=" + e.getClass().getSimpleName(), e);
return defaultConfig();
}
}
static void saveConfig(
Context context,
boolean enabled,
String webhookId,
String secret,
boolean sendFullBodyDebug,
boolean filterVerificationCode) {
Config config = new Config(enabled, webhookId, secret, sendFullBodyDebug, filterVerificationCode);
File file = configFile(context);
try {
writeFile(file, configToJson(config).toString(2));
Log.d(TAG, "save feishu config path=" + file.getAbsolutePath()
+ ", enabled=" + enabled
+ ", webhookConfigured=" + config.hasWebhookId()
+ ", secretConfigured=" + config.hasSecret()
+ ", debugBody=" + sendFullBodyDebug
+ ", filterCode=" + filterVerificationCode);
} catch (IOException | JSONException e) {
Log.w(TAG, "save feishu config failed path=" + file.getAbsolutePath()
+ ", reason=" + e.getClass().getSimpleName(), e);
}
}
static void saveLastResult(Context context, FeishuWebhookPushResult result) {
preferences(context).edit()
.putLong(KEY_LAST_TIME, result.timeMillis)
.putBoolean(KEY_LAST_SUCCESS, result.success)
.putString(KEY_LAST_STATUS, result.status)
.putString(KEY_LAST_MESSAGE, result.message)
.putInt(KEY_LAST_HTTP_STATUS, result.httpStatus)
.putInt(KEY_LAST_API_CODE, result.apiCode)
.apply();
}
static boolean wasSmsPushed(Context context, CaptureResult result) {
String smsKey = buildSmsDedupKey(result);
if (isEmpty(smsKey)) {
return false;
}
SharedPreferences prefs = preferences(context);
String lastKey = prefs.getString(KEY_LAST_PUSHED_SMS_KEY, "");
String contentKey = buildSmsContentKey(result);
String lastContentKey = prefs.getString(KEY_LAST_PUSHED_SMS_CONTENT_KEY, "");
long receivedSecond = receivedSecond(result);
long lastSecond = prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L);
boolean exactDuplicate = smsKey.equals(lastKey);
boolean tolerantDuplicate = !isEmpty(contentKey)
&& contentKey.equals(lastContentKey)
&& lastSecond > 0L
&& Math.abs(receivedSecond - lastSecond) <= DUPLICATE_SMS_TIME_TOLERANCE_SECONDS;
boolean duplicate = exactDuplicate || tolerantDuplicate;
if (duplicate) {
Log.d(TAG, "Feishu dedup hit smsKey=" + smsKey
+ ", receivedSecond=" + receivedSecond
+ ", lastSecond=" + lastSecond
+ ", exact=" + exactDuplicate
+ ", tolerant=" + tolerantDuplicate);
}
return duplicate;
}
static void saveLastPushedSms(Context context, CaptureResult result) {
String smsKey = buildSmsDedupKey(result);
if (isEmpty(smsKey)) {
return;
}
long receivedSecond = receivedSecond(result);
preferences(context).edit()
.putLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, receivedSecond)
.putString(KEY_LAST_PUSHED_SMS_KEY, smsKey)
.putString(KEY_LAST_PUSHED_SMS_CONTENT_KEY, buildSmsContentKey(result))
.apply();
Log.d(TAG, "save last pushed sms receivedSecond=" + receivedSecond
+ ", smsProviderId=" + result.smsProviderId
+ ", smsKey=" + smsKey);
}
static LastResult loadLastResult(Context context) {
SharedPreferences prefs = preferences(context);
return new LastResult(
prefs.getLong(KEY_LAST_TIME, 0L),
prefs.getBoolean(KEY_LAST_SUCCESS, false),
prefs.getString(KEY_LAST_STATUS, ""),
prefs.getString(KEY_LAST_MESSAGE, ""),
prefs.getInt(KEY_LAST_HTTP_STATUS, 0),
prefs.getInt(KEY_LAST_API_CODE, 0));
}
static LastPushedSms loadLastPushedSms(Context context) {
SharedPreferences prefs = preferences(context);
return new LastPushedSms(
prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L),
prefs.getString(KEY_LAST_PUSHED_SMS_KEY, ""));
}
static String maskSecret(String secret) {
if (isEmpty(secret)) {
return "";
}
if (secret.length() <= 6) {
return "***";
}
return secret.substring(0, 3) + "***" + secret.substring(secret.length() - 3);
}
static String configPath(Context context) {
return configFile(context).getAbsolutePath();
}
static String defaultConfigPath(Context context) {
return defaultConfigFile(context).getAbsolutePath();
}
static String configTemplate() {
try {
return configToJson(defaultConfig()).toString(2);
} catch (JSONException e) {
return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\",\"send_full_body_debug\":false,\"filter_verification_code\":false}";
}
}
private static SharedPreferences preferences(Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
private static void ensureDefaultConfigFile(Context context) {
File file = defaultConfigFile(context);
if (file.exists()) {
return;
}
try {
writeFile(file, configTemplate());
Log.d(TAG, "created default feishu config template path=" + file.getAbsolutePath());
} catch (IOException e) {
Log.w(TAG, "create default feishu config template failed path=" + file.getAbsolutePath(), e);
}
}
private static File configFile(Context context) {
return new File(configDir(context), CONFIG_FILE);
}
private static File defaultConfigFile(Context context) {
return new File(configDir(context), DEFAULT_CONFIG_FILE);
}
private static File configDir(Context context) {
File appExternalDir = context.getExternalFilesDir(null);
File appDataDir = appExternalDir == null ? context.getExternalFilesDir(CONFIG_DIR) : appExternalDir.getParentFile();
if (appDataDir == null) {
appDataDir = new File(context.getFilesDir(), "external_config_fallback");
}
return new File(appDataDir, CONFIG_DIR);
}
private static Config defaultConfig() {
return new Config(false, "", "", false, false);
}
private static Config configFromJson(JSONObject json) {
return new Config(
json.optBoolean(JSON_ENABLED, false),
json.optString(JSON_WEBHOOK_ID, ""),
json.optString(JSON_SECRET, ""),
json.optBoolean(JSON_SEND_FULL_BODY_DEBUG, false),
json.optBoolean(JSON_FILTER_VERIFICATION_CODE, false));
}
private static JSONObject configToJson(Config config) throws JSONException {
return new JSONObject()
.put(JSON_ENABLED, config.enabled)
.put(JSON_WEBHOOK_ID, config.webhookId)
.put(JSON_SECRET, config.secret)
.put(JSON_SEND_FULL_BODY_DEBUG, config.sendFullBodyDebug)
.put(JSON_FILTER_VERIFICATION_CODE, config.filterVerificationCode);
}
private static String readFile(File file) throws IOException {
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
new FileInputStream(file), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(line);
}
}
return builder.toString();
}
private static void writeFile(File file, String content) throws IOException {
File parent = file.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("mkdir failed: " + parent.getAbsolutePath());
}
try (OutputStreamWriter writer = new OutputStreamWriter(
new FileOutputStream(file, false), StandardCharsets.UTF_8)) {
writer.write(content == null ? "" : content);
writer.write('\n');
}
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
private static long receivedSecond(CaptureResult result) {
return result == null ? 0L : result.receivedAtMillis / 1000L;
}
private static String buildSmsDedupKey(CaptureResult result) {
if (result == null || result.receivedAtMillis <= 0L) {
return "";
}
return receivedSecond(result)
+ "|"
+ buildSmsContentKey(result);
}
private static String buildSmsContentKey(CaptureResult result) {
if (result == null) {
return "";
}
return normalize(result.sender)
+ "|"
+ Integer.toHexString((result.body == null ? "" : result.body).hashCode());
}
static final class Config {
final boolean enabled;
final String webhookId;
final String secret;
final boolean sendFullBodyDebug;
final boolean filterVerificationCode;
Config(
boolean enabled,
String webhookId,
String secret,
boolean sendFullBodyDebug,
boolean filterVerificationCode) {
this.enabled = enabled;
this.webhookId = normalize(webhookId);
this.secret = normalize(secret);
this.sendFullBodyDebug = sendFullBodyDebug;
this.filterVerificationCode = filterVerificationCode;
}
boolean hasWebhookId() {
return !isEmpty(webhookId);
}
boolean hasSecret() {
return !isEmpty(secret);
}
}
static final class LastResult {
final long timeMillis;
final boolean success;
final String status;
final String message;
final int httpStatus;
final int apiCode;
LastResult(long timeMillis, boolean success, String status, String message, int httpStatus, int apiCode) {
this.timeMillis = timeMillis;
this.success = success;
this.status = status == null ? "" : status;
this.message = message == null ? "" : message;
this.httpStatus = httpStatus;
this.apiCode = apiCode;
}
}
static final class LastPushedSms {
final long receivedSecond;
final String smsKey;
LastPushedSms(long receivedSecond, String smsKey) {
this.receivedSecond = receivedSecond;
this.smsKey = smsKey == null ? "" : smsKey;
}
}
private static boolean isEmpty(String value) {
return value == null || value.length() == 0;
}
}
@@ -0,0 +1,47 @@
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());
}
}
@@ -0,0 +1,87 @@
package com.smsreceive.app;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
final class KeepAliveDatabase {
private static final String TAG = "[SMS]SmsReceive";
private static final String DATABASE_NAME = "sms_keep_alive.db";
private static final int DATABASE_VERSION = 1;
private static final String TABLE_META = "keep_alive_meta";
private static final String COLUMN_KEY = "meta_key";
private static final String COLUMN_VALUE_LONG = "value_long";
private static final String KEY_LAST_ACTIVE_TIME = "lastActiveTime";
private KeepAliveDatabase() {
}
static long writeLastActiveTime(Context context) {
long now = System.currentTimeMillis();
SQLiteDatabase database = helper(context).getWritableDatabase();
ContentValues values = new ContentValues();
values.put(COLUMN_KEY, KEY_LAST_ACTIVE_TIME);
values.put(COLUMN_VALUE_LONG, now);
database.insertWithOnConflict(TABLE_META, null, values, SQLiteDatabase.CONFLICT_REPLACE);
Log.d(TAG, "KeepAliveDatabase.writeLastActiveTime millis=" + now
+ ", time=" + formatTime(now));
return now;
}
static long readLastActiveTime(Context context) {
SQLiteDatabase database = helper(context).getReadableDatabase();
try (Cursor cursor = database.query(
TABLE_META,
new String[]{COLUMN_VALUE_LONG},
COLUMN_KEY + "=?",
new String[]{KEY_LAST_ACTIVE_TIME},
null,
null,
null,
"1")) {
if (cursor == null || !cursor.moveToFirst()) {
Log.d(TAG, "KeepAliveDatabase.readLastActiveTime empty");
return 0L;
}
long value = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_VALUE_LONG));
Log.d(TAG, "KeepAliveDatabase.readLastActiveTime millis=" + value
+ ", time=" + formatTime(value));
return value;
}
}
private static Helper helper(Context context) {
return new Helper(context.getApplicationContext());
}
private static String formatTime(long timeMillis) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.CHINA).format(new Date(timeMillis));
}
private static final class Helper extends SQLiteOpenHelper {
Helper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
Log.d(TAG, "KeepAliveDatabase.onCreate");
db.execSQL("CREATE TABLE IF NOT EXISTS " + TABLE_META + " ("
+ COLUMN_KEY + " TEXT PRIMARY KEY, "
+ COLUMN_VALUE_LONG + " INTEGER NOT NULL)");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.d(TAG, "KeepAliveDatabase.onUpgrade oldVersion=" + oldVersion
+ ", newVersion=" + newVersion);
}
}
}
@@ -0,0 +1,62 @@
package com.smsreceive.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
final class KeepAliveNotification {
static final int NOTIFICATION_ID = 2101;
private static final String TAG = "[SMS]SmsReceive";
private static final String CHANNEL_ID = "sms_keep_alive";
private static final String CHANNEL_NAME = "短信后台保活";
private KeepAliveNotification() {
}
static Notification build(Context context, String contentText) {
Log.d(TAG, "KeepAliveNotification.build text=" + contentText);
ensureChannel(context);
Intent intent = new Intent(context, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags);
Notification.Builder builder = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? new Notification.Builder(context, CHANNEL_ID)
: new Notification.Builder(context);
return builder
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("短信验证码监听运行中")
.setContentText(contentText)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
.build();
}
private static void ensureChannel(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null || manager.getNotificationChannel(CHANNEL_ID) != null) {
return;
}
Log.d(TAG, "KeepAliveNotification.createChannel id=" + CHANNEL_ID);
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW);
channel.setDescription("用于显示 SmsReceive 后台监听状态");
manager.createNotificationChannel(channel);
}
}
@@ -0,0 +1,166 @@
package com.smsreceive.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import android.util.Log;
final class KeepAliveStateStore {
private static final String TAG = "[SMS]SmsReceive";
private static final String PREFS = "sms_keep_alive";
private static final String KEY_ENABLED_BY_USER = "enabled_by_user";
private static final String KEY_SERVICE_RUNNING = "service_running";
private static final String KEY_LAST_HEARTBEAT = "last_heartbeat";
private static final String KEY_LAST_BOOT_EVENT = "last_boot_event";
private static final String KEY_LAST_BOOT_TIME = "last_boot_time";
private static final String KEY_LAST_SERVICE_START_FAILURE = "last_service_start_failure";
private static final String KEY_MANUAL_AUTOSTART_CONFIRMED = "manual_autostart_confirmed";
private static final String KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED = "manual_battery_unrestricted_confirmed";
private static final String KEY_BATTERY_OPTIMIZATION_IGNORED = "battery_optimization_ignored";
private static final String KEY_TOAST_ON_DATABASE_WRITE = "toast_on_database_write";
private KeepAliveStateStore() {
}
static void setEnabledByUser(Context context, boolean enabled) {
Log.d(TAG, "KeepAliveStateStore.setEnabledByUser enabled=" + enabled);
preferences(context).edit()
.putBoolean(KEY_ENABLED_BY_USER, enabled)
.apply();
}
static void recordServiceStarted(Context context) {
long now = System.currentTimeMillis();
Log.d(TAG, "KeepAliveStateStore.recordServiceStarted time=" + now);
preferences(context).edit()
.putBoolean(KEY_SERVICE_RUNNING, true)
.putLong(KEY_LAST_HEARTBEAT, now)
.putString(KEY_LAST_SERVICE_START_FAILURE, "")
.apply();
}
static void recordServiceStopped(Context context, String reason) {
Log.d(TAG, "KeepAliveStateStore.recordServiceStopped reason=" + reason);
preferences(context).edit()
.putBoolean(KEY_SERVICE_RUNNING, false)
.putString(KEY_LAST_SERVICE_START_FAILURE, safe(reason))
.apply();
}
static void recordHeartbeat(Context context) {
Log.d(TAG, "KeepAliveStateStore.recordHeartbeat");
preferences(context).edit()
.putBoolean(KEY_SERVICE_RUNNING, true)
.putLong(KEY_LAST_HEARTBEAT, System.currentTimeMillis())
.apply();
}
static void recordBootEvent(Context context, String action) {
long now = System.currentTimeMillis();
Log.d(TAG, "KeepAliveStateStore.recordBootEvent action=" + action + ", time=" + now);
preferences(context).edit()
.putString(KEY_LAST_BOOT_EVENT, safe(action))
.putLong(KEY_LAST_BOOT_TIME, now)
.apply();
}
static void recordServiceStartFailure(Context context, String reason) {
Log.w(TAG, "KeepAliveStateStore.recordServiceStartFailure reason=" + reason);
preferences(context).edit()
.putBoolean(KEY_SERVICE_RUNNING, false)
.putString(KEY_LAST_SERVICE_START_FAILURE, safe(reason))
.apply();
}
static void setManualAutostartConfirmed(Context context, boolean confirmed) {
Log.d(TAG, "KeepAliveStateStore.setManualAutostartConfirmed confirmed=" + confirmed);
preferences(context).edit()
.putBoolean(KEY_MANUAL_AUTOSTART_CONFIRMED, confirmed)
.apply();
}
static void setManualBatteryUnrestrictedConfirmed(Context context, boolean confirmed) {
Log.d(TAG, "KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed confirmed=" + confirmed);
preferences(context).edit()
.putBoolean(KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED, confirmed)
.apply();
}
static void setBatteryOptimizationIgnored(Context context, boolean ignored) {
Log.d(TAG, "KeepAliveStateStore.setBatteryOptimizationIgnored ignored=" + ignored);
preferences(context).edit()
.putBoolean(KEY_BATTERY_OPTIMIZATION_IGNORED, ignored)
.apply();
}
static void setToastOnDatabaseWrite(Context context, boolean enabled) {
Log.d(TAG, "KeepAliveStateStore.setToastOnDatabaseWrite enabled=" + enabled);
preferences(context).edit()
.putBoolean(KEY_TOAST_ON_DATABASE_WRITE, enabled)
.apply();
}
static boolean isToastOnDatabaseWriteEnabled(Context context) {
return preferences(context).getBoolean(KEY_TOAST_ON_DATABASE_WRITE, false);
}
static State load(Context context) {
SharedPreferences prefs = preferences(context);
return new State(
prefs.getBoolean(KEY_ENABLED_BY_USER, false),
prefs.getBoolean(KEY_SERVICE_RUNNING, false),
prefs.getLong(KEY_LAST_HEARTBEAT, 0L),
prefs.getString(KEY_LAST_BOOT_EVENT, ""),
prefs.getLong(KEY_LAST_BOOT_TIME, 0L),
prefs.getString(KEY_LAST_SERVICE_START_FAILURE, ""),
prefs.getBoolean(KEY_MANUAL_AUTOSTART_CONFIRMED, false),
prefs.getBoolean(KEY_MANUAL_BATTERY_UNRESTRICTED_CONFIRMED, false),
prefs.getBoolean(KEY_BATTERY_OPTIMIZATION_IGNORED, false),
prefs.getBoolean(KEY_TOAST_ON_DATABASE_WRITE, false));
}
private static SharedPreferences preferences(Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
private static String safe(String value) {
return TextUtils.isEmpty(value) ? "" : value;
}
static final class State {
final boolean enabledByUser;
final boolean serviceRunning;
final long lastHeartbeatMillis;
final String lastBootEvent;
final long lastBootTimeMillis;
final String lastServiceStartFailure;
final boolean manualAutostartConfirmed;
final boolean manualBatteryUnrestrictedConfirmed;
final boolean batteryOptimizationIgnored;
final boolean toastOnDatabaseWrite;
State(
boolean enabledByUser,
boolean serviceRunning,
long lastHeartbeatMillis,
String lastBootEvent,
long lastBootTimeMillis,
String lastServiceStartFailure,
boolean manualAutostartConfirmed,
boolean manualBatteryUnrestrictedConfirmed,
boolean batteryOptimizationIgnored,
boolean toastOnDatabaseWrite) {
this.enabledByUser = enabledByUser;
this.serviceRunning = serviceRunning;
this.lastHeartbeatMillis = lastHeartbeatMillis;
this.lastBootEvent = safe(lastBootEvent);
this.lastBootTimeMillis = lastBootTimeMillis;
this.lastServiceStartFailure = safe(lastServiceStartFailure);
this.manualAutostartConfirmed = manualAutostartConfirmed;
this.manualBatteryUnrestrictedConfirmed = manualBatteryUnrestrictedConfirmed;
this.batteryOptimizationIgnored = batteryOptimizationIgnored;
this.toastOnDatabaseWrite = toastOnDatabaseWrite;
}
}
}
@@ -0,0 +1,902 @@
package com.smsreceive.app;
import android.Manifest;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.provider.Settings;
import android.provider.Telephony;
import android.text.InputType;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import android.widget.ScrollView;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
public final class MainActivity extends Activity {
private static final String TAG = "[SMS]SmsReceive";
private static final int REQUEST_RECEIVE_SMS = 1001;
private static final String SOURCE_INBOX_OBSERVER = "sms_inbox_observer";
private static final String SOURCE_INBOX_MANUAL = "sms_inbox_manual";
private static final long DATABASE_HEARTBEAT_INTERVAL_MILLIS = 10_000L;
private static final long DATABASE_HEARTBEAT_STALE_MILLIS = 30_000L;
private TextView permissionText;
private TextView googlePlayText;
private TextView keepAliveText;
private TextView databaseHeartbeatText;
private TextView deliveryDiagnosticsText;
private TextView feishuPushText;
private TextView latestText;
private Button keepAliveButton;
private Button autostartConfirmButton;
private Button batteryConfirmButton;
private Button pollingButton;
private RadioButton toastOnDatabaseWriteRadio;
private CheckBox feishuPushEnabledCheckBox;
private CheckBox feishuDebugBodyCheckBox;
private Switch feishuFilterCodeSwitch;
private EditText feishuWebhookIdEdit;
private EditText feishuSecretEdit;
private EditText pollingIntervalEdit;
private long lastInboxSmsId = -1L;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final BroadcastReceiver updateReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
refreshUi();
}
};
private final ContentObserver smsObserver = new ContentObserver(mainHandler) {
@Override
public void onChange(boolean selfChange) {
super.onChange(selfChange);
Log.d(TAG, "Sms inbox ContentObserver.onChange selfChange=" + selfChange);
readLatestInboxSms(SOURCE_INBOX_OBSERVER, true);
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "MainActivity.onCreate");
setContentView(createContentView());
}
@Override
protected void onResume() {
super.onResume();
Log.d(TAG, "MainActivity.onResume");
IntentFilter updateFilter = new IntentFilter(SmsCaptureStore.ACTION_CAPTURE_UPDATED);
updateFilter.addAction(FeishuWebhookConfigStore.ACTION_PUSH_UPDATED);
registerReceiver(updateReceiver, updateFilter);
Log.d(TAG, "registered update receiver actions=" + SmsCaptureStore.ACTION_CAPTURE_UPDATED
+ ", " + FeishuWebhookConfigStore.ACTION_PUSH_UPDATED);
if (hasReadSmsPermission()) {
getContentResolver().registerContentObserver(Telephony.Sms.CONTENT_URI, true, smsObserver);
Log.d(TAG, "registered SMS content observer uri=" + Telephony.Sms.CONTENT_URI);
} else {
Log.d(TAG, "skip SMS content observer: READ_SMS not granted");
}
refreshUi();
readLatestInboxSms(SOURCE_INBOX_MANUAL, false);
}
@Override
protected void onPause() {
super.onPause();
Log.d(TAG, "MainActivity.onPause");
Log.d(TAG, "unregister capture update receiver");
unregisterReceiver(updateReceiver);
Log.d(TAG, "unregister SMS content observer");
getContentResolver().unregisterContentObserver(smsObserver);
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_RECEIVE_SMS) {
Log.d(TAG, "onRequestPermissionsResult RECEIVE_SMS granted=" + hasReceiveSmsPermission()
+ ", READ_SMS granted=" + hasReadSmsPermission());
Toast.makeText(this, hasAnySmsPermission() ? "短信权限已授权" : "短信权限未授权", Toast.LENGTH_SHORT).show();
refreshUi();
readLatestInboxSms(SOURCE_INBOX_MANUAL, false);
}
}
private View createContentView() {
ScrollView scrollView = new ScrollView(this);
scrollView.setFillViewport(true);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setPadding(dp(20), dp(24), dp(20), dp(24));
scrollView.addView(root, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
TextView title = new TextView(this);
title.setText("短信验证码接收");
title.setTextSize(24);
title.setTextColor(0xFF17202A);
title.setGravity(Gravity.START);
root.addView(title, matchWrap());
TextView subtitle = new TextView(this);
subtitle.setText("主路径:RECEIVE_SMS + SMS_RECEIVED_ACTION。收到短信后只保存验证码和诊断摘要。");
subtitle.setTextSize(14);
subtitle.setTextColor(0xFF5F6B7A);
subtitle.setPadding(0, dp(6), 0, dp(16));
root.addView(subtitle, matchWrap());
permissionText = section(root, "权限状态");
googlePlayText = section(root, "Google API 诊断");
keepAliveText = section(root, "后台保活状态");
databaseHeartbeatText = section(root, "数据库心跳诊断");
deliveryDiagnosticsText = section(root, "短信广播诊断");
feishuPushText = section(root, "飞书推送状态");
latestText = section(root, "最近结果");
LinearLayout actions = new LinearLayout(this);
actions.setOrientation(LinearLayout.VERTICAL);
actions.setPadding(0, dp(12), 0, 0);
root.addView(actions, matchWrap());
Button requestPermissionButton = button("申请短信权限");
requestPermissionButton.setOnClickListener(v -> requestSmsPermission());
actions.addView(requestPermissionButton, matchWrap());
keepAliveButton = button("开启常驻保活");
keepAliveButton.setOnClickListener(v -> toggleKeepAlive());
actions.addView(keepAliveButton, matchWrap());
toastOnDatabaseWriteRadio = new RadioButton(this);
toastOnDatabaseWriteRadio.setText("每次写入数据库时弹 Toast");
toastOnDatabaseWriteRadio.setTextSize(14);
toastOnDatabaseWriteRadio.setTextColor(0xFF27313F);
toastOnDatabaseWriteRadio.setOnClickListener(v -> toggleToastOnDatabaseWrite());
actions.addView(toastOnDatabaseWriteRadio, matchWrap());
Button readInboxButton = button("读取最新短信");
readInboxButton.setOnClickListener(v -> readLatestInboxSms(SOURCE_INBOX_MANUAL, true));
actions.addView(readInboxButton, matchWrap());
Button dumpRecentButton = button("打印最近30条短信");
dumpRecentButton.setOnClickListener(v -> dumpRecentMessages());
actions.addView(dumpRecentButton, matchWrap());
pollingButton = button("开始1秒轮询验证码");
pollingButton.setOnClickListener(v -> togglePolling());
actions.addView(pollingButton, matchWrap());
pollingIntervalEdit = new EditText(this);
pollingIntervalEdit.setHint("轮询间隔秒数,默认 1");
pollingIntervalEdit.setSingleLine(true);
pollingIntervalEdit.setInputType(InputType.TYPE_CLASS_NUMBER);
actions.addView(pollingIntervalEdit, matchWrap());
Button savePollingIntervalButton = button("保存轮询间隔");
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);
Toast.makeText(this, "已清空最近结果", Toast.LENGTH_SHORT).show();
refreshUi();
});
actions.addView(clearButton, matchWrap());
Button refreshButton = button("刷新状态");
refreshButton.setOnClickListener(v -> refreshUi());
actions.addView(refreshButton, matchWrap());
return scrollView;
}
private TextView section(LinearLayout root, String label) {
TextView title = new TextView(this);
title.setText(label);
title.setTextSize(16);
title.setTextColor(0xFF17202A);
title.setPadding(0, dp(12), 0, dp(4));
root.addView(title, matchWrap());
TextView value = new TextView(this);
value.setTextSize(14);
value.setTextColor(0xFF27313F);
value.setLineSpacing(dp(2), 1.0f);
value.setPadding(dp(12), dp(10), dp(12), dp(10));
value.setBackgroundColor(0xFFFFFFFF);
root.addView(value, matchWrap());
return value;
}
private Button button(String text) {
Button button = new Button(this);
button.setText(text);
button.setAllCaps(false);
return button;
}
private LinearLayout.LayoutParams matchWrap() {
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMargins(0, dp(4), 0, dp(4));
return params;
}
private void refreshUi() {
boolean receiveGranted = hasReceiveSmsPermission();
boolean readGranted = hasReadSmsPermission();
Log.d(TAG, "refreshUi receiveSmsPermissionGranted=" + receiveGranted
+ ", readSmsPermissionGranted=" + readGranted
+ ", googlePlayInstalled=" + isGooglePlayServicesInstalled());
permissionText.setText("RECEIVE_SMS" + (receiveGranted ? "已授权" : "未授权")
+ "\nREAD_SMS" + (readGranted ? "已授权" : "未授权")
+ "\n说明:如果 receiver 收不到广播,前台会用 READ_SMS 读取最新收件箱作为兜底。");
googlePlayText.setText(isGooglePlayServicesInstalled()
? "已检测到 com.google.android.gms。SMS User Consent / Retriever 可作为后续备选路径验证。"
: "未检测到 com.google.android.gms。当前实现不依赖 Google API,主路径仍是系统短信广播。");
refreshKeepAliveUi();
refreshDatabaseHeartbeatUi();
refreshDeliveryDiagnosticsUi();
refreshPollingUi();
refreshFeishuPushUi();
SmsCaptureStore.StoredCapture capture = SmsCaptureStore.load(this);
if (capture.timeMillis <= 0L) {
latestText.setText("暂无短信接收记录。可以先授权,再从另一台手机发送:验证码 123456,5 分钟内有效。");
return;
}
StringBuilder builder = new StringBuilder();
builder.append("时间:").append(formatTime(capture.timeMillis)).append('\n');
builder.append("来源:").append(emptyAsDash(capture.source)).append('\n');
builder.append("发送方:").append(emptyAsDash(capture.sender)).append('\n');
if (!TextUtils.isEmpty(capture.code)) {
builder.append("验证码:").append(capture.code).append('\n');
builder.append("策略:").append(capture.strategy).append(" / ").append(capture.confidence).append('\n');
} else {
builder.append("验证码:-").append('\n');
builder.append("失败原因:").append(emptyAsDash(capture.failure)).append('\n');
}
builder.append("正文摘要:").append(emptyAsDash(capture.bodyPreview));
latestText.setText(builder.toString());
}
private void refreshKeepAliveUi() {
boolean batteryIgnored = isIgnoringBatteryOptimizations();
KeepAliveStateStore.setBatteryOptimizationIgnored(this, batteryIgnored);
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
Log.d(TAG, "refreshKeepAliveUi enabled=" + state.enabledByUser
+ ", running=" + state.serviceRunning
+ ", lastHeartbeat=" + state.lastHeartbeatMillis
+ ", lastBootEvent=" + state.lastBootEvent
+ ", batteryIgnored=" + batteryIgnored
+ ", notificationsEnabled=" + areNotificationsEnabled()
+ ", manualAutostart=" + state.manualAutostartConfirmed
+ ", manualBatteryUnrestricted=" + state.manualBatteryUnrestrictedConfirmed);
if (keepAliveButton != null) {
keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活");
}
if (autostartConfirmButton != null) {
autostartConfirmButton.setText(state.manualAutostartConfirmed ? "取消自启动确认" : "确认已开启小米自启动");
}
if (batteryConfirmButton != null) {
batteryConfirmButton.setText(state.manualBatteryUnrestrictedConfirmed ? "取消省电无限制确认" : "确认省电策略已设为无限制");
}
StringBuilder builder = new StringBuilder();
builder.append("用户开关:").append(state.enabledByUser ? "已开启" : "未开启").append('\n');
builder.append("服务状态:").append(state.serviceRunning ? "最近记录为运行中" : "未运行").append('\n');
builder.append("最近心跳:").append(formatOptionalTime(state.lastHeartbeatMillis)).append('\n');
builder.append("最近开机事件:").append(emptyAsDash(state.lastBootEvent));
if (state.lastBootTimeMillis > 0L) {
builder.append(" / ").append(formatTime(state.lastBootTimeMillis));
}
builder.append('\n');
builder.append("启动失败:").append(emptyAsDash(state.lastServiceStartFailure)).append('\n');
builder.append("Android 电池优化白名单:").append(batteryIgnored ? "已忽略优化" : "未忽略优化").append('\n');
builder.append("通知可见性:").append(areNotificationsEnabled() ? "系统允许通知" : "通知可能被关闭").append('\n');
builder.append("小米自启动:").append(state.manualAutostartConfirmed ? "已人工确认" : "未确认").append('\n');
builder.append("省电无限制:").append(state.manualBatteryUnrestrictedConfirmed ? "已人工确认" : "未确认");
keepAliveText.setText(builder.toString());
}
private void refreshDatabaseHeartbeatUi() {
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
if (toastOnDatabaseWriteRadio != null) {
toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite);
}
long now = System.currentTimeMillis();
long lastActiveTime = KeepAliveDatabase.readLastActiveTime(this);
long gapMillis = lastActiveTime > 0L ? now - lastActiveTime : 0L;
boolean stale = lastActiveTime > 0L && gapMillis > DATABASE_HEARTBEAT_STALE_MILLIS;
Log.d(TAG, "refreshDatabaseHeartbeatUi now=" + now
+ ", lastActiveTime=" + lastActiveTime
+ ", gapMillis=" + gapMillis
+ ", stale=" + stale
+ ", toastOnDatabaseWrite=" + state.toastOnDatabaseWrite);
StringBuilder builder = new StringBuilder();
builder.append("写入间隔:").append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L).append("").append('\n');
builder.append("断档阈值:").append(DATABASE_HEARTBEAT_STALE_MILLIS / 1000L).append("").append('\n');
builder.append("Toast 开关:").append(state.toastOnDatabaseWrite ? "已开启" : "未开启").append('\n');
if (lastActiveTime <= 0L) {
builder.append("最后写入:-").append('\n');
builder.append("判断:数据库还没有 lastActiveTime。开启常驻保活后会开始写入。");
databaseHeartbeatText.setBackgroundColor(0xFFFFFFFF);
} else {
builder.append("最后写入:").append(formatTimeWithMillis(lastActiveTime)).append('\n');
builder.append("距离现在:").append(gapMillis).append(" ms").append('\n');
if (stale) {
builder.append("判断:疑似后台进程已停止。最后一次确认存活时间为 ")
.append(formatTimeWithMillis(lastActiveTime))
.append(",大约在此后 ")
.append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L)
.append(" 秒内停止写入。");
databaseHeartbeatText.setBackgroundColor(0xFFFFE0E0);
} else {
builder.append("判断:数据库心跳仍在正常窗口内。");
databaseHeartbeatText.setBackgroundColor(0xFFE8F5E9);
}
}
databaseHeartbeatText.setText(builder.toString());
}
private void refreshDeliveryDiagnosticsUi() {
SmsCaptureStore.DeliveryDiagnostics diagnostics = SmsCaptureStore.loadDeliveryDiagnostics(this);
Log.d(TAG, "refreshDeliveryDiagnosticsUi lastBroadcast=" + diagnostics.lastBroadcastTimeMillis
+ ", lastInbox=" + diagnostics.lastInboxTimeMillis
+ ", lastInboxSource=" + diagnostics.lastInboxSource
+ ", inboxNewerThanBroadcast=" + diagnostics.inboxNewerThanBroadcast());
StringBuilder builder = new StringBuilder();
builder.append("最近短信广播:").append(formatOptionalTime(diagnostics.lastBroadcastTimeMillis)).append('\n');
builder.append("最近收件箱兜底:").append(formatOptionalTime(diagnostics.lastInboxTimeMillis))
.append(" / ").append(emptyAsDash(diagnostics.lastInboxSource)).append('\n');
if (diagnostics.inboxNewerThanBroadcast()) {
builder.append("判断:短信已进入收件箱,但广播路径没有更新,优先排查 RECEIVE_SMS、force-stop、小米自启动和省电策略。");
} else {
builder.append("判断:暂无收件箱新于广播的异常记录。");
}
deliveryDiagnosticsText.setText(builder.toString());
}
private void refreshPollingUi() {
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
if (pollingButton != null) {
pollingButton.setText(state.enabledByUser ? "停止1秒轮询验证码" : "开始1秒轮询验证码");
}
if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) {
pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds));
}
Log.d(TAG, "refreshPollingUi enabled=" + state.enabledByUser
+ ", running=" + state.running
+ ", startTime=" + state.startTimeMillis
+ ", lastHitId=" + state.lastHitId
+ ", lastHitTime=" + state.lastHitTimeMillis
+ ", lastFailure=" + state.lastFailure
+ ", intervalSeconds=" + state.intervalSeconds);
}
private void refreshFeishuPushUi() {
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(this);
FeishuWebhookConfigStore.LastResult lastResult = FeishuWebhookConfigStore.loadLastResult(this);
FeishuWebhookConfigStore.LastPushedSms lastPushedSms = FeishuWebhookConfigStore.loadLastPushedSms(this);
if (feishuPushEnabledCheckBox != null) {
feishuPushEnabledCheckBox.setChecked(config.enabled);
}
if (feishuDebugBodyCheckBox != null) {
feishuDebugBodyCheckBox.setChecked(config.sendFullBodyDebug);
}
if (feishuFilterCodeSwitch != null) {
feishuFilterCodeSwitch.setChecked(config.filterVerificationCode);
}
if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) {
feishuWebhookIdEdit.setText(config.webhookId);
}
if (feishuSecretEdit != null && !feishuSecretEdit.hasFocus()) {
feishuSecretEdit.setText(config.secret);
}
StringBuilder builder = new StringBuilder();
builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n');
builder.append("默认模板:").append(FeishuWebhookConfigStore.defaultConfigPath(this)).append('\n');
builder.append("推送开关:").append(config.enabled ? "已开启" : "未开启").append('\n');
builder.append("验证码过滤:").append(config.filterVerificationCode ? "已开启" : "未开启").append('\n');
builder.append("Webhook ID").append(config.hasWebhookId() ? "已配置" : "未配置").append('\n');
builder.append("Secret").append(config.hasSecret()
? FeishuWebhookConfigStore.maskSecret(config.secret)
: "未配置").append('\n');
builder.append("完整正文上传:").append(config.sendFullBodyDebug ? "已开启" : "未开启").append('\n');
builder.append("已推送短信时间秒:")
.append(lastPushedSms.receivedSecond > 0L ? String.valueOf(lastPushedSms.receivedSecond) : "-")
.append('\n');
if (lastResult.timeMillis <= 0L) {
builder.append("最近推送:-");
} else {
builder.append("最近推送:").append(lastResult.success ? "成功" : "失败").append('\n');
builder.append("时间:").append(formatTime(lastResult.timeMillis)).append('\n');
builder.append("状态:").append(emptyAsDash(lastResult.status)).append('\n');
builder.append("消息:").append(emptyAsDash(lastResult.message));
if (lastResult.httpStatus > 0 || lastResult.apiCode != 0) {
builder.append('\n').append("HTTP/API")
.append(lastResult.httpStatus)
.append(" / ")
.append(lastResult.apiCode);
}
}
boolean configIssue = !config.enabled || !config.hasWebhookId() || !config.hasSecret()
|| FeishuWebhookPushResult.STATUS_DISABLED.equals(lastResult.status)
|| FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(lastResult.status);
feishuPushText.setBackgroundColor(configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF);
feishuPushText.setText(builder.toString());
}
private void requestSmsPermission() {
if (!hasReceiveSmsPermission() || !hasReadSmsPermission()) {
Log.d(TAG, "requestSmsPermission launch runtime request");
Toast.makeText(this, "正在申请短信权限", Toast.LENGTH_SHORT).show();
requestPermissions(new String[]{Manifest.permission.RECEIVE_SMS, Manifest.permission.READ_SMS}, REQUEST_RECEIVE_SMS);
} else {
Log.d(TAG, "requestSmsPermission skipped: already granted");
Toast.makeText(this, "短信权限已授权", Toast.LENGTH_SHORT).show();
}
}
private void toggleKeepAlive() {
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
Log.d(TAG, "toggleKeepAlive currentEnabled=" + state.enabledByUser
+ ", serviceRunning=" + state.serviceRunning);
if (state.enabledByUser) {
KeepAliveStateStore.setEnabledByUser(this, false);
Log.d(TAG, "toggleKeepAlive stopping SmsKeepAliveService");
SmsKeepAliveService.stop(this);
Toast.makeText(this, "已关闭常驻保活", Toast.LENGTH_SHORT).show();
refreshUi();
return;
}
KeepAliveStateStore.setEnabledByUser(this, true);
try {
Log.d(TAG, "toggleKeepAlive starting SmsKeepAliveService");
SmsKeepAliveService.start(this);
Toast.makeText(this, "已开启常驻保活", Toast.LENGTH_SHORT).show();
} catch (RuntimeException e) {
String reason = "启动常驻保活失败:" + e.getClass().getSimpleName();
Log.w(TAG, reason, e);
KeepAliveStateStore.recordServiceStartFailure(this, reason);
Toast.makeText(this, reason, Toast.LENGTH_LONG).show();
}
refreshUi();
}
private void toggleManualAutostartConfirmed() {
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
boolean confirmed = !state.manualAutostartConfirmed;
Log.d(TAG, "toggleManualAutostartConfirmed confirmed=" + confirmed);
KeepAliveStateStore.setManualAutostartConfirmed(this, confirmed);
refreshUi();
}
private void toggleManualBatteryConfirmed() {
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
boolean confirmed = !state.manualBatteryUnrestrictedConfirmed;
Log.d(TAG, "toggleManualBatteryConfirmed confirmed=" + confirmed);
KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed(this, confirmed);
refreshUi();
}
private void toggleToastOnDatabaseWrite() {
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
boolean enabled = !state.toastOnDatabaseWrite;
Log.d(TAG, "toggleToastOnDatabaseWrite enabled=" + enabled);
KeepAliveStateStore.setToastOnDatabaseWrite(this, enabled);
if (toastOnDatabaseWriteRadio != null) {
toastOnDatabaseWriteRadio.setChecked(enabled);
}
refreshUi();
}
private void saveFeishuConfigFromUi() {
boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked();
boolean debugBody = feishuDebugBodyCheckBox != null && feishuDebugBodyCheckBox.isChecked();
boolean filterCode = feishuFilterCodeSwitch != null && feishuFilterCodeSwitch.isChecked();
String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString();
String secret = feishuSecretEdit == null ? "" : feishuSecretEdit.getText().toString();
Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled
+ ", webhookConfigured=" + !TextUtils.isEmpty(webhookId)
+ ", secretConfigured=" + !TextUtils.isEmpty(secret)
+ ", debugBody=" + debugBody
+ ", filterCode=" + filterCode);
FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret, debugBody, filterCode);
Toast.makeText(this, "已保存飞书推送配置", Toast.LENGTH_SHORT).show();
refreshUi();
}
private void testFeishuPush() {
saveFeishuConfigFromUi();
if (!hasReadSmsPermission()) {
Log.w(TAG, "testFeishuPush blocked: READ_SMS not granted");
Toast.makeText(this, "READ_SMS 未授权,无法读取最近短信测试推送", Toast.LENGTH_LONG).show();
return;
}
SmsInboxReader.InboxResult inboxResult = SmsInboxReader.readLatest(this);
if (!inboxResult.success) {
Log.w(TAG, "testFeishuPush read latest SMS failed: " + inboxResult.failureReason);
Toast.makeText(this, inboxResult.failureReason, Toast.LENGTH_LONG).show();
return;
}
String markdown = buildLatestSmsTestMarkdown(inboxResult);
Log.d(TAG, "testFeishuPush dispatch async");
Toast.makeText(this, "已发起飞书测试推送", Toast.LENGTH_SHORT).show();
FeishuWebhookClient.pushMarkdownAsync(this, markdown);
}
private String buildLatestSmsTestMarkdown(SmsInboxReader.InboxResult inboxResult) {
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body);
StringBuilder builder = new StringBuilder();
builder.append("**SmsReceive 最近短信测试推送**").append('\n');
builder.append("时间:").append(formatTime(inboxResult.dateMillis)).append('\n');
builder.append("发送方:").append(maskSender(inboxResult.sender)).append('\n');
builder.append("短信ID").append(inboxResult.id).append('\n');
if (parseResult.success) {
builder.append("验证码:").append(parseResult.code).append('\n');
builder.append("解析:").append(parseResult.strategy).append(" / ").append(parseResult.confidence).append('\n');
} else {
builder.append("验证码:-").append('\n');
builder.append("解析失败:").append(emptyAsDash(parseResult.failureReason)).append('\n');
}
builder.append("正文:").append(emptyAsDash(inboxResult.body));
return builder.toString();
}
private boolean hasReceiveSmsPermission() {
return checkSelfPermission(Manifest.permission.RECEIVE_SMS) == PackageManager.PERMISSION_GRANTED;
}
private boolean hasReadSmsPermission() {
return checkSelfPermission(Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED;
}
private boolean hasAnySmsPermission() {
return hasReceiveSmsPermission() || hasReadSmsPermission();
}
private void readLatestInboxSms(String source, boolean showToast) {
if (!hasReadSmsPermission()) {
Log.w(TAG, "readLatestInboxSms skip: READ_SMS not granted source=" + source);
if (showToast) {
Toast.makeText(this, "READ_SMS 未授权,无法读取收件箱", Toast.LENGTH_LONG).show();
}
return;
}
SmsInboxReader.InboxResult inboxResult = SmsInboxReader.readLatest(this);
if (!inboxResult.success) {
Log.w(TAG, "readLatestInboxSms failed source=" + source + ", reason=" + inboxResult.failureReason);
if (showToast) {
Toast.makeText(this, inboxResult.failureReason, Toast.LENGTH_LONG).show();
}
return;
}
if (inboxResult.id == lastInboxSmsId && SOURCE_INBOX_OBSERVER.equals(source)) {
Log.d(TAG, "readLatestInboxSms ignore duplicate id=" + inboxResult.id);
return;
}
lastInboxSmsId = inboxResult.id;
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body);
CaptureResult captureResult;
if (parseResult.success) {
Log.d(TAG, "readLatestInboxSms parse success source=" + source
+ ", id=" + inboxResult.id
+ ", code=" + parseResult.code
+ ", strategy=" + parseResult.strategy);
captureResult = CaptureResult.success(
inboxResult.dateMillis,
inboxResult.id,
inboxResult.sender,
inboxResult.body,
parseResult,
source);
if (showToast) {
Toast.makeText(this, "最新短信验证码:" + parseResult.code, Toast.LENGTH_LONG).show();
}
} else {
Log.w(TAG, "readLatestInboxSms parse failed source=" + source
+ ", id=" + inboxResult.id
+ ", reason=" + parseResult.failureReason);
captureResult = CaptureResult.failure(
inboxResult.dateMillis,
inboxResult.id,
inboxResult.sender,
inboxResult.body,
source,
parseResult.failureReason);
if (showToast) {
Toast.makeText(this, "最新短信未解析到验证码", Toast.LENGTH_LONG).show();
}
}
SmsCaptureStore.save(this, captureResult);
FeishuWebhookClient.pushCaptureResultAsync(this, captureResult);
refreshUi();
}
private void dumpRecentMessages() {
if (!hasReadSmsPermission()) {
Log.w(TAG, "dumpRecentMessages skip: READ_SMS not granted");
Toast.makeText(this, "READ_SMS 未授权,无法打印短信库", Toast.LENGTH_LONG).show();
return;
}
int count = SmsInboxReader.logRecentMessages(this, 30);
Toast.makeText(this, "已打印最近 " + count + " 条短信到 logcat", Toast.LENGTH_LONG).show();
}
private void savePollingIntervalFromUi() {
int intervalSeconds = parsePollingIntervalSeconds();
SmsPollingStateStore.setIntervalSeconds(this, intervalSeconds);
Toast.makeText(this, "已保存轮询间隔:" + intervalSeconds + "", Toast.LENGTH_SHORT).show();
refreshUi();
}
private int parsePollingIntervalSeconds() {
String raw = pollingIntervalEdit == null ? "" : pollingIntervalEdit.getText().toString().trim();
if (TextUtils.isEmpty(raw)) {
return SmsPollingStateStore.getIntervalSeconds(this);
}
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e) {
Log.w(TAG, "parsePollingIntervalSeconds invalid raw=" + raw, e);
return SmsPollingStateStore.getIntervalSeconds(this);
}
}
private void togglePolling() {
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
if (state.enabledByUser) {
stopPolling();
} else {
startPolling();
}
}
private void startPolling() {
if (!hasReadSmsPermission()) {
Log.w(TAG, "startPolling blocked: READ_SMS not granted");
Toast.makeText(this, "READ_SMS 未授权,无法轮询短信库", Toast.LENGTH_LONG).show();
return;
}
savePollingIntervalFromUi();
Log.d(TAG, "startPolling via SmsPollingService");
SmsPollingService.start(this);
Toast.makeText(this,
"已启动后台轮询验证码,间隔 " + SmsPollingStateStore.getIntervalSeconds(this) + "",
Toast.LENGTH_SHORT).show();
refreshUi();
}
private void stopPolling() {
Log.d(TAG, "stopPolling via SmsPollingService");
SmsPollingService.stop(this);
Toast.makeText(this, "已停止后台短信轮询", Toast.LENGTH_SHORT).show();
refreshUi();
}
private boolean isGooglePlayServicesInstalled() {
try {
getPackageManager().getPackageInfo("com.google.android.gms", 0);
return true;
} catch (PackageManager.NameNotFoundException e) {
return false;
}
}
private void openAppSettings() {
Log.d(TAG, "openAppSettings package=" + getPackageName());
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", getPackageName(), null));
startActivity(intent);
}
private void openBatteryOptimizationSettings() {
try {
Log.d(TAG, "openBatteryOptimizationSettings action="
+ Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS));
} catch (ActivityNotFoundException e) {
Log.w(TAG, "openBatteryOptimizationSettings fallback to app settings", e);
openAppSettings();
}
}
private void requestIgnoreBatteryOptimizations() {
if (isIgnoringBatteryOptimizations()) {
Log.d(TAG, "requestIgnoreBatteryOptimizations skipped: already ignored");
Toast.makeText(this, "当前已在电池优化白名单", Toast.LENGTH_SHORT).show();
return;
}
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + getPackageName()));
try {
Log.d(TAG, "requestIgnoreBatteryOptimizations action="
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
startActivity(intent);
} catch (ActivityNotFoundException e) {
Log.w(TAG, "requestIgnoreBatteryOptimizations fallback to battery settings", e);
openBatteryOptimizationSettings();
}
}
private void openXiaomiAutostartSettings() {
Log.d(TAG, "openXiaomiAutostartSettings start");
Intent[] candidates = new Intent[]{
new Intent().setComponent(new ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity")),
new Intent().setComponent(new ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.permissions.PermissionsEditorActivity")),
new Intent("miui.intent.action.OP_AUTO_START").setPackage("com.miui.securitycenter")
};
for (Intent candidate : candidates) {
if (tryStartActivity(candidate)) {
Log.d(TAG, "openXiaomiAutostartSettings launched intent=" + candidate);
return;
}
}
Toast.makeText(this, "未找到小米自启动页,已打开应用详情", Toast.LENGTH_LONG).show();
openAppSettings();
}
private boolean tryStartActivity(Intent intent) {
try {
Log.d(TAG, "tryStartActivity intent=" + intent);
startActivity(intent);
return true;
} catch (RuntimeException e) {
Log.w(TAG, "tryStartActivity failed intent=" + intent, e);
return false;
}
}
private boolean isIgnoringBatteryOptimizations() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true;
}
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
return powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName());
}
private boolean areNotificationsEnabled() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return true;
}
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
return manager == null || manager.areNotificationsEnabled();
}
private String formatTime(long timeMillis) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date(timeMillis));
}
private String formatTimeWithMillis(long timeMillis) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.CHINA).format(new Date(timeMillis));
}
private String formatOptionalTime(long timeMillis) {
return timeMillis > 0L ? formatTime(timeMillis) : "-";
}
private String emptyAsDash(String value) {
return TextUtils.isEmpty(value) ? "-" : value;
}
private String maskSender(String sender) {
if (TextUtils.isEmpty(sender)) {
return "-";
}
if (sender.length() <= 4) {
return sender;
}
return "***" + sender.substring(sender.length() - 4);
}
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
}
}
@@ -0,0 +1,149 @@
package com.smsreceive.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import android.util.Log;
final class SmsCaptureStore {
static final String ACTION_CAPTURE_UPDATED = "com.smsreceive.app.ACTION_CAPTURE_UPDATED";
private static final String TAG = "[SMS]SmsReceive";
private static final String PREFS = "sms_capture";
private static final String KEY_TIME = "time";
private static final String KEY_SENDER = "sender";
private static final String KEY_CODE = "code";
private static final String KEY_STRATEGY = "strategy";
private static final String KEY_CONFIDENCE = "confidence";
private static final String KEY_SOURCE = "source";
private static final String KEY_FAILURE = "failure";
private static final String KEY_BODY_PREVIEW = "body_preview";
private static final String KEY_LAST_BROADCAST_TIME = "last_broadcast_time";
private static final String KEY_LAST_INBOX_TIME = "last_inbox_time";
private static final String KEY_LAST_INBOX_SOURCE = "last_inbox_source";
private SmsCaptureStore() {
}
static void save(Context context, CaptureResult result) {
VerificationCodeParser.ParseResult parse = result.parseResult;
Log.d(TAG, "SmsCaptureStore.save source=" + result.source
+ ", success=" + parse.success
+ ", code=" + parse.code
+ ", failure=" + (TextUtils.isEmpty(result.failureReason) ? parse.failureReason : result.failureReason));
SharedPreferences.Editor editor = preferences(context).edit()
.putLong(KEY_TIME, result.receivedAtMillis)
.putString(KEY_SENDER, summarizeSender(result.sender))
.putString(KEY_CODE, parse.code)
.putString(KEY_STRATEGY, parse.strategy)
.putInt(KEY_CONFIDENCE, parse.confidence)
.putString(KEY_SOURCE, result.source)
.putString(KEY_FAILURE, TextUtils.isEmpty(result.failureReason) ? parse.failureReason : result.failureReason)
.putString(KEY_BODY_PREVIEW, previewBody(result.body));
if ("system_sms_broadcast".equals(result.source)) {
Log.d(TAG, "SmsCaptureStore.save delivery source=system_sms_broadcast time="
+ result.receivedAtMillis);
editor.putLong(KEY_LAST_BROADCAST_TIME, result.receivedAtMillis);
} else if (!TextUtils.isEmpty(result.source) && result.source.startsWith("sms_inbox_")) {
Log.d(TAG, "SmsCaptureStore.save delivery source=" + result.source
+ ", time=" + result.receivedAtMillis);
editor.putLong(KEY_LAST_INBOX_TIME, result.receivedAtMillis)
.putString(KEY_LAST_INBOX_SOURCE, result.source);
}
editor.apply();
}
static StoredCapture load(Context context) {
SharedPreferences prefs = preferences(context);
return new StoredCapture(
prefs.getLong(KEY_TIME, 0L),
prefs.getString(KEY_SENDER, ""),
prefs.getString(KEY_CODE, ""),
prefs.getString(KEY_STRATEGY, ""),
prefs.getInt(KEY_CONFIDENCE, 0),
prefs.getString(KEY_SOURCE, ""),
prefs.getString(KEY_FAILURE, ""),
prefs.getString(KEY_BODY_PREVIEW, ""));
}
static void clear(Context context) {
Log.d(TAG, "SmsCaptureStore.clear");
preferences(context).edit().clear().apply();
}
static DeliveryDiagnostics loadDeliveryDiagnostics(Context context) {
SharedPreferences prefs = preferences(context);
return new DeliveryDiagnostics(
prefs.getLong(KEY_LAST_BROADCAST_TIME, 0L),
prefs.getLong(KEY_LAST_INBOX_TIME, 0L),
prefs.getString(KEY_LAST_INBOX_SOURCE, ""));
}
private static SharedPreferences preferences(Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
private static String summarizeSender(String sender) {
if (TextUtils.isEmpty(sender)) {
return "";
}
if (sender.length() <= 4) {
return sender;
}
return "***" + sender.substring(sender.length() - 4);
}
private static String previewBody(String body) {
if (TextUtils.isEmpty(body)) {
return "";
}
String normalized = body.replace('\n', ' ').replace('\r', ' ').trim();
return normalized.length() <= 48 ? normalized : normalized.substring(0, 48) + "...";
}
static final class StoredCapture {
final long timeMillis;
final String sender;
final String code;
final String strategy;
final int confidence;
final String source;
final String failure;
final String bodyPreview;
StoredCapture(
long timeMillis,
String sender,
String code,
String strategy,
int confidence,
String source,
String failure,
String bodyPreview) {
this.timeMillis = timeMillis;
this.sender = sender;
this.code = code;
this.strategy = strategy;
this.confidence = confidence;
this.source = source;
this.failure = failure;
this.bodyPreview = bodyPreview;
}
}
static final class DeliveryDiagnostics {
final long lastBroadcastTimeMillis;
final long lastInboxTimeMillis;
final String lastInboxSource;
DeliveryDiagnostics(long lastBroadcastTimeMillis, long lastInboxTimeMillis, String lastInboxSource) {
this.lastBroadcastTimeMillis = lastBroadcastTimeMillis;
this.lastInboxTimeMillis = lastInboxTimeMillis;
this.lastInboxSource = lastInboxSource == null ? "" : lastInboxSource;
}
boolean inboxNewerThanBroadcast() {
return lastInboxTimeMillis > 0L && lastInboxTimeMillis > lastBroadcastTimeMillis;
}
}
}
@@ -0,0 +1,313 @@
package com.smsreceive.app;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.Telephony;
import android.text.TextUtils;
import android.util.Log;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
final class SmsInboxReader {
private static final String TAG = "[SMS]SmsReceive";
private static final Uri SMS_INBOX_URI = Uri.parse("content://sms/inbox");
private SmsInboxReader() {
}
static InboxResult readLatest(Context context) {
String[] projection = {
Telephony.Sms._ID,
Telephony.Sms.ADDRESS,
Telephony.Sms.BODY,
Telephony.Sms.DATE
};
try (Cursor cursor = context.getContentResolver().query(
SMS_INBOX_URI,
projection,
null,
null,
Telephony.Sms.DATE + " DESC LIMIT 1")) {
if (cursor == null) {
Log.w(TAG, "SmsInboxReader.readLatest failed: cursor is null");
return InboxResult.failure("短信库查询 cursor 为空");
}
if (!cursor.moveToFirst()) {
Log.w(TAG, "SmsInboxReader.readLatest failed: inbox empty");
return InboxResult.failure("短信收件箱为空");
}
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));
if (TextUtils.isEmpty(body)) {
Log.w(TAG, "SmsInboxReader.readLatest failed: empty body id=" + id);
return InboxResult.failure("最新短信正文为空");
}
Log.d(TAG, "SmsInboxReader.readLatest success id=" + id
+ ", sender=" + maskSender(sender)
+ ", date=" + date
+ ", bodyLength=" + body.length());
return InboxResult.success(id, sender, body, date);
} catch (SecurityException e) {
Log.w(TAG, "SmsInboxReader.readLatest failed: READ_SMS denied", e);
return InboxResult.failure("READ_SMS 未授权");
} catch (Exception e) {
Log.w(TAG, "SmsInboxReader.readLatest failed", e);
return InboxResult.failure("短信库查询失败:" + e.getClass().getSimpleName());
}
}
static int logRecentMessages(Context context, int limit) {
Uri uri = Telephony.Sms.CONTENT_URI;
String[] projection = {
Telephony.Sms._ID,
Telephony.Sms.ADDRESS,
Telephony.Sms.BODY,
Telephony.Sms.DATE,
Telephony.Sms.TYPE
};
int safeLimit = Math.max(1, Math.min(limit, 100));
Log.d(TAG, "SmsInboxReader.logRecentMessages start uri=" + uri + ", limit=" + safeLimit);
try (Cursor cursor = context.getContentResolver().query(
uri,
projection,
null,
null,
Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) {
if (cursor == null) {
Log.w(TAG, "SmsInboxReader.logRecentMessages cursor is null");
return 0;
}
int count = 0;
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID));
String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS));
String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY));
long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE));
int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE));
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(body);
Log.d(TAG, "SMS[" + count + "] id=" + id
+ ", type=" + smsTypeName(type)
+ ", date=" + formatDate(date)
+ ", sender=" + maskSender(sender)
+ ", parseSuccess=" + parseResult.success
+ ", code=" + parseResult.code
+ ", strategy=" + parseResult.strategy
+ ", bodyPreview=" + previewBody(body));
count++;
}
Log.d(TAG, "SmsInboxReader.logRecentMessages end count=" + count);
return count;
} catch (SecurityException e) {
Log.w(TAG, "SmsInboxReader.logRecentMessages failed: READ_SMS denied", e);
return 0;
} catch (Exception e) {
Log.w(TAG, "SmsInboxReader.logRecentMessages failed", e);
return 0;
}
}
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;
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));
String selection = minDateMillis > 0L ? Telephony.Sms.DATE + ">=?" : null;
String[] selectionArgs = minDateMillis > 0L ? new String[]{String.valueOf(minDateMillis)} : null;
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode start limit=" + safeLimit
+ ", minDate=" + (minDateMillis > 0L ? formatDate(minDateMillis) : "none"));
try (Cursor cursor = context.getContentResolver().query(
uri,
projection,
selection,
selectionArgs,
Telephony.Sms.DATE + " DESC LIMIT " + safeLimit)) {
if (cursor == null) {
Log.w(TAG, "SmsInboxReader.findLatestVerificationCode cursor is null");
return RecentCodeResult.failure("短信库查询 cursor 为空");
}
int scanned = 0;
RecentCodeResult latest = null;
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms._ID));
String sender = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS));
String body = cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Sms.BODY));
long date = cursor.getLong(cursor.getColumnIndexOrThrow(Telephony.Sms.DATE));
int type = cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Sms.TYPE));
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(body);
Log.d(TAG, "poll scan SMS[" + scanned + "] id=" + id
+ ", type=" + smsTypeName(type)
+ ", date=" + formatDate(date)
+ ", sender=" + maskSender(sender)
+ ", parseSuccess=" + parseResult.success
+ ", code=" + parseResult.code
+ ", strategy=" + parseResult.strategy
+ ", bodyPreview=" + previewBody(body));
scanned++;
if (parseResult.success) {
RecentCodeResult candidate = RecentCodeResult.success(id, sender, body, date, parseResult, scanned);
if (latest == null || candidate.dateMillis > latest.dateMillis) {
latest = candidate;
}
Log.d(TAG, "SmsInboxReader.findLatestVerificationCode candidate id=" + id
+ ", code=" + parseResult.code
+ ", date=" + formatDate(date)
+ ", scanned=" + scanned);
}
}
if (latest != null) {
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) {
Log.w(TAG, "SmsInboxReader.findLatestVerificationCode failed: READ_SMS denied", e);
return RecentCodeResult.failure("READ_SMS 未授权");
} catch (Exception e) {
Log.w(TAG, "SmsInboxReader.findLatestVerificationCode failed", e);
return RecentCodeResult.failure("短信库查询失败:" + e.getClass().getSimpleName());
}
}
private static String maskSender(String sender) {
if (sender == null || sender.length() <= 4) {
return sender == null ? "" : sender;
}
return "***" + sender.substring(sender.length() - 4);
}
private static String previewBody(String body) {
if (TextUtils.isEmpty(body)) {
return "";
}
String normalized = body.replace('\n', ' ').replace('\r', ' ').trim();
return normalized.length() <= 80 ? normalized : normalized.substring(0, 80) + "...";
}
private static String formatDate(long dateMillis) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date(dateMillis));
}
private static String smsTypeName(int type) {
switch (type) {
case Telephony.Sms.MESSAGE_TYPE_INBOX:
return "INBOX";
case Telephony.Sms.MESSAGE_TYPE_SENT:
return "SENT";
case Telephony.Sms.MESSAGE_TYPE_DRAFT:
return "DRAFT";
case Telephony.Sms.MESSAGE_TYPE_OUTBOX:
return "OUTBOX";
case Telephony.Sms.MESSAGE_TYPE_FAILED:
return "FAILED";
case Telephony.Sms.MESSAGE_TYPE_QUEUED:
return "QUEUED";
default:
return "UNKNOWN(" + type + ")";
}
}
static final class InboxResult {
final boolean success;
final long id;
final String sender;
final String body;
final long dateMillis;
final String failureReason;
private InboxResult(boolean success, long id, String sender, String body, long dateMillis, String failureReason) {
this.success = success;
this.id = id;
this.sender = sender == null ? "" : sender;
this.body = body == null ? "" : body;
this.dateMillis = dateMillis;
this.failureReason = failureReason == null ? "" : failureReason;
}
static InboxResult success(long id, String sender, String body, long dateMillis) {
return new InboxResult(true, id, sender, body, dateMillis, "");
}
static InboxResult failure(String reason) {
return new InboxResult(false, -1L, "", "", System.currentTimeMillis(), reason);
}
}
static final class RecentCodeResult {
final boolean success;
final long id;
final String sender;
final String body;
final long dateMillis;
final VerificationCodeParser.ParseResult parseResult;
final int scannedCount;
final String failureReason;
private RecentCodeResult(
boolean success,
long id,
String sender,
String body,
long dateMillis,
VerificationCodeParser.ParseResult parseResult,
int scannedCount,
String failureReason) {
this.success = success;
this.id = id;
this.sender = sender == null ? "" : sender;
this.body = body == null ? "" : body;
this.dateMillis = dateMillis;
this.parseResult = parseResult;
this.scannedCount = scannedCount;
this.failureReason = failureReason == null ? "" : failureReason;
}
static RecentCodeResult success(
long id,
String sender,
String body,
long dateMillis,
VerificationCodeParser.ParseResult parseResult,
int scannedCount) {
return new RecentCodeResult(true, id, sender, body, dateMillis, parseResult, scannedCount, "");
}
static RecentCodeResult noCode(int scannedCount) {
return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(),
VerificationCodeParser.ParseResult.failure("最近短信未找到验证码"), scannedCount, "最近短信未找到验证码");
}
static RecentCodeResult failure(String reason) {
return new RecentCodeResult(false, -1L, "", "", System.currentTimeMillis(),
VerificationCodeParser.ParseResult.failure(reason), 0, reason);
}
RecentCodeResult withScannedCount(int scannedCount) {
return new RecentCodeResult(success, id, sender, body, dateMillis, parseResult, scannedCount, failureReason);
}
}
}
@@ -0,0 +1,102 @@
package com.smsreceive.app;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
public final class SmsKeepAliveService extends Service {
private static final String TAG = "[SMS]SmsReceive";
private static final long HEARTBEAT_INTERVAL_MILLIS = 10_000L;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable heartbeatRunnable = new Runnable() {
@Override
public void run() {
Log.d(TAG, "SmsKeepAliveService.heartbeat start intervalMs=" + HEARTBEAT_INTERVAL_MILLIS);
KeepAliveStateStore.recordHeartbeat(SmsKeepAliveService.this);
long lastActiveTime = KeepAliveDatabase.writeLastActiveTime(SmsKeepAliveService.this);
if (KeepAliveStateStore.isToastOnDatabaseWriteEnabled(SmsKeepAliveService.this)) {
Toast.makeText(
SmsKeepAliveService.this,
"[SMS]保活 lastActiveTime" + formatTime(lastActiveTime),
Toast.LENGTH_SHORT).show();
}
Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED);
updateIntent.setPackage(getPackageName());
sendBroadcast(updateIntent);
startForeground(
KeepAliveNotification.NOTIFICATION_ID,
KeepAliveNotification.build(SmsKeepAliveService.this, "数据库心跳:" + formatTime(lastActiveTime)));
handler.postDelayed(this, HEARTBEAT_INTERVAL_MILLIS);
}
};
static void start(Context context) {
Log.d(TAG, "SmsKeepAliveService.start requested sdk=" + Build.VERSION.SDK_INT);
Intent intent = new Intent(context, SmsKeepAliveService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent);
} else {
context.startService(intent);
}
}
static void stop(Context context) {
Log.d(TAG, "SmsKeepAliveService.stop requested");
context.stopService(new Intent(context, SmsKeepAliveService.class));
}
@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "SmsKeepAliveService.onCreate");
KeepAliveStateStore.recordServiceStarted(this);
long lastActiveTime = KeepAliveDatabase.writeLastActiveTime(this);
Log.d(TAG, "SmsKeepAliveService.onCreate wrote lastActiveTime=" + lastActiveTime
+ ", time=" + formatTime(lastActiveTime));
startForeground(
KeepAliveNotification.NOTIFICATION_ID,
KeepAliveNotification.build(this, "数据库心跳:" + formatTime(lastActiveTime)));
handler.post(heartbeatRunnable);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.d(TAG, "SmsKeepAliveService.onStartCommand flags=" + flags + ", startId=" + startId);
KeepAliveStateStore.recordServiceStarted(this);
long lastActiveTime = KeepAliveDatabase.writeLastActiveTime(this);
Log.d(TAG, "SmsKeepAliveService.onStartCommand wrote lastActiveTime=" + lastActiveTime
+ ", time=" + formatTime(lastActiveTime));
startForeground(
KeepAliveNotification.NOTIFICATION_ID,
KeepAliveNotification.build(this, "数据库心跳:" + formatTime(lastActiveTime)));
return START_STICKY;
}
@Override
public void onDestroy() {
Log.d(TAG, "SmsKeepAliveService.onDestroy");
handler.removeCallbacks(heartbeatRunnable);
KeepAliveStateStore.recordServiceStopped(this, "服务已停止");
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private static String formatTime(long timeMillis) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.CHINA).format(new Date(timeMillis));
}
}
@@ -0,0 +1,88 @@
package com.smsreceive.app;
import android.content.Intent;
import android.provider.Telephony;
import android.telephony.SmsMessage;
import android.text.TextUtils;
import android.util.Log;
final class SmsMessageReader {
private static final String TAG = "[SMS]SmsReceive";
private SmsMessageReader() {
}
static ReadResult read(Intent intent) {
if (intent == null) {
Log.w(TAG, "SmsMessageReader.read failed: intent is null");
return ReadResult.failure("intent 为空");
}
SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
if (messages == null || messages.length == 0) {
Log.w(TAG, "SmsMessageReader.read failed: no messages in intent");
return ReadResult.failure("未解析到 SMS message");
}
Log.d(TAG, "SmsMessageReader.read messageCount=" + messages.length);
StringBuilder bodyBuilder = new StringBuilder();
String sender = "";
long timestamp = System.currentTimeMillis();
for (SmsMessage message : messages) {
if (message == null) {
continue;
}
if (TextUtils.isEmpty(sender)) {
sender = nullToEmpty(message.getOriginatingAddress());
}
if (message.getTimestampMillis() > 0L) {
timestamp = message.getTimestampMillis();
}
bodyBuilder.append(nullToEmpty(message.getMessageBody()));
}
String body = bodyBuilder.toString();
if (TextUtils.isEmpty(body)) {
Log.w(TAG, "SmsMessageReader.read failed: empty body");
return ReadResult.failure("短信正文为空");
}
Log.d(TAG, "SmsMessageReader.read success sender=" + maskSender(sender)
+ ", timestamp=" + timestamp
+ ", bodyLength=" + body.length());
return ReadResult.success(sender, body, timestamp);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static String maskSender(String sender) {
if (sender == null || sender.length() <= 4) {
return sender == null ? "" : sender;
}
return "***" + sender.substring(sender.length() - 4);
}
static final class ReadResult {
final boolean success;
final String sender;
final String body;
final long timestampMillis;
final String failureReason;
private ReadResult(boolean success, String sender, String body, long timestampMillis, String failureReason) {
this.success = success;
this.sender = sender;
this.body = body;
this.timestampMillis = timestampMillis;
this.failureReason = failureReason;
}
static ReadResult success(String sender, String body, long timestampMillis) {
return new ReadResult(true, sender, body, timestampMillis, "");
}
static ReadResult failure(String reason) {
return new ReadResult(false, "", "", System.currentTimeMillis(), reason);
}
}
}
@@ -0,0 +1,135 @@
package com.smsreceive.app;
import android.Manifest;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
public final class SmsPollingService extends Service {
private static final String TAG = "[SMS]SmsReceive";
private static final String SOURCE_INBOX_POLLING = "sms_inbox_polling";
private static final int NOTIFICATION_ID = 2102;
private final Handler handler = new Handler(Looper.getMainLooper());
private long pollingStartMillis;
private long lastHitSmsId = -1L;
private final Runnable pollingRunnable = new Runnable() {
@Override
public void run() {
pollRecentSmsForCode();
long intervalMillis = SmsPollingStateStore.getIntervalSeconds(SmsPollingService.this) * 1000L;
Log.d(TAG, "SmsPollingService.schedule next intervalMs=" + intervalMillis);
handler.postDelayed(this, intervalMillis);
}
};
static void start(Context context) {
Log.d(TAG, "SmsPollingService.start requested sdk=" + Build.VERSION.SDK_INT);
long startTimeMillis = System.currentTimeMillis() - 2_000L;
SmsPollingStateStore.recordStarted(context, startTimeMillis);
Intent intent = new Intent(context, SmsPollingService.class);
intent.putExtra("start_time", startTimeMillis);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent);
} else {
context.startService(intent);
}
}
static void stop(Context context) {
Log.d(TAG, "SmsPollingService.stop requested");
SmsPollingStateStore.recordStopped(context, "用户停止轮询");
context.stopService(new Intent(context, SmsPollingService.class));
}
@Override
public void onCreate() {
super.onCreate();
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
pollingStartMillis = state.startTimeMillis > 0L ? state.startTimeMillis : System.currentTimeMillis() - 2_000L;
lastHitSmsId = state.lastHitId;
SmsPollingStateStore.recordServiceRunning(this);
startForeground(NOTIFICATION_ID, KeepAliveNotification.build(this, "短信轮询运行中"));
handler.post(pollingRunnable);
Log.d(TAG, "SmsPollingService.onCreate startTime=" + pollingStartMillis + ", lastHitId=" + lastHitSmsId);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.d(TAG, "SmsPollingService.onStartCommand flags=" + flags + ", startId=" + startId);
if (intent != null && intent.getLongExtra("start_time", 0L) > 0L) {
pollingStartMillis = intent.getLongExtra("start_time", pollingStartMillis);
}
SmsPollingStateStore.recordServiceRunning(this);
startForeground(NOTIFICATION_ID, KeepAliveNotification.build(this, "短信轮询运行中"));
return START_STICKY;
}
@Override
public void onDestroy() {
Log.d(TAG, "SmsPollingService.onDestroy");
handler.removeCallbacks(pollingRunnable);
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
if (state.enabledByUser) {
SmsPollingStateStore.recordServiceStopped(this, "轮询服务已停止,等待系统恢复");
}
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void pollRecentSmsForCode() {
if (!hasReadSmsPermission()) {
Log.w(TAG, "SmsPollingService.poll stop: READ_SMS not granted");
Toast.makeText(this, "READ_SMS 未授权,已停止短信轮询", Toast.LENGTH_LONG).show();
SmsPollingStateStore.recordStopped(this, "READ_SMS 未授权");
stopSelf();
return;
}
SmsInboxReader.RecentCodeResult result = SmsInboxReader.findLatestVerificationCode(this, 3, pollingStartMillis);
if (!result.success) {
Log.d(TAG, "SmsPollingService.poll no code scanned=" + result.scannedCount
+ ", reason=" + result.failureReason);
return;
}
if (result.id == lastHitSmsId) {
return;
}
lastHitSmsId = result.id;
SmsPollingStateStore.recordHit(this, result.id, result.dateMillis);
Log.d(TAG, "SmsPollingService.poll hit id=" + result.id
+ ", code=" + result.parseResult.code
+ ", strategy=" + result.parseResult.strategy
+ ", confidence=" + result.parseResult.confidence);
CaptureResult captureResult = CaptureResult.success(
result.dateMillis,
result.id,
result.sender,
result.body,
result.parseResult,
SOURCE_INBOX_POLLING);
SmsCaptureStore.save(this, captureResult);
FeishuWebhookClient.pushCaptureResultAsync(this, captureResult);
Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED);
updateIntent.setPackage(getPackageName());
sendBroadcast(updateIntent);
Toast.makeText(this, "轮询提取验证码:" + result.parseResult.code, Toast.LENGTH_LONG).show();
}
private boolean hasReadSmsPermission() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| checkSelfPermission(Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED;
}
}
@@ -0,0 +1,129 @@
package com.smsreceive.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import android.util.Log;
final class SmsPollingStateStore {
private static final String TAG = "[SMS]SmsReceive";
private static final String PREFS = "sms_polling";
private static final String KEY_ENABLED_BY_USER = "enabled_by_user";
private static final String KEY_RUNNING = "running";
private static final String KEY_START_TIME = "start_time";
private static final String KEY_LAST_HIT_ID = "last_hit_id";
private static final String KEY_LAST_HIT_TIME = "last_hit_time";
private static final String KEY_LAST_FAILURE = "last_failure";
private static final String KEY_INTERVAL_SECONDS = "interval_seconds";
private static final int DEFAULT_INTERVAL_SECONDS = 1;
private static final int MIN_INTERVAL_SECONDS = 1;
private static final int MAX_INTERVAL_SECONDS = 3600;
private SmsPollingStateStore() {
}
static void recordStarted(Context context, long startTimeMillis) {
Log.d(TAG, "SmsPollingStateStore.recordStarted startTime=" + startTimeMillis);
preferences(context).edit()
.putBoolean(KEY_ENABLED_BY_USER, true)
.putBoolean(KEY_RUNNING, true)
.putLong(KEY_START_TIME, startTimeMillis)
.putString(KEY_LAST_FAILURE, "")
.apply();
}
static void recordStopped(Context context, String reason) {
Log.d(TAG, "SmsPollingStateStore.recordStopped reason=" + reason);
preferences(context).edit()
.putBoolean(KEY_ENABLED_BY_USER, false)
.putBoolean(KEY_RUNNING, false)
.putString(KEY_LAST_FAILURE, safe(reason))
.apply();
}
static void recordServiceStopped(Context context, String reason) {
Log.d(TAG, "SmsPollingStateStore.recordServiceStopped reason=" + reason);
preferences(context).edit()
.putBoolean(KEY_RUNNING, false)
.putString(KEY_LAST_FAILURE, safe(reason))
.apply();
}
static void recordServiceRunning(Context context) {
preferences(context).edit()
.putBoolean(KEY_RUNNING, true)
.apply();
}
static void recordHit(Context context, long smsId, long hitTimeMillis) {
Log.d(TAG, "SmsPollingStateStore.recordHit id=" + smsId + ", time=" + hitTimeMillis);
preferences(context).edit()
.putLong(KEY_LAST_HIT_ID, smsId)
.putLong(KEY_LAST_HIT_TIME, hitTimeMillis)
.putString(KEY_LAST_FAILURE, "")
.apply();
}
static void setIntervalSeconds(Context context, int seconds) {
int safeSeconds = clampIntervalSeconds(seconds);
Log.d(TAG, "SmsPollingStateStore.setIntervalSeconds seconds=" + safeSeconds);
preferences(context).edit()
.putInt(KEY_INTERVAL_SECONDS, safeSeconds)
.apply();
}
static int getIntervalSeconds(Context context) {
return clampIntervalSeconds(preferences(context).getInt(KEY_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS));
}
static State load(Context context) {
SharedPreferences prefs = preferences(context);
return new State(
prefs.getBoolean(KEY_ENABLED_BY_USER, false),
prefs.getBoolean(KEY_RUNNING, false),
prefs.getLong(KEY_START_TIME, 0L),
prefs.getLong(KEY_LAST_HIT_ID, -1L),
prefs.getLong(KEY_LAST_HIT_TIME, 0L),
prefs.getString(KEY_LAST_FAILURE, ""),
clampIntervalSeconds(prefs.getInt(KEY_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS)));
}
private static SharedPreferences preferences(Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
private static String safe(String value) {
return TextUtils.isEmpty(value) ? "" : value;
}
private static int clampIntervalSeconds(int seconds) {
return Math.max(MIN_INTERVAL_SECONDS, Math.min(seconds, MAX_INTERVAL_SECONDS));
}
static final class State {
final boolean enabledByUser;
final boolean running;
final long startTimeMillis;
final long lastHitId;
final long lastHitTimeMillis;
final String lastFailure;
final int intervalSeconds;
State(
boolean enabledByUser,
boolean running,
long startTimeMillis,
long lastHitId,
long lastHitTimeMillis,
String lastFailure,
int intervalSeconds) {
this.enabledByUser = enabledByUser;
this.running = running;
this.startTimeMillis = startTimeMillis;
this.lastHitId = lastHitId;
this.lastHitTimeMillis = lastHitTimeMillis;
this.lastFailure = safe(lastFailure);
this.intervalSeconds = intervalSeconds;
}
}
}
@@ -0,0 +1,89 @@
package com.smsreceive.app;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.provider.Telephony;
import android.util.Log;
import android.widget.Toast;
public final class SmsReceiver extends BroadcastReceiver {
private static final String TAG = "[SMS]SmsReceive";
private static final String SOURCE_SYSTEM_BROADCAST = "system_sms_broadcast";
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "SmsReceiver.onReceive start");
if (context == null || intent == null) {
Log.w(TAG, "SmsReceiver.onReceive abort: context or intent is null");
return;
}
Log.d(TAG, "SmsReceiver.onReceive action=" + intent.getAction());
if (!Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(intent.getAction())) {
Log.d(TAG, "SmsReceiver.onReceive ignore non SMS action");
return;
}
Toast.makeText(context, "收到短信广播,开始解析", Toast.LENGTH_SHORT).show();
SmsMessageReader.ReadResult readResult = SmsMessageReader.read(intent);
CaptureResult captureResult;
if (readResult.success) {
Log.d(TAG, "SMS read success sender=" + maskSender(readResult.sender)
+ ", bodyPreview=" + preview(readResult.body));
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(readResult.body);
if (parseResult.success) {
Log.d(TAG, "verification code parse success code=" + parseResult.code
+ ", strategy=" + parseResult.strategy
+ ", confidence=" + parseResult.confidence);
Toast.makeText(context, "验证码:" + parseResult.code, Toast.LENGTH_LONG).show();
captureResult = CaptureResult.success(
readResult.timestampMillis,
readResult.sender,
readResult.body,
parseResult,
SOURCE_SYSTEM_BROADCAST);
} else {
Log.w(TAG, "verification code parse failed reason=" + parseResult.failureReason);
Toast.makeText(context, "短信已收到,未解析到验证码", Toast.LENGTH_LONG).show();
captureResult = CaptureResult.failure(
readResult.timestampMillis,
readResult.sender,
readResult.body,
SOURCE_SYSTEM_BROADCAST,
parseResult.failureReason);
}
} else {
Log.w(TAG, "SMS read failed reason=" + readResult.failureReason);
Toast.makeText(context, "短信读取失败:" + readResult.failureReason, Toast.LENGTH_LONG).show();
captureResult = CaptureResult.failure(
readResult.timestampMillis,
"",
"",
SOURCE_SYSTEM_BROADCAST,
readResult.failureReason);
}
SmsCaptureStore.save(context, captureResult);
FeishuWebhookClient.pushCaptureResultAsync(context, captureResult);
Intent updateIntent = new Intent(SmsCaptureStore.ACTION_CAPTURE_UPDATED);
updateIntent.setPackage(context.getPackageName());
context.sendBroadcast(updateIntent);
Log.d(TAG, "SmsReceiver.onReceive end source=" + SOURCE_SYSTEM_BROADCAST
+ ", success=" + captureResult.parseResult.success);
}
private static String maskSender(String sender) {
if (sender == null || sender.length() <= 4) {
return sender == null ? "" : sender;
}
return "***" + sender.substring(sender.length() - 4);
}
private static String preview(String body) {
if (body == null) {
return "";
}
String normalized = body.replace('\n', ' ').replace('\r', ' ').trim();
return normalized.length() <= 40 ? normalized : normalized.substring(0, 40) + "...";
}
}
@@ -0,0 +1,175 @@
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);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
<resources>
<color name="screen_bg">#F6F7F9</color>
<color name="text_primary">#17202A</color>
<color name="text_secondary">#5F6B7A</color>
<color name="accent">#1E7A5F</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">SmsReceive</string>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowBackground">@color/screen_bg</item>
<item name="android:colorAccent">@color/accent</item>
</style>
</resources>
@@ -0,0 +1,66 @@
package com.smsreceive.app;
import org.json.JSONObject;
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 FeishuWebhookClientTest {
@Test
public void generateSignMatchesPythonReference() throws Exception {
String sign = FeishuWebhookClient.generateSign("my_secret", 1717020800L);
assertEquals("ajQIGQQbC+ykXA6alen/inmS3NYWGbE2LBj9v2+G6VM=", sign);
}
@Test
public void buildRequestJsonMatchesFeishuMarkdownShape() throws Exception {
String json = FeishuWebhookClient.buildRequestJson("验证码 123456", 1717020800L, "sign_value");
JSONObject root = new JSONObject(json);
assertEquals("interactive", root.getString("msg_type"));
assertEquals("1717020800", root.getString("timestamp"));
assertEquals("sign_value", root.getString("sign"));
JSONObject markdown = root.getJSONObject("card").getJSONArray("elements").getJSONObject(0);
assertEquals("markdown", markdown.getString("tag"));
assertEquals("验证码 123456", markdown.getString("content"));
}
@Test
public void parseResponseAcceptsCodeZero() {
FeishuWebhookPushResult result = FeishuWebhookClient.parseResponse("{\"code\":0,\"msg\":\"success\"}");
assertTrue(result.success);
assertEquals(FeishuWebhookPushResult.STATUS_SUCCESS, result.status);
assertEquals(0, result.apiCode);
}
@Test
public void parseResponseClassifiesApiError() {
FeishuWebhookPushResult result = FeishuWebhookClient.parseResponse("{\"code\":19021,\"msg\":\"invalid sign\"}");
assertFalse(result.success);
assertEquals(FeishuWebhookPushResult.STATUS_API_ERROR, result.status);
assertEquals(19021, result.apiCode);
}
@Test
public void parseResponseClassifiesInvalidJson() {
FeishuWebhookPushResult result = FeishuWebhookClient.parseResponse("<html>bad</html>");
assertFalse(result.success);
assertEquals(FeishuWebhookPushResult.STATUS_INVALID_JSON, result.status);
}
@Test
public void pushMarkdownRejectsMissingConfigBeforeNetwork() {
FeishuWebhookConfigStore.Config config = new FeishuWebhookConfigStore.Config(true, "", "", false, false);
FeishuWebhookPushResult result = FeishuWebhookClient.pushMarkdown(config, "test", 1717020800L);
assertFalse(result.success);
assertEquals(FeishuWebhookPushResult.STATUS_MISSING_CONFIG, result.status);
}
}
@@ -0,0 +1,47 @@
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);
}
}