diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/BootReceiver.java b/app/src/main/java/com/miraclegarden/smsmessage/service/BootReceiver.java new file mode 100644 index 0000000..894858d --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/BootReceiver.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationExtractor.java b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationExtractor.java new file mode 100644 index 0000000..7fd78b5 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationExtractor.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationHealthCheckWorker.java b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationHealthCheckWorker.java new file mode 100644 index 0000000..d6e6947 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationHealthCheckWorker.java @@ -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()); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/RetryManager.java b/app/src/main/java/com/miraclegarden/smsmessage/service/RetryManager.java new file mode 100644 index 0000000..9ad771b --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/RetryManager.java @@ -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 持久化)。 + *

+ * 主要职责: + * 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 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 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 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(); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/util/OEMBackgroundHelper.java b/app/src/main/java/com/miraclegarden/smsmessage/util/OEMBackgroundHelper.java new file mode 100644 index 0000000..a2dafb8 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/util/OEMBackgroundHelper.java @@ -0,0 +1,147 @@ +package com.miraclegarden.smsmessage.util; + +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.PowerManager; +import android.provider.Settings; + +public class OEMBackgroundHelper { + + private static final String MIUI_SECURITY = "com.miui.securitycenter"; + private static final String MIUI_AUTOSTART = "com.miui.permcenter.autostart.AutoStartManagementActivity"; + + private static final String COLOROS_SAFECENTER = "com.coloros.safecenter"; + private static final String COLOROS_STARTUP = "com.coloros.safecenter.permission.startup.StartupAppListActivity"; + + private static final String OPPO_SAFE = "com.oppo.safe"; + private static final String OPPO_STARTUP = "com.oppo.safe.permission.startup.StartupAppListActivity"; + + private static final String VIVO_PERMISSION = "com.vivo.permissionmanager"; + private static final String VIVO_BGSTARTUP = "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"; + + private static final String IQOO_SECURE = "com.iqoo.secure"; + private static final String IQOO_BGSTARTUP = "com.iqoo.secure.ui.phoneoptimize.BgStartUpManager"; + + private static final String HUAWEI_SYSTEM_MANAGER = "com.huawei.systemmanager"; + private static final String HUAWEI_STARTUP_CTRL = "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"; + private static final String HUAWEI_POWER_MANAGER = "com.huawei.systemmanager.power.ui.HwPowerManagerActivity"; + + public static void openAutoStartSettings(Context context) { + String manufacturer = Build.MANUFACTURER.toLowerCase(); + + Intent intent = new Intent(); + boolean success = false; + + try { + if (manufacturer.contains("xiaomi") || manufacturer.contains("redmi")) { + intent.setComponent(new ComponentName(MIUI_SECURITY, MIUI_AUTOSTART)); + success = true; + } else if (manufacturer.contains("oppo")) { + if (tryStartActivity(context, intent, COLOROS_SAFECENTER, COLOROS_STARTUP)) return; + if (tryStartActivity(context, intent, OPPO_SAFE, OPPO_STARTUP)) return; + } else if (manufacturer.contains("realme")) { + if (tryStartActivity(context, intent, COLOROS_SAFECENTER, COLOROS_STARTUP)) return; + } else if (manufacturer.contains("vivo")) { + if (tryStartActivity(context, intent, VIVO_PERMISSION, VIVO_BGSTARTUP)) return; + if (tryStartActivity(context, intent, IQOO_SECURE, IQOO_BGSTARTUP)) return; + } else if (manufacturer.contains("huawei") || manufacturer.contains("honor")) { + if (tryStartActivity(context, intent, HUAWEI_SYSTEM_MANAGER, HUAWEI_STARTUP_CTRL)) return; + if (tryStartActivity(context, intent, HUAWEI_SYSTEM_MANAGER, HUAWEI_POWER_MANAGER)) return; + } else if (manufacturer.contains("oneplus")) { + openBatteryOptimizationSettings(context); + return; + } else if (manufacturer.contains("samsung")) { + openAppBatterySettings(context); + return; + } + + if (success) { + context.startActivity(intent); + } else { + openBatteryOptimizationSettings(context); + } + } catch (ActivityNotFoundException e) { + openBatteryOptimizationSettings(context); + } + } + + public static void openBatteryOptimizationSettings(Context context) { + try { + Intent intent = new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Intent intent = new Intent(Settings.ACTION_SETTINGS); + context.startActivity(intent); + } + } + + public static void requestBatteryOptimizationExemption(Context context) { + if (!isIgnoringBatteryOptimizations(context)) { + try { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + context.getPackageName())); + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + openBatteryOptimizationSettings(context); + } + } + } + + public static boolean isIgnoringBatteryOptimizations(Context context) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + return pm.isIgnoringBatteryOptimizations(context.getPackageName()); + } + + public static String getManufacturer() { + return Build.MANUFACTURER; + } + + public static boolean needsAutoStartPermission() { + String manufacturer = Build.MANUFACTURER.toLowerCase(); + return manufacturer.contains("xiaomi") || manufacturer.contains("redmi") + || manufacturer.contains("oppo") || manufacturer.contains("realme") + || manufacturer.contains("vivo") || manufacturer.contains("huawei") + || manufacturer.contains("honor") || manufacturer.contains("oneplus"); + } + + public static String getAutoStartPermissionName() { + String manufacturer = Build.MANUFACTURER.toLowerCase(); + if (manufacturer.contains("xiaomi") || manufacturer.contains("redmi")) { + return "自启动"; + } else if (manufacturer.contains("oppo") || manufacturer.contains("realme")) { + return "自动启动"; + } else if (manufacturer.contains("vivo")) { + return "后台启动"; + } else if (manufacturer.contains("huawei") || manufacturer.contains("honor")) { + return "自动启动管理"; + } else if (manufacturer.contains("oneplus")) { + return "电池优化"; + } + return "电池/自启动"; + } + + private static boolean tryStartActivity(Context context, Intent intent, String pkg, String cls) { + try { + intent.setComponent(new ComponentName(pkg, cls)); + context.startActivity(intent); + return true; + } catch (ActivityNotFoundException e) { + return false; + } + } + + private static void openAppBatterySettings(Context context) { + try { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.parse("package:" + context.getPackageName())); + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Intent intent = new Intent(Settings.ACTION_SETTINGS); + context.startActivity(intent); + } + } +} \ No newline at end of file