From 790afd679e1059bce7cf8f4c34a351da81c6b926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=B6=85?= Date: Mon, 18 May 2026 11:10:52 +0800 Subject: [PATCH] [update] init --- .gitignore | 8 + README.en.md | 36 + README.md | 39 + app/build.gradle | 46 + app/proguard-rules.pro | 1 + .../app/SmsProviderInstrumentedTest.java | 22 + app/src/main/AndroidManifest.xml | 58 ++ .../java/com/smsreceive/app/BootReceiver.java | 56 ++ .../com/smsreceive/app/CaptureResult.java | 69 ++ .../smsreceive/app/FeishuWebhookClient.java | 299 ++++++ .../app/FeishuWebhookConfigStore.java | 352 +++++++ .../app/FeishuWebhookPushResult.java | 47 + .../com/smsreceive/app/KeepAliveDatabase.java | 87 ++ .../smsreceive/app/KeepAliveNotification.java | 62 ++ .../smsreceive/app/KeepAliveStateStore.java | 166 ++++ .../java/com/smsreceive/app/MainActivity.java | 902 ++++++++++++++++++ .../com/smsreceive/app/SmsCaptureStore.java | 149 +++ .../com/smsreceive/app/SmsInboxReader.java | 313 ++++++ .../smsreceive/app/SmsKeepAliveService.java | 102 ++ .../com/smsreceive/app/SmsMessageReader.java | 88 ++ .../com/smsreceive/app/SmsPollingService.java | 135 +++ .../smsreceive/app/SmsPollingStateStore.java | 129 +++ .../java/com/smsreceive/app/SmsReceiver.java | 89 ++ .../app/VerificationCodeParser.java | 175 ++++ app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/styles.xml | 7 + .../app/FeishuWebhookClientTest.java | 66 ++ .../app/VerificationCodeParserTest.java | 47 + build.gradle | 29 + gradle.properties | 5 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 49896 bytes gradle/wrapper/gradle-wrapper.properties | 5 + .../add-feishu-webhook-push/.openspec.yaml | 2 + .../changes/add-feishu-webhook-push/design.md | 188 ++++ .../add-feishu-webhook-push/proposal.md | 67 ++ .../specs/feishu-webhook-push/spec.md | 96 ++ .../changes/add-feishu-webhook-push/tasks.md | 67 ++ .../.openspec.yaml | 2 + .../add-xiaomi-background-keepalive/design.md | 203 ++++ .../proposal.md | 70 ++ .../specs/sms-background-keepalive/spec.md | 61 ++ .../sms-receiver-delivery-diagnostics/spec.md | 65 ++ .../xiaomi-hyperos-background-setup/spec.md | 56 ++ .../add-xiaomi-background-keepalive/tasks.md | 84 ++ .../.openspec.yaml | 2 + .../build-sms-code-receiver-app/design.md | 156 +++ .../build-sms-code-receiver-app/proposal.md | 53 + .../specs/sms-code-capture/spec.md | 57 ++ .../sms-code-validation-workflow/spec.md | 45 + .../specs/sms-permission-diagnostics/spec.md | 45 + .../build-sms-code-receiver-app/tasks.md | 71 ++ openspec/config.yaml | 25 + settings.gradle | 2 + 54 files changed, 5015 insertions(+) create mode 100644 .gitignore create mode 100644 README.en.md create mode 100644 README.md create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/smsreceive/app/SmsProviderInstrumentedTest.java create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/smsreceive/app/BootReceiver.java create mode 100644 app/src/main/java/com/smsreceive/app/CaptureResult.java create mode 100644 app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java create mode 100644 app/src/main/java/com/smsreceive/app/FeishuWebhookConfigStore.java create mode 100644 app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java create mode 100644 app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java create mode 100644 app/src/main/java/com/smsreceive/app/KeepAliveNotification.java create mode 100644 app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java create mode 100644 app/src/main/java/com/smsreceive/app/MainActivity.java create mode 100644 app/src/main/java/com/smsreceive/app/SmsCaptureStore.java create mode 100644 app/src/main/java/com/smsreceive/app/SmsInboxReader.java create mode 100644 app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java create mode 100644 app/src/main/java/com/smsreceive/app/SmsMessageReader.java create mode 100644 app/src/main/java/com/smsreceive/app/SmsPollingService.java create mode 100644 app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java create mode 100644 app/src/main/java/com/smsreceive/app/SmsReceiver.java create mode 100644 app/src/main/java/com/smsreceive/app/VerificationCodeParser.java create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/test/java/com/smsreceive/app/FeishuWebhookClientTest.java create mode 100644 app/src/test/java/com/smsreceive/app/VerificationCodeParserTest.java create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 openspec/changes/add-feishu-webhook-push/.openspec.yaml create mode 100644 openspec/changes/add-feishu-webhook-push/design.md create mode 100644 openspec/changes/add-feishu-webhook-push/proposal.md create mode 100644 openspec/changes/add-feishu-webhook-push/specs/feishu-webhook-push/spec.md create mode 100644 openspec/changes/add-feishu-webhook-push/tasks.md create mode 100644 openspec/changes/add-xiaomi-background-keepalive/.openspec.yaml create mode 100644 openspec/changes/add-xiaomi-background-keepalive/design.md create mode 100644 openspec/changes/add-xiaomi-background-keepalive/proposal.md create mode 100644 openspec/changes/add-xiaomi-background-keepalive/specs/sms-background-keepalive/spec.md create mode 100644 openspec/changes/add-xiaomi-background-keepalive/specs/sms-receiver-delivery-diagnostics/spec.md create mode 100644 openspec/changes/add-xiaomi-background-keepalive/specs/xiaomi-hyperos-background-setup/spec.md create mode 100644 openspec/changes/add-xiaomi-background-keepalive/tasks.md create mode 100644 openspec/changes/build-sms-code-receiver-app/.openspec.yaml create mode 100644 openspec/changes/build-sms-code-receiver-app/design.md create mode 100644 openspec/changes/build-sms-code-receiver-app/proposal.md create mode 100644 openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md create mode 100644 openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md create mode 100644 openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md create mode 100644 openspec/changes/build-sms-code-receiver-app/tasks.md create mode 100644 openspec/config.yaml create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9170b87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +*/build/ +local.properties +*.iml +.idea/ +.DS_Store +app/release diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..f79fd2a --- /dev/null +++ b/README.en.md @@ -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/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0491d6c --- /dev/null +++ b/README.md @@ -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/) diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..a1df480 --- /dev/null +++ b/app/build.gradle @@ -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' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..5e1c735 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Keep default debug build simple; release minification is disabled. diff --git a/app/src/androidTest/java/com/smsreceive/app/SmsProviderInstrumentedTest.java b/app/src/androidTest/java/com/smsreceive/app/SmsProviderInstrumentedTest.java new file mode 100644 index 0000000..0d9761d --- /dev/null +++ b/app/src/androidTest/java/com/smsreceive/app/SmsProviderInstrumentedTest.java @@ -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); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c3a4ad5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/smsreceive/app/BootReceiver.java b/app/src/main/java/com/smsreceive/app/BootReceiver.java new file mode 100644 index 0000000..39108b7 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/BootReceiver.java @@ -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); + } +} diff --git a/app/src/main/java/com/smsreceive/app/CaptureResult.java b/app/src/main/java/com/smsreceive/app/CaptureResult.java new file mode 100644 index 0000000..39eb183 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/CaptureResult.java @@ -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); + } +} diff --git a/app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java b/app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java new file mode 100644 index 0000000..ab29327 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/FeishuWebhookClient.java @@ -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(); + } +} diff --git a/app/src/main/java/com/smsreceive/app/FeishuWebhookConfigStore.java b/app/src/main/java/com/smsreceive/app/FeishuWebhookConfigStore.java new file mode 100644 index 0000000..2774e1f --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/FeishuWebhookConfigStore.java @@ -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; + } +} diff --git a/app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java b/app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java new file mode 100644 index 0000000..0d66fdf --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/FeishuWebhookPushResult.java @@ -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()); + } +} diff --git a/app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java b/app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java new file mode 100644 index 0000000..d4fab6e --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/KeepAliveDatabase.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/smsreceive/app/KeepAliveNotification.java b/app/src/main/java/com/smsreceive/app/KeepAliveNotification.java new file mode 100644 index 0000000..4d44b08 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/KeepAliveNotification.java @@ -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); + } +} diff --git a/app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java b/app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java new file mode 100644 index 0000000..a1d75c0 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/KeepAliveStateStore.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/smsreceive/app/MainActivity.java b/app/src/main/java/com/smsreceive/app/MainActivity.java new file mode 100644 index 0000000..00c5f30 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/MainActivity.java @@ -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); + } +} diff --git a/app/src/main/java/com/smsreceive/app/SmsCaptureStore.java b/app/src/main/java/com/smsreceive/app/SmsCaptureStore.java new file mode 100644 index 0000000..4b2c111 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/SmsCaptureStore.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/smsreceive/app/SmsInboxReader.java b/app/src/main/java/com/smsreceive/app/SmsInboxReader.java new file mode 100644 index 0000000..9b12186 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/SmsInboxReader.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java b/app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java new file mode 100644 index 0000000..8ec1138 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/SmsKeepAliveService.java @@ -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)); + } +} diff --git a/app/src/main/java/com/smsreceive/app/SmsMessageReader.java b/app/src/main/java/com/smsreceive/app/SmsMessageReader.java new file mode 100644 index 0000000..e28864a --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/SmsMessageReader.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/smsreceive/app/SmsPollingService.java b/app/src/main/java/com/smsreceive/app/SmsPollingService.java new file mode 100644 index 0000000..0ec0aa9 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/SmsPollingService.java @@ -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; + } +} diff --git a/app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java b/app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java new file mode 100644 index 0000000..6cc23db --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/SmsPollingStateStore.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/smsreceive/app/SmsReceiver.java b/app/src/main/java/com/smsreceive/app/SmsReceiver.java new file mode 100644 index 0000000..78c3540 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/SmsReceiver.java @@ -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) + "..."; + } +} diff --git a/app/src/main/java/com/smsreceive/app/VerificationCodeParser.java b/app/src/main/java/com/smsreceive/app/VerificationCodeParser.java new file mode 100644 index 0000000..a3f5d55 --- /dev/null +++ b/app/src/main/java/com/smsreceive/app/VerificationCodeParser.java @@ -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("(? 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); + } + } +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..fc81459 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #F6F7F9 + #17202A + #5F6B7A + #1E7A5F + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..40e6081 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SmsReceive + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..76ce212 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/test/java/com/smsreceive/app/FeishuWebhookClientTest.java b/app/src/test/java/com/smsreceive/app/FeishuWebhookClientTest.java new file mode 100644 index 0000000..54270d1 --- /dev/null +++ b/app/src/test/java/com/smsreceive/app/FeishuWebhookClientTest.java @@ -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("bad"); + + 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); + } +} diff --git a/app/src/test/java/com/smsreceive/app/VerificationCodeParserTest.java b/app/src/test/java/com/smsreceive/app/VerificationCodeParserTest.java new file mode 100644 index 0000000..d13e32f --- /dev/null +++ b/app/src/test/java/com/smsreceive/app/VerificationCodeParserTest.java @@ -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); + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..8f07d52 --- /dev/null +++ b/build.gradle @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f86ba92 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8c0fb64a8698b08ecc4158d828ca593c4928e9dd GIT binary patch literal 49896 zcmagFb986H(k`5d^NVfUwr$(C?M#x1ZQHiZiEVpg+jrjgoQrerx!>1o_ul)D>ebz~ zs=Mmxr&>W81QY-S1PKWQ%N-;H^tS;2*XwVA`dej1RRn1z<;3VgfE4~kaG`A%QSPsR z#ovnZe+tS9%1MfeDyz`RirvdjPRK~p(#^q2(^5@O&NM19EHdvN-A&StN>0g6QA^VN z0Gx%Gq#PD$QMRFzmK+utjS^Y1F0e8&u&^=w5K<;4Rz|i3A=o|IKLY+g`iK6vfr9?+ z-`>gmU&i?FGSL5&F?TXFu`&Js6h;15QFkXp2M1H9|Eq~bpov-GU(uz%mH0n55wUl- zv#~ccAz`F5wlQ>e_KlJS3@{)B?^v*EQM=IxLa&76^y51a((wq|2-`qON>+4dLc{Oo z51}}o^Zen(oAjxDK7b++9_Yg`67p$bPo3~BCpGM7uAWmvIhWc5Gi+gQZ|Pwa-Gll@<1xmcPy z|NZmu6m)g5Ftu~BG&Xdxclw7Cij{xbBMBn-LMII#Slp`AElb&2^Hw+w>(3crLH!;I zN+Vk$D+wP1#^!MDCiad@vM>H#6+`Ct#~6VHL4lzmy;lSdk>`z6)=>Wh15Q2)dQtGqvn0vJU@+(B5{MUc*qs4!T+V=q=wy)<6$~ z!G>e_4dN@lGeF_$q9`Ju6Ncb*x?O7=l{anm7Eahuj_6lA{*#Gv*TaJclevPVbbVYu z(NY?5q+xxbO6%g1xF0r@Ix8fJ~u)VRUp`S%&rN$&e!Od`~s+64J z5*)*WSi*i{k%JjMSIN#X;jC{HG$-^iX+5f5BGOIHWAl*%15Z#!xntpk($-EGKCzKa zT7{siZ9;4TICsWQ$pu&wKZQTCvpI$Xvzwxoi+XkkpeE&&kFb!B?h2hi%^YlXt|-@5 zHJ~%AN!g_^tmn1?HSm^|gCE#!GRtK2(L{9pL#hp0xh zME}|DB>(5)`iE7CM)&_+S}-Bslc#@B5W4_+k4Cp$l>iVyg$KP>CN?SVGZ(&02>iZK zB<^HP$g$Lq*L$BWd?2(F?-MUbNWTJVQdW7$#8a|k_30#vHAD1Z{c#p;bETk0VnU5A zBgLe2HFJ3032$G<`m*OB!KM$*sdM20jm)It5OSru@tXpK5LT>#8)N!*skNu1$TpIw zufjjdp#lyH5bZ%|Iuo|iu9vG1HrIVWLH>278xo>aVBkPN3V$~!=KnlXQ4eDqS7%E% zQ!z^$Q$b^6Q)g#cLpwur(|<0gWHo6A6jc;n`t(V9T;LzTAU{IAu*uEQ%Ort1k+Kn+f_N`9|bxYC+~Z1 zCC1UCWv*Orx$_@ydv9mIe(liLfOr7mhbV@tKw{6)q^1DH1nmvZ0cj215R<~&I<4S| zgnr;9Cdjqpz#o8i0CQjtl`}{c*P)aSdH|abxGdrR)-3z+02-eX(k*B)Uqv6~^nh** z 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 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 方案真机验证后再决定。 diff --git a/openspec/changes/add-xiaomi-background-keepalive/proposal.md b/openspec/changes/add-xiaomi-background-keepalive/proposal.md new file mode 100644 index 0000000..df7adc0 --- /dev/null +++ b/openspec/changes/add-xiaomi-background-keepalive/proposal.md @@ -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 变化。 diff --git a/openspec/changes/add-xiaomi-background-keepalive/specs/sms-background-keepalive/spec.md b/openspec/changes/add-xiaomi-background-keepalive/specs/sms-background-keepalive/spec.md new file mode 100644 index 0000000..af9778b --- /dev/null +++ b/openspec/changes/add-xiaomi-background-keepalive/specs/sms-background-keepalive/spec.md @@ -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 diff --git a/openspec/changes/add-xiaomi-background-keepalive/specs/sms-receiver-delivery-diagnostics/spec.md b/openspec/changes/add-xiaomi-background-keepalive/specs/sms-receiver-delivery-diagnostics/spec.md new file mode 100644 index 0000000..0321ee6 --- /dev/null +++ b/openspec/changes/add-xiaomi-background-keepalive/specs/sms-receiver-delivery-diagnostics/spec.md @@ -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 diff --git a/openspec/changes/add-xiaomi-background-keepalive/specs/xiaomi-hyperos-background-setup/spec.md b/openspec/changes/add-xiaomi-background-keepalive/specs/xiaomi-hyperos-background-setup/spec.md new file mode 100644 index 0000000..02ac8a5 --- /dev/null +++ b/openspec/changes/add-xiaomi-background-keepalive/specs/xiaomi-hyperos-background-setup/spec.md @@ -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 diff --git a/openspec/changes/add-xiaomi-background-keepalive/tasks.md b/openspec/changes/add-xiaomi-background-keepalive/tasks.md new file mode 100644 index 0000000..25a67d2 --- /dev/null +++ b/openspec/changes/add-xiaomi-background-keepalive/tasks.md @@ -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 代码实现完成后通知用户,不强制编译 diff --git a/openspec/changes/build-sms-code-receiver-app/.openspec.yaml b/openspec/changes/build-sms-code-receiver-app/.openspec.yaml new file mode 100644 index 0000000..ab7f13b --- /dev/null +++ b/openspec/changes/build-sms-code-receiver-app/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-16 diff --git a/openspec/changes/build-sms-code-receiver-app/design.md b/openspec/changes/build-sms-code-receiver-app/design.md new file mode 100644 index 0000000..7662f64 --- /dev/null +++ b/openspec/changes/build-sms-code-receiver-app/design.md @@ -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 内展示。 +- 是否需要支持验证码自动复制到剪贴板;这会带来额外隐私和系统提示问题,建议先不做。 diff --git a/openspec/changes/build-sms-code-receiver-app/proposal.md b/openspec/changes/build-sms-code-receiver-app/proposal.md new file mode 100644 index 0000000..510351e --- /dev/null +++ b/openspec/changes/build-sms-code-receiver-app/proposal.md @@ -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 场景下的权限与后台行为风险。 +- 后续实现前必须能从任务列表直接进入编码,不再需要重新讨论核心架构。 +- 后续实现完成后,必须在目标真机上完成至少一轮短信接收、解析和诊断验证。 diff --git a/openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md new file mode 100644 index 0000000..733c584 --- /dev/null +++ b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-capture/spec.md @@ -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 diff --git a/openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md new file mode 100644 index 0000000..cebb4cb --- /dev/null +++ b/openspec/changes/build-sms-code-receiver-app/specs/sms-code-validation-workflow/spec.md @@ -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 diff --git a/openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md b/openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md new file mode 100644 index 0000000..234fd89 --- /dev/null +++ b/openspec/changes/build-sms-code-receiver-app/specs/sms-permission-diagnostics/spec.md @@ -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 diff --git a/openspec/changes/build-sms-code-receiver-app/tasks.md b/openspec/changes/build-sms-code-receiver-app/tasks.md new file mode 100644 index 0000000..8eff704 --- /dev/null +++ b/openspec/changes/build-sms-code-receiver-app/tasks.md @@ -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 能解释无权限、未收到广播、未解析到验证码三类失败 diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..4500317 --- /dev/null +++ b/openspec/config.yaml @@ -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 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..c6534e6 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'SmsReceive' +include ':app'