[update] init
This commit is contained in:
commit
790afd679e
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
*/build/
|
||||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
app/release
|
||||
36
README.en.md
Normal file
36
README.en.md
Normal 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
39
README.md
Normal 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
46
app/build.gradle
Normal 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
1
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1 @@
|
||||
# Keep default debug build simple; release minification is disabled.
|
||||
@ -0,0 +1,22 @@
|
||||
package com.smsreceive.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class SmsProviderInstrumentedTest {
|
||||
private static final String TAG = "[SMS]SmsReceive";
|
||||
|
||||
@Test
|
||||
public void testLogRecentThirtyMessages() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
int count = SmsInboxReader.logRecentMessages(context, 30);
|
||||
Log.d(TAG, "SmsProviderInstrumentedTest.testLogRecentThirtyMessages count=" + count);
|
||||
assertTrue("Expected query to complete. Count can be 0 if SMS provider is empty.", count >= 0);
|
||||
}
|
||||
}
|
||||
58
app/src/main/AndroidManifest.xml
Normal file
58
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
56
app/src/main/java/com/smsreceive/app/BootReceiver.java
Normal file
56
app/src/main/java/com/smsreceive/app/BootReceiver.java
Normal 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);
|
||||
}
|
||||
}
|
||||
69
app/src/main/java/com/smsreceive/app/CaptureResult.java
Normal file
69
app/src/main/java/com/smsreceive/app/CaptureResult.java
Normal 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);
|
||||
}
|
||||
}
|
||||
299
app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java
Normal file
299
app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,352 @@
|
||||
package com.smsreceive.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
final class FeishuWebhookConfigStore {
|
||||
static final String ACTION_PUSH_UPDATED = "com.smsreceive.app.ACTION_FEISHU_PUSH_UPDATED";
|
||||
|
||||
private static final String TAG = "[SMS]SmsReceive";
|
||||
private static final String PREFS = "feishu_webhook";
|
||||
private static final String KEY_LAST_TIME = "last_time";
|
||||
private static final String KEY_LAST_SUCCESS = "last_success";
|
||||
private static final String KEY_LAST_STATUS = "last_status";
|
||||
private static final String KEY_LAST_MESSAGE = "last_message";
|
||||
private static final String KEY_LAST_HTTP_STATUS = "last_http_status";
|
||||
private static final String KEY_LAST_API_CODE = "last_api_code";
|
||||
private static final String KEY_LAST_PUSHED_SMS_RECEIVED_SECOND = "last_pushed_sms_received_second";
|
||||
private static final String KEY_LAST_PUSHED_SMS_KEY = "last_pushed_sms_key";
|
||||
private static final String KEY_LAST_PUSHED_SMS_CONTENT_KEY = "last_pushed_sms_content_key";
|
||||
private static final long DUPLICATE_SMS_TIME_TOLERANCE_SECONDS = 5L;
|
||||
private static final String CONFIG_DIR = "config";
|
||||
private static final String CONFIG_FILE = "feishu.json";
|
||||
private static final String DEFAULT_CONFIG_FILE = "def_config_feishu.json";
|
||||
private static final String JSON_ENABLED = "enabled";
|
||||
private static final String JSON_WEBHOOK_ID = "webhook_id";
|
||||
private static final String JSON_SECRET = "secret";
|
||||
private static final String JSON_SEND_FULL_BODY_DEBUG = "send_full_body_debug";
|
||||
private static final String JSON_FILTER_VERIFICATION_CODE = "filter_verification_code";
|
||||
|
||||
private FeishuWebhookConfigStore() {
|
||||
}
|
||||
|
||||
static Config loadConfig(Context context) {
|
||||
ensureDefaultConfigFile(context);
|
||||
File file = configFile(context);
|
||||
if (!file.exists()) {
|
||||
return defaultConfig();
|
||||
}
|
||||
try {
|
||||
JSONObject json = new JSONObject(readFile(file));
|
||||
return configFromJson(json);
|
||||
} catch (IOException | JSONException e) {
|
||||
Log.w(TAG, "load feishu config failed path=" + file.getAbsolutePath()
|
||||
+ ", reason=" + e.getClass().getSimpleName(), e);
|
||||
return defaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
static void saveConfig(
|
||||
Context context,
|
||||
boolean enabled,
|
||||
String webhookId,
|
||||
String secret,
|
||||
boolean sendFullBodyDebug,
|
||||
boolean filterVerificationCode) {
|
||||
Config config = new Config(enabled, webhookId, secret, sendFullBodyDebug, filterVerificationCode);
|
||||
File file = configFile(context);
|
||||
try {
|
||||
writeFile(file, configToJson(config).toString(2));
|
||||
Log.d(TAG, "save feishu config path=" + file.getAbsolutePath()
|
||||
+ ", enabled=" + enabled
|
||||
+ ", webhookConfigured=" + config.hasWebhookId()
|
||||
+ ", secretConfigured=" + config.hasSecret()
|
||||
+ ", debugBody=" + sendFullBodyDebug
|
||||
+ ", filterCode=" + filterVerificationCode);
|
||||
} catch (IOException | JSONException e) {
|
||||
Log.w(TAG, "save feishu config failed path=" + file.getAbsolutePath()
|
||||
+ ", reason=" + e.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
static void saveLastResult(Context context, FeishuWebhookPushResult result) {
|
||||
preferences(context).edit()
|
||||
.putLong(KEY_LAST_TIME, result.timeMillis)
|
||||
.putBoolean(KEY_LAST_SUCCESS, result.success)
|
||||
.putString(KEY_LAST_STATUS, result.status)
|
||||
.putString(KEY_LAST_MESSAGE, result.message)
|
||||
.putInt(KEY_LAST_HTTP_STATUS, result.httpStatus)
|
||||
.putInt(KEY_LAST_API_CODE, result.apiCode)
|
||||
.apply();
|
||||
}
|
||||
|
||||
static boolean wasSmsPushed(Context context, CaptureResult result) {
|
||||
String smsKey = buildSmsDedupKey(result);
|
||||
if (isEmpty(smsKey)) {
|
||||
return false;
|
||||
}
|
||||
SharedPreferences prefs = preferences(context);
|
||||
String lastKey = prefs.getString(KEY_LAST_PUSHED_SMS_KEY, "");
|
||||
String contentKey = buildSmsContentKey(result);
|
||||
String lastContentKey = prefs.getString(KEY_LAST_PUSHED_SMS_CONTENT_KEY, "");
|
||||
long receivedSecond = receivedSecond(result);
|
||||
long lastSecond = prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L);
|
||||
boolean exactDuplicate = smsKey.equals(lastKey);
|
||||
boolean tolerantDuplicate = !isEmpty(contentKey)
|
||||
&& contentKey.equals(lastContentKey)
|
||||
&& lastSecond > 0L
|
||||
&& Math.abs(receivedSecond - lastSecond) <= DUPLICATE_SMS_TIME_TOLERANCE_SECONDS;
|
||||
boolean duplicate = exactDuplicate || tolerantDuplicate;
|
||||
if (duplicate) {
|
||||
Log.d(TAG, "Feishu dedup hit smsKey=" + smsKey
|
||||
+ ", receivedSecond=" + receivedSecond
|
||||
+ ", lastSecond=" + lastSecond
|
||||
+ ", exact=" + exactDuplicate
|
||||
+ ", tolerant=" + tolerantDuplicate);
|
||||
}
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
static void saveLastPushedSms(Context context, CaptureResult result) {
|
||||
String smsKey = buildSmsDedupKey(result);
|
||||
if (isEmpty(smsKey)) {
|
||||
return;
|
||||
}
|
||||
long receivedSecond = receivedSecond(result);
|
||||
preferences(context).edit()
|
||||
.putLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, receivedSecond)
|
||||
.putString(KEY_LAST_PUSHED_SMS_KEY, smsKey)
|
||||
.putString(KEY_LAST_PUSHED_SMS_CONTENT_KEY, buildSmsContentKey(result))
|
||||
.apply();
|
||||
Log.d(TAG, "save last pushed sms receivedSecond=" + receivedSecond
|
||||
+ ", smsProviderId=" + result.smsProviderId
|
||||
+ ", smsKey=" + smsKey);
|
||||
}
|
||||
|
||||
static LastResult loadLastResult(Context context) {
|
||||
SharedPreferences prefs = preferences(context);
|
||||
return new LastResult(
|
||||
prefs.getLong(KEY_LAST_TIME, 0L),
|
||||
prefs.getBoolean(KEY_LAST_SUCCESS, false),
|
||||
prefs.getString(KEY_LAST_STATUS, ""),
|
||||
prefs.getString(KEY_LAST_MESSAGE, ""),
|
||||
prefs.getInt(KEY_LAST_HTTP_STATUS, 0),
|
||||
prefs.getInt(KEY_LAST_API_CODE, 0));
|
||||
}
|
||||
|
||||
static LastPushedSms loadLastPushedSms(Context context) {
|
||||
SharedPreferences prefs = preferences(context);
|
||||
return new LastPushedSms(
|
||||
prefs.getLong(KEY_LAST_PUSHED_SMS_RECEIVED_SECOND, 0L),
|
||||
prefs.getString(KEY_LAST_PUSHED_SMS_KEY, ""));
|
||||
}
|
||||
|
||||
static String maskSecret(String secret) {
|
||||
if (isEmpty(secret)) {
|
||||
return "";
|
||||
}
|
||||
if (secret.length() <= 6) {
|
||||
return "***";
|
||||
}
|
||||
return secret.substring(0, 3) + "***" + secret.substring(secret.length() - 3);
|
||||
}
|
||||
|
||||
static String configPath(Context context) {
|
||||
return configFile(context).getAbsolutePath();
|
||||
}
|
||||
|
||||
static String defaultConfigPath(Context context) {
|
||||
return defaultConfigFile(context).getAbsolutePath();
|
||||
}
|
||||
|
||||
static String configTemplate() {
|
||||
try {
|
||||
return configToJson(defaultConfig()).toString(2);
|
||||
} catch (JSONException e) {
|
||||
return "{\"enabled\":false,\"webhook_id\":\"\",\"secret\":\"\",\"send_full_body_debug\":false,\"filter_verification_code\":false}";
|
||||
}
|
||||
}
|
||||
|
||||
private static SharedPreferences preferences(Context context) {
|
||||
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private static void ensureDefaultConfigFile(Context context) {
|
||||
File file = defaultConfigFile(context);
|
||||
if (file.exists()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
writeFile(file, configTemplate());
|
||||
Log.d(TAG, "created default feishu config template path=" + file.getAbsolutePath());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "create default feishu config template failed path=" + file.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static File configFile(Context context) {
|
||||
return new File(configDir(context), CONFIG_FILE);
|
||||
}
|
||||
|
||||
private static File defaultConfigFile(Context context) {
|
||||
return new File(configDir(context), DEFAULT_CONFIG_FILE);
|
||||
}
|
||||
|
||||
private static File configDir(Context context) {
|
||||
File appExternalDir = context.getExternalFilesDir(null);
|
||||
File appDataDir = appExternalDir == null ? context.getExternalFilesDir(CONFIG_DIR) : appExternalDir.getParentFile();
|
||||
if (appDataDir == null) {
|
||||
appDataDir = new File(context.getFilesDir(), "external_config_fallback");
|
||||
}
|
||||
return new File(appDataDir, CONFIG_DIR);
|
||||
}
|
||||
|
||||
private static Config defaultConfig() {
|
||||
return new Config(false, "", "", false, false);
|
||||
}
|
||||
|
||||
private static Config configFromJson(JSONObject json) {
|
||||
return new Config(
|
||||
json.optBoolean(JSON_ENABLED, false),
|
||||
json.optString(JSON_WEBHOOK_ID, ""),
|
||||
json.optString(JSON_SECRET, ""),
|
||||
json.optBoolean(JSON_SEND_FULL_BODY_DEBUG, false),
|
||||
json.optBoolean(JSON_FILTER_VERIFICATION_CODE, false));
|
||||
}
|
||||
|
||||
private static JSONObject configToJson(Config config) throws JSONException {
|
||||
return new JSONObject()
|
||||
.put(JSON_ENABLED, config.enabled)
|
||||
.put(JSON_WEBHOOK_ID, config.webhookId)
|
||||
.put(JSON_SECRET, config.secret)
|
||||
.put(JSON_SEND_FULL_BODY_DEBUG, config.sendFullBodyDebug)
|
||||
.put(JSON_FILTER_VERIFICATION_CODE, config.filterVerificationCode);
|
||||
}
|
||||
|
||||
private static String readFile(File file) throws IOException {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append('\n');
|
||||
}
|
||||
builder.append(line);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static void writeFile(File file, String content) throws IOException {
|
||||
File parent = file.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
throw new IOException("mkdir failed: " + parent.getAbsolutePath());
|
||||
}
|
||||
try (OutputStreamWriter writer = new OutputStreamWriter(
|
||||
new FileOutputStream(file, false), StandardCharsets.UTF_8)) {
|
||||
writer.write(content == null ? "" : content);
|
||||
writer.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static long receivedSecond(CaptureResult result) {
|
||||
return result == null ? 0L : result.receivedAtMillis / 1000L;
|
||||
}
|
||||
|
||||
private static String buildSmsDedupKey(CaptureResult result) {
|
||||
if (result == null || result.receivedAtMillis <= 0L) {
|
||||
return "";
|
||||
}
|
||||
return receivedSecond(result)
|
||||
+ "|"
|
||||
+ buildSmsContentKey(result);
|
||||
}
|
||||
|
||||
private static String buildSmsContentKey(CaptureResult result) {
|
||||
if (result == null) {
|
||||
return "";
|
||||
}
|
||||
return normalize(result.sender)
|
||||
+ "|"
|
||||
+ Integer.toHexString((result.body == null ? "" : result.body).hashCode());
|
||||
}
|
||||
|
||||
static final class Config {
|
||||
final boolean enabled;
|
||||
final String webhookId;
|
||||
final String secret;
|
||||
final boolean sendFullBodyDebug;
|
||||
final boolean filterVerificationCode;
|
||||
|
||||
Config(
|
||||
boolean enabled,
|
||||
String webhookId,
|
||||
String secret,
|
||||
boolean sendFullBodyDebug,
|
||||
boolean filterVerificationCode) {
|
||||
this.enabled = enabled;
|
||||
this.webhookId = normalize(webhookId);
|
||||
this.secret = normalize(secret);
|
||||
this.sendFullBodyDebug = sendFullBodyDebug;
|
||||
this.filterVerificationCode = filterVerificationCode;
|
||||
}
|
||||
|
||||
boolean hasWebhookId() {
|
||||
return !isEmpty(webhookId);
|
||||
}
|
||||
|
||||
boolean hasSecret() {
|
||||
return !isEmpty(secret);
|
||||
}
|
||||
}
|
||||
|
||||
static final class LastResult {
|
||||
final long timeMillis;
|
||||
final boolean success;
|
||||
final String status;
|
||||
final String message;
|
||||
final int httpStatus;
|
||||
final int apiCode;
|
||||
|
||||
LastResult(long timeMillis, boolean success, String status, String message, int httpStatus, int apiCode) {
|
||||
this.timeMillis = timeMillis;
|
||||
this.success = success;
|
||||
this.status = status == null ? "" : status;
|
||||
this.message = message == null ? "" : message;
|
||||
this.httpStatus = httpStatus;
|
||||
this.apiCode = apiCode;
|
||||
}
|
||||
}
|
||||
|
||||
static final class LastPushedSms {
|
||||
final long receivedSecond;
|
||||
final String smsKey;
|
||||
|
||||
LastPushedSms(long receivedSecond, String smsKey) {
|
||||
this.receivedSecond = receivedSecond;
|
||||
this.smsKey = smsKey == null ? "" : smsKey;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEmpty(String value) {
|
||||
return value == null || value.length() == 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.smsreceive.app;
|
||||
|
||||
final class FeishuWebhookPushResult {
|
||||
static final String STATUS_SUCCESS = "success";
|
||||
static final String STATUS_DISABLED = "disabled";
|
||||
static final String STATUS_MISSING_CONFIG = "missing_config";
|
||||
static final String STATUS_SIGN_ERROR = "sign_error";
|
||||
static final String STATUS_NETWORK_ERROR = "network_error";
|
||||
static final String STATUS_TIMEOUT = "timeout";
|
||||
static final String STATUS_HTTP_ERROR = "http_error";
|
||||
static final String STATUS_INVALID_JSON = "invalid_json";
|
||||
static final String STATUS_API_ERROR = "api_error";
|
||||
|
||||
final boolean success;
|
||||
final String status;
|
||||
final String message;
|
||||
final int httpStatus;
|
||||
final int apiCode;
|
||||
final long timeMillis;
|
||||
|
||||
private FeishuWebhookPushResult(
|
||||
boolean success,
|
||||
String status,
|
||||
String message,
|
||||
int httpStatus,
|
||||
int apiCode,
|
||||
long timeMillis) {
|
||||
this.success = success;
|
||||
this.status = status == null ? "" : status;
|
||||
this.message = message == null ? "" : message;
|
||||
this.httpStatus = httpStatus;
|
||||
this.apiCode = apiCode;
|
||||
this.timeMillis = timeMillis;
|
||||
}
|
||||
|
||||
static FeishuWebhookPushResult success(String message) {
|
||||
return new FeishuWebhookPushResult(true, STATUS_SUCCESS, message, 200, 0, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
static FeishuWebhookPushResult failure(String status, String message) {
|
||||
return failure(status, message, 0, 0);
|
||||
}
|
||||
|
||||
static FeishuWebhookPushResult failure(String status, String message, int httpStatus, int apiCode) {
|
||||
return new FeishuWebhookPushResult(false, status, message, httpStatus, apiCode, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
87
app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java
Normal file
87
app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
166
app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java
Normal file
166
app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
902
app/src/main/java/com/smsreceive/app/MainActivity.java
Normal file
902
app/src/main/java/com/smsreceive/app/MainActivity.java
Normal 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("暂无短信接收记录。可以先授权,再从另一台手机发送:验证码 123456,5 分钟内有效。");
|
||||
return;
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("时间:").append(formatTime(capture.timeMillis)).append('\n');
|
||||
builder.append("来源:").append(emptyAsDash(capture.source)).append('\n');
|
||||
builder.append("发送方:").append(emptyAsDash(capture.sender)).append('\n');
|
||||
if (!TextUtils.isEmpty(capture.code)) {
|
||||
builder.append("验证码:").append(capture.code).append('\n');
|
||||
builder.append("策略:").append(capture.strategy).append(" / ").append(capture.confidence).append('\n');
|
||||
} else {
|
||||
builder.append("验证码:-").append('\n');
|
||||
builder.append("失败原因:").append(emptyAsDash(capture.failure)).append('\n');
|
||||
}
|
||||
builder.append("正文摘要:").append(emptyAsDash(capture.bodyPreview));
|
||||
latestText.setText(builder.toString());
|
||||
}
|
||||
|
||||
private void refreshKeepAliveUi() {
|
||||
boolean batteryIgnored = isIgnoringBatteryOptimizations();
|
||||
KeepAliveStateStore.setBatteryOptimizationIgnored(this, batteryIgnored);
|
||||
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
||||
Log.d(TAG, "refreshKeepAliveUi enabled=" + state.enabledByUser
|
||||
+ ", running=" + state.serviceRunning
|
||||
+ ", lastHeartbeat=" + state.lastHeartbeatMillis
|
||||
+ ", lastBootEvent=" + state.lastBootEvent
|
||||
+ ", batteryIgnored=" + batteryIgnored
|
||||
+ ", notificationsEnabled=" + areNotificationsEnabled()
|
||||
+ ", manualAutostart=" + state.manualAutostartConfirmed
|
||||
+ ", manualBatteryUnrestricted=" + state.manualBatteryUnrestrictedConfirmed);
|
||||
if (keepAliveButton != null) {
|
||||
keepAliveButton.setText(state.enabledByUser ? "关闭常驻保活" : "开启常驻保活");
|
||||
}
|
||||
if (autostartConfirmButton != null) {
|
||||
autostartConfirmButton.setText(state.manualAutostartConfirmed ? "取消自启动确认" : "确认已开启小米自启动");
|
||||
}
|
||||
if (batteryConfirmButton != null) {
|
||||
batteryConfirmButton.setText(state.manualBatteryUnrestrictedConfirmed ? "取消省电无限制确认" : "确认省电策略已设为无限制");
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("用户开关:").append(state.enabledByUser ? "已开启" : "未开启").append('\n');
|
||||
builder.append("服务状态:").append(state.serviceRunning ? "最近记录为运行中" : "未运行").append('\n');
|
||||
builder.append("最近心跳:").append(formatOptionalTime(state.lastHeartbeatMillis)).append('\n');
|
||||
builder.append("最近开机事件:").append(emptyAsDash(state.lastBootEvent));
|
||||
if (state.lastBootTimeMillis > 0L) {
|
||||
builder.append(" / ").append(formatTime(state.lastBootTimeMillis));
|
||||
}
|
||||
builder.append('\n');
|
||||
builder.append("启动失败:").append(emptyAsDash(state.lastServiceStartFailure)).append('\n');
|
||||
builder.append("Android 电池优化白名单:").append(batteryIgnored ? "已忽略优化" : "未忽略优化").append('\n');
|
||||
builder.append("通知可见性:").append(areNotificationsEnabled() ? "系统允许通知" : "通知可能被关闭").append('\n');
|
||||
builder.append("小米自启动:").append(state.manualAutostartConfirmed ? "已人工确认" : "未确认").append('\n');
|
||||
builder.append("省电无限制:").append(state.manualBatteryUnrestrictedConfirmed ? "已人工确认" : "未确认");
|
||||
keepAliveText.setText(builder.toString());
|
||||
}
|
||||
|
||||
private void refreshDatabaseHeartbeatUi() {
|
||||
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
||||
if (toastOnDatabaseWriteRadio != null) {
|
||||
toastOnDatabaseWriteRadio.setChecked(state.toastOnDatabaseWrite);
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long lastActiveTime = KeepAliveDatabase.readLastActiveTime(this);
|
||||
long gapMillis = lastActiveTime > 0L ? now - lastActiveTime : 0L;
|
||||
boolean stale = lastActiveTime > 0L && gapMillis > DATABASE_HEARTBEAT_STALE_MILLIS;
|
||||
Log.d(TAG, "refreshDatabaseHeartbeatUi now=" + now
|
||||
+ ", lastActiveTime=" + lastActiveTime
|
||||
+ ", gapMillis=" + gapMillis
|
||||
+ ", stale=" + stale
|
||||
+ ", toastOnDatabaseWrite=" + state.toastOnDatabaseWrite);
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("写入间隔:").append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L).append(" 秒").append('\n');
|
||||
builder.append("断档阈值:").append(DATABASE_HEARTBEAT_STALE_MILLIS / 1000L).append(" 秒").append('\n');
|
||||
builder.append("Toast 开关:").append(state.toastOnDatabaseWrite ? "已开启" : "未开启").append('\n');
|
||||
if (lastActiveTime <= 0L) {
|
||||
builder.append("最后写入:-").append('\n');
|
||||
builder.append("判断:数据库还没有 lastActiveTime。开启常驻保活后会开始写入。");
|
||||
databaseHeartbeatText.setBackgroundColor(0xFFFFFFFF);
|
||||
} else {
|
||||
builder.append("最后写入:").append(formatTimeWithMillis(lastActiveTime)).append('\n');
|
||||
builder.append("距离现在:").append(gapMillis).append(" ms").append('\n');
|
||||
if (stale) {
|
||||
builder.append("判断:疑似后台进程已停止。最后一次确认存活时间为 ")
|
||||
.append(formatTimeWithMillis(lastActiveTime))
|
||||
.append(",大约在此后 ")
|
||||
.append(DATABASE_HEARTBEAT_INTERVAL_MILLIS / 1000L)
|
||||
.append(" 秒内停止写入。");
|
||||
databaseHeartbeatText.setBackgroundColor(0xFFFFE0E0);
|
||||
} else {
|
||||
builder.append("判断:数据库心跳仍在正常窗口内。");
|
||||
databaseHeartbeatText.setBackgroundColor(0xFFE8F5E9);
|
||||
}
|
||||
}
|
||||
databaseHeartbeatText.setText(builder.toString());
|
||||
}
|
||||
|
||||
private void refreshDeliveryDiagnosticsUi() {
|
||||
SmsCaptureStore.DeliveryDiagnostics diagnostics = SmsCaptureStore.loadDeliveryDiagnostics(this);
|
||||
Log.d(TAG, "refreshDeliveryDiagnosticsUi lastBroadcast=" + diagnostics.lastBroadcastTimeMillis
|
||||
+ ", lastInbox=" + diagnostics.lastInboxTimeMillis
|
||||
+ ", lastInboxSource=" + diagnostics.lastInboxSource
|
||||
+ ", inboxNewerThanBroadcast=" + diagnostics.inboxNewerThanBroadcast());
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("最近短信广播:").append(formatOptionalTime(diagnostics.lastBroadcastTimeMillis)).append('\n');
|
||||
builder.append("最近收件箱兜底:").append(formatOptionalTime(diagnostics.lastInboxTimeMillis))
|
||||
.append(" / ").append(emptyAsDash(diagnostics.lastInboxSource)).append('\n');
|
||||
if (diagnostics.inboxNewerThanBroadcast()) {
|
||||
builder.append("判断:短信已进入收件箱,但广播路径没有更新,优先排查 RECEIVE_SMS、force-stop、小米自启动和省电策略。");
|
||||
} else {
|
||||
builder.append("判断:暂无收件箱新于广播的异常记录。");
|
||||
}
|
||||
deliveryDiagnosticsText.setText(builder.toString());
|
||||
}
|
||||
|
||||
private void refreshPollingUi() {
|
||||
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
|
||||
if (pollingButton != null) {
|
||||
pollingButton.setText(state.enabledByUser ? "停止1秒轮询验证码" : "开始1秒轮询验证码");
|
||||
}
|
||||
if (pollingIntervalEdit != null && !pollingIntervalEdit.hasFocus()) {
|
||||
pollingIntervalEdit.setText(String.valueOf(state.intervalSeconds));
|
||||
}
|
||||
Log.d(TAG, "refreshPollingUi enabled=" + state.enabledByUser
|
||||
+ ", running=" + state.running
|
||||
+ ", startTime=" + state.startTimeMillis
|
||||
+ ", lastHitId=" + state.lastHitId
|
||||
+ ", lastHitTime=" + state.lastHitTimeMillis
|
||||
+ ", lastFailure=" + state.lastFailure
|
||||
+ ", intervalSeconds=" + state.intervalSeconds);
|
||||
}
|
||||
|
||||
private void refreshFeishuPushUi() {
|
||||
FeishuWebhookConfigStore.Config config = FeishuWebhookConfigStore.loadConfig(this);
|
||||
FeishuWebhookConfigStore.LastResult lastResult = FeishuWebhookConfigStore.loadLastResult(this);
|
||||
FeishuWebhookConfigStore.LastPushedSms lastPushedSms = FeishuWebhookConfigStore.loadLastPushedSms(this);
|
||||
if (feishuPushEnabledCheckBox != null) {
|
||||
feishuPushEnabledCheckBox.setChecked(config.enabled);
|
||||
}
|
||||
if (feishuDebugBodyCheckBox != null) {
|
||||
feishuDebugBodyCheckBox.setChecked(config.sendFullBodyDebug);
|
||||
}
|
||||
if (feishuFilterCodeSwitch != null) {
|
||||
feishuFilterCodeSwitch.setChecked(config.filterVerificationCode);
|
||||
}
|
||||
if (feishuWebhookIdEdit != null && !feishuWebhookIdEdit.hasFocus()) {
|
||||
feishuWebhookIdEdit.setText(config.webhookId);
|
||||
}
|
||||
if (feishuSecretEdit != null && !feishuSecretEdit.hasFocus()) {
|
||||
feishuSecretEdit.setText(config.secret);
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("配置文件:").append(FeishuWebhookConfigStore.configPath(this)).append('\n');
|
||||
builder.append("默认模板:").append(FeishuWebhookConfigStore.defaultConfigPath(this)).append('\n');
|
||||
builder.append("推送开关:").append(config.enabled ? "已开启" : "未开启").append('\n');
|
||||
builder.append("验证码过滤:").append(config.filterVerificationCode ? "已开启" : "未开启").append('\n');
|
||||
builder.append("Webhook ID:").append(config.hasWebhookId() ? "已配置" : "未配置").append('\n');
|
||||
builder.append("Secret:").append(config.hasSecret()
|
||||
? FeishuWebhookConfigStore.maskSecret(config.secret)
|
||||
: "未配置").append('\n');
|
||||
builder.append("完整正文上传:").append(config.sendFullBodyDebug ? "已开启" : "未开启").append('\n');
|
||||
builder.append("已推送短信时间秒:")
|
||||
.append(lastPushedSms.receivedSecond > 0L ? String.valueOf(lastPushedSms.receivedSecond) : "-")
|
||||
.append('\n');
|
||||
if (lastResult.timeMillis <= 0L) {
|
||||
builder.append("最近推送:-");
|
||||
} else {
|
||||
builder.append("最近推送:").append(lastResult.success ? "成功" : "失败").append('\n');
|
||||
builder.append("时间:").append(formatTime(lastResult.timeMillis)).append('\n');
|
||||
builder.append("状态:").append(emptyAsDash(lastResult.status)).append('\n');
|
||||
builder.append("消息:").append(emptyAsDash(lastResult.message));
|
||||
if (lastResult.httpStatus > 0 || lastResult.apiCode != 0) {
|
||||
builder.append('\n').append("HTTP/API:")
|
||||
.append(lastResult.httpStatus)
|
||||
.append(" / ")
|
||||
.append(lastResult.apiCode);
|
||||
}
|
||||
}
|
||||
boolean configIssue = !config.enabled || !config.hasWebhookId() || !config.hasSecret()
|
||||
|| FeishuWebhookPushResult.STATUS_DISABLED.equals(lastResult.status)
|
||||
|| FeishuWebhookPushResult.STATUS_MISSING_CONFIG.equals(lastResult.status);
|
||||
feishuPushText.setBackgroundColor(configIssue ? 0xFFFFE0E0 : 0xFFFFFFFF);
|
||||
feishuPushText.setText(builder.toString());
|
||||
}
|
||||
|
||||
private void requestSmsPermission() {
|
||||
if (!hasReceiveSmsPermission() || !hasReadSmsPermission()) {
|
||||
Log.d(TAG, "requestSmsPermission launch runtime request");
|
||||
Toast.makeText(this, "正在申请短信权限", Toast.LENGTH_SHORT).show();
|
||||
requestPermissions(new String[]{Manifest.permission.RECEIVE_SMS, Manifest.permission.READ_SMS}, REQUEST_RECEIVE_SMS);
|
||||
} else {
|
||||
Log.d(TAG, "requestSmsPermission skipped: already granted");
|
||||
Toast.makeText(this, "短信权限已授权", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void toggleKeepAlive() {
|
||||
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
||||
Log.d(TAG, "toggleKeepAlive currentEnabled=" + state.enabledByUser
|
||||
+ ", serviceRunning=" + state.serviceRunning);
|
||||
if (state.enabledByUser) {
|
||||
KeepAliveStateStore.setEnabledByUser(this, false);
|
||||
Log.d(TAG, "toggleKeepAlive stopping SmsKeepAliveService");
|
||||
SmsKeepAliveService.stop(this);
|
||||
Toast.makeText(this, "已关闭常驻保活", Toast.LENGTH_SHORT).show();
|
||||
refreshUi();
|
||||
return;
|
||||
}
|
||||
|
||||
KeepAliveStateStore.setEnabledByUser(this, true);
|
||||
try {
|
||||
Log.d(TAG, "toggleKeepAlive starting SmsKeepAliveService");
|
||||
SmsKeepAliveService.start(this);
|
||||
Toast.makeText(this, "已开启常驻保活", Toast.LENGTH_SHORT).show();
|
||||
} catch (RuntimeException e) {
|
||||
String reason = "启动常驻保活失败:" + e.getClass().getSimpleName();
|
||||
Log.w(TAG, reason, e);
|
||||
KeepAliveStateStore.recordServiceStartFailure(this, reason);
|
||||
Toast.makeText(this, reason, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private void toggleManualAutostartConfirmed() {
|
||||
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
||||
boolean confirmed = !state.manualAutostartConfirmed;
|
||||
Log.d(TAG, "toggleManualAutostartConfirmed confirmed=" + confirmed);
|
||||
KeepAliveStateStore.setManualAutostartConfirmed(this, confirmed);
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private void toggleManualBatteryConfirmed() {
|
||||
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
||||
boolean confirmed = !state.manualBatteryUnrestrictedConfirmed;
|
||||
Log.d(TAG, "toggleManualBatteryConfirmed confirmed=" + confirmed);
|
||||
KeepAliveStateStore.setManualBatteryUnrestrictedConfirmed(this, confirmed);
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private void toggleToastOnDatabaseWrite() {
|
||||
KeepAliveStateStore.State state = KeepAliveStateStore.load(this);
|
||||
boolean enabled = !state.toastOnDatabaseWrite;
|
||||
Log.d(TAG, "toggleToastOnDatabaseWrite enabled=" + enabled);
|
||||
KeepAliveStateStore.setToastOnDatabaseWrite(this, enabled);
|
||||
if (toastOnDatabaseWriteRadio != null) {
|
||||
toastOnDatabaseWriteRadio.setChecked(enabled);
|
||||
}
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private void saveFeishuConfigFromUi() {
|
||||
boolean enabled = feishuPushEnabledCheckBox != null && feishuPushEnabledCheckBox.isChecked();
|
||||
boolean debugBody = feishuDebugBodyCheckBox != null && feishuDebugBodyCheckBox.isChecked();
|
||||
boolean filterCode = feishuFilterCodeSwitch != null && feishuFilterCodeSwitch.isChecked();
|
||||
String webhookId = feishuWebhookIdEdit == null ? "" : feishuWebhookIdEdit.getText().toString();
|
||||
String secret = feishuSecretEdit == null ? "" : feishuSecretEdit.getText().toString();
|
||||
Log.d(TAG, "saveFeishuConfigFromUi enabled=" + enabled
|
||||
+ ", webhookConfigured=" + !TextUtils.isEmpty(webhookId)
|
||||
+ ", secretConfigured=" + !TextUtils.isEmpty(secret)
|
||||
+ ", debugBody=" + debugBody
|
||||
+ ", filterCode=" + filterCode);
|
||||
FeishuWebhookConfigStore.saveConfig(this, enabled, webhookId, secret, debugBody, filterCode);
|
||||
Toast.makeText(this, "已保存飞书推送配置", Toast.LENGTH_SHORT).show();
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private void testFeishuPush() {
|
||||
saveFeishuConfigFromUi();
|
||||
if (!hasReadSmsPermission()) {
|
||||
Log.w(TAG, "testFeishuPush blocked: READ_SMS not granted");
|
||||
Toast.makeText(this, "READ_SMS 未授权,无法读取最近短信测试推送", Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
SmsInboxReader.InboxResult inboxResult = SmsInboxReader.readLatest(this);
|
||||
if (!inboxResult.success) {
|
||||
Log.w(TAG, "testFeishuPush read latest SMS failed: " + inboxResult.failureReason);
|
||||
Toast.makeText(this, inboxResult.failureReason, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
String markdown = buildLatestSmsTestMarkdown(inboxResult);
|
||||
Log.d(TAG, "testFeishuPush dispatch async");
|
||||
Toast.makeText(this, "已发起飞书测试推送", Toast.LENGTH_SHORT).show();
|
||||
FeishuWebhookClient.pushMarkdownAsync(this, markdown);
|
||||
}
|
||||
|
||||
private String buildLatestSmsTestMarkdown(SmsInboxReader.InboxResult inboxResult) {
|
||||
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body);
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("**SmsReceive 最近短信测试推送**").append('\n');
|
||||
builder.append("时间:").append(formatTime(inboxResult.dateMillis)).append('\n');
|
||||
builder.append("发送方:").append(maskSender(inboxResult.sender)).append('\n');
|
||||
builder.append("短信ID:").append(inboxResult.id).append('\n');
|
||||
if (parseResult.success) {
|
||||
builder.append("验证码:").append(parseResult.code).append('\n');
|
||||
builder.append("解析:").append(parseResult.strategy).append(" / ").append(parseResult.confidence).append('\n');
|
||||
} else {
|
||||
builder.append("验证码:-").append('\n');
|
||||
builder.append("解析失败:").append(emptyAsDash(parseResult.failureReason)).append('\n');
|
||||
}
|
||||
builder.append("正文:").append(emptyAsDash(inboxResult.body));
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private boolean hasReceiveSmsPermission() {
|
||||
return checkSelfPermission(Manifest.permission.RECEIVE_SMS) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
private boolean hasReadSmsPermission() {
|
||||
return checkSelfPermission(Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
private boolean hasAnySmsPermission() {
|
||||
return hasReceiveSmsPermission() || hasReadSmsPermission();
|
||||
}
|
||||
|
||||
private void readLatestInboxSms(String source, boolean showToast) {
|
||||
if (!hasReadSmsPermission()) {
|
||||
Log.w(TAG, "readLatestInboxSms skip: READ_SMS not granted source=" + source);
|
||||
if (showToast) {
|
||||
Toast.makeText(this, "READ_SMS 未授权,无法读取收件箱", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
SmsInboxReader.InboxResult inboxResult = SmsInboxReader.readLatest(this);
|
||||
if (!inboxResult.success) {
|
||||
Log.w(TAG, "readLatestInboxSms failed source=" + source + ", reason=" + inboxResult.failureReason);
|
||||
if (showToast) {
|
||||
Toast.makeText(this, inboxResult.failureReason, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (inboxResult.id == lastInboxSmsId && SOURCE_INBOX_OBSERVER.equals(source)) {
|
||||
Log.d(TAG, "readLatestInboxSms ignore duplicate id=" + inboxResult.id);
|
||||
return;
|
||||
}
|
||||
lastInboxSmsId = inboxResult.id;
|
||||
|
||||
VerificationCodeParser.ParseResult parseResult = VerificationCodeParser.parse(inboxResult.body);
|
||||
CaptureResult captureResult;
|
||||
if (parseResult.success) {
|
||||
Log.d(TAG, "readLatestInboxSms parse success source=" + source
|
||||
+ ", id=" + inboxResult.id
|
||||
+ ", code=" + parseResult.code
|
||||
+ ", strategy=" + parseResult.strategy);
|
||||
captureResult = CaptureResult.success(
|
||||
inboxResult.dateMillis,
|
||||
inboxResult.id,
|
||||
inboxResult.sender,
|
||||
inboxResult.body,
|
||||
parseResult,
|
||||
source);
|
||||
if (showToast) {
|
||||
Toast.makeText(this, "最新短信验证码:" + parseResult.code, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "readLatestInboxSms parse failed source=" + source
|
||||
+ ", id=" + inboxResult.id
|
||||
+ ", reason=" + parseResult.failureReason);
|
||||
captureResult = CaptureResult.failure(
|
||||
inboxResult.dateMillis,
|
||||
inboxResult.id,
|
||||
inboxResult.sender,
|
||||
inboxResult.body,
|
||||
source,
|
||||
parseResult.failureReason);
|
||||
if (showToast) {
|
||||
Toast.makeText(this, "最新短信未解析到验证码", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
SmsCaptureStore.save(this, captureResult);
|
||||
FeishuWebhookClient.pushCaptureResultAsync(this, captureResult);
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private void dumpRecentMessages() {
|
||||
if (!hasReadSmsPermission()) {
|
||||
Log.w(TAG, "dumpRecentMessages skip: READ_SMS not granted");
|
||||
Toast.makeText(this, "READ_SMS 未授权,无法打印短信库", Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
int count = SmsInboxReader.logRecentMessages(this, 30);
|
||||
Toast.makeText(this, "已打印最近 " + count + " 条短信到 logcat", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
private void savePollingIntervalFromUi() {
|
||||
int intervalSeconds = parsePollingIntervalSeconds();
|
||||
SmsPollingStateStore.setIntervalSeconds(this, intervalSeconds);
|
||||
Toast.makeText(this, "已保存轮询间隔:" + intervalSeconds + " 秒", Toast.LENGTH_SHORT).show();
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private int parsePollingIntervalSeconds() {
|
||||
String raw = pollingIntervalEdit == null ? "" : pollingIntervalEdit.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(raw)) {
|
||||
return SmsPollingStateStore.getIntervalSeconds(this);
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(raw);
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "parsePollingIntervalSeconds invalid raw=" + raw, e);
|
||||
return SmsPollingStateStore.getIntervalSeconds(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void togglePolling() {
|
||||
SmsPollingStateStore.State state = SmsPollingStateStore.load(this);
|
||||
if (state.enabledByUser) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
private void startPolling() {
|
||||
if (!hasReadSmsPermission()) {
|
||||
Log.w(TAG, "startPolling blocked: READ_SMS not granted");
|
||||
Toast.makeText(this, "READ_SMS 未授权,无法轮询短信库", Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
savePollingIntervalFromUi();
|
||||
Log.d(TAG, "startPolling via SmsPollingService");
|
||||
SmsPollingService.start(this);
|
||||
Toast.makeText(this,
|
||||
"已启动后台轮询验证码,间隔 " + SmsPollingStateStore.getIntervalSeconds(this) + " 秒",
|
||||
Toast.LENGTH_SHORT).show();
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private void stopPolling() {
|
||||
Log.d(TAG, "stopPolling via SmsPollingService");
|
||||
SmsPollingService.stop(this);
|
||||
Toast.makeText(this, "已停止后台短信轮询", Toast.LENGTH_SHORT).show();
|
||||
refreshUi();
|
||||
}
|
||||
|
||||
private boolean isGooglePlayServicesInstalled() {
|
||||
try {
|
||||
getPackageManager().getPackageInfo("com.google.android.gms", 0);
|
||||
return true;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void openAppSettings() {
|
||||
Log.d(TAG, "openAppSettings package=" + getPackageName());
|
||||
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
|
||||
intent.setData(Uri.fromParts("package", getPackageName(), null));
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openBatteryOptimizationSettings() {
|
||||
try {
|
||||
Log.d(TAG, "openBatteryOptimizationSettings action="
|
||||
+ Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
|
||||
startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS));
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "openBatteryOptimizationSettings fallback to app settings", e);
|
||||
openAppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private void requestIgnoreBatteryOptimizations() {
|
||||
if (isIgnoringBatteryOptimizations()) {
|
||||
Log.d(TAG, "requestIgnoreBatteryOptimizations skipped: already ignored");
|
||||
Toast.makeText(this, "当前已在电池优化白名单", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
|
||||
intent.setData(Uri.parse("package:" + getPackageName()));
|
||||
try {
|
||||
Log.d(TAG, "requestIgnoreBatteryOptimizations action="
|
||||
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
|
||||
startActivity(intent);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "requestIgnoreBatteryOptimizations fallback to battery settings", e);
|
||||
openBatteryOptimizationSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private void openXiaomiAutostartSettings() {
|
||||
Log.d(TAG, "openXiaomiAutostartSettings start");
|
||||
Intent[] candidates = new Intent[]{
|
||||
new Intent().setComponent(new ComponentName(
|
||||
"com.miui.securitycenter",
|
||||
"com.miui.permcenter.autostart.AutoStartManagementActivity")),
|
||||
new Intent().setComponent(new ComponentName(
|
||||
"com.miui.securitycenter",
|
||||
"com.miui.permcenter.permissions.PermissionsEditorActivity")),
|
||||
new Intent("miui.intent.action.OP_AUTO_START").setPackage("com.miui.securitycenter")
|
||||
};
|
||||
for (Intent candidate : candidates) {
|
||||
if (tryStartActivity(candidate)) {
|
||||
Log.d(TAG, "openXiaomiAutostartSettings launched intent=" + candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Toast.makeText(this, "未找到小米自启动页,已打开应用详情", Toast.LENGTH_LONG).show();
|
||||
openAppSettings();
|
||||
}
|
||||
|
||||
private boolean tryStartActivity(Intent intent) {
|
||||
try {
|
||||
Log.d(TAG, "tryStartActivity intent=" + intent);
|
||||
startActivity(intent);
|
||||
return true;
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "tryStartActivity failed intent=" + intent, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isIgnoringBatteryOptimizations() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return true;
|
||||
}
|
||||
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
return powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName());
|
||||
}
|
||||
|
||||
private boolean areNotificationsEnabled() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return true;
|
||||
}
|
||||
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
return manager == null || manager.areNotificationsEnabled();
|
||||
}
|
||||
|
||||
private String formatTime(long timeMillis) {
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date(timeMillis));
|
||||
}
|
||||
|
||||
private String formatTimeWithMillis(long timeMillis) {
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.CHINA).format(new Date(timeMillis));
|
||||
}
|
||||
|
||||
private String formatOptionalTime(long timeMillis) {
|
||||
return timeMillis > 0L ? formatTime(timeMillis) : "-";
|
||||
}
|
||||
|
||||
private String emptyAsDash(String value) {
|
||||
return TextUtils.isEmpty(value) ? "-" : value;
|
||||
}
|
||||
|
||||
private String maskSender(String sender) {
|
||||
if (TextUtils.isEmpty(sender)) {
|
||||
return "-";
|
||||
}
|
||||
if (sender.length() <= 4) {
|
||||
return sender;
|
||||
}
|
||||
return "***" + sender.substring(sender.length() - 4);
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
|
||||
}
|
||||
}
|
||||
149
app/src/main/java/com/smsreceive/app/SmsCaptureStore.java
Normal file
149
app/src/main/java/com/smsreceive/app/SmsCaptureStore.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
313
app/src/main/java/com/smsreceive/app/SmsInboxReader.java
Normal file
313
app/src/main/java/com/smsreceive/app/SmsInboxReader.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java
Normal file
102
app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java
Normal 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));
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/com/smsreceive/app/SmsMessageReader.java
Normal file
88
app/src/main/java/com/smsreceive/app/SmsMessageReader.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
135
app/src/main/java/com/smsreceive/app/SmsPollingService.java
Normal file
135
app/src/main/java/com/smsreceive/app/SmsPollingService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
129
app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java
Normal file
129
app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/src/main/java/com/smsreceive/app/SmsReceiver.java
Normal file
89
app/src/main/java/com/smsreceive/app/SmsReceiver.java
Normal 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) + "...";
|
||||
}
|
||||
}
|
||||
175
app/src/main/java/com/smsreceive/app/VerificationCodeParser.java
Normal file
175
app/src/main/java/com/smsreceive/app/VerificationCodeParser.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<color name="screen_bg">#F6F7F9</color>
|
||||
<color name="text_primary">#17202A</color>
|
||||
<color name="text_secondary">#5F6B7A</color>
|
||||
<color name="accent">#1E7A5F</color>
|
||||
</resources>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
3
app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">SmsReceive</string>
|
||||
</resources>
|
||||
7
app/src/main/res/values/styles.xml
Normal file
7
app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowBackground">@color/screen_bg</item>
|
||||
<item name="android:colorAccent">@color/accent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -0,0 +1,66 @@
|
||||
package com.smsreceive.app;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class FeishuWebhookClientTest {
|
||||
@Test
|
||||
public void generateSignMatchesPythonReference() throws Exception {
|
||||
String sign = FeishuWebhookClient.generateSign("my_secret", 1717020800L);
|
||||
|
||||
assertEquals("ajQIGQQbC+ykXA6alen/inmS3NYWGbE2LBj9v2+G6VM=", sign);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildRequestJsonMatchesFeishuMarkdownShape() throws Exception {
|
||||
String json = FeishuWebhookClient.buildRequestJson("验证码 123456", 1717020800L, "sign_value");
|
||||
JSONObject root = new JSONObject(json);
|
||||
|
||||
assertEquals("interactive", root.getString("msg_type"));
|
||||
assertEquals("1717020800", root.getString("timestamp"));
|
||||
assertEquals("sign_value", root.getString("sign"));
|
||||
JSONObject markdown = root.getJSONObject("card").getJSONArray("elements").getJSONObject(0);
|
||||
assertEquals("markdown", markdown.getString("tag"));
|
||||
assertEquals("验证码 123456", markdown.getString("content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseResponseAcceptsCodeZero() {
|
||||
FeishuWebhookPushResult result = FeishuWebhookClient.parseResponse("{\"code\":0,\"msg\":\"success\"}");
|
||||
|
||||
assertTrue(result.success);
|
||||
assertEquals(FeishuWebhookPushResult.STATUS_SUCCESS, result.status);
|
||||
assertEquals(0, result.apiCode);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseResponseClassifiesApiError() {
|
||||
FeishuWebhookPushResult result = FeishuWebhookClient.parseResponse("{\"code\":19021,\"msg\":\"invalid sign\"}");
|
||||
|
||||
assertFalse(result.success);
|
||||
assertEquals(FeishuWebhookPushResult.STATUS_API_ERROR, result.status);
|
||||
assertEquals(19021, result.apiCode);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseResponseClassifiesInvalidJson() {
|
||||
FeishuWebhookPushResult result = FeishuWebhookClient.parseResponse("<html>bad</html>");
|
||||
|
||||
assertFalse(result.success);
|
||||
assertEquals(FeishuWebhookPushResult.STATUS_INVALID_JSON, result.status);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pushMarkdownRejectsMissingConfigBeforeNetwork() {
|
||||
FeishuWebhookConfigStore.Config config = new FeishuWebhookConfigStore.Config(true, "", "", false, false);
|
||||
|
||||
FeishuWebhookPushResult result = FeishuWebhookClient.pushMarkdown(config, "test", 1717020800L);
|
||||
|
||||
assertFalse(result.success);
|
||||
assertEquals(FeishuWebhookPushResult.STATUS_MISSING_CONFIG, result.status);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.smsreceive.app;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class VerificationCodeParserTest {
|
||||
@Test
|
||||
public void parsesChineseKeywordCode() {
|
||||
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("【测试】验证码 123456,5 分钟内有效。");
|
||||
|
||||
assertTrue(result.success);
|
||||
assertEquals("123456", result.code);
|
||||
assertEquals("keyword_before_code", result.strategy);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesEnglishOtpCode() {
|
||||
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("Your OTP code is 839204. Do not share it.");
|
||||
|
||||
assertTrue(result.success);
|
||||
assertEquals("839204", result.code);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalizesSpacesAndHyphens() {
|
||||
assertEquals("123456", VerificationCodeParser.parse("验证码:12 34 56").code);
|
||||
assertEquals("123456", VerificationCodeParser.parse("验证码:123-456").code);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void prefersKeywordCandidate() {
|
||||
VerificationCodeParser.ParseResult result = VerificationCodeParser.parse("订单 998877,验证码 246810,请勿泄露。");
|
||||
|
||||
assertTrue(result.success);
|
||||
assertEquals("246810", result.code);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsCommonFalsePositives() {
|
||||
assertFalse(VerificationCodeParser.parse("订单金额 1234 元,手机号 13800138000。").success);
|
||||
assertFalse(VerificationCodeParser.parse("会议日期 2026-05-16,无验证码。").success);
|
||||
assertFalse(VerificationCodeParser.parse("这是一条普通通知。").success);
|
||||
}
|
||||
}
|
||||
29
build.gradle
Normal file
29
build.gradle
Normal 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
5
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
2
openspec/changes/add-feishu-webhook-push/.openspec.yaml
Normal file
2
openspec/changes/add-feishu-webhook-push/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-17
|
||||
188
openspec/changes/add-feishu-webhook-push/design.md
Normal file
188
openspec/changes/add-feishu-webhook-push/design.md
Normal 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 到飞书 webhook,HTTP 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 是否只用于本机调试,还是需要后续提供导入/导出配置。
|
||||
- 默认推送内容是否只发验证码,还是需要包含发送方掩码和解析策略。
|
||||
- 是否需要推送所有收到的字符串,还是只在验证码解析成功时推送。
|
||||
- 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。
|
||||
67
openspec/changes/add-feishu-webhook-push/proposal.md
Normal file
67
openspec/changes/add-feishu-webhook-push/proposal.md
Normal 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 变化。
|
||||
@ -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
|
||||
67
openspec/changes/add-feishu-webhook-push/tasks.md
Normal file
67
openspec/changes/add-feishu-webhook-push/tasks.md
Normal 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,空消息体,HmacSHA256,Base64 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 代码实现完成后通知用户,不强制编译
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-17
|
||||
203
openspec/changes/add-xiaomi-background-keepalive/design.md
Normal file
203
openspec/changes/add-xiaomi-background-keepalive/design.md
Normal 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-stop;Android 不会为 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 方案真机验证后再决定。
|
||||
70
openspec/changes/add-xiaomi-background-keepalive/proposal.md
Normal file
70
openspec/changes/add-xiaomi-background-keepalive/proposal.md
Normal 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 变化。
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
84
openspec/changes/add-xiaomi-background-keepalive/tasks.md
Normal file
84
openspec/changes/add-xiaomi-background-keepalive/tasks.md
Normal 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 代码实现完成后通知用户,不强制编译
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-16
|
||||
156
openspec/changes/build-sms-code-receiver-app/design.md
Normal file
156
openspec/changes/build-sms-code-receiver-app/design.md
Normal 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 验证。
|
||||
|
||||
真机验证:
|
||||
|
||||
- 前台打开应用后,向目标号码发送测试短信:`【测试】验证码 123456,5 分钟内有效。`
|
||||
- 应用退到后台后,重复发送不同验证码。
|
||||
- 锁屏状态下发送短信,解锁后检查最近结果。
|
||||
- 若可以控制短信格式,发送带 app hash 的 SMS Retriever 测试短信。
|
||||
- 若广播路径失败,打开前台 consent flow 再发送短信,观察是否弹出授权并读取正文。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 目标小米 12S 当前是否安装并启用了 Google Play services。
|
||||
- 用户是否接受为了后台稳定性关闭 HyperOS 对该 app 的省电限制。
|
||||
- 首版是否需要常驻通知显示最近验证码,还是只在 app 内展示。
|
||||
- 是否需要支持验证码自动复制到剪贴板;这会带来额外隐私和系统提示问题,建议先不做。
|
||||
53
openspec/changes/build-sms-code-receiver-app/proposal.md
Normal file
53
openspec/changes/build-sms-code-receiver-app/proposal.md
Normal 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 场景下的权限与后台行为风险。
|
||||
- 后续实现前必须能从任务列表直接进入编码,不再需要重新讨论核心架构。
|
||||
- 后续实现完成后,必须在目标真机上完成至少一轮短信接收、解析和诊断验证。
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
71
openspec/changes/build-sms-code-receiver-app/tasks.md
Normal file
71
openspec/changes/build-sms-code-receiver-app/tasks.md
Normal 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
25
openspec/config.yaml
Normal 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
2
settings.gradle
Normal file
@ -0,0 +1,2 @@
|
||||
rootProject.name = 'SmsReceive'
|
||||
include ':app'
|
||||
Loading…
x
Reference in New Issue
Block a user