[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

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.gradle/
build/
*/build/
local.properties
*.iml
.idea/
.DS_Store
app/release

36
README.en.md Normal file
View File

@ -0,0 +1,36 @@
# SmsReceive
#### Description
{**When you're done, you can delete the content in this README and update the file with details for others getting started with your repository**}
#### Software Architecture
Software architecture description
#### Installation
1. xxxx
2. xxxx
3. xxxx
#### Instructions
1. xxxx
2. xxxx
3. xxxx
#### Contribution
1. Fork the repository
2. Create Feat_xxx branch
3. Commit your code
4. Create Pull Request
#### Gitee Feature
1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
4. The most valuable open source project [GVP](https://gitee.com/gvp)
5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

39
README.md Normal file
View File

@ -0,0 +1,39 @@
# SmsReceive
#### 介绍
{**以下是 Gitee 平台说明,您可以替换此简介**
Gitee 是 OSCHINA 推出的基于 Git 的代码托管平台(同时支持 SVN。专为开发者提供稳定、高效、安全的云端软件开发协作平台
无论是个人、团队、或是企业,都能够用 Gitee 实现代码托管、项目管理、协作开发。企业项目请看 [https://gitee.com/enterprises](https://gitee.com/enterprises)}
#### 软件架构
软件架构说明
#### 安装教程
1. xxxx
2. xxxx
3. xxxx
#### 使用说明
1. xxxx
2. xxxx
3. xxxx
#### 参与贡献
1. Fork 本仓库
2. 新建 Feat_xxx 分支
3. 提交代码
4. 新建 Pull Request
#### 特技
1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

46
app/build.gradle Normal file
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
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
# Keep default debug build simple; release minification is disabled.

View File

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

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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("暂无短信接收记录。可以先授权,再从另一台手机发送:验证码 1234565 分钟内有效。");
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) + "...";
}
}

View File

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

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>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">SmsReceive</string>
</resources>

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>

View File

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

View File

@ -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("【测试】验证码 1234565 分钟内有效。");
assertTrue(result.success);
assertEquals("123456", result.code);
assertEquals("keyword_before_code", result.strategy);
}
@Test
public void parsesEnglishOtpCode() {
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("Your OTP code is 839204. Do not share it.");
assertTrue(result.success);
assertEquals("839204", result.code);
}
@Test
public void normalizesSpacesAndHyphens() {
assertEquals("123456", VerificationCodeParser.parse("验证码12 34 56").code);
assertEquals("123456", VerificationCodeParser.parse("验证码123-456").code);
}
@Test
public void prefersKeywordCandidate() {
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("订单 998877验证码 246810请勿泄露。");
assertTrue(result.success);
assertEquals("246810", result.code);
}
@Test
public void rejectsCommonFalsePositives() {
assertFalse(VerificationCodeParser.parse("订单金额 1234 元,手机号 13800138000。").success);
assertFalse(VerificationCodeParser.parse("会议日期 2026-05-16无验证码。").success);
assertFalse(VerificationCodeParser.parse("这是一条普通通知。").success);
}
}

29
build.gradle Normal file
View File

@ -0,0 +1,29 @@
buildscript {
repositories {
google()
mavenCentral()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.2'
}
}
allprojects {
repositories {
google()
mavenCentral()
jcenter()
}
}
ext {
minSdkVersion = 21
compileSdkVersion = 30
targetSdkVersion = 30
buildToolsVersion = '30.0.3'
}
task clean(type: Delete) {
delete rootProject.buildDir
}

5
gradle.properties Normal file
View File

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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17

View File

@ -0,0 +1,188 @@
## Context
用户给出的 Python 实现包含两个核心函数:
- `generate_sign(secret, timestamp)`:签名字符串为 `timestamp + "\n" + secret`,把这个完整字符串作为 HMAC key消息体为空字节数组算法为 SHA-256结果做 Base64 编码。
- `push_markdown(markdown_content, webhook_id, secret)`:构造飞书 interactive markdown 卡片,加上 `timestamp``sign`10 秒超时 POST 到飞书 webhookHTTP 200 且响应 JSON `code == 0` 表示成功。
当前 Android 工程是 Java + Android Gradle Plugin 7.2.2`minSdkVersion=21``targetSdkVersion=30`。工程没有 Kotlin、Retrofit、OkHttp 或协程依赖。为了把需求做小、做稳,首版应选择 Java + OkHttp + `org.json`,不引入 Retrofit 接口层。
## Goals / Non-Goals
**Goals:**
- 先形成完整 spec不直接写代码。
- Android 侧等价实现 Python 已有飞书 webhook 推送协议。
- 推送模块独立于短信接收模块,方便单元测试和手动测试。
- 网络请求后台执行,避免阻塞主线程和短信广播处理。
- 提供清晰的本地诊断结果让用户知道是签名、配置、网络、HTTP、JSON 还是飞书业务错误。
- 推送内容包含短信原文,便于当前调试远端接收结果。
**Non-Goals:**
- 不改造服务端协议。
- 不做重试队列、离线持久化队列或 WorkManager 长任务调度。
- 不把网络发送成功作为短信接收成功的前置条件。
- 不把 webhook secret 写死到源码或提交到版本库。
- 不要求本轮编译。
## API Strategy
### 1. Android 签名算法保持 Python 等价
Python 逻辑的关键点不是常见的“secret 作为 key、timestamp 作为 message”而是
- `string_to_sign = f"{timestamp}\n{secret}"`
- HMAC key 为 `string_to_sign.encode("utf-8")`
- HMAC message 为空字节数组
- digest 为 SHA-256
- 输出 Base64 字符串
Android 侧应实现:
- `String stringToSign = timestampSeconds + "\n" + secret;`
- `Mac mac = Mac.getInstance("HmacSHA256");`
- `mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));`
- `byte[] signData = mac.doFinal(new byte[0]);`
- `Base64.encodeToString(signData, Base64.NO_WRAP);`
签名单元测试必须固定 `secret``timestamp`,用 Python 参考算法生成期望值,防止迁移时误用 key/message。
### 2. 请求体保持 Python 结构
请求 JSON
```json
{
"msg_type": "interactive",
"card": {
"elements": [
{
"tag": "markdown",
"content": "..."
}
]
},
"timestamp": "秒级时间戳字符串",
"sign": "Base64签名"
}
```
Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串。`timestamp` 保持字符串类型,与 Python 实现一致。请求头设置 `Content-Type: application/json; charset=utf-8`
### 3. 网络库选择 OkHttp
首版推荐 OkHttp而不是 Retrofit
- 当前需求只有一个动态 webhook URL 的 POST。
- Retrofit 更适合多接口、固定 baseUrl、声明式 API这里会引入额外抽象但收益有限。
- OkHttp 可以直接设置 10 秒 connect/read/write/call timeout并明确拿到 HTTP 状态码和响应体。
- Java 工程接入成本低,测试时可用 `MockWebServer`,也可以先只做纯 Java 请求体和响应解析测试。
如果后续服务器接口变多,再引入 Retrofit。
### 4. 线程模型
`BroadcastReceiver.onReceive` 生命周期短,不能同步执行网络请求。后续实现应采用:
- `FeishuWebhookClient.pushMarkdownAsync(...)` 提交到后台 `ExecutorService`
- `SmsReceiver` 在验证码解析成功、保存本地结果后,只触发异步推送,不等待网络结果。
- 推送结果写入 `SharedPreferences` 或轻量状态 store并通过本地广播刷新 UI。
- 若未来需要保证进程退出后仍发送,可另起 change 引入 WorkManager本轮不做。
### 5. 配置策略
首版本地配置文件:
- `webhook_id`
- `secret`
- `enabled`
- `sendFullSmsBodyForDebug`,默认 false
- 最近一次推送状态、时间、错误类型和错误摘要
飞书 webhook 配置保存到 `/sdcard/Android/data/{applicationId}/config/feishu.json`。如果 `feishu.json` 不存在app 生成默认模板 `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json`,但不把模板当作生效配置。这样调试时可以直接改 `feishu.json`,不需要重新编译 Android 代码。
最近一次推送状态仍可保存在 `SharedPreferences`因为它不是动态调试配置。UI 展示时 secret 必须掩码,不在日志中打印。
### 6. 推送内容策略
验证码解析成功时,默认 markdown 内容建议包含:
- 验证码:解析出的 code。
- 来源:`system_sms_broadcast` / `sms_inbox_*`
- 发送方:掩码后的 sender。
- 时间:本地格式化时间。
- 解析策略和置信度:便于诊断。
默认包含完整短信原文,便于远端调试确认。仍不建议在 logcat 打印完整正文。
### 7. 响应解析和错误分类
成功条件:
- HTTP status 为 200。
- 响应体是 JSON。
- JSON `code` 为数字 0。
错误分类:
- `disabled`:用户未开启推送。
- `missing_config`:缺少 `webhook_id``secret`
- `sign_error`HMAC 算法或 key 初始化失败。
- `network_error`DNS、连接失败、TLS、Socket 异常等。
- `timeout`:请求超过 10 秒。
- `http_error`HTTP status 非 200。
- `invalid_json`:响应不是合法 JSON。
- `api_error`:飞书返回 `code != 0`,记录 code 和 msg。
日志策略:
- 可以记录错误类型、HTTP status、飞书 code/msg。
- 不记录 secret、sign、完整 webhook URL。
- 不默认记录完整 markdown 内容。
## Privacy Boundaries
- 默认仅上传验证码和必要诊断摘要。
- 飞书推送默认包含完整短信正文;开启推送即表示允许验证码短信离开设备。
- 不上传联系人通讯录、短信历史或设备标识。
- 本地保存 secret 时使用 `SharedPreferences` 只满足个人调试场景;如需更严格保护,应另起 change 使用 Android Keystore 加密。
- 推送到飞书意味着验证码会离开设备UI 应让用户明确知道远端推送已开启。
## Test Strategy
单元测试:
- `generateSign` 固定输入输出测试。
- 请求 JSON 构造测试,验证字段名、类型和嵌套结构。
- webhook URL 拼接测试,防止多余斜杠和空 id。
- 响应解析测试:
- `{"code":0,"msg":"success"}` 成功。
- `{"code":19021,"msg":"invalid sign"}` 返回 `api_error`
- 空响应、HTML、非法 JSON 返回 `invalid_json`
- 配置缺失测试。
手动验证:
- 在 UI 输入 webhook id 和 secret点击“测试推送”。
- 使用真实飞书机器人确认收到 markdown 卡片。
- 故意填错 secret确认 UI 显示飞书业务错误而不是短信接收失败。
- 断网或飞行模式下测试 `network_error`/`timeout`
- 收到真实验证码短信后,确认本地结果先保存,远端推送状态独立更新。
## Rollout Plan
1. 完成本 OpenSpec 并 validate。
2. 新增 OkHttp 依赖和 `INTERNET` 权限。
3. 实现签名、请求体构造、响应解析和异步发送模块。
4. 增加 JSON 配置 store 和最近推送状态。
5. 在 `MainActivity` 增加配置、测试发送和状态展示。
6. 将验证码解析成功后的推送接入异步链路。
7. 增加聚焦单元测试,不要求本轮编译。
## Open Questions
- webhook id 和 secret 是否只用于本机调试,还是需要后续提供导入/导出配置。
- 默认推送内容是否只发验证码,还是需要包含发送方掩码和解析策略。
- 是否需要推送所有收到的字符串,还是只在验证码解析成功时推送。
- 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。

View File

@ -0,0 +1,67 @@
## Why
当前 `SmsReceive` 已经具备短信验证码接收、解析、存储和本地诊断能力。新需求是在收到字符串内容后,把它发送到服务器。用户已经给出 Python 参考实现,目标服务器实际是飞书机器人 webhook构造 interactive markdown 卡片,使用 `timestamp + "\n" + secret` 生成 HmacSHA256 + Base64 签名,然后 POST 到 `https://open.feishu.cn/open-apis/bot/v2/hook/{webhook_id}`
这次变更的重点不是重新设计服务端协议,而是把 Python 中已经验证过的请求协议等价迁移到 Android并与当前短信接收链路解耦。Android 侧应提供一个可测试、可诊断、可复用的发送模块,后续可以由 `SmsReceiver`、手动测试按钮或其它业务入口调用。
## What Changes
- 新增飞书 webhook 推送规格,覆盖:
- 签名算法:保持与 Python `generate_sign(secret, timestamp)` 完全一致。
- 请求体:保持 `msg_type=interactive`,卡片元素为 markdown content。
- URL`https://open.feishu.cn/open-apis/bot/v2/hook/{webhook_id}`
- 网络请求Android 侧使用 OkHttp 作为首选实现,避免为了一个简单 POST 引入 Retrofit 接口层。
- 线程模型:所有网络请求必须在后台线程执行,不能阻塞 `BroadcastReceiver.onReceive` 或主线程。
- 结果判断HTTP 200 且响应 JSON `code == 0` 才视为成功。
- 失败诊断区分签名失败、参数缺失、HTTP 非 200、响应 JSON 异常、飞书业务错误码、网络异常和超时。
- 新增轻量配置入口:
- `webhook_id``secret` 不写死在代码中。
- 实际配置从 `/sdcard/Android/data/{applicationId}/config/feishu.json` 读取,便于动态调试。
- 如果 `feishu.json` 不存在,则生成默认模板 `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json`
- 后续实现可接入当前短信结果:
- 成功解析验证码后,可以把验证码摘要、来源、时间和发送方掩码作为 markdown 内容推送。
- 按调试需求推送完整短信原文,便于确认服务端收到的内容与本机短信一致。
## Capabilities
### New Capabilities
- `feishu-webhook-push`: 定义 Android 侧飞书机器人 webhook 推送能力,包括签名、请求体、网络执行、响应解析、错误诊断和隐私边界。
### Modified Capabilities
- `sms-code-capture`: 后续实现可以在验证码解析成功后触发推送,但短信捕获本身不依赖网络成功。
- `sms-receiver-delivery-diagnostics`: 后续诊断应区分“短信接收/解析成功”和“远端推送成功/失败”,避免把网络失败误判为短信接收失败。
## Impact
- 预期后续会修改:
- `app/build.gradle`:新增 OkHttp 依赖,必要时新增 JSON 解析依赖或使用 `org.json`
- `app/src/main/AndroidManifest.xml`:新增 `android.permission.INTERNET`
- `app/src/main/java/com/smsreceive/app/`:新增飞书推送相关 Java 类,例如 `FeishuWebhookClient``FeishuWebhookConfigStore``FeishuWebhookPushResult`
- `MainActivity`:新增 JSON 配置路径展示、配置输入、测试发送和最近推送结果展示。
- `SmsReceiver` 或统一结果处理层:在验证码解析成功后触发异步推送。
- 推荐技术选择:
- 网络库OkHttp。
- JSON 构造/解析:优先使用 Android 自带 `org.json`,降低依赖数量。
- 签名:`javax.crypto.Mac` + `SecretKeySpec` + `android.util.Base64.NO_WRAP`
- 异步执行:小型 `ExecutorService` 或现有轻量后台执行器,不引入协程/RxJava。
- 隐私影响:
- 默认推送验证码摘要、来源、发送方掩码、时间和短信原文。
- 不在 logcat 打印 secret、sign、完整 webhook URL 或完整短信正文。
## Validation
- OpenSpec 文档结构完整,包含 `proposal.md``design.md``tasks.md` 和 capability spec。
- 方案必须能解释以下问题:
- Python 签名算法如何在 Android 中等价实现。
- 为什么首选 OkHttp 而不是 Retrofit。
- 为什么网络请求不能直接在 `BroadcastReceiver.onReceive` 内同步执行。
- 如何判断推送成功,以及失败时如何诊断。
- 哪些短信内容可以上传,哪些默认不上传。
- 后续代码完成后:
- 签名单元测试使用固定 `secret``timestamp` 对比 Python 算法输出。
- 请求体构造测试验证 JSON 字段与 Python 参考实现一致。
- 响应解析测试覆盖 `code == 0`、业务错误码、非 JSON 响应。
- 不要求本轮编译;代码完成后通知用户即可。
- 每次 commit 或 push 前必须检查 diff避免引入非预期 EOF newline 变化。

View File

@ -0,0 +1,96 @@
## ADDED Requirements
### Requirement: Generate Feishu webhook signatures equivalent to the Python reference
The app SHALL generate Feishu webhook signatures with the same algorithm and input layout as the provided Python implementation.
#### Scenario: Signature is generated for a timestamp and secret
- **WHEN** the app signs a request with a second-level timestamp and secret
- **THEN** it MUST build the signing key as `timestamp + "\n" + secret`
- **AND** it MUST use HmacSHA256 with an empty message body
- **AND** it MUST return a Base64 encoded signature without inserted line breaks
#### Scenario: Signing fails
- **WHEN** the HMAC algorithm or key initialization fails
- **THEN** the app MUST return a structured `sign_error`
- **AND** it MUST NOT attempt to send the webhook request
### Requirement: Send interactive markdown cards to Feishu webhook
The app SHALL send markdown content to the Feishu bot webhook using the same request structure as the Python reference.
#### Scenario: Markdown content is sent
- **WHEN** push is enabled and webhook configuration is valid
- **THEN** the app MUST POST JSON to `https://open.feishu.cn/open-apis/bot/v2/hook/{webhook_id}`
- **AND** the JSON body MUST contain `msg_type` as `interactive`
- **AND** the JSON body MUST contain a card element with `tag` as `markdown` and `content` as the markdown string
- **AND** the JSON body MUST contain `timestamp` as a string and `sign` as the generated signature
#### Scenario: Webhook configuration is missing
- **WHEN** push is requested without a webhook id or secret
- **THEN** the app MUST return a structured `missing_config` result
- **AND** it MUST NOT perform a network request
### Requirement: Execute webhook network requests off the main and broadcast threads
The app SHALL avoid blocking UI and SMS broadcast handling while sending webhook requests.
#### Scenario: SMS receiver triggers a push
- **WHEN** a verification code is parsed successfully in `SmsReceiver`
- **THEN** the app MUST save the local capture result first
- **AND** it MUST dispatch the webhook push asynchronously
- **AND** it MUST NOT wait for the network request before returning from broadcast handling
#### Scenario: User sends a test push
- **WHEN** the user taps a test push action in the UI
- **THEN** the app MUST run the network request on a background executor
- **AND** it MUST update the latest push status when the request completes
### Requirement: Classify webhook push results
The app SHALL classify success and failure states so SMS receiving diagnostics remain separate from network diagnostics.
#### Scenario: Feishu returns success
- **WHEN** the HTTP status is 200 and the response JSON contains `code` equal to 0
- **THEN** the app MUST record the push as successful
#### Scenario: HTTP status is not successful
- **WHEN** the webhook response HTTP status is not 200
- **THEN** the app MUST record an `http_error` with the HTTP status code
#### Scenario: Response is not valid JSON
- **WHEN** the webhook response body cannot be parsed as JSON
- **THEN** the app MUST record an `invalid_json` result
#### Scenario: Feishu returns a business error
- **WHEN** the webhook response JSON contains `code` not equal to 0
- **THEN** the app MUST record an `api_error`
- **AND** it MUST include the Feishu `code` and `msg` when available
#### Scenario: Network request fails or times out
- **WHEN** OkHttp reports an I/O failure
- **THEN** the app MUST record `network_error` or `timeout` according to the failure type
### Requirement: Keep webhook configuration private while pushing complete SMS content
The app SHALL avoid exposing webhook secrets while sending complete SMS content for debugging.
#### Scenario: Runtime config is loaded
- **WHEN** the app needs Feishu webhook settings
- **THEN** it MUST read them from `/sdcard/Android/data/{applicationId}/config/feishu.json`
- **AND** changing that JSON file MUST NOT require recompiling Android code
#### Scenario: Runtime config file is missing
- **WHEN** `/sdcard/Android/data/{applicationId}/config/feishu.json` does not exist
- **THEN** the app MUST create `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json`
- **AND** it MUST keep remote push disabled until a valid `feishu.json` is provided or saved
#### Scenario: Configuration is displayed or logged
- **WHEN** the app displays or logs webhook configuration
- **THEN** it MUST mask the secret
- **AND** it MUST NOT log the generated signature or full webhook URL
#### Scenario: Verification code push content is built
- **WHEN** the app builds markdown content from a parsed SMS result
- **THEN** it MUST include the verification code and minimal diagnostics such as source, masked sender, time, strategy, and confidence
- **AND** it MUST include the full original SMS body
#### Scenario: Webhook push is disabled
- **WHEN** a verification code is parsed while push is disabled
- **THEN** the app MUST keep local SMS capture behavior unchanged
- **AND** it MUST record or report that remote push was skipped because it is disabled

View File

@ -0,0 +1,67 @@
## 1. Spec And Context Validation
- [x] 1.1 阅读 Python 参考实现确认签名、请求体、URL、超时和成功判定
- [x] 1.2 阅读当前 Android 工程结构,确认 Java、Gradle、权限和短信接收链路现状
- [x] 1.3 生成飞书 webhook 推送 OpenSpec proposal、design、tasks 和 capability spec
- [x] 1.4 用 `npx openspec validate add-feishu-webhook-push` 校验规格结构
- [x] 1.5 后续提交或 push 前检查 diff避免引入非预期 EOF newline 变化
## 2. Dependencies And Manifest
- [x] 2.1 在 `app/build.gradle` 新增 OkHttp 依赖
- [x] 2.2 在 `AndroidManifest.xml` 新增 `android.permission.INTERNET`
- [x] 2.3 不引入 Retrofit、RxJava、协程或 WorkManager
## 3. Signing And Request Model
- [x] 3.1 新增签名方法,使用 `timestamp + "\n" + secret` 作为 HMAC key空消息体HmacSHA256Base64 NO_WRAP
- [x] 3.2 新增请求体构造方法,输出与 Python 参考实现一致的 interactive markdown JSON
- [x] 3.3 新增 webhook URL 拼接方法,处理空 id 和首尾空格
- [x] 3.4 避免在日志中输出 secret、sign 和完整 webhook URL
## 4. Network Client
- [x] 4.1 新增 `FeishuWebhookClient`
- [x] 4.2 使用 OkHttp POST JSON
- [x] 4.3 设置 10 秒请求超时
- [x] 4.4 HTTP 200 且响应 JSON `code == 0` 时返回成功
- [x] 4.5 分类返回配置缺失、签名失败、网络异常、超时、HTTP 错误、JSON 解析失败和飞书业务错误
- [x] 4.6 网络请求只在后台线程执行
## 5. Config And State
- [x] 5.1 新增 `FeishuWebhookConfigStore`,通过 `/sdcard/Android/data/{applicationId}/config/feishu.json` 保存 enabled、webhook id、secret 和 debug 上传开关
- [x] 5.2 新增最近推送状态,包含时间、成功状态、错误类型和错误摘要
- [x] 5.3 UI 展示 secret 时使用掩码
- [x] 5.4 默认推送完整短信原文,便于远端调试
- [x] 5.5 `feishu.json` 不存在时生成默认模板 `def_config_feishu.json`
## 6. UI And Manual Test
- [x] 6.1 在 `MainActivity` 增加飞书推送配置区域
- [x] 6.2 增加 webhook id 和 secret 输入入口
- [x] 6.3 增加启用/停用远端推送开关
- [x] 6.4 增加“测试推送”按钮,发送固定 markdown 测试内容
- [x] 6.5 展示最近一次推送结果,并与短信接收结果分开
## 7. SMS Flow Integration
- [x] 7.1 验证码解析成功后构造默认 markdown 摘要
- [x] 7.2 `SmsReceiver` 保存本地结果后触发异步推送
- [x] 7.3 推送失败不影响本地短信捕获、解析和 UI 刷新
- [x] 7.4 默认推送内容包含验证码、来源、发送方掩码、时间、解析摘要和短信原文
## 8. Tests
- [x] 8.1 增加签名单元测试,固定输入输出对齐 Python 实现
- [x] 8.2 增加请求体构造测试
- [x] 8.3 增加响应解析测试,覆盖成功、业务错误和非法 JSON
- [x] 8.4 增加配置缺失测试
- [x] 8.5 不要求本轮编译;代码完成后通知用户
## 9. Delivery Criteria
- [x] 9.1 OpenSpec validate 通过
- [x] 9.2 相关上下文逻辑分析通过
- [x] 9.3 相关测试通过
- [x] 9.4 代码实现完成后通知用户,不强制编译

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17

View File

@ -0,0 +1,203 @@
## Context
当前 app 已有短信验证码接收实现Manifest 声明了 `RECEIVE_SMS``READ_SMS`,静态 `SmsReceiver` 监听 `android.provider.Telephony.SMS_RECEIVED``MainActivity` 提供权限申请、最近结果展示、`READ_SMS` 最新短信读取、ContentObserver 和短时轮询诊断。用户反馈“逻辑试过了能用”,但不确定为什么某些场景 `onReceive` 收不到。
目标设备是小米 12S、澎湃 OS 3、Android 15。根据 Android 官方文档,`RECEIVE_SMS` 允许应用接收 SMS但它是 dangerous 且 hard restricted 权限,是否能真正持有可能受安装来源/安装器 allowlist 影响。Android 15 又新增了 `BOOT_COMPLETED` 启动部分前台服务类型的限制Doze/App Standby 白名单也不是无限制后台执行。小米官方支持文档说明 HyperOS/小米系统存在“Background autostart”用户开关路径为 Settings > Apps > Permissions > Background autostart。
参考资料:
- Android 15 前台服务类型变更https://developer.android.com/about/versions/15/changes/foreground-service-types
- Android 15 行为变更https://developer.android.com/about/versions/15/behavior-changes-15
- Doze/App Standby 与电池优化https://developer.android.com/training/monitoring-device-state/doze-standby
- `RECEIVE_SMS` 权限https://developer.android.com/reference/android/Manifest.permission#RECEIVE_SMS
- 小米后台自启动设置https://www.mi.com/global/support/faq/details/KA-507608/
## Goals / Non-Goals
**Goals:**
- 先形成完整 spec不直接写代码。
- 在当前可用短信接收 app 上补齐后台保活和开机恢复方案。
- 让用户能手动完成小米设置,并在 app 内看到哪些设置已完成、哪些只能人工确认。
- 让 `onReceive` 收不到时有明确诊断权限、安装来源、force-stop、系统广播、HyperOS 后台策略、短信是否进入收件箱。
- 后续实现保持可解释、可关闭、可验证。
**Non-Goals:**
- 不做无通知、不可感知、规避系统策略的隐藏保活。
- 不承诺杀进程、force-stop、清后台、系统级省电清理后仍 100% 存活。
- 不把 C++ fork/native daemon 当成可靠能力Android 应用沙箱和系统进程管理不会因为 native 代码而失效。
- 不把 app 做成默认短信客户端。
- 不要求本轮编译。
## API Strategy
### 1. 系统短信广播仍是主路径
短信进入设备时,主路径仍是 `SMS_RECEIVED_ACTION`。这是最直接的验证码捕获路径,但它依赖:
- 应用真正持有 `RECEIVE_SMS`
- 应用未被用户 force-stop。
- 系统或厂商策略允许静态 receiver 在当前状态下被拉起。
- 短信确实由 Android Telephony 入库/分发,而不是被系统短信 app 或安全策略特殊处理。
现有 `SmsReceiver` 的 manifest 写法包含 `android:permission="android.permission.BROADCAST_SMS"`,该写法用于限制只有持有系统广播权限的发送方能投递该 receiver系统短信广播通常满足此条件。后续诊断要验证它不是问题根因但不建议先移除。
### 2. 前台服务用于“可见后台运行”,不用于读取短信
新增 `SmsKeepAliveService`
- app 前台点击“开启常驻保活”后调用 `startForegroundService`
- 服务在 5 秒内调用 `startForeground`,展示低打扰常驻通知。
- 通知内容显示“短信监听运行中”、最近一次心跳、最近一次短信来源。
- 服务只做状态心跳、通知刷新、诊断状态保存。
- 短信接收仍由 `SmsReceiver` 和收件箱兜底路径处理。
这样做的原因是前台服务可以提升后台进程可见性,但不能替代短信广播,也不能保证被厂商永不杀。它的价值是让系统和用户明确知道该 app 在后台运行,并给 HyperOS “省电无限制 + 自启动”一个更稳定的承载对象。
### 3. 开机自启动采用轻量 BootReceiver
新增 `BootReceiver` 监听:
- `android.intent.action.BOOT_COMPLETED`
- `android.intent.action.LOCKED_BOOT_COMPLETED`
- `android.intent.action.MY_PACKAGE_REPLACED`
收到后:
- 记录 boot 事件和时间。
- 检查用户是否曾开启“常驻保活”。
- 如果已开启,则尝试启动 `SmsKeepAliveService`
- 捕获 `ForegroundServiceStartNotAllowedException` 等异常,写入诊断,不崩溃。
Android 15 对 `BOOT_COMPLETED` 启动前台服务的限制集中在 dataSync、camera、mediaPlayback、phoneCall、mediaProjection、microphone 等类型。当前保活服务不应声明这些类型;如果后续升级 targetSdk 到 35仍必须真机验证 BootReceiver 启动服务是否被 HyperOS 额外限制。
### 4. 电池优化与 HyperOS 设置采用“检测 + 引导”
Android 标准能力:
- 用 `PowerManager.isIgnoringBatteryOptimizations(packageName)` 展示是否在电池优化白名单。
- 提供 `Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS` 打开白名单设置。
- 个人自用场景可考虑 `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` 直接请求,但 UI 必须说明它仍不等于无限后台。
小米/HyperOS 能力:
- 提供“打开小米自启动设置”按钮,优先尝试常见 MIUI/HyperOS 安全中心组件。
- 如果显式组件不可用,回退 `ACTION_APPLICATION_DETAILS_SETTINGS` 和通用系统设置。
- UI 显示人工清单:
- 权限:短信权限已授权。
- 自启动:用户已在 HyperOS 开启。
- 省电策略:用户已设为无限制。
- 通知:常驻通知未被关闭。
- 保活服务:正在运行。
自启动和省电无限制通常没有稳定公开 API 可直接读取真实状态,因此必须允许用户手动勾选“我已完成设置”,并把这部分标记为人工确认。
### 5. Native/C++ 方案只做实验诊断
可以加入一个可选 native heartbeat
- Java 服务加载 native library。
- native 层定期写入一个本地 heartbeat 文件或通过 JNI 回调返回时间戳。
- 用于确认进程活着、JNI 正常、服务被杀后心跳停止。
不建议实现 native fork daemon
- Android 应用进程被系统管理,子进程同属应用 UID/cgroup厂商清理通常会一起处理。
- 后台私自 fork 常驻进程会带来电量、兼容性和安全风险。
- 对短信广播恢复没有直接帮助。
如果用户坚持尝试,必须放在“实验开关”下,并以诊断结论交付,不作为保活主链路。
## Why `onReceive` May Not Fire
后续 UI 和 logcat 诊断必须覆盖以下根因:
- `RECEIVE_SMS` 未授权,或因 hard restricted 机制安装后实际不可持有。
- app 被 force-stopAndroid 不会为 force-stopped app 投递大多数隐式广播,直到用户再次打开。
- 小米自启动未开启,系统不允许后台拉起静态 receiver 或服务。
- 省电策略不是无限制,后台/锁屏/待机时进程或广播处理被延迟/限制。
- app 刚安装后从未启动,部分系统不会让 receiver 正常进入用户期望状态。
- 短信由运营商/RCS/系统短信能力特殊处理,未走普通 SMS_RECEIVED。
- 双卡或短信格式异常导致 PDU 解析失败,但此时 receiver 应该已有日志。
- Manifest receiver 被禁用、包名不匹配、构建安装的不是当前调试版本。
- 通知权限关闭不应直接阻止短信广播,但可能影响前台服务可见性和用户判断。
诊断顺序建议:
1. 看 UI 权限状态和最近 boot/service 心跳。
2. 发送短信时抓 logcat `SmsReceive`
3. 如果 receiver 无日志,点“读取最新短信”确认短信是否入库。
4. 如果短信已入库但 receiver 无日志重点排查权限、force-stop、HyperOS 自启动和省电。
5. 如果 receiver 有日志但无验证码,排查 PDU/body/parser。
## Data Model
新增 `KeepAliveState` 本地状态:
- `enabledByUser`: 用户是否开启常驻保活。
- `serviceRunning`: 最近一次服务 onCreate/onStartCommand/onDestroy 状态。
- `lastHeartbeatMillis`: 服务最近心跳。
- `lastBootEvent`: 最近一次 boot/package replaced 事件。
- `lastServiceStartFailure`: 最近一次服务启动失败原因。
- `batteryOptimizationIgnored`: 标准 Android 电池优化白名单状态。
- `manualAutostartConfirmed`: 用户手动确认已开启小米自启动。
- `manualBatteryUnrestrictedConfirmed`: 用户手动确认已设置省电无限制。
- `notificationEnabledHint`: 通知是否可能可见。
保存方式优先使用 `SharedPreferences`,与现有 `SmsCaptureStore` 保持简单一致。
## UI Strategy
首版继续使用现有 Java View UI不引入新框架
- 增加“后台保活状态”区域:
- 保活开关。
- 服务运行状态。
- 最近心跳。
- 最近开机事件。
- 最近启动失败原因。
- 增加“系统设置”区域:
- 打开应用详情。
- 打开电池优化设置。
- 请求忽略电池优化。
- 打开小米自启动设置。
- 人工确认自启动已开启。
- 人工确认省电无限制已开启。
- 增加“短信广播诊断”区域:
- 最近 `system_sms_broadcast` 时间。
- 最近 `sms_inbox_*` 时间。
- 当收件箱有新短信但广播未到时提示“疑似广播未投递”。
## Test Strategy
单元/本地测试:
- `KeepAliveStateStore` 读写测试。
- `BootReceiver` 在不同 action 下生成正确状态。
- `SmsKeepAliveService` 的状态更新逻辑抽出为纯 Java 方法测试。
- 设置 intent builder 测试:小米组件不可用时能 fallback。
ADB/真机验证:
- `adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -n com.smsreceive.app/.BootReceiver`
- 重启手机后验证保活服务是否恢复。
- 开启保活通知后,后台 30 分钟、锁屏 30 分钟分别发送短信。
- 开启/关闭小米自启动、开启/关闭省电无限制,对比短信广播和收件箱兜底结果。
- 手动 force-stop 后发送短信,确认不承诺接收,并在再次打开 app 后展示诊断。
- Android Doze 测试可参考官方命令 `dumpsys deviceidle force-idle``am set-inactive`
## Rollout Plan
1. 完成本 OpenSpec 并 validate。
2. 先实现 Java 层 BootReceiver、Foreground Service、通知和状态 UI。
3. 再补小米设置入口和人工确认状态。
4. 最后根据真机结果决定是否加入 native heartbeat 实验。
5. 如果前台服务/开机恢复在 HyperOS 上仍不稳定,交付时明确记录限制和必须手动设置项。
## Open Questions
- 当前安装方式是 Android Studio/adb 直装,还是包管理器/文件管理器安装 APK这会影响 hard restricted SMS 权限表现。
- 用户是否接受常驻通知一直显示。
- 是否希望 app 开机后自动开启保活,还是必须用户先在 UI 中开启一次后才持久化。
- 是否需要 native heartbeat 实验;建议第一轮 Java 方案真机验证后再决定。

View File

@ -0,0 +1,70 @@
## Why
当前 `SmsReceive` 已经能在部分场景接收并解析验证码短信,但目标设备是小米 12S、澎湃 OS 3、Android 15后台策略比标准 Android 更激进。仅靠 `SMS_RECEIVED_ACTION` 静态广播不够,需要补齐“开机后自动恢复监听、后台运行可见、系统设置可引导、失败可诊断”的完整方案。
这次需求的重点不是规避系统限制,而是在个人自用 sideload/debug app 的边界内,把 Android 官方后台机制、HyperOS 人工设置、短信广播兜底路径和可验证诊断组合起来。用户也会手动在小米设置中开启“自启动”和“省电策略-无限制”,因此方案应显式利用这个前提。
## What Changes
- 新增小米/Android 15 后台保活规格,覆盖:
- 开机自启动:注册 `BOOT_COMPLETED``LOCKED_BOOT_COMPLETED``MY_PACKAGE_REPLACED`,开机后恢复轻量状态并尝试启动保活服务。
- 通知栏常驻:增加前台服务 + 低打扰常驻通知,用于让进程在后台更可见、更不容易被系统回收。
- 短信广播链路:保留当前 `RECEIVE_SMS` + `SMS_RECEIVED_ACTION` 主路径,增加更清晰的“为什么 onReceive 收不到”的诊断。
- 服务链路:增加 `SmsKeepAliveService`,只负责常驻通知、状态心跳和轻量诊断,不在服务内做耗时短信扫描。
- 收件箱兜底:保留 `READ_SMS` 最新短信读取、ContentObserver、手动读取和短时轮询用于确认短信已入库但广播未到达的场景。
- HyperOS 设置引导:提供打开应用详情、忽略电池优化设置、小米后台自启动设置的入口,并在 UI 展示用户需要手动完成的清单。
- Native/C++ 尝试边界:允许加入 NDK native heartbeat 作为实验诊断,但不把 C++ fork/daemon 作为可靠保活主方案。
- 明确 Android 15 限制:
- `RECEIVE_SMS` 是 dangerous 且 hard restricted 权限,安装来源/安装器 allowlist 可能影响授权。
- Android 15 对 `BOOT_COMPLETED` 启动部分类型前台服务有限制,不能把 dataSync/media 等类型当成开机拉起通道。
- Doze/App Standby 即使加入电池优化白名单也仍会有部分限制,必须用真机验证而不是只看 API 成功。
- 不直接修改业务代码。后续实现前需要先用 OpenSpec 校验本 change。
## Capabilities
### New Capabilities
- `sms-background-keepalive`: 定义开机广播、前台服务、常驻通知、保活心跳和失败恢复行为。
- `xiaomi-hyperos-background-setup`: 定义小米/HyperOS 自启动、省电无限制、电池优化、权限设置入口和用户操作清单。
- `sms-receiver-delivery-diagnostics`: 定义 `onReceive` 不触发时的排查维度包括权限、安装来源、force-stop、开机广播、厂商后台策略、短信是否入库、广播是否被系统限制。
### Modified Capabilities
- `sms-code-capture`: 后续实现应把短信广播主路径和收件箱兜底路径接入统一诊断来源,明确区分 `system_sms_broadcast``sms_inbox_observer``sms_inbox_manual``sms_inbox_polling``boot_keepalive`
- `sms-permission-diagnostics`: 后续实现应补充电池优化、前台服务、开机接收器、HyperOS 设置状态和最近心跳状态。
- `sms-code-validation-workflow`: 后续验证应增加重启、锁屏、后台、省电策略、force-stop、开机后第一条短信等设备场景。
## Impact
- 预期后续会修改:
- `app/src/main/AndroidManifest.xml`
- `app/src/main/java/com/smsreceive/app/MainActivity.java`
- 新增 `BootReceiver``SmsKeepAliveService``KeepAliveNotification``BackgroundSetupGuide``KeepAliveStateStore` 等 Java 类
- 可选新增 NDK/C++ heartbeat 实验文件,但默认不作为交付必需项
- 预期新增权限:
- `android.permission.RECEIVE_BOOT_COMPLETED`
- `android.permission.FOREGROUND_SERVICE`
- 视 targetSdk 和实现情况考虑 `android.permission.POST_NOTIFICATIONS`
- 视是否直接请求白名单考虑 `android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`
- 主要 API 和设置入口:
- `Intent.ACTION_BOOT_COMPLETED`
- `Intent.ACTION_LOCKED_BOOT_COMPLETED`
- `Intent.ACTION_MY_PACKAGE_REPLACED`
- `Context.startForegroundService`
- `Service.startForeground`
- `PowerManager.isIgnoringBatteryOptimizations`
- `Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS`
- `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`
- 小米自启动设置入口优先使用显式组件尝试,失败时回退应用详情页或系统设置页
- 该方案面向个人自用,不承诺 Play Store 合规;但仍不应隐藏通知、不应滥用后台执行、不应上传短信内容。
## Validation
- OpenSpec 文档结构完整,包含 `proposal.md``design.md``tasks.md` 和三个 capability spec。
- 方案必须能解释以下问题:
- 为什么 `onReceive` 可能收不到短信。
- 前台服务能解决什么,不能解决什么。
- 开机自启动在 Android 15 和 HyperOS 上的真实限制。
- C++/native 进程为什么只能作为诊断实验,不能作为可靠保活。
- 代码实现完成后不要求本轮编译,但后续至少需要完成单元测试和真机验证清单。
- 每次 commit 或 push 前必须检查 diff避免引入非预期 EOF newline 变化。

View File

@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: Keep SMS monitoring visible through a foreground service
The app SHALL provide a user-controlled foreground service that keeps SMS monitoring diagnostics visible while the app is in the background.
#### Scenario: User enables keepalive
- **WHEN** the user enables background keepalive in the app
- **THEN** the app MUST start a foreground service with a persistent notification
- **AND** the app MUST persist that the user enabled keepalive
#### Scenario: Foreground service is running
- **WHEN** the keepalive service is active
- **THEN** it MUST periodically update a local heartbeat timestamp
- **AND** it MUST NOT perform long-running SMS database scans as its normal work
#### Scenario: User disables keepalive
- **WHEN** the user disables background keepalive in the app
- **THEN** the app MUST stop the foreground service
- **AND** it MUST remove or cancel the persistent keepalive notification
### Requirement: Restore keepalive after boot or package replacement
The app SHALL attempt to restore user-enabled keepalive after supported system lifecycle broadcasts.
#### Scenario: Device boot completes
- **WHEN** the app receives `BOOT_COMPLETED` or `LOCKED_BOOT_COMPLETED`
- **AND** the user previously enabled keepalive
- **THEN** the app MUST record the boot event
- **AND** it MUST attempt to start the keepalive foreground service
#### Scenario: App package is replaced
- **WHEN** the app receives `MY_PACKAGE_REPLACED`
- **AND** the user previously enabled keepalive
- **THEN** the app MUST attempt to restore the keepalive service
#### Scenario: Foreground service start is blocked
- **WHEN** Android or HyperOS rejects service startup from a boot receiver
- **THEN** the app MUST catch the failure
- **AND** it MUST store a diagnostic reason instead of crashing
### Requirement: Respect Android 15 foreground service limits
The app SHALL avoid using foreground service types that Android 15 restricts from `BOOT_COMPLETED` receivers for this keepalive feature.
#### Scenario: Service is declared in the manifest
- **WHEN** the keepalive service is declared
- **THEN** it MUST NOT be declared as `dataSync`, `camera`, `mediaPlayback`, `phoneCall`, `mediaProjection`, or `microphone` for the boot-started keepalive path
#### Scenario: Target SDK is upgraded
- **WHEN** the project target SDK is upgraded to Android 15 or higher
- **THEN** the boot-start keepalive behavior MUST be revalidated on the Xiaomi target device
### Requirement: Treat native keepalive as experimental only
The app SHALL NOT depend on a native daemon or C++ child process for reliable SMS delivery.
#### Scenario: Native heartbeat is added
- **WHEN** a native heartbeat experiment is enabled
- **THEN** it MUST be clearly labeled as diagnostic
- **AND** SMS reception MUST remain implemented through Android platform APIs
#### Scenario: Native process stops
- **WHEN** the native heartbeat stops because the app process or service is killed
- **THEN** the app MUST report the heartbeat loss as a keepalive limitation rather than attempting hidden restart loops

View File

@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: Diagnose missing SMS receiver delivery
The app SHALL explain why `SmsReceiver.onReceive` may not be called when a new SMS arrives.
#### Scenario: SMS permission is missing or unusable
- **WHEN** `RECEIVE_SMS` is not granted or is unusable because of restricted permission behavior
- **THEN** the app MUST report that the system SMS broadcast path is blocked by permission state
#### Scenario: App was force-stopped
- **WHEN** diagnostics indicate the app has not run since a user force-stop or package inactive state
- **THEN** the app MUST report that force-stopped apps are not expected to receive background broadcasts until manually opened
#### Scenario: HyperOS background policy is likely blocking delivery
- **WHEN** SMS appears in the inbox fallback path but no system SMS broadcast was recorded
- **THEN** the app MUST report that HyperOS autostart or battery policy may be blocking receiver delivery
- **AND** it MUST point the user to the background setup checklist
### Requirement: Correlate broadcast and inbox fallback results
The app SHALL record enough source metadata to distinguish SMS broadcast success from inbox fallback success.
#### Scenario: System broadcast receives SMS
- **WHEN** `SmsReceiver` handles `SMS_RECEIVED_ACTION`
- **THEN** the app MUST store the source as `system_sms_broadcast`
- **AND** update the latest broadcast receive timestamp
#### Scenario: Inbox observer sees SMS
- **WHEN** the ContentObserver or manual inbox reader finds a new SMS
- **THEN** the app MUST store an inbox source such as `sms_inbox_observer`, `sms_inbox_manual`, or `sms_inbox_polling`
- **AND** keep it distinct from system broadcast delivery
#### Scenario: Inbox succeeds after broadcast silence
- **WHEN** inbox fallback finds a verification SMS newer than the last recorded broadcast
- **THEN** the app MUST show a diagnostic message that the SMS was present in the inbox but was not delivered through the receiver path
### Requirement: Keep diagnostics local and privacy-minimized
The app SHALL diagnose receiver delivery without unnecessarily retaining full SMS bodies.
#### Scenario: Diagnostic record is saved
- **WHEN** the app stores SMS delivery diagnostics
- **THEN** it MUST store timestamp, source, sender summary, parse status, and failure reason
- **AND** it SHOULD avoid persistent storage of full SMS body unless a debug-only setting is enabled
#### Scenario: Logcat output is produced
- **WHEN** receiver or keepalive code logs diagnostic details
- **THEN** it MUST avoid logging full SMS body by default
- **AND** it MUST make source path and failure reason visible enough for debugging
### Requirement: Validate delivery across target device states
The app SHALL validate SMS receiver delivery under foreground, background, lockscreen, reboot, and force-stop states.
#### Scenario: Background keepalive is enabled
- **WHEN** the app is backgrounded and the keepalive notification is visible
- **THEN** receiving a test SMS MUST either update the latest result through `system_sms_broadcast`
- **OR** report a specific fallback/blocked state
#### Scenario: Device reboots
- **WHEN** the target Xiaomi phone reboots
- **AND** the user previously enabled keepalive and Xiaomi autostart
- **THEN** the app MUST record whether boot restore ran
- **AND** whether the first post-boot SMS reached `SmsReceiver`
#### Scenario: User force-stops the app
- **WHEN** the user force-stops the app from system settings
- **THEN** the app MUST NOT claim reliable SMS delivery until the user manually opens the app again

View File

@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Guide the user through Xiaomi background setup
The app SHALL provide explicit guidance and shortcuts for the Xiaomi/HyperOS settings required for reliable background behavior.
#### Scenario: User opens background setup
- **WHEN** the user opens the app's background setup section
- **THEN** the app MUST show checklist items for SMS permission, autostart, battery unrestricted mode, notification visibility, and keepalive service state
#### Scenario: Xiaomi autostart settings shortcut is available
- **WHEN** the app can resolve a Xiaomi/HyperOS autostart settings activity
- **THEN** it MUST open that settings page from the setup UI
#### Scenario: Xiaomi autostart shortcut is unavailable
- **WHEN** the explicit Xiaomi settings activity cannot be resolved or launched
- **THEN** the app MUST fall back to application details or general settings
- **AND** it MUST keep the manual setup checklist visible
### Requirement: Represent manual-only settings honestly
The app SHALL distinguish settings that can be detected through Android APIs from settings that require manual user confirmation.
#### Scenario: Battery optimization state is queried
- **WHEN** the app checks Android battery optimization status
- **THEN** it MUST use `PowerManager.isIgnoringBatteryOptimizations` where available
- **AND** display whether Android reports the app as ignoring battery optimizations
#### Scenario: Xiaomi autostart state cannot be read
- **WHEN** no stable public API exists to read the Xiaomi autostart switch
- **THEN** the app MUST ask the user to manually confirm completion instead of pretending to detect it
#### Scenario: Battery unrestricted state cannot be read
- **WHEN** no stable public API exists to read the HyperOS per-app battery unrestricted option
- **THEN** the app MUST ask the user to manually confirm completion
### Requirement: Provide battery optimization actions
The app SHALL provide Android-standard actions for battery optimization setup.
#### Scenario: User opens battery optimization settings
- **WHEN** the user taps the battery optimization settings action
- **THEN** the app MUST launch `Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS` when available
#### Scenario: User requests direct exemption
- **WHEN** the app offers a direct ignore-battery-optimization request
- **THEN** it MUST use `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`
- **AND** explain that exemption still does not guarantee unlimited background execution
### Requirement: Preserve user control
The app SHALL keep background keepalive opt-in and reversible.
#### Scenario: First app launch after install
- **WHEN** the app is launched for the first time
- **THEN** background keepalive MUST be disabled until the user enables it
#### Scenario: User turns off keepalive
- **WHEN** the user turns off keepalive
- **THEN** boot restore MUST no longer restart the keepalive service

View File

@ -0,0 +1,84 @@
## 1. Spec And API Validation
- [x] 1.1 阅读现有 `SmsReceive` 实现,确认短信广播、收件箱兜底和诊断 UI 的当前状态
- [x] 1.2 查询 Android 15 前台服务、Doze/App Standby、`RECEIVE_SMS` hard restricted、小米后台自启动相关资料
- [x] 1.3 生成后台保活 OpenSpec proposal、design、tasks 和 capability specs
- [x] 1.4 用 `npx openspec validate add-xiaomi-background-keepalive` 校验规格结构
- [ ] 1.5 后续提交或 push 前检查 diff避免引入非预期 EOF newline 变化
## 2. Manifest And Permission Plan
- [x] 2.1 增加 `RECEIVE_BOOT_COMPLETED` 权限
- [x] 2.2 增加前台服务所需权限,按当前 targetSdk 兼容处理
- [ ] 2.3 如 targetSdk 升到 33+,补充 `POST_NOTIFICATIONS` 申请和诊断
- [x] 2.4 如使用直接请求电池优化白名单,增加 `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`
- [x] 2.5 声明 `BootReceiver`,覆盖 `BOOT_COMPLETED``LOCKED_BOOT_COMPLETED``MY_PACKAGE_REPLACED`
- [x] 2.6 声明 `SmsKeepAliveService`,避免声明 Android 15 从 boot 禁止启动的前台服务类型
## 3. Keepalive Service
- [x] 3.1 新增 `SmsKeepAliveService`
- [x] 3.2 服务启动后立即创建通知渠道和低打扰常驻通知
- [x] 3.3 服务在规定时间内调用 `startForeground`
- [x] 3.4 服务定期写入心跳状态,不执行耗时短信扫描
- [x] 3.5 服务停止或异常时写入明确诊断原因
- [x] 3.6 增加 UI 按钮开启/关闭常驻保活,并持久化用户选择
## 4. Boot And Package Restore
- [x] 4.1 新增 `BootReceiver`
- [x] 4.2 收到开机或包替换广播时记录事件和时间
- [x] 4.3 如果用户已开启保活,尝试启动 `SmsKeepAliveService`
- [x] 4.4 捕获前台服务启动限制异常并写入 `KeepAliveState`
- [x] 4.5 在 UI 展示最近开机事件和服务恢复结果
## 5. Xiaomi / HyperOS Setup Guide
- [x] 5.1 增加标准应用详情页入口
- [x] 5.2 增加 Android 电池优化设置入口
- [x] 5.3 增加可选的直接请求忽略电池优化入口
- [x] 5.4 增加小米后台自启动设置入口,显式组件失败时 fallback
- [x] 5.5 增加“我已开启自启动”人工确认状态
- [x] 5.6 增加“我已设置省电无限制”人工确认状态
- [x] 5.7 UI 明确说明人工确认项无法通过稳定公开 API 完全读取
## 6. SMS Receiver Delivery Diagnostics
- [x] 6.1 记录最近一次 `system_sms_broadcast` 到达时间
- [x] 6.2 记录最近一次 `sms_inbox_observer``sms_inbox_manual``sms_inbox_polling` 命中时间
- [x] 6.3 当收件箱兜底发现新验证码但广播未到达时,展示“疑似短信广播未投递”
- [x] 6.4 在 UI 中列出 `onReceive` 不触发的排查清单
- [x] 6.5 增加 logcat 输出区分权限缺失、body 为空、parser 失败、广播未到达
## 7. Optional Native Experiment
- [x] 7.1 第一轮 Java 保活方案真机验证前,不实现 native fork/daemon
- [ ] 7.2 如用户仍要求尝试,新增 NDK heartbeat 实验开关
- [ ] 7.3 native heartbeat 只写本地诊断状态,不承担短信监听职责
- [ ] 7.4 文档记录 native 子进程被系统同组清理的限制
## 8. Tests
- [ ] 8.1 为 `KeepAliveStateStore` 增加状态读写测试
- [ ] 8.2 为 Boot action 处理逻辑增加单元测试
- [ ] 8.3 为设置 intent fallback 增加测试或可验证日志
- [x] 8.4 保持现有验证码解析测试通过
- [x] 8.5 不要求本轮编译;代码完成后再按用户要求通知
## 9. Xiaomi 12S / HyperOS 3 Device Validation
- [ ] 9.1 手动开启小米自启动
- [ ] 9.2 手动设置省电策略为无限制
- [ ] 9.3 开启常驻通知,后台 30 分钟后发送验证码短信
- [ ] 9.4 锁屏 30 分钟后发送验证码短信
- [ ] 9.5 重启手机,确认保活服务是否自动恢复
- [ ] 9.6 重启后未打开 app 直接发送第一条短信,记录广播是否到达
- [ ] 9.7 手动 force-stop 后发送短信,确认不承诺接收,并记录诊断表现
- [ ] 9.8 如 `onReceive` 不到但收件箱有短信,记录 HyperOS 设置、权限状态和 logcat
## 10. Delivery Criteria
- [x] 10.1 OpenSpec validate 通过
- [x] 10.2 相关上下文逻辑分析通过
- [x] 10.3 相关测试通过
- [x] 10.4 代码实现完成后通知用户,不强制编译

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-16

View File

@ -0,0 +1,156 @@
## Context
当前 `SmsReceive` 目录几乎为空,只有 macOS 生成的 `.DS_Store`,没有 Android 工程和既有 OpenSpec 目录。用户要求先生成完整 spec 方案,再开始编码;同时明确 Android Studio、Gradle、JDK 等环境不在本次方案范围内,后续实现要参考 `Weather reference project` 的构建环境。
目标设备是小米 12S、澎湃 OS 3、Android 15。需求本质是个人自用工具收到手机短信验证码后应用读取短信正文、提取验证码并展示。由于不是 Play Store 上架应用,方案可以直接使用短信权限,但仍要面对 Android 运行时权限、Android 15 受限权限策略、厂商后台管理和短信广播分发行为。
官方 API 判断如下:
- Android `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` 是收到文本短信的系统广播,需要 `RECEIVE_SMS` 权限。它是读取任意短信验证码最直接的路径。
- Google `SMS Retriever API` 不需要 `READ_SMS``RECEIVE_SMS`,但短信必须包含 app hash适合服务端短信模板可控的手机号验证不适合读取所有第三方验证码。
- Google `SMS User Consent API` 可以请求用户授权读取单条包含验证码的短信,不要求 app hash但需要弹出用户确认适合作为受限权限或广播异常时的对比验证路径。
## Goals / Non-Goals
**Goals:**
- 先形成可执行 spec不直接写业务代码。
- 建立 Android 15 上读取验证码短信的主路径和备选路径。
- 明确验证码解析、权限状态、诊断状态和真机验证标准。
- 后续实现应保持最小化:一个主界面、一个接收链路、一组诊断信息,不做复杂产品化。
- 保持短信内容本地处理,默认只展示验证码、来源、时间和短诊断摘要。
**Non-Goals:**
- 不做完整短信客户端,不替代系统短信 App。
- 不实现发送短信、删除短信、读取历史短信库或同步短信到云端。
- 不处理 Android Studio、Gradle、JDK 的重新安装和环境拉取。
- 不以 Google Play 上架合规作为约束目标。
- 不保证所有银行、平台、运营商验证码都能被无条件读取;必须通过真机验证确认。
## Decisions
### Decision 1: 主路径使用 `SMS_RECEIVED_ACTION` + `RECEIVE_SMS`
主路径选择系统短信广播。原因是目标是读取“我自己的手机收到的验证码”,短信来源不可控,很多验证码短信不会带当前 app 的 hash`SMS Retriever API` 无法覆盖任意验证码。系统广播能拿到完整 PDU再通过 `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` 合并为正文,是最符合目标的能力。
实现要求:
- Manifest 声明 `android.permission.RECEIVE_SMS`
- 对 Android 6.0+ 执行运行时权限申请。
- 注册接收 `android.provider.Telephony.SMS_RECEIVED` 的 receiver。
- receiver 内只做轻量解析和状态分发,避免长耗时。
- 记录最近一次接收时间、sender、body 摘要、提取结果和失败原因。
备选方案:
- `READ_SMS` 可读取短信数据库,但需求是监听新验证码,不需要读取历史短信;默认不纳入主路径。
- 默认短信应用角色权限更强,但目标不是做短信客户端;不作为一期要求。
### Decision 2: 备选验证路径引入 `SMS User Consent API`
`SMS User Consent API` 用于验证两类问题:
- 当系统广播路径在 HyperOS 上被后台策略影响时,前台触发 consent flow 是否能拿到单条短信。
- 当用户不愿或系统不允许直接授予短信权限时,是否仍能通过一次性确认读取验证码。
限制:
- 它不是静默读取,需要用户确认。
- 它适合前台验证流程,不适合后台长期监听所有验证码。
- 它依赖 Google Play services国内 ROM 环境下需要确认设备实际可用性。
### Decision 3: `SMS Retriever API` 只作为受控短信模板能力
`SMS Retriever API` 的优点是无需短信权限,体验干净;但它要求短信包含 app hash且通常需要服务端发送符合格式的短信。对于读取第三方平台验证码它大概率不适用。因此一期只实现或预留为“自发测试短信/未来自控服务端验证码”的能力,不作为读取任意验证码的主线。
### Decision 4: 验证码解析采用多阶段规则
解析规则必须保守,避免把手机号、金额、日期误识别为验证码。
建议顺序:
1. 优先匹配包含关键词的模式:`验证码``校验码``动态码``code``verification``OTP` 附近的 4-8 位数字或字母数字。
2. 次级匹配短信中独立出现的 4-8 位数字,排除明显日期、手机号片段、金额和订单号。
3. 对带空格或短横线的验证码做归一化,例如 `12 34 56``123-456`
4. 若多个候选值并存,选择距离关键词最近、长度在 4-6 位优先、出现位置更靠前的候选。
5. 解析失败时保留失败原因,不展示完整正文。
### Decision 5: UI 首版只做诊断型工具界面
首版 UI 应该服务验证,而不是做复杂产品。建议显示:
- 当前短信权限状态。
- 主路径 receiver 状态。
- Google Play services / SMS User Consent 可用性。
- 最近一次收到短信的时间、发送方、验证码、解析策略命中类型。
- 最近失败原因例如无权限、未收到广播、正文为空、未找到验证码、API timeout。
- 手动清空最近结果按钮。
### Decision 6: 本地隐私边界
即使是自用 app也不应默认保存完整短信正文。建议
- 内存中可短暂保留最近一条完整正文用于调试开关。
- 默认持久化只保存验证码、时间、sender 摘要和解析状态。
- 不做网络上传。
- 日志避免输出完整短信正文debug 模式如需输出,必须集中开关控制。
## Android 15 And HyperOS Risk Analysis
- [Risk] `RECEIVE_SMS` 在 Android 15 或厂商系统上被标记为高风险/受限权限,安装来源和系统设置可能影响授权。
→ Mitigation: 首次启动展示权限状态;如果权限申请失败,引导到应用详情页检查“受限权限/权限管理”;同时使用 consent API 做对比验证。
- [Risk] 后台接收被 HyperOS 省电策略限制。
→ Mitigation: 首轮验证覆盖前台、后台、锁屏三种状态;如后台不稳定,增加前台服务或引导关闭省电限制作为后续任务。
- [Risk] Google Play services 在目标设备上不可用或版本不满足。
→ Mitigation: Google API 作为备选路径,主路径不依赖它;诊断页显示可用性。
- [Risk] 双卡、国际短信、长短信 PDU 合并导致 sender 或正文异常。
→ Mitigation: 使用官方 `getMessagesFromIntent` 解析,按 message body 拼接,记录 subscription id 如可用。
- [Risk] 正则误识别。
→ Mitigation: 解析结果带命中策略和置信度;测试用例覆盖误判样本。
## Migration Plan
本项目当前没有既有代码,迁移计划等同于实施顺序:
1. 完成本次 OpenSpec 评审。
2. 参考 Weather 项目创建或复制最小 Android 构建骨架。
3. 实现权限和诊断 UI。
4. 实现系统短信广播主路径。
5. 实现验证码解析器和单元测试。
6. 在小米 12S 上跑真机验证。
7. 根据真机结果决定是否补 `SMS User Consent API` 或后台稳定性处理。
Rollback 策略:
- 如果系统广播路径被目标设备限制,保留解析器和 UI降级为 `SMS User Consent API` 前台读取验证。
- 如果 Google API 不可用,不影响系统广播主路径。
## Validation Strategy
上下文验证:
- 确认 Weather 项目可作为构建环境参考。
- 确认 `SmsReceive` OpenSpec 通过 CLI validate。
- 确认 spec 任务列表不包含重装 Android Studio、Gradle、JDK。
代码验证:
- 验证码解析器单元测试覆盖中文、英文、空格、短横线、多候选、无验证码样本。
- receiver 解析逻辑可通过构造 Intent/PDU 或抽象 message input 测试核心逻辑。
- 权限状态和诊断状态可通过 ViewModel/unit test 验证。
真机验证:
- 前台打开应用后,向目标号码发送测试短信:`【测试】验证码 1234565 分钟内有效。`
- 应用退到后台后,重复发送不同验证码。
- 锁屏状态下发送短信,解锁后检查最近结果。
- 若可以控制短信格式,发送带 app hash 的 SMS Retriever 测试短信。
- 若广播路径失败,打开前台 consent flow 再发送短信,观察是否弹出授权并读取正文。
## Open Questions
- 目标小米 12S 当前是否安装并启用了 Google Play services。
- 用户是否接受为了后台稳定性关闭 HyperOS 对该 app 的省电限制。
- 首版是否需要常驻通知显示最近验证码,还是只在 app 内展示。
- 是否需要支持验证码自动复制到剪贴板;这会带来额外隐私和系统提示问题,建议先不做。

View File

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

View File

@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Receive new SMS messages through the system broadcast path
The app SHALL use Android's new SMS received broadcast path as the primary mechanism for capturing verification SMS messages on the user's own device.
#### Scenario: SMS broadcast is received with permission granted
- **WHEN** the app has `RECEIVE_SMS` permission and the device receives a text SMS
- **THEN** the app MUST process `Telephony.Sms.Intents.SMS_RECEIVED_ACTION` and extract message objects from the received intent
#### Scenario: SMS broadcast is unavailable because permission is missing
- **WHEN** the app does not have `RECEIVE_SMS` permission
- **THEN** the app MUST report that the primary SMS capture path is blocked by missing permission
### Requirement: Parse complete SMS message bodies
The app SHALL parse SMS bodies using Android SMS message APIs rather than ad hoc PDU handling in business logic.
#### Scenario: Multi-part SMS is received
- **WHEN** the received intent contains multiple SMS message segments
- **THEN** the app MUST combine the message bodies in received order before verification code extraction
#### Scenario: Sender and timestamp are available
- **WHEN** Android exposes sender address or timestamp for the SMS message
- **THEN** the app MUST attach those values to the capture result for diagnostics and display
### Requirement: Extract verification code candidates
The app SHALL extract verification code candidates from SMS bodies with a conservative parser optimized for common Chinese and English verification messages.
#### Scenario: Chinese verification keyword is present
- **WHEN** the SMS body contains a keyword such as `验证码``校验码` or `动态码` near a 4-8 character code
- **THEN** the app MUST extract the nearby code as the preferred verification code candidate
#### Scenario: English verification keyword is present
- **WHEN** the SMS body contains a keyword such as `code`, `verification` or `OTP` near a 4-8 character code
- **THEN** the app MUST extract the nearby code as the preferred verification code candidate
#### Scenario: Code contains spaces or hyphens
- **WHEN** the SMS body contains a verification code formatted with spaces or hyphens
- **THEN** the app MUST normalize the code before displaying it
#### Scenario: No reliable code candidate exists
- **WHEN** the SMS body does not contain a reliable verification code candidate
- **THEN** the app MUST return a structured parse failure instead of displaying a guessed code
### Requirement: Support optional Google SMS verification APIs
The app SHALL support Google SMS verification APIs only as optional paths and MUST NOT depend on them for the primary capture behavior.
#### Scenario: SMS User Consent path is available
- **WHEN** Google Play services supports SMS User Consent and the user authorizes reading a single SMS
- **THEN** the app MUST parse that SMS body through the same verification code parser used by the primary path
#### Scenario: SMS Retriever path is used with app hash
- **WHEN** a controlled test SMS includes the app hash required by SMS Retriever
- **THEN** the app MUST accept the retrieved message and parse the verification code
#### Scenario: Google SMS API is unavailable
- **WHEN** Google Play services is missing, disabled, incompatible, times out, or the user declines consent
- **THEN** the app MUST keep the system SMS broadcast path usable and report the optional path failure separately

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Validate on the target Xiaomi Android 15 device
The app SHALL be validated on the user's Xiaomi 12S running HyperOS 3 and Android 15 before the SMS capture behavior is considered complete.
#### Scenario: Foreground validation
- **WHEN** the app is open in the foreground and a test SMS containing `验证码 123456` is received
- **THEN** the app MUST show `123456` as the latest parsed verification code
#### Scenario: Background validation
- **WHEN** the app has been moved to the background and a test SMS containing a new verification code is received
- **THEN** the app MUST either show the new code after returning to the app or report that background delivery was blocked
#### Scenario: Lock screen validation
- **WHEN** the device is locked and a test SMS is received
- **THEN** the app MUST show the received result after unlock or report that delivery was blocked under lock screen conditions
### Requirement: Validate parser behavior with representative samples
The verification parser SHALL be validated with representative verification SMS examples and negative examples.
#### Scenario: Common valid samples
- **WHEN** parser tests include Chinese verification codes, English OTP messages, space-separated codes, hyphen-separated codes, and multiple candidates
- **THEN** all expected verification codes MUST be extracted with the correct normalized value
#### Scenario: Negative samples
- **WHEN** parser tests include messages with phone numbers, dates, money amounts, tracking numbers, or no verification code
- **THEN** the parser MUST avoid returning a false verification code unless a stronger keyword-nearby rule applies
### Requirement: Validate optional API assumptions separately
The app SHALL validate Google SMS APIs independently from the primary system broadcast path.
#### Scenario: Google Play services is available
- **WHEN** Google Play services is installed and supports SMS User Consent or SMS Retriever
- **THEN** the app MUST run an explicit optional-path test and record the result separately from system broadcast validation
#### Scenario: Google Play services is unavailable
- **WHEN** Google Play services is unavailable or incompatible on the target phone
- **THEN** the app MUST mark Google SMS API validation as skipped or unavailable without failing the primary SMS broadcast validation
### Requirement: Validate implementation without rebuilding development environment
The app SHALL reuse the existing local Android build environment reference and MUST NOT require reinstalling Android Studio, Gradle, or JDK as part of the implementation validation.
#### Scenario: Build configuration is prepared
- **WHEN** implementation begins after spec approval
- **THEN** the project MUST align its build setup with the existing Weather project environment or document any minimal project-specific difference

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Display SMS permission state
The app SHALL display whether the SMS receive permission is granted, denied, or blocked by system settings.
#### Scenario: Permission is granted
- **WHEN** `RECEIVE_SMS` permission is granted
- **THEN** the app MUST show that the primary SMS capture path can be attempted
#### Scenario: Permission is denied
- **WHEN** `RECEIVE_SMS` permission is denied
- **THEN** the app MUST show that incoming SMS cannot be captured through the primary path until permission is granted
### Requirement: Explain capture path status
The app SHALL expose diagnostic state for each supported SMS capture path.
#### Scenario: Primary path receives an SMS
- **WHEN** the system broadcast path receives and parses an SMS
- **THEN** the app MUST show the latest receive time, source path, sender summary, parsed code, and parse strategy
#### Scenario: Primary path fails before parsing
- **WHEN** the app cannot receive or parse an SMS through the primary path
- **THEN** the app MUST show a specific reason such as missing permission, no broadcast received, empty body, or parser failure
#### Scenario: Optional Google API path fails
- **WHEN** SMS User Consent or SMS Retriever cannot complete
- **THEN** the app MUST show whether the failure came from unavailable Google Play services, timeout, user cancellation, or unmatched SMS format
### Requirement: Avoid unnecessary SMS content retention
The app SHALL minimize retention and logging of full SMS content.
#### Scenario: Verification code is parsed successfully
- **WHEN** the app extracts a verification code from an SMS
- **THEN** the app MUST display or retain the code, sender summary, timestamp, and parse metadata without requiring persistent storage of the full SMS body
#### Scenario: Debug body visibility is enabled
- **WHEN** a debug-only setting enables full body visibility
- **THEN** the app MUST keep that behavior local to the device and clearly separate it from normal display state
### Requirement: Provide recovery actions for permission problems
The app SHALL provide a clear recovery path when Android or HyperOS blocks SMS capture permissions.
#### Scenario: Permission cannot be granted in normal prompt
- **WHEN** the runtime permission prompt does not grant usable SMS access
- **THEN** the app MUST provide an action to open the system application details or permission settings page

View File

@ -0,0 +1,71 @@
## 1. Spec And Project Baseline
- [x] 1.1 评审本次 OpenSpec 的 proposal、design、specs、tasks 是否覆盖需求
- [x] 1.2 用 `npx openspec validate build-sms-code-receiver-app` 校验方案结构
- [x] 1.3 检查 Weather 项目的 Gradle、AGP、Kotlin/Java、compileSdk 配置,作为后续实现参考
- [x] 1.4 确认本次不处理 Android Studio、Gradle、JDK 重新安装
- [ ] 1.5 后续提交或 push 前检查 diff避免引入非预期 EOF newline 变化
## 2. Android Skeleton
- [x] 2.1 基于 Weather 项目环境建立最小 Android app 工程骨架
- [x] 2.2 设置包名、minSdk、targetSdk、compileSdk并保持与现有可用环境兼容
- [x] 2.3 创建单 Activity 工具型界面,用于权限申请、状态展示和最近验证码展示
- [x] 2.4 建立基础日志标签和 debug 开关
## 3. Permission And Diagnostics
- [x] 3.1 在 Manifest 声明 `android.permission.RECEIVE_SMS`
- [x] 3.2 实现 Android 运行时短信权限申请和权限状态刷新
- [x] 3.3 在 UI 展示权限状态、receiver 状态、最近接收时间和失败原因
- [x] 3.4 增加跳转应用详情页的入口,用于处理 HyperOS 权限或受限权限设置
- [x] 3.5 检测 Google Play services 可用性,为 SMS User Consent / Retriever 备选路径提供诊断
## 4. System SMS Broadcast Path
- [x] 4.1 实现 `SMS_RECEIVED_ACTION` BroadcastReceiver
- [x] 4.2 使用 `Telephony.Sms.Intents.getMessagesFromIntent(Intent)` 解析短信消息
- [x] 4.3 处理多段短信 body 合并、sender、timestamp 和 subscription id
- [x] 4.4 将 receiver 结果分发到应用状态层,避免在 receiver 内执行重任务
- [x] 4.5 在无权限、body 为空、解析失败时输出结构化失败原因
## 5. Verification Code Parser
- [x] 5.1 实现关键词邻近匹配支持验证码、校验码、动态码、code、verification、OTP
- [x] 5.2 实现 4-8 位数字或字母数字候选提取
- [x] 5.3 支持空格和短横线归一化,例如 `12 34 56``123-456`
- [x] 5.4 增加误判排除规则,降低手机号、日期、金额、订单号误识别概率
- [x] 5.5 为多候选短信输出命中策略和置信度
## 6. Optional Google SMS APIs
- [x] 6.1 评估目标设备 Google Play services 是否可用
- [ ] 6.2 预留或实现 SMS User Consent API 前台读取单条短信路径
- [ ] 6.3 预留或实现 SMS Retriever API 获取 app hash 与受控短信模板验证
- [ ] 6.4 在 UI 中区分系统广播、User Consent、Retriever 三种来源
- [ ] 6.5 为 Google API 超时、不可用、用户拒绝授权输出明确诊断
## 7. Tests
- [x] 7.1 为验证码解析器添加中文验证码样本测试
- [x] 7.2 为验证码解析器添加英文 OTP/code 样本测试
- [x] 7.3 为验证码解析器添加空格、短横线、多候选样本测试
- [x] 7.4 为验证码解析器添加无验证码、手机号、日期、金额误判样本测试
- [ ] 7.5 为短信接收状态层添加权限状态和失败原因测试
## 8. Xiaomi 12S / HyperOS 3 Device Validation
- [ ] 8.1 前台打开应用时发送测试短信并验证接收结果
- [ ] 8.2 应用退到后台时发送测试短信并验证接收结果
- [ ] 8.3 锁屏状态发送测试短信,解锁后验证最近结果
- [ ] 8.4 验证权限被拒绝、权限重新授予后的状态恢复
- [ ] 8.5 如后台接收失败,记录 HyperOS 省电、后台运行、受限权限相关设置
- [ ] 8.6 如 Google Play services 可用,验证 SMS User Consent API 前台授权读取
## 9. Delivery Criteria
- [x] 9.1 相关上下文逻辑分析通过
- [x] 9.2 OpenSpec validate 通过
- [x] 9.3 单元测试通过
- [ ] 9.4 目标真机至少完成一条验证码短信接收和解析
- [x] 9.5 诊断 UI 能解释无权限、未收到广播、未解析到验证码三类失败

25
openspec/config.yaml Normal file
View File

@ -0,0 +1,25 @@
schema: spec-driven
context: |
Project: SmsReceive
Primary platform: Android
Reference build environment: Weather reference project
Target device: Xiaomi 12S, HyperOS 3, Android 15
Primary goal: build a personal Android app that can receive and surface SMS verification codes on the user's own phone.
Audience: an experienced Android engineer who wants a practical Android implementation plan before coding.
rules:
proposal:
- Write in Chinese
- Always include Why, What Changes, Capabilities, Impact, Validation
- Make the recommended SMS capture path explicit instead of listing only generic options
- Keep Android Studio, Gradle, and JDK setup out of scope; reuse the Weather project build environment as reference
design:
- Write in Chinese
- Include API strategy, Android 15 permission constraints, Xiaomi/HyperOS validation risks, parsing strategy, diagnostics, privacy boundaries, and test strategy
- Treat implementation as a personal sideload/debug app, not a Play Store compliance exercise
tasks:
- Write in Chinese
- Break work into actionable engineering steps
- Separate spec/design validation from implementation and device validation
- Include checks for unintended EOF newline changes before commit or push

2
settings.gradle Normal file
View File

@ -0,0 +1,2 @@
rootProject.name = 'SmsReceive'
include ':app'