新增服务组件和工具类

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
wchino
2026-04-05 22:29:42 +08:00
parent e11841f19d
commit f0fe8de4e2
5 changed files with 600 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
package com.miraclegarden.smsmessage.service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
/**
* 开机后重启监听服务。
*/
public class BootReceiver extends BroadcastReceiver {
private static final long BOOT_DELAY_MS = 5000;
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
new Handler(Looper.getMainLooper()).postDelayed(() -> {
NotificationService.requestStartMonitoring(context);
}, BOOT_DELAY_MS);
}
}
}

View File

@@ -0,0 +1,122 @@
package com.miraclegarden.smsmessage.service;
import android.app.Notification;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
/**
* @Author wchino
* 创建时间 2026/03/30
* 用途:通知内容多层提取工具,兼容不同厂商字段差异
*/
public class NotificationExtractor {
private NotificationExtractor() {
}
/**
* @Author wchino
* 创建时间 2026/03/30
* 用途:提取通知标题、内容和提取时间戳
*/
public static Result extract(StatusBarNotification sbn) {
long timestamp = System.currentTimeMillis();
if (sbn == null || sbn.getNotification() == null) {
return new Result("", "", timestamp);
}
Notification notification = sbn.getNotification();
Bundle extras = notification.extras;
String title = "";
String content = "";
if (extras != null) {
title = pickFirstNonEmpty(
extras.getCharSequence(Notification.EXTRA_TITLE),
extras.getCharSequence(Notification.EXTRA_TITLE_BIG)
);
content = pickFirstNonEmpty(
extras.getCharSequence(Notification.EXTRA_TEXT),
extras.getCharSequence(Notification.EXTRA_BIG_TEXT),
extras.getCharSequence(Notification.EXTRA_INFO_TEXT),
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)
);
}
if (TextUtils.isEmpty(title)) {
title = firstSegment(notification.tickerText);
}
if (TextUtils.isEmpty(content)) {
content = normalized(notification.tickerText);
}
if (title == null) {
title = "";
}
if (content == null) {
content = "";
}
return new Result(title, content, timestamp);
}
private static String pickFirstNonEmpty(CharSequence... values) {
if (values == null || values.length == 0) {
return "";
}
for (CharSequence value : values) {
String normalized = normalized(value);
if (!TextUtils.isEmpty(normalized)) {
return normalized;
}
}
return "";
}
private static String normalized(CharSequence value) {
if (value == null) {
return "";
}
String result = value.toString().trim();
if (TextUtils.isEmpty(result)) {
return "";
}
if ("null".equalsIgnoreCase(result)) {
return "";
}
return result;
}
private static String firstSegment(CharSequence tickerText) {
String ticker = normalized(tickerText);
if (TextUtils.isEmpty(ticker)) {
return "";
}
int spaceIndex = ticker.indexOf(' ');
if (spaceIndex > 0) {
return ticker.substring(0, spaceIndex).trim();
}
return ticker;
}
/**
* @Author wchino
* 创建时间 2026/03/30
* 用途:通知提取结果对象
*/
public static class Result {
public String title;
public String content;
public long timestamp;
public Result(String title, String content, long timestamp) {
this.title = title == null ? "" : title;
this.content = content == null ? "" : content;
this.timestamp = timestamp;
}
}
}

View File

@@ -0,0 +1,35 @@
package com.miraclegarden.smsmessage.service;
import android.content.Context;
import android.provider.Settings;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
public class NotificationHealthCheckWorker extends Worker {
public NotificationHealthCheckWorker(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
if (!isNotificationListenerEnabled()) {
NotificationService.toggleNotificationListenerService(getApplicationContext());
}
return Result.success();
}
private boolean isNotificationListenerEnabled() {
String flat = Settings.Secure.getString(
getApplicationContext().getContentResolver(),
"enabled_notification_listeners");
if (TextUtils.isEmpty(flat)) {
return false;
}
return flat.contains(getApplicationContext().getPackageName());
}
}

View File

@@ -0,0 +1,272 @@
package com.miraclegarden.smsmessage.service;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* 上传失败重试管理器(内存队列 + SharedPreferences 持久化)。
* <p>
* 主要职责:
* 1. 入队失败上传任务并立即尝试上传;
* 2. 上传失败后按延迟策略重试;
* 3. 进程重启后从本地恢复重试队列;
* 4. 统计成功上传总数,供前台通知展示。
*/
public class RetryManager {
private static final String TAG = "RetryManager";
private static final String SP_NAME = "server";
private static final String KEY_RETRY_QUEUE = "retry_queue";
private static final String KEY_UPLOADED_COUNT = "uploaded_count";
private static final String KEY_TOTAL_COUNT = "total_count";
private static final String KEY_FAILED_COUNT = "failed_count";
private static final int MAX_QUEUE_SIZE = 100;
private static final long[] RETRY_DELAYS = new long[]{2000L, 5000L, 15000L};
private final SharedPreferences sharedPreferences;
private final Handler handler;
private final List<QueueItem> retryQueue = new ArrayList<>();
private UploadCallback uploadCallback;
private int uploadedCount;
private int totalCount;
private int failedCount;
/**
* 上传抽象回调。
*/
public interface UploadCallback {
void onUpload(String jsonPayload, UploadResultListener listener);
}
/**
* 上传结果监听。
*/
public interface UploadResultListener {
void onSuccess();
void onFailure(String error);
}
/**
* 重试队列元素。
*/
private static class QueueItem {
String jsonPayload;
int retryCount; // starts at 0, max 2 (3 attempts total: initial + 2 retries)
long nextRetryTime;
}
public RetryManager(Context context) {
Context appContext = context.getApplicationContext();
this.sharedPreferences = appContext.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
this.handler = new Handler(Looper.getMainLooper());
this.uploadedCount = sharedPreferences.getInt(KEY_UPLOADED_COUNT, 0);
this.totalCount = sharedPreferences.getInt(KEY_TOTAL_COUNT, 0);
this.failedCount = sharedPreferences.getInt(KEY_FAILED_COUNT, 0);
}
/**
* 设置上传实现。
*/
public void setUploadCallback(UploadCallback callback) {
this.uploadCallback = callback;
}
/**
* 添加任务到队列并立即尝试上传。
*/
public void enqueue(String jsonPayload) {
if (jsonPayload == null || jsonPayload.trim().isEmpty()) {
Log.w(TAG, "enqueue payload 为空,忽略");
return;
}
incrementTotalCount();
QueueItem item = new QueueItem();
item.jsonPayload = jsonPayload;
item.retryCount = 0;
item.nextRetryTime = SystemClock.elapsedRealtime();
synchronized (retryQueue) {
if (retryQueue.size() >= MAX_QUEUE_SIZE) {
QueueItem removed = retryQueue.remove(0);
Log.w(TAG, "重试队列已满,丢弃最旧任务: " + (removed == null ? "null" : removed.jsonPayload));
}
retryQueue.add(item);
persistQueue();
}
attemptUpload(item);
}
/**
* 从持久化恢复重试队列并重新触发上传。
*/
public void restoreFromPersistence() {
String queueJson = sharedPreferences.getString(KEY_RETRY_QUEUE, "");
if (queueJson == null || queueJson.trim().isEmpty()) {
return;
}
List<QueueItem> restoredItems = new ArrayList<>();
try {
JSONArray jsonArray = new JSONArray(queueJson);
for (int i = 0; i < jsonArray.length(); i++) {
JSONObject object = jsonArray.optJSONObject(i);
if (object == null) {
continue;
}
QueueItem item = new QueueItem();
item.jsonPayload = object.optString("jsonPayload", "");
item.retryCount = object.optInt("retryCount", 0);
item.nextRetryTime = object.optLong("nextRetryTime", SystemClock.elapsedRealtime());
if (item.jsonPayload == null || item.jsonPayload.trim().isEmpty()) {
continue;
}
restoredItems.add(item);
}
} catch (JSONException e) {
Log.e(TAG, "恢复重试队列失败", e);
return;
}
synchronized (retryQueue) {
retryQueue.clear();
retryQueue.addAll(restoredItems);
}
for (QueueItem item : new ArrayList<>(restoredItems)) {
long delay = item.nextRetryTime - SystemClock.elapsedRealtime();
if (delay > 0) {
handler.postDelayed(() -> attemptUpload(item), delay);
} else {
attemptUpload(item);
}
}
}
/**
* 获取累计成功上传数量。
*/
public int getUploadedCount() {
return uploadedCount;
}
public int getTotalCount() {
return totalCount;
}
public int getFailedCount() {
return failedCount;
}
private void incrementTotalCount() {
totalCount++;
sharedPreferences.edit()
.putInt(KEY_TOTAL_COUNT, totalCount)
.apply();
}
private void incrementFailedCount() {
failedCount++;
sharedPreferences.edit()
.putInt(KEY_FAILED_COUNT, failedCount)
.apply();
}
/**
* 清理回调,通常在 Service 销毁时调用。
*/
public void destroy() {
handler.removeCallbacksAndMessages(null);
}
private void attemptUpload(final QueueItem item) {
if (item == null) {
return;
}
if (uploadCallback == null) {
Log.w(TAG, "uploadCallback 未设置,无法上传");
return;
}
uploadCallback.onUpload(item.jsonPayload, new UploadResultListener() {
@Override
public void onSuccess() {
synchronized (retryQueue) {
retryQueue.remove(item);
persistQueue();
}
uploadedCount++;
sharedPreferences.edit()
.putInt(KEY_UPLOADED_COUNT, uploadedCount)
.apply();
}
@Override
public void onFailure(String error) {
synchronized (retryQueue) {
if (!retryQueue.contains(item)) {
return;
}
if (item.retryCount < 3) {
long delay = RETRY_DELAYS[Math.min(item.retryCount, RETRY_DELAYS.length - 1)];
item.retryCount++;
item.nextRetryTime = SystemClock.elapsedRealtime() + delay;
handler.postDelayed(() -> attemptUpload(item), delay);
persistQueue();
Log.w(TAG, "上传失败准备重试。retryCount=" + item.retryCount + ", error=" + error);
} else {
retryQueue.remove(item);
persistQueue();
incrementFailedCount();
Log.e(TAG, "上传失败达到上限,丢弃任务: " + error);
}
}
}
});
}
private void persistQueue() {
JSONArray jsonArray = new JSONArray();
synchronized (retryQueue) {
Iterator<QueueItem> iterator = retryQueue.iterator();
while (iterator.hasNext()) {
QueueItem item = iterator.next();
JSONObject object = new JSONObject();
try {
object.put("jsonPayload", item.jsonPayload);
object.put("retryCount", item.retryCount);
object.put("nextRetryTime", item.nextRetryTime);
jsonArray.put(object);
} catch (JSONException e) {
Log.e(TAG, "序列化重试项失败", e);
}
}
}
sharedPreferences.edit()
.putString(KEY_RETRY_QUEUE, jsonArray.toString())
.apply();
}
}