diff --git a/app/build.gradle b/app/build.gradle index 813d8d4..2fd1430 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,8 +18,8 @@ android { defaultConfig { applicationId "com.aoleyun.sn" - versionCode 239 - versionName "1.6.0914" + versionCode 240 + versionName "1.6.0915" //There are no CERT files because If the mini sdk version is 23+, the AGP will ignore the V1 scheme signature. minSdkVersion 24 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 514ee4c..3cf1003 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -314,6 +314,15 @@ + + + + + + > getBlackListObservable() { return withCache(schedule(getSystemSettingApi().getBlackList(Utils.getSerial(mContext))), - UrlAddress.BLACK_LIST, - 360 * CACHE_EXPIRE_ONE_MINUTE); + UrlAddress.BLACK_LIST, DEFAULT_CACHE_EXPIRE); } public Observable> getWhiteListObservable() { return withCache(schedule(getSystemSettingApi().getWhiteList(Utils.getSerial(mContext))), - UrlAddress.WHITE_LIST, - 360 * CACHE_EXPIRE_ONE_MINUTE); + UrlAddress.WHITE_LIST, DEFAULT_CACHE_EXPIRE); } /** @@ -3610,13 +3609,13 @@ public class NetInterfaceManager { @Override public void onNext(BaseResponse> listBaseResponse) { Log.e("getPoweroffTime", "onNext: " + listBaseResponse); - if (listBaseResponse.isSuccess()) { - List poweroffBeanList = listBaseResponse.data; - String poweroffTime = GsonUtils.toJSONString(poweroffBeanList); - mMMKV.encode("poweroffTime", poweroffTime); + if (listBaseResponse.isSuccess() && null != listBaseResponse.data) { + PowerOffAlarmUtils.savePowerOffTime(GsonUtils.toJSONString(listBaseResponse.data)); } else { - mMMKV.encode("PoweroffTime", ""); + PowerOffAlarmUtils.savePowerOffTime(""); } + //关机策略更新后重新安排定时关机闹钟 + PowerOffAlarmUtils.schedule(mContext); } @Override diff --git a/app/src/main/java/com/aoleyun/sn/network/interceptor/RepeatRequestInterceptor.java b/app/src/main/java/com/aoleyun/sn/network/interceptor/RepeatRequestInterceptor.java index b8239a9..a0deeb1 100644 --- a/app/src/main/java/com/aoleyun/sn/network/interceptor/RepeatRequestInterceptor.java +++ b/app/src/main/java/com/aoleyun/sn/network/interceptor/RepeatRequestInterceptor.java @@ -8,9 +8,13 @@ import com.aoleyun.sn.utils.MD5Util; import org.jetbrains.annotations.NotNull; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import okhttp3.Headers; import okhttp3.Interceptor; +import okhttp3.MediaType; import okhttp3.Protocol; import okhttp3.Request; import okhttp3.RequestBody; @@ -19,93 +23,308 @@ import okhttp3.ResponseBody; import okio.Buffer; /** - * v1.0 2022-07-15 16:16:52 + * 重复请求拦截器。 + * + *

原实现是在 {@code chain.proceed()} 之后才判断是否为重复请求,此时请求早已发到服务器, + * 只能丢弃返回结果,所以服务端依然会收到两次(甚至多次)请求;并且记录在 finally 中立即删除, + * 去重窗口几乎为零,重复请求根本拦不住。

+ * + *

优化后的策略:

+ *
    + *
  • 发请求之前先判断是否已经有相同请求:有则等待它的结果并复用,不再访问服务器;
  • + *
  • 请求完成后在 {@link #REUSE_WINDOW_MS} 内保留结果,短时间内相同的请求直接复用结果;
  • + *
  • 复用时使用真实的状态码、响应头和响应体,调用方无感知(原来的空 {@link Response} 会让 + * 解析层直接抛异常);
  • + *
  • 无响应体或响应体过大(超过 {@link #MAX_DEDUP_BODY_BYTES})的请求不参与去重,直接透传, + * 避免占用大量内存。
  • + *
+ * + *

v1.0 2022-07-15 16:16:52

*/ public class RepeatRequestInterceptor implements Interceptor { private static final String TAG = "RepeatRequestInterceptor"; - private final ConcurrentHashMap requestIdsMap = new ConcurrentHashMap<>(); - public static final String REPEAT_REQUEST_PROTOCOL = "OKHTTP_REPEAT_REQUEST_PROTOCOL"; + /** + * 等待进行中请求返回结果的最长时间,超时后本次请求自己访问服务器。 + * 需要大于 OkHttpClient 的超时时间(当前为 5 秒)。 + */ + private static final long WAIT_IN_FLIGHT_TIMEOUT_MS = 10_000L; + + /** + * 请求完成后结果保留的时间。在该时间内收到相同请求,直接复用结果,不再访问服务器。 + * 需要小于业务侧的刷新间隔(ConnectMode 最小为 1 分钟)。 + */ + private static final long REUSE_WINDOW_MS = 500L; + + /** + * 超过该大小的响应体不参与去重(直接透传),避免内存占用 + */ + private static final long MAX_DEDUP_BODY_BYTES = 1024 * 1024L; + + /** + * 参与去重 key 计算的请求体最大字节数 + */ + private static final int MAX_BODY_BYTES_FOR_KEY = 8 * 1024; + + /** + * 日志中请求体、响应体的最大长度 + */ + private static final int MAX_LOG_BODY_LENGTH = 10240; + + private final ConcurrentHashMap recordMap = new ConcurrentHashMap<>(); @NotNull @Override public Response intercept(@NotNull Chain chain) throws IOException { Request request = chain.request(); - Response response = chain.proceed(request); - ResponseBody responseBody = response.body(); + String bodyText = requestBodyToString(request.body()); + String requestKey = buildRequestKey(request, bodyText); - //responseBody.string()消费了请求,就会导致多次请求。 - String content = responseBody.string(); -// Response copy = response.newBuilder().body(responseBody).build(); - ResponseBody copy = ResponseBody.create(responseBody.contentType(), content); - if (BuildConfig.DEBUG) { - if (content.length() < 10240) { - Log.e(TAG, "请求体返回:| Response: " + request.url() + "\t body: " + content); - }else { - Log.e(TAG, "请求体返回:| Response: " + request.url() + "\t body: too long" ); - } + clearExpiredRecords(); + + RepeatRecord record = acquireRecord(requestKey); + if (record == null) { + // 已有相同请求,但没有拿到可用结果(上一次失败、超时或结果过大),本次请求直接访问服务器 + return chain.proceed(request); } - //相同的请求 - String requestKey = MD5Util.getUpperMD5Str(request.method() + request.url().toString() + requestBodyToString(request.body())); - long time = System.currentTimeMillis();//请求时间 + if (!record.isOwner()) { + // 复用相同请求的结果:服务端只会收到一次请求 + log("复用请求:", requestKey, request, bodyText); + return record.newResponse(request); + } + + log("注册请求:", requestKey, request, bodyText); try { - if (requestIdsMap.size() > 0 && requestIdsMap.containsKey(requestKey)) { - log("重复请求:", requestKey, request); - //下面这行写了不会抛出onerror -// chain.call().cancel(); - return new Response.Builder() - .protocol(Protocol.get(REPEAT_REQUEST_PROTOCOL)) - .request(request) //multi thread - .build(); + Response response = chain.proceed(request); + ResponseBody responseBody = response.body(); + if (responseBody == null || responseBody.contentLength() > MAX_DEDUP_BODY_BYTES) { + // 无响应体或响应体过大:不消费 body,也不参与去重,直接透传 + logResponse(request, null); + return response; } - requestIdsMap.put(requestKey, time); - log("注册请求:", requestKey, request); -// RepeatRequestInterceptor.Builder builder = request.newBuilder(); -// builder.addHeader("header", jsonObject.toString()); - return response.newBuilder().body(copy).build(); + + // body 只能被消费一次,读出来之后重建一个新的 body 返回给调用方 + MediaType contentType = responseBody.contentType(); + byte[] bytes = responseBody.bytes(); + logResponse(request, bytes); + if (bytes.length <= MAX_DEDUP_BODY_BYTES) { + // 缓存结果,供等待中的、以及短时间内后续的相同请求复用 + record.complete(response, bytes, contentType); + } + return response.newBuilder().body(ResponseBody.create(contentType, bytes)).build(); } catch (IOException e) { Log.e(TAG, "intercept: " + e.getMessage()); throw e; } finally { - if (requestIdsMap.containsKey(requestKey) && requestIdsMap.containsValue(time)) {//请求任务完成删除map中的数据 - requestIdsMap.remove(requestKey); - log("移除请求:", requestKey, request); + // 兜底:无论成功、失败还是异常,都结束记录,避免等待中的相同请求被一直阻塞 + record.finish(); + } + } + + /** + * 获取请求对应的去重记录。 + * + * @return 返回记录表示本次请求要么成为 owner 去访问服务器、要么直接复用已有结果; + * 返回 null 表示已有相同请求但结果不可用,本次请求自己访问服务器(不参与去重) + */ + private RepeatRecord acquireRecord(String requestKey) { + while (true) { + long now = System.currentTimeMillis(); + RepeatRecord record = recordMap.get(requestKey); + if (record == null) { + RepeatRecord created = new RepeatRecord(); + if (recordMap.putIfAbsent(requestKey, created) == null) { + created.markOwner(); + return created; + } + continue;// 其他线程抢先注册,重新判断 + } + if (record.isExpired(now)) { + // 结果已过期,替换为本次请求的记录并成为 owner + RepeatRecord created = new RepeatRecord(); + if (recordMap.replace(requestKey, record, created)) { + created.markOwner(); + return created; + } + continue; + } + // 已有相同请求:等待它的结果,拿到结果直接复用 + if (record.await(WAIT_IN_FLIGHT_TIMEOUT_MS)) { + return record; + } + return null; + } + } + + /** + * 清理过期记录,释放其缓存的响应体 + */ + private void clearExpiredRecords() { + if (recordMap.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + for (Map.Entry entry : recordMap.entrySet()) { + RepeatRecord record = entry.getValue(); + if (record.isExpired(now)) { + recordMap.remove(entry.getKey(), record); } } } - private void log(String action, String requestKey, Request request) { + private static String buildRequestKey(Request request, String bodyText) { + return MD5Util.getUpperMD5Str(request.method() + "|" + request.url() + "|" + bodyText); + } + + /** + * 请求体转字符串,用于计算去重 key 和输出日志。 + * 只读取可重复读写的请求体,避免把一次性(one-shot)请求体读空。 + */ + private static String requestBodyToString(RequestBody body) { + if (body == null || body.isOneShot()) { + return ""; + } + try { + Buffer buffer = new Buffer(); + body.writeTo(buffer); + long size = buffer.size(); + if (size > MAX_BODY_BYTES_FOR_KEY) { + // 请求体过大时只取前一部分参与去重,避免不必要的内存开销 + return size + ":" + buffer.readByteString(MAX_BODY_BYTES_FOR_KEY).hex(); + } + return buffer.readUtf8(); + } catch (Exception e) { + return ""; + } + } + + private static void log(String action, String requestKey, Request request, String bodyText) { if (BuildConfig.DEBUG) { - Log.e("REPEAT-REQUEST", action + requestKey + " Method @" + request.method() + " --- " + " URL = " + request.url().encodedPath() + "\t" + bodyToString(request)); + String body = bodyText.length() > MAX_LOG_BODY_LENGTH ? "-too long" : bodyText; + Log.e("REPEAT-REQUEST", action + requestKey + " Method @" + request.method() + + " --- " + " URL = " + request.url().encodedPath() + "\t" + body); } else { Log.e("REPEAT-REQUEST", action + requestKey + " Method @" + request.method()); } } - private static String bodyToString(final Request request) { - try { - final Request copy = request.newBuilder().build(); - final Buffer buffer = new Buffer(); - copy.body().writeTo(buffer); - if (buffer.size() > 10240) { - return "-too long"; - } - return buffer.readUtf8(); - } catch (Exception e) { - return "-"; + private static void logResponse(Request request, byte[] bytes) { + if (!BuildConfig.DEBUG) { + return; + } + if (bytes == null || bytes.length > MAX_LOG_BODY_LENGTH) { + Log.e(TAG, "请求体返回:| Response: " + request.url() + "\t body: too long"); + } else { + Log.e(TAG, "请求体返回:| Response: " + request.url() + + "\t body: " + new String(bytes, StandardCharsets.UTF_8)); } } - private static String requestBodyToString(RequestBody body) { - try { - final Buffer buffer = new Buffer(); - body.writeTo(buffer); - if (buffer.size() > 10240) { - return "-too long"; + /** + * 一次请求的去重记录:owner 线程负责访问服务器,其他相同请求等待并复用它的结果 + */ + private static final class RepeatRecord { + private final Object lock = new Object(); + private final long createdTime = System.currentTimeMillis(); + + private boolean owner; + private boolean finished; + private boolean succeed; + private long finishedTime; + private byte[] body; + private MediaType contentType; + private Headers headers; + private Protocol protocol; + private int code; + private String message; + + void markOwner() { + owner = true; + } + + boolean isOwner() { + return owner; + } + + /** + * 等待 owner 请求结束 + * + * @return true 表示已拿到可复用的结果 + */ + boolean await(long timeoutMs) { + synchronized (lock) { + if (!finished) { + try { + lock.wait(timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return finished && succeed; + } + } + + /** + * 请求成功,缓存结果并唤醒等待的请求 + */ + void complete(Response response, byte[] body, MediaType contentType) { + synchronized (lock) { + if (finished) { + return; + } + this.body = body; + this.contentType = contentType; + this.headers = response.headers(); + this.protocol = response.protocol(); + this.code = response.code(); + this.message = response.message(); + this.succeed = true; + finished = true; + finishedTime = System.currentTimeMillis(); + lock.notifyAll(); + } + } + + /** + * 请求结束(失败或异常时没有可复用的结果);重复调用忽略 + */ + void finish() { + synchronized (lock) { + if (finished) { + return; + } + finished = true; + finishedTime = System.currentTimeMillis(); + lock.notifyAll(); + } + } + + /** + * 进行中的记录超过等待超时时间、已完成的记录超过结果保留时间即视为过期 + */ + boolean isExpired(long now) { + synchronized (lock) { + if (!finished) { + return now - createdTime > WAIT_IN_FLIGHT_TIMEOUT_MS; + } + return now - finishedTime > REUSE_WINDOW_MS; + } + } + + /** + * 用缓存的结果构建响应,状态码、响应头、响应体都是真实数据 + */ + Response newResponse(Request request) { + synchronized (lock) { + return new Response.Builder() + .request(request) + .protocol(protocol) + .code(code) + .message(message) + .headers(headers) + .body(ResponseBody.create(contentType, body)) + .build(); } - return buffer.readUtf8(); - } catch (Exception e) { - return "-"; } } } diff --git a/app/src/main/java/com/aoleyun/sn/receiver/BootReceiver.java b/app/src/main/java/com/aoleyun/sn/receiver/BootReceiver.java index 8b60bf1..7a9f86a 100644 --- a/app/src/main/java/com/aoleyun/sn/receiver/BootReceiver.java +++ b/app/src/main/java/com/aoleyun/sn/receiver/BootReceiver.java @@ -10,6 +10,7 @@ import com.aoleyun.sn.manager.ConnectManager; import com.aoleyun.sn.rlog.LogDBManager; import com.aoleyun.sn.utils.BootManager; import com.aoleyun.sn.utils.JgyUtils; +import com.aoleyun.sn.utils.PowerOffAlarmUtils; import com.aoleyun.sn.utils.SPUtils; import com.aoleyun.sn.utils.TimeUtils; import com.tencent.mmkv.MMKV; @@ -77,6 +78,8 @@ public class BootReceiver extends BroadcastReceiver { case Intent.ACTION_BOOT_COMPLETED: mMMKV.encode(CommonConfig.DEVICES_REBOOT, true); mMMKV.encode(ConnectManager.REBOOT_LAST_ONNECT_TIME, System.currentTimeMillis()); + //设备重启后 AlarmManager 中的闹钟会被系统清空,重新安排定时关机 + PowerOffAlarmUtils.schedule(context); if ((int) SPUtils.get(context, CommonConfig.FIRST_STARTUP, 0) == 0) { LogDBManager.getInstance().creatRebootLog("首次启动", TimeUtils.transferLongToDate(System.currentTimeMillis())); SPUtils.put(context, CommonConfig.FIRST_STARTUP, 1); diff --git a/app/src/main/java/com/aoleyun/sn/receiver/PowerOffReceiver.java b/app/src/main/java/com/aoleyun/sn/receiver/PowerOffReceiver.java new file mode 100644 index 0000000..5d7ce8c --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/receiver/PowerOffReceiver.java @@ -0,0 +1,34 @@ +package com.aoleyun.sn.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.aoleyun.sn.utils.JgyUtils; +import com.aoleyun.sn.utils.PowerOffAlarmUtils; + +/** + * 定时关机广播接收器 + *

+ * 由 {@link PowerOffAlarmUtils} 通过 AlarmManager 的精确闹钟触发。 + * 使用 manifest 静态注册,应用进程被杀掉后依然能被系统拉起并执行关机。 + * + * @author aoleyun + */ +public class PowerOffReceiver extends BroadcastReceiver { + private static final String TAG = "PowerOffReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + String action = null == intent ? null : intent.getAction(); + Log.e(TAG, "onReceive: " + action); + if (!PowerOffAlarmUtils.ACTION_POWER_OFF_ALARM.equals(action)) { + return; + } + //先安排下一次(循环定时关机),再关机,避免关机过程中闹钟安排失败 + PowerOffAlarmUtils.schedule(context); + Log.e(TAG, "onReceive: 设备关机"); + JgyUtils.getInstance().shutdown(); + } +} diff --git a/app/src/main/java/com/aoleyun/sn/service/main/MainService.java b/app/src/main/java/com/aoleyun/sn/service/main/MainService.java index 0825928..2e61138 100644 --- a/app/src/main/java/com/aoleyun/sn/service/main/MainService.java +++ b/app/src/main/java/com/aoleyun/sn/service/main/MainService.java @@ -41,7 +41,6 @@ import com.aoleyun.sn.BuildConfig; import com.aoleyun.sn.R; import com.aoleyun.sn.activity.main.MainActivity; import com.aoleyun.sn.base.BaseService; -import com.aoleyun.sn.bean.PoweroffBean; import com.aoleyun.sn.bean.ScreenLockState; import com.aoleyun.sn.comm.CommonConfig; import com.aoleyun.sn.comm.JGYActions; @@ -56,24 +55,21 @@ import com.aoleyun.sn.utils.ApkUtils; import com.aoleyun.sn.utils.CacheUtils; import com.aoleyun.sn.utils.ForegroundAppUtil; import com.aoleyun.sn.utils.JgyUtils; +import com.aoleyun.sn.utils.PowerOffAlarmUtils; import com.aoleyun.sn.utils.SPUtils; import com.aoleyun.sn.utils.SysSettingUtils; import com.aoleyun.sn.utils.TimeUtils; import com.aoleyun.sn.utils.Utils; import com.arialyy.aria.core.Aria; import com.blankj.utilcode.util.NetworkUtils; -import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; import com.hjq.toast.Toaster; import com.tencent.mmkv.MMKV; -import java.lang.reflect.Type; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; import java.util.HashSet; -import java.util.List; import java.util.concurrent.TimeUnit; import io.reactivex.rxjava3.core.Observable; @@ -127,6 +123,8 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt super.onPostExecute(aLong); AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE); am.setTime(aLong); + //系统时间被修改后重新安排定时关机闹钟 + PowerOffAlarmUtils.schedule(MainService.this); new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { @Override public void run() { @@ -444,6 +442,8 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt } SPUtils.put(this, CommonConfig.SN_VERSIONCODE, BuildConfig.VERSION_CODE); + //根据本地保存的策略安排定时关机闹钟(服务重启、设备重启后重新计算) + PowerOffAlarmUtils.schedule(this); registerReceivers(); NetworkUtils.registerNetworkStatusChangedListener(this); timeChangedStart.onstar(System.currentTimeMillis()); @@ -1035,7 +1035,8 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt case Intent.ACTION_TIME_CHANGED: case Intent.ACTION_TIMEZONE_CHANGED: case Intent.ACTION_TIME_TICK: - checkShutdownTime(); + //时间被修改(手动修改/NTP 校时)后重新安排定时关机闹钟 + PowerOffAlarmUtils.schedule(MainService.this); checkUploadLogTime(); setFloatingWindow(); JgyUtils.getInstance().checkTimePeriod(); @@ -1049,29 +1050,6 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt } } - private void checkShutdownTime() { - String poweroffTime = mMMKV.decodeString("poweroffTime", ""); - Log.e("checkShutdownTime", "poweroffTime: " + poweroffTime); - Gson gson = new Gson(); - Type listType = new TypeToken>() { - }.getType(); - List poweroffBeanList = gson.fromJson(poweroffTime, listType); - if (poweroffBeanList != null && poweroffBeanList.size() != 0) { - boolean inTime = false; - for (PoweroffBean poweroffBean : poweroffBeanList) { - if (TimeUtils.isShutdownTime(poweroffBean)) { - inTime = true; - } - } - if (inTime) { - JgyUtils.getInstance().shutdown(); - Log.e(TAG, "checkShutdownTime: " + "设备关机"); - } else { - Log.e(TAG, "checkShutdownTime: " + "没有关机事件"); - } - } - } - private DisplayReceiver mDisplayReceiver; public void registerDisplayReceiver() { diff --git a/app/src/main/java/com/aoleyun/sn/utils/ApkUtils.java b/app/src/main/java/com/aoleyun/sn/utils/ApkUtils.java index 026b73f..2758178 100644 --- a/app/src/main/java/com/aoleyun/sn/utils/ApkUtils.java +++ b/app/src/main/java/com/aoleyun/sn/utils/ApkUtils.java @@ -1447,7 +1447,7 @@ public class ApkUtils { if (factoryapp.contains(packageName) && !G30_APP.contains(packageName)) { continue; } - //排除所有系统应用,不显示 + //排除系统应用,不显示 if (isSystemApp(context, packageName)) { if (!AoleyunOSApp.contains(packageName) && !aoleyunAPP.contains(packageName) @@ -1456,6 +1456,8 @@ public class ApkUtils { && !yixuepaiApp.contains(packageName) && !mJxwApp.contains(packageName) && !G30_APP.contains(packageName) + && !DX11App.contains(packageName) + && !MX11App.contains(packageName) && !allPkgSet.contains(packageName) ) { Log.e(TAG, "getRunningAppInfo system: continue = " + packageName); diff --git a/app/src/main/java/com/aoleyun/sn/utils/PowerOffAlarmUtils.java b/app/src/main/java/com/aoleyun/sn/utils/PowerOffAlarmUtils.java new file mode 100644 index 0000000..c0386fa --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/utils/PowerOffAlarmUtils.java @@ -0,0 +1,144 @@ +package com.aoleyun.sn.utils; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; + +import com.aoleyun.sn.bean.PoweroffBean; +import com.aoleyun.sn.comm.CommonConfig; +import com.aoleyun.sn.receiver.PowerOffReceiver; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.tencent.mmkv.MMKV; + +import java.lang.reflect.Type; +import java.util.List; + +/** + * 定时关机闹钟管理类 + *

+ * 采用官方推荐的 {@link AlarmManager} 精确闹钟(RTC_WAKEUP + setExactAndAllowWhileIdle)实现定时关机, + * 只在需要的时间点唤醒系统,而不是靠 ACTION_TIME_TICK 每分钟轮询: + * 1. 设备休眠(Doze)时依然能准时触发; + * 2. 系统时间被修改(手动修改时间、NTP 校时)后重新计算并安排; + * 3. 设备重启后由 {@link com.aoleyun.sn.receiver.BootReceiver} 重新拉起服务并重新安排; + * 4. 服务器下发新的关机策略后重新安排。 + * + * @author aoleyun + */ +public final class PowerOffAlarmUtils { + + private static final String TAG = "PowerOffAlarmUtils"; + + /** + * 服务器下发的定时关机策略在 MMKV 中的存储 key + */ + public static final String KEY_POWEROFF_TIME = "poweroffTime"; + + /** + * 定时关机闹钟触发时发送的广播 + */ + public static final String ACTION_POWER_OFF_ALARM = "com.aoleyun.sn.action.POWER_OFF_ALARM"; + + /** + * 闹钟的唯一标识,用于取消/覆盖已有的闹钟 + */ + private static final int REQUEST_CODE = 1001; + + private PowerOffAlarmUtils() { + } + + /** + * 保存服务器下发的定时关机策略,key 与 {@link #schedule(Context)} 保持一致 + */ + public static void savePowerOffTime(String poweroffTime) { + String value = TextUtils.isEmpty(poweroffTime) ? "" : poweroffTime; + Log.e(TAG, "savePowerOffTime: " + value); + getMMKV().encode(KEY_POWEROFF_TIME, value); + } + + /** + * 根据本地保存的关机策略安排下一次定时关机闹钟,没有可用策略时会取消已有的闹钟。 + * 该方法可以重复调用,用于时间被修改、设备重启、策略更新等场景下的重新安排。 + */ + public static void schedule(Context context) { + if (null == context) { + return; + } + Context appContext = context.getApplicationContext(); + if (null == appContext) { + return; + } + AlarmManager alarmManager = (AlarmManager) appContext.getSystemService(Context.ALARM_SERVICE); + if (null == alarmManager) { + Log.e(TAG, "schedule: AlarmManager is null"); + return; + } + + PendingIntent pendingIntent = buildPendingIntent(appContext); + long triggerAtMillis = getNextPowerOffTime(); + if (triggerAtMillis <= System.currentTimeMillis()) { + alarmManager.cancel(pendingIntent); + Log.e(TAG, "schedule: 没有可用的定时关机策略,已取消闹钟"); + return; + } + + try { + // RTC_WAKEUP 使用系统墙钟时间,设备休眠时同样会唤醒并触发 + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); + Log.e(TAG, "schedule: 下一次定时关机时间 = " + TimeUtils.transferLongToDate(triggerAtMillis)); + } catch (SecurityException e) { + // 精确闹钟权限不可用时降级为非精确闹钟,保证关机策略依然生效 + Log.e(TAG, "schedule: 精确闹钟不可用,降级为非精确闹钟: " + e.getMessage()); + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); + } + } + + /** + * @return 所有关机策略中最近的一次触发时间戳,没有可用策略时返回 0 + */ + public static long getNextPowerOffTime() { + List poweroffBeanList = getPowerOffTimeList(); + if (null == poweroffBeanList || poweroffBeanList.isEmpty()) { + return 0; + } + long nowTime = System.currentTimeMillis(); + long nextTime = Long.MAX_VALUE; + for (PoweroffBean poweroffBean : poweroffBeanList) { + long shutdownTime = TimeUtils.getNextShutdownTime(poweroffBean); + if (shutdownTime > nowTime && shutdownTime < nextTime) { + nextTime = shutdownTime; + } + } + return nextTime == Long.MAX_VALUE ? 0 : nextTime; + } + + public static List getPowerOffTimeList() { + String poweroffTime = getMMKV().decodeString(KEY_POWEROFF_TIME, ""); + Log.e(TAG, "getPowerOffTimeList: " + poweroffTime); + if (TextUtils.isEmpty(poweroffTime)) { + return null; + } + Type listType = new TypeToken>() { + }.getType(); + return new Gson().fromJson(poweroffTime, listType); + } + + private static PendingIntent buildPendingIntent(Context context) { + Intent intent = new Intent(context, PowerOffReceiver.class); + intent.setAction(ACTION_POWER_OFF_ALARM); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + return PendingIntent.getBroadcast(context, REQUEST_CODE, intent, flags); + } + + private static MMKV getMMKV() { + return MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE); + } +} diff --git a/app/src/main/java/com/aoleyun/sn/utils/TimeUtils.java b/app/src/main/java/com/aoleyun/sn/utils/TimeUtils.java index beb588e..ea9ef49 100644 --- a/app/src/main/java/com/aoleyun/sn/utils/TimeUtils.java +++ b/app/src/main/java/com/aoleyun/sn/utils/TimeUtils.java @@ -2,15 +2,13 @@ package com.aoleyun.sn.utils; import android.content.Context; import android.content.Intent; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - import android.os.Build; import android.os.SystemClock; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import com.aoleyun.sn.bean.PoweroffBean; import com.aoleyun.sn.service.main.MainService; @@ -24,6 +22,7 @@ import java.text.SimpleDateFormat; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.Calendar; import java.util.Date; /** @@ -51,26 +50,75 @@ public class TimeUtils { // // } - public static boolean isShutdownTime(PoweroffBean poweroffBean) { - int type = poweroffBean.getType(); - if (type == 1) { //单次定时关机 - return isShutdownTime(poweroffBean.getTime()); - } else if (type == 2) {//循环定时关机 - return isShutdownTime(getZeroTiemstamp() + getLoopTime(poweroffBean.getTime())); + /** + * 单次定时关机 + */ + public static final int TYPE_SINGLE = 1; + /** + * 循环定时关机(每天重复) + */ + public static final int TYPE_LOOP = 2; + + /** + * 计算定时关机的下一次触发时间 + * + * @param poweroffBean 服务器下发的关机策略 + * @return 下一次触发的时间戳,策略无效时返回 -1 + */ + public static long getNextShutdownTime(PoweroffBean poweroffBean) { + if (null == poweroffBean || TextUtils.isEmpty(poweroffBean.getTime())) { + return -1; } - return false; + int type = poweroffBean.getType(); + if (type == TYPE_SINGLE) { + return getSingleShutdownTime(poweroffBean.getTime()); + } else if (type == TYPE_LOOP) { + return getLoopShutdownTime(poweroffBean.getTime()); + } + return -1; } /** - * @return 获取今日0点时间戳 - * 没有管时区问题 + * 单次定时关机,时间格式:yyyy-MM-dd HH:mm:ss */ - private static long getZeroTiemstamp() { - long nowTime = System.currentTimeMillis(); - long dayMillisecond = 60 * 60 * 24 * 1000; - long zeroTime = ((nowTime) / dayMillisecond) * dayMillisecond; - Log.e(TAG, "getZeroTiemstamp: " + zeroTime); - return zeroTime; + private static long getSingleShutdownTime(String time) { + //"2021-11-18 00:00:00" + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + try { + Date date = sdf.parse(time.trim()); + return null == date ? -1 : date.getTime(); + } catch (ParseException e) { + Log.e(TAG, "getSingleShutdownTime: 时间格式错误: " + time); + return -1; + } + } + + /** + * 循环定时关机,时间格式:HH:mm:ss,每天重复 + * 今天的时间点已经过去则顺延到明天 + */ + private static long getLoopShutdownTime(String time) { + //"15:34:39" + String[] times = time.trim().split(":"); + if (times.length < 2) { + Log.e(TAG, "getLoopShutdownTime: 时间格式错误: " + time); + return -1; + } + try { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(times[0].trim())); + calendar.set(Calendar.MINUTE, Integer.parseInt(times[1].trim())); + calendar.set(Calendar.SECOND, times.length > 2 ? Integer.parseInt(times[2].trim()) : 0); + calendar.set(Calendar.MILLISECOND, 0); + if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { + calendar.add(Calendar.DAY_OF_YEAR, 1); + } + Log.e(TAG, "getLoopShutdownTime: " + transferLongToDate(calendar.getTimeInMillis())); + return calendar.getTimeInMillis(); + } catch (NumberFormatException e) { + Log.e(TAG, "getLoopShutdownTime: 时间格式错误: " + time); + return -1; + } } public static String getPhotoDate() { @@ -102,47 +150,6 @@ public class TimeUtils { return sdf.format(date); } - private static long getLoopTime(String timestamp) { - //"15:34:39" - SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); - try { - Date d = sdf.parse(timestamp); - long loopTime = d.getTime(); - Log.e(TAG, "getLoopTime: LoopTime = " + loopTime); - return loopTime; - } catch (ParseException e) { - Log.e(TAG, "getLoopTime: e: " + e.getStackTrace()); - return 0; - } - } - - public static boolean isShutdownTime(String timestamp) { - //"2021-11-18 00:00:00" - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - try { - Date d = sdf.parse(timestamp); - long realTime = d.getTime(); - return isShutdownTime(realTime); - } catch (ParseException e) { - Log.e(TAG, "isShutdownTime: " + e.getStackTrace()); - return false; - } - } - - public static boolean isShutdownTime(long timestamp) { - long realTime = timestamp; - long nowTime = System.currentTimeMillis(); - if (String.valueOf(timestamp).length() < String.valueOf(nowTime).length()) { - realTime = timestamp * 1000; - } - if (realTime >= nowTime && realTime <= (nowTime + 1000 * 60)) { - return true; - } else { - return false; - } - } - - /** * 是否在管控时间内 *