fix: 修复重复请求拦截并优化定时关机

- 重复请求拦截器改为请求前去重并复用真实响应,避免服务端收到重复请求
- 新增 PowerOffAlarmUtils 通过 AlarmManager 精确调度定时关机
- 设备重启、服务启动、系统时间变更后重新安排关机闹钟
- 重写定时关机时间计算逻辑,修复循环关机跨天与时间格式问题
- 黑名单/白名单缓存过期时间改用默认值
- 运行应用列表排除 DX11、MX11 系统应用
- 版本号升级至 1.6.0915
This commit is contained in:
2026-09-16 17:01:31 +08:00
parent 8e5a620764
commit 35ef418786
10 changed files with 552 additions and 157 deletions

View File

@@ -98,6 +98,7 @@ import com.aoleyun.sn.utils.ForegroundAppUtil;
import com.aoleyun.sn.utils.JgyUtils;
import com.aoleyun.sn.utils.MD5Util;
import com.aoleyun.sn.utils.NetworkUtils;
import com.aoleyun.sn.utils.PowerOffAlarmUtils;
import com.aoleyun.sn.utils.SPUtils;
import com.aoleyun.sn.utils.SysSettingUtils;
import com.aoleyun.sn.utils.TimeUtils;
@@ -759,14 +760,12 @@ public class NetInterfaceManager {
public Observable<BaseResponse<BlacklistBean>> 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<BaseResponse<WhitelistBean>> 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<List<PoweroffBean>> listBaseResponse) {
Log.e("getPoweroffTime", "onNext: " + listBaseResponse);
if (listBaseResponse.isSuccess()) {
List<PoweroffBean> 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

View File

@@ -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
* 重复请求拦截器。
*
* <p>原实现是在 {@code chain.proceed()} 之后才判断是否为重复请求,此时请求早已发到服务器,
* 只能丢弃返回结果,所以服务端依然会收到两次(甚至多次)请求;并且记录在 finally 中立即删除,
* 去重窗口几乎为零,重复请求根本拦不住。</p>
*
* <p>优化后的策略:</p>
* <ul>
* <li>发请求之前先判断是否已经有相同请求:有则等待它的结果并复用,不再访问服务器;</li>
* <li>请求完成后在 {@link #REUSE_WINDOW_MS} 内保留结果,短时间内相同的请求直接复用结果;</li>
* <li>复用时使用真实的状态码、响应头和响应体,调用方无感知(原来的空 {@link Response} 会让
* 解析层直接抛异常);</li>
* <li>无响应体或响应体过大(超过 {@link #MAX_DEDUP_BODY_BYTES})的请求不参与去重,直接透传,
* 避免占用大量内存。</li>
* </ul>
*
* <p>v1.0 2022-07-15 16:16:52</p>
*/
public class RepeatRequestInterceptor implements Interceptor {
private static final String TAG = "RepeatRequestInterceptor";
private final ConcurrentHashMap<String, Long> 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<String, RepeatRecord> 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<String, RepeatRecord> 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 "-";
}
}
}

View File

@@ -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);

View File

@@ -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;
/**
* 定时关机广播接收器
* <p>
* 由 {@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();
}
}

View File

@@ -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<List<PoweroffBean>>() {
}.getType();
List<PoweroffBean> 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() {

View File

@@ -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);

View File

@@ -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;
/**
* 定时关机闹钟管理类
* <p>
* 采用官方推荐的 {@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<PoweroffBean> 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<PoweroffBean> getPowerOffTimeList() {
String poweroffTime = getMMKV().decodeString(KEY_POWEROFF_TIME, "");
Log.e(TAG, "getPowerOffTimeList: " + poweroffTime);
if (TextUtils.isEmpty(poweroffTime)) {
return null;
}
Type listType = new TypeToken<List<PoweroffBean>>() {
}.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);
}
}

View File

@@ -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;
}
}
/**
* 是否在管控时间内
*