+ * 绑定请求弹窗(contentType = 20)中勾选「不再接收该手机的请求」后记录,
+ * 之后该手机号发起的绑定请求将被设备端直接忽略,不再弹窗。
+ */
+ public static final String KEY_BIND_REJECT_PHONES = "bind_reject_phones_key";
+
// ===================== 设备策略开关(由服务端策略推送 contentType=19 下发) =====================
/** USB偏好设置(1开 0关) */
public static final String KEY_POLICY_USB_DATA = "policy_usb_data_key";
@@ -94,7 +105,7 @@ public class CommonConfig {
/** WiFi热点开关(1开 0关) */
public static final String KEY_POLICY_WIFI_HOTSPOT = "policy_wifi_hotspot_key";
/** 蓝牙开关(1开 0关) */
- public static final String KEY_POLICY_BLUETOOTH_SWITCH = "policy_bluetooth_switch_key";
+ public static final String KEY_POLICY_BLUETOOTH = "policy_bluetooth_key";
/** 壁纸功能(1开 0关) */
public static final String KEY_POLICY_WALLPAPER = "policy_wallpaper_key";
/** 通知栏显示(1开 0关) */
@@ -108,11 +119,11 @@ public class CommonConfig {
/** 导航条选项(1开 0关) */
public static final String KEY_POLICY_NAV_BAR_OPTIONS = "policy_nav_bar_options_key";
/** 蓝牙功能开关(1开 0关) */
- public static final String KEY_POLICY_BLUETOOTH_FUNCTION = "policy_bluetooth_function_key";
+ public static final String KEY_POLICY_CONFIG_BLUETOOTH = "policy_config_bluetooth_key";
/** OTA升级管控(1开 0关) */
public static final String KEY_POLICY_OTA_UPGRADE = "policy_ota_upgrade_key";
/** 应用安装开关(1开 0关) */
- public static final String KEY_POLICY_APP_INSTALL = "policy_app_install_key";
+ public static final String KEY_POLICY_INSTALL_APPS = "policy_install_apps_key";
/** 自动旋转(1开 0关) */
public static final String KEY_POLICY_AUTO_ROTATE = "policy_auto_rotate_key";
/** 自动亮度(1开 0关) */
@@ -121,5 +132,39 @@ public class CommonConfig {
public static final String KEY_POLICY_EYE_PROTECTION = "policy_eye_protection_key";
/** 深色模式(1开 0关) */
public static final String KEY_POLICY_DARK_MODE = "policy_dark_mode_key";
+ /** 允许进入安全模式(1开 0关) */
+ public static final String KEY_POLICY_SAFE_BOOT = "policy_safe_boot_key";
+ /** 开启ADB/USB调试(1开 0关) */
+ public static final String KEY_POLICY_ADB_USB_DEBUG = "policy_adb_usb_debug_key";
+ /** 挂载外部存储(SD卡/U盘)(1开 0关) */
+ public static final String KEY_POLICY_MOUNT_PHYSICAL_STORAGE = "policy_mount_physical_storage_key";
+ /** 修改系统时间与时区(1开 0关) */
+ public static final String KEY_POLICY_MODIFY_SYSTEM_TIME_ZONE = "policy_modify_system_time_zone_key";
+ /** 切换飞行模式(1开 0关) */
+ public static final String KEY_POLICY_TOGGLE_AIRPLANE_MODE = "policy_toggle_airplane_mode_key";
+ /** 修改移动网络/APN(1开 0关) */
+ public static final String KEY_POLICY_MODIFY_MOBILE_APN = "policy_modify_mobile_apn_key";
+ /** 卸载应用(1开 0关) */
+ public static final String KEY_POLICY_UNINSTALL_APPS = "policy_uninstall_apps_key";
+ /** 摄像头(1开 0关) */
+ public static final String KEY_POLICY_CAMERA = "policy_camera_key";
+ /** USB文件传输(MTP)(1开 0关) */
+ public static final String KEY_POLICY_USB_FILE_TRANSFER = "policy_usb_file_transfer_key";
+ /** 修改定位开关(1开 0关) */
+ public static final String KEY_POLICY_MODIFY_LOCATION_SWITCH = "policy_modify_location_switch_key";
+ /** 蓝牙对外分享(1开 0关) */
+ public static final String KEY_POLICY_BLUETOOTH_SHARING = "policy_bluetooth_sharing_key";
+ /** 重置网络设置(1开 0关) */
+ public static final String KEY_POLICY_RESET_NETWORK_SETTINGS = "policy_reset_network_settings_key";
+ /** 调节全局音量(1开 0关) */
+ public static final String KEY_POLICY_ADJUST_GLOBAL_VOLUME = "policy_adjust_global_volume_key";
+ /** 修改屏幕超时休眠时间(1开 0关) */
+ public static final String KEY_POLICY_MODIFY_SCREEN_TIMEOUT = "policy_modify_screen_timeout_key";
+ /** 手动调整系统时间与时区(1开 0关) */
+ public static final String KEY_POLICY_MANUAL_ADJUST_TIME_ZONE = "policy_manual_adjust_time_zone_key";
+ /** 修改系统语言与区域设置(1开 0关) */
+ public static final String KEY_POLICY_MODIFY_SYSTEM_LANGUAGE = "policy_modify_system_language_key";
+ /** 手动调整系统亮度与自动亮度开关(1开 0关) */
+ public static final String KEY_POLICY_MANUAL_ADJUST_BRIGHTNESS = "policy_manual_adjust_brightness_key";
}
diff --git a/app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java b/app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java
index a6eaf89..c498912 100644
--- a/app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java
+++ b/app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java
@@ -4,7 +4,9 @@ import androidx.annotation.NonNull;
import com.ttstd.dialer.bean.BaseResponse;
import com.ttstd.dialer.bean.DeveloperOptions;
+import com.ttstd.dialer.bean.WallpaperInfo;
import com.ttstd.dialer.bean.req.SnHardwareInfoReq;
+import com.ttstd.dialer.db.alarm.AlarmInfo;
import com.ttstd.dialer.bean.req.SnLocationReq;
import com.ttstd.dialer.bean.req.SnNetworkInfoReq;
import com.ttstd.dialer.bean.req.SnOtherInfoReq;
@@ -39,11 +41,46 @@ public class SnRemoteDataSource {
}
/**
- * 设备注册(sn + model)
+ * 获取设备壁纸配置
*/
@NonNull
- public Observable
+ * 设备端同意家属绑定请求后调用。设备首次绑定前可能尚未注册(服务端无公钥),
+ * 故本接口采用 PoP:生成设备公钥并对 {@code sn:ts:nonce} 签名,
+ * 服务端验签后自动完成「注册设备 + 绑定手机」两步。
+ *
+ * @param phone 同意绑定的家属手机号
*/
@NonNull
- public Observable 私钥由 AndroidKeyStore 生成并保管,不可导出,签名运算在系统级安全硬件内完成,
+ * 即使设备被 root 也无法提取私钥,符合「零信任设备」目标。 对外能力: 算法契约(与服务端 {@code MobileApiSignatureFilter} 完全一致): 保留旧的无密钥 SHA-256 签名(用于 WebSocket 信令等历史路径),
+ * 新增 ECDSA P-256 设备签名(用于 {@code /api/v1/sn/**} 接口,签名原文
+ * {@code METHOD|path|bodyHash|ts|nonce})。
+ * 家属端发起绑定请求、设备端同意后调用。设备首次绑定前可能尚未注册,
+ * 该接口与注册接口一致采用 PoP 验签:携带公钥 + challenge(sn:ts:nonce) + ECDSA 签名,
+ * 服务端验签后自动完成「注册设备 + 绑定手机」两步。
+ */
@FormUrlEncoded
- @POST(UrlConstants.SN_REGISTER)
- Observable 为 {@code /api/v1/sn/**} 设备接口自动注入签名头: 签名原文 canonical 与服务端 {@code MobileApiSignatureFilter} 完全一致,
+ * 用设备 AndroidKeyStore 私钥(ECDSA P-256)签名,服务端按 SN 查公钥验签。
+ * 请求体参与签名(bodyHash),堵住明文篡改 body。 注册接口({@code /register})因服务端尚无该设备公钥,由服务端走 PoP 验签,
+ * 本拦截器对注册请求跳过设备签名头,避免服务端因查不到公钥而误拒。
+ * 仅当距上次成功同步超过 {@link #SYNC_INTERVAL_MILLIS}(或从未同步过)时,
+ * 通过 {@link LiveEventBus} 发送一次同步请求事件,通知 {@code MainServiceModel.syncSystemTime()}
+ * 在子线程异步执行 NTP 同步,不阻塞本请求线程。
+ *
+ * 注意:此处无法感知同步成功与否(同步在 MainServiceModel 中异步执行),
+ * 因此只在触发事件时记录时间,避免请求频繁重复发送事件。
+ * 使用 synchronized 防止并发请求重复触发。
+ */
+ private static void notifyTimeSyncIfNeeded() {
+ if (System.currentTimeMillis() - lastSyncTimeMillis < SYNC_INTERVAL_MILLIS) {
+ return;
+ }
+ synchronized (AuthInterceptor.class) {
+ // 双重检查:可能其他线程刚发送过事件
+ if (System.currentTimeMillis() - lastSyncTimeMillis < SYNC_INTERVAL_MILLIS) {
+ return;
+ }
+ lastSyncTimeMillis = System.currentTimeMillis();
+ Logger.i("AuthInterceptor", "notifyTimeSyncIfNeeded: post NTP sync request event");
+ LiveEventBus.get(CommonConfig.NTP_SYNC_REQUEST_KEY).post(null);
+ }
+ }
+
+ private boolean isRegisterPath(String path) {
+ return REGISTER_PATH.equals(path);
+ }
+
+ /**
+ * 计算请求体 SHA-256(十六进制小写)。
+ * multipart(文件上传)body 体积大且二进制,不参与 bodyHash,返回空串。
+ *
+ * @return bodyHash 十六进制小写;无 body 或 multipart 返回空串
+ */
+ private String computeBodyHash(Request request) {
+ try {
+ RequestBody body = request.body();
+ if (body == null) {
+ return "";
+ }
+ MediaType contentType = body.contentType();
+ if (contentType != null && "multipart".equalsIgnoreCase(contentType.type())) {
+ return "";
+ }
+ Buffer buffer = new Buffer();
+ body.writeTo(buffer);
+ byte[] bytes = buffer.readByteArray();
+ return SecurityUtils.sha256Hex(bytes);
+ } catch (Exception e) {
+ Log.e("AuthInterceptor", "computeBodyHash error", e);
+ return "";
+ }
+ }
+}
diff --git a/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java b/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java
index c33d0cf..83c369c 100644
--- a/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java
+++ b/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java
@@ -1,18 +1,28 @@
package com.ttstd.dialer.push;
+import android.Manifest;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
+import android.app.StatusBarManager;
+import android.app.WallpaperManager;
import android.content.Context;
+import android.content.Intent;
import android.content.pm.PackageManager;
+import android.net.Uri;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
+import android.hardware.usb.UsbManager;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
+import android.os.UserManager;
import android.text.TextUtils;
import android.util.Log;
import android.view.ContextThemeWrapper;
import android.view.WindowManager;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.Toast;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -20,10 +30,12 @@ import com.google.gson.JsonParser;
import com.ttstd.dialer.BuildConfig;
import com.ttstd.dialer.R;
import com.ttstd.dialer.activity.settings.utils.SettingsUtilsActivity;
+import com.ttstd.dialer.alarmclock.AlarmSyncManager;
import com.ttstd.dialer.bean.ApkInstalledInfo;
import com.ttstd.dialer.bean.BaseResponse;
import com.ttstd.dialer.bean.DeveloperOptions;
import com.ttstd.dialer.bean.PushMessage;
+import com.ttstd.dialer.bean.WallpaperInfo;
import com.ttstd.dialer.config.CommonConfig;
import com.ttstd.dialer.data.repository.SnRepository;
import com.ttstd.dialer.db.app.AppDatabase;
@@ -35,6 +47,7 @@ import com.ttstd.dialer.mdm.DeviceInfoCollector;
import com.ttstd.dialer.mdm.DeviceManagerService;
import com.ttstd.dialer.network.BaseObserver;
import com.ttstd.dialer.network.OkHttpManager;
+import com.ttstd.dialer.network.UrlConstants;
import com.ttstd.dialer.utils.ApkUtils;
import com.ttstd.dialer.utils.CameraUtil;
import com.ttstd.dialer.utils.SystemUtils;
@@ -47,6 +60,7 @@ import com.tencent.mmkv.MMKV;
import com.ttstd.iconloader.utils.FileUtils;
import java.io.File;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -60,9 +74,13 @@ import io.reactivex.rxjava3.core.Observer;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers;
+import okhttp3.Call;
+import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
+import okhttp3.Request;
import okhttp3.RequestBody;
+import okhttp3.Response;
public class PushExecutor {
private static final String TAG = "PushExecutor";
@@ -74,6 +92,14 @@ public class PushExecutor {
private Context mContext;
private PackageManager mPackageManager;
+ // StatusBarManager 内部常量(Android 源码中的定义)
+ public static final int DISABLE_EXPAND = 0x00010000; // 禁止下拉状态栏
+ public static final int DISABLE_NAVIGATION = 0x00200000; // 隐藏/禁用导航栏 (Back, Home, Recent)
+ public static final int DISABLE_BACK = 0x00400000; // 禁用返回键
+ public static final int DISABLE_HOME = 0x00200000; // 禁用 Home 键
+ public static final int DISABLE_RECENT = 0x01000000; // 禁用任务键
+ public static final int DISABLE_NONE = 0x00000000; // 恢复正常
+
private static final String DEVICE_REFRESH = "1";
private static final String DEVICE_SCREEN_SNAPSHOT = "2";
private static final String DEVICE_REBOOT = "3";
@@ -93,6 +119,21 @@ public class PushExecutor {
private static final String DEVICE_SCREEN_LOCK = "17";
private static final String DEVICE_UNBIND_MOBILE = "18";
private static final String DEVICE_POLICY = "19";
+ /** 设备绑定请求(家属端发起,设备端弹窗确认) */
+ private static final String DEVICE_BIND_REQUEST = "20";
+ /** 设备绑定成功(绑定完成后回推设备) */
+ private static final String DEVICE_BIND_SUCCESS = "21";
+ /** 反向拨号(家属端发起,设备主动呼叫指定号码) */
+ private static final String DEVICE_REVERSE_DIAL = "22";
+ /** 壁纸变更推送(家属端上传壁纸后触发,设备拉取并应用) */
+ private static final String DEVICE_WALLPAPER = "23";
+ /** 图片发送推送(家属端上传图片后触发,设备弹窗显示图片) */
+ private static final String DEVICE_PICTURE = "24";
+ /** 闹钟同步推送(家属端增删改闹钟后触发,设备拉取整表覆盖本地 Room) */
+ private static final String DEVICE_ALARM = "25";
+
+ /** 绑定请求弹窗倒计时时长(秒) */
+ private static final long BIND_REQUEST_COUNTDOWN_SECONDS = 60L;
/**
@@ -108,6 +149,15 @@ public class PushExecutor {
private SnRepository mSnRepository;
+ /** 轮换壁纸图片URL列表(完整地址) */
+ private List
+ * 由后端 {@code ClientController.reverseDial} 下发,extra 携带 {@code phone} 字段。
+ * 收到后设备主动外呼该号码:有 CALL_PHONE 权限时用 {@code ACTION_CALL} 直接拨打,
+ * 否则退回 {@code ACTION_DIAL} 打开拨号盘,实现「家属端发起、设备端呼叫」的反向拨号。
+ */
+ private void reverseDial(String extra) {
+ Logger.e(TAG, "reverseDial: 收到反向拨号推送, extra=" + extra);
+ if (TextUtils.isEmpty(extra)) {
+ Logger.e(TAG, "reverseDial: extra 为空");
+ return;
+ }
+ try {
+ JsonObject json = JsonParser.parseString(extra).getAsJsonObject();
+ if (!json.has("phone")) {
+ Logger.e(TAG, "reverseDial: 缺少 phone 字段");
+ return;
+ }
+ String phone = json.get("phone").getAsString().trim();
+ if (TextUtils.isEmpty(phone)) {
+ Logger.e(TAG, "reverseDial: phone 为空");
+ return;
+ }
+ boolean hasCallPermission = mContext.checkSelfPermission(Manifest.permission.CALL_PHONE)
+ == PackageManager.PERMISSION_GRANTED;
+ Intent intent = new Intent(hasCallPermission ? Intent.ACTION_CALL : Intent.ACTION_DIAL);
+ intent.setData(Uri.parse("tel:" + phone));
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ mContext.startActivity(intent);
+ Logger.e(TAG, "reverseDial: 已发起呼叫 " + phone + ", action="
+ + (hasCallPermission ? "ACTION_CALL" : "ACTION_DIAL"));
+ } catch (Exception e) {
+ Logger.e(TAG, "reverseDial: 拨号失败 " + e.getMessage());
+ }
+ }
+
+ /**
+ * 壁纸变更(contentType = 23, wallpaper)。
+ *
+ * 由家属端上传壁纸后由后端推送。收到后主动调用 {@code /api/v1/sn/get_wallpaper}
+ * 拉取壁纸配置(mode + 图片URL列表),下载图片并通过 {@link WallpaperManager} 应用:
+ * 单个模式设置单张(主屏 + 锁屏),轮换模式按固定间隔循环切换。
+ */
+ private void applyWallpaper() {
+ Logger.e(TAG, "applyWallpaper: 收到壁纸变更推送");
+ mSnRepository.getWallpaper()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(new BaseObserver
+ * 家属端增删改闹钟后由后端推送,设备收到后从服务端拉取整表覆盖本地 Room,
+ * 保留本地未同步(pending)闹钟并补推。
+ */
+ private void syncAlarmFromServer() {
+ Logger.e(TAG, "syncAlarmFromServer: 收到闹钟同步推送");
+ try {
+ AlarmSyncManager.getInstance(mContext).pullAndApply();
+ } catch (Exception e) {
+ Logger.e(TAG, "syncAlarmFromServer: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 图片发送(contentType = 24, devicePicture)。
+ *
+ * 由家属端从相册等入口上传图片后由后端推送,extras 携带 {@code image}(相对 URL)。
+ * 收到后解析图片地址 → 下载 → 弹窗显示给老人查看。
+ */
+ private void showPictureDialog(String extra) {
+ Logger.e(TAG, "showPictureDialog: 收到图片发送推送");
+ try {
+ JsonObject json = JsonParser.parseString(extra).getAsJsonObject();
+ String image = json.has("image") ? json.get("image").getAsString() : null;
+ if (TextUtils.isEmpty(image)) {
+ Logger.e(TAG, "showPictureDialog: 图片地址为空,跳过");
+ return;
+ }
+ String fullUrl = resolveWallpaperUrl(image);
+ if (fullUrl == null) {
+ Logger.e(TAG, "showPictureDialog: 图片地址无效 " + image);
+ return;
+ }
+ downloadAndShowPicture(fullUrl);
+ } catch (Exception e) {
+ Logger.e(TAG, "showPictureDialog: 解析推送失败 " + e.getMessage());
+ }
+ }
+
+ /**
+ * 下载图片并弹窗展示。
+ */
+ private void downloadAndShowPicture(String url) {
+ Request request = new Request.Builder().url(url).get().build();
+ OkHttpManager.getInstance().getOkHttpClient().newCall(request).enqueue(new Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ Logger.e(TAG, "downloadAndShowPicture: 下载图片失败 " + url + " " + e.getMessage());
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) {
+ try (okhttp3.ResponseBody body = response.body()) {
+ if (!response.isSuccessful() || body == null) {
+ Logger.e(TAG, "downloadAndShowPicture: 下载图片失败 code=" + response.code() + " url=" + url);
+ return;
+ }
+ Bitmap bitmap = decodeSampledBitmap(body.bytes());
+ if (bitmap == null) {
+ Logger.e(TAG, "downloadAndShowPicture: 图片解码失败 " + url);
+ return;
+ }
+ showPictureDialogWithBitmap(bitmap);
+ } catch (Exception e) {
+ Logger.e(TAG, "downloadAndShowPicture: 处理图片失败 " + e.getMessage());
+ }
+ }
+ });
+ }
+
+ /**
+ * 主线程弹窗显示图片。
+ */
+ private void showPictureDialogWithBitmap(Bitmap bitmap) {
+ new Handler(Looper.getMainLooper()).post(() -> {
+ try {
+ ContextThemeWrapper wrapper = new ContextThemeWrapper(
+ mContext, android.R.style.Theme_DeviceDefault_Light_Dialog_Alert);
+ ImageView imageView = new ImageView(wrapper);
+ imageView.setImageBitmap(bitmap);
+ imageView.setAdjustViewBounds(true);
+ // 限制最大宽高,避免大图撑满屏幕
+ int maxWidth = (int) (mContext.getResources().getDisplayMetrics().widthPixels * 0.85f);
+ int maxHeight = (int) (mContext.getResources().getDisplayMetrics().heightPixels * 0.6f);
+ imageView.setMaxWidth(maxWidth);
+ imageView.setMaxHeight(maxHeight);
+ imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
+
+ AlertDialog dialog = new AlertDialog.Builder(wrapper)
+ .setTitle(R.string.picture_dialog_title)
+ .setView(imageView)
+ .setPositiveButton(android.R.string.ok, (d, which) -> d.dismiss())
+ .setCancelable(true)
+ .create();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
+ } else {
+ dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
+ }
+ dialog.show();
+ Logger.e(TAG, "showPictureDialogWithBitmap: 图片弹窗已显示");
+ } catch (Exception e) {
+ Logger.e(TAG, "showPictureDialogWithBitmap error: " + e.getMessage());
+ }
+ });
+ }
+
/**
* 解除设备手机号绑定(contentType = 18, unbindMobile)。
*
@@ -800,6 +1173,21 @@ public class PushExecutor {
Logger.e(TAG, "setDevicePolicy: extra 为空");
return;
}
+ UserManager userManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE);
+
+// // 禁用改变 Wi-Fi 状态/配置
+// userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_WIFI, true);
+//
+// userManager.setUserRestriction(UserManager.DISALLOW_NETWORK_RESET, true);
+
+ UsbManager usbManager = (UsbManager) mContext.getSystemService(Context.USB_SERVICE);
+ usbManager.setCurrentFunctions(UsbManager.FUNCTION_NONE);
+
+ StatusBarManager statusBarManager = (StatusBarManager) mContext.getSystemService(Context.STATUS_BAR_SERVICE);
+// statusBarManager.disable(StatusBarManager.DISABLE_BACK);
+// statusBarManager.disable(StatusBarManager.DISABLE_HOME);
+// statusBarManager.disable(StatusBarManager.DISABLE_RECENT);
+
try {
JsonObject json = JsonParser.parseString(extra).getAsJsonObject();
DeviceManagerService mdm = DeviceManagerService.getInstance();
@@ -819,30 +1207,48 @@ public class PushExecutor {
// 恢复出厂
int factoryReset = getIntField(json, "factoryReset", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_FACTORY_RESET, factoryReset);
+ // 彻底禁止恢复出厂设置
+ userManager.setUserRestriction(UserManager.DISALLOW_FACTORY_RESET, factoryReset == 0);
// WiFi热点开关
int wifiHotspot = getIntField(json, "wifiHotspot", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_WIFI_HOTSPOT, wifiHotspot);
+ // 禁用移动热点与网络共享
+ userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_TETHERING, wifiHotspot == 0);
// 蓝牙开关
- int bluetoothSwitch = getIntField(json, "bluetoothSwitch", 1);
- mMMKV.encode(CommonConfig.KEY_POLICY_BLUETOOTH_SWITCH, bluetoothSwitch);
+ int bluetooth = getIntField(json, "bluetooth", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_BLUETOOTH, bluetooth);
+ // 禁用蓝牙
+ userManager.setUserRestriction(UserManager.DISALLOW_BLUETOOTH, bluetooth == 0);
// 壁纸功能
int wallpaper = getIntField(json, "wallpaper", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_WALLPAPER, wallpaper);
+ userManager.setUserRestriction(UserManager.DISALLOW_SET_WALLPAPER, wallpaper == 0);
// 通知栏显示
int notificationBar = getIntField(json, "notificationBar", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_NOTIFICATION_BAR, notificationBar);
+// statusBarManager.disable(StatusBarManager.DISABLE_NONE);
+
+ int statusBarCode = 0;
+
// 状态栏下拉开关
int statusBarPullDown = getIntField(json, "statusBarPullDown", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_STATUS_BAR_PULL_DOWN, statusBarPullDown);
+ if (statusBarPullDown == 0) {
+ statusBarCode = StatusBarManager.DISABLE_EXPAND;
+ }
// 系统导航条显示
int systemNavBarShow = getIntField(json, "systemNavBarShow", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_SYSTEM_NAV_BAR_SHOW, systemNavBarShow);
+ if (systemNavBarShow == 0) {
+ statusBarCode= statusBarCode|StatusBarManager.DISABLE_NAVIGATION;
+ }
+ statusBarManager.disable(statusBarCode);
// 系统导航栏设置
int systemNavBarSetting = getIntField(json, "systemNavBarSetting", 1);
@@ -853,16 +1259,20 @@ public class PushExecutor {
mMMKV.encode(CommonConfig.KEY_POLICY_NAV_BAR_OPTIONS, navBarOptions);
// 蓝牙功能开关
- int bluetoothFunction = getIntField(json, "bluetoothFunction", 1);
- mMMKV.encode(CommonConfig.KEY_POLICY_BLUETOOTH_FUNCTION, bluetoothFunction);
+ int configBluetooth = getIntField(json, "configBluetooth", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_CONFIG_BLUETOOTH, configBluetooth);
// OTA升级管控
int otaUpgrade = getIntField(json, "otaUpgrade", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_OTA_UPGRADE, otaUpgrade);
// 应用安装开关(对应 deviceController.installPackage / uninstallPackage 的放行控制)
- int appInstall = getIntField(json, "appInstall", 1);
- mMMKV.encode(CommonConfig.KEY_POLICY_APP_INSTALL, appInstall);
+ int installApps = getIntField(json, "installApps", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_INSTALL_APPS, installApps);
+ // 1. 禁止通过未知来源(非系统应用商店/文件管理器)安装
+ userManager.setUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES, installApps == 0);
+ // 2. 彻底禁止安装所有应用(包括 ADB 安装、第三方商店和静默安装)
+ userManager.setUserRestriction(UserManager.DISALLOW_INSTALL_APPS, installApps == 0);
// 自动旋转
int autoRotate = getIntField(json, "autoRotate", 1);
@@ -880,25 +1290,122 @@ public class PushExecutor {
int darkMode = getIntField(json, "darkMode", 1);
mMMKV.encode(CommonConfig.KEY_POLICY_DARK_MODE, darkMode);
+ // 允许进入安全模式
+ int safeBoot = getIntField(json, "safeBoot", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_SAFE_BOOT, safeBoot);
+
+ // 开启ADB/USB调试
+ int adbUsbDebug = getIntField(json, "adbUsbDebug", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_ADB_USB_DEBUG, adbUsbDebug);
+
+ // 挂载外部存储(SD卡/U盘)
+ int mountPhysicalStorage = getIntField(json, "mountPhysicalStorage", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MOUNT_PHYSICAL_STORAGE, mountPhysicalStorage);
+
+ // 修改系统时间与时区
+ int modifySystemTimeZone = getIntField(json, "modifySystemTimeZone", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MODIFY_SYSTEM_TIME_ZONE, modifySystemTimeZone);
+
+ // 切换飞行模式
+ int toggleAirplaneMode = getIntField(json, "toggleAirplaneMode", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_TOGGLE_AIRPLANE_MODE, toggleAirplaneMode);
+ userManager.setUserRestriction(UserManager.DISALLOW_AIRPLANE_MODE, toggleAirplaneMode == 0);
+
+ // 修改移动网络/APN
+ int modifyMobileApn = getIntField(json, "modifyMobileApn", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MODIFY_MOBILE_APN, modifyMobileApn);
+ userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS, modifyMobileApn == 0);
+
+ // 卸载应用
+ int uninstallApps = getIntField(json, "uninstallApps", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_UNINSTALL_APPS, uninstallApps);
+ userManager.setUserRestriction(UserManager.DISALLOW_UNINSTALL_APPS, uninstallApps == 0);
+
+ // 摄像头
+ int camera = getIntField(json, "camera", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_CAMERA, camera);
+ userManager.setUserRestriction(UserManager.DISALLOW_CAMERA, camera == 0);
+
+ // USB文件传输(MTP)
+ int usbFileTransfer = getIntField(json, "usbFileTransfer", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_USB_FILE_TRANSFER, usbFileTransfer);
+
+ // 修改定位开关
+ int modifyLocationSwitch = getIntField(json, "modifyLocationSwitch", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MODIFY_LOCATION_SWITCH, modifyLocationSwitch);
+ userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_LOCATION, modifyLocationSwitch == 0);
+
+ // 蓝牙对外分享
+ int bluetoothSharing = getIntField(json, "bluetoothSharing", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_BLUETOOTH_SHARING, bluetoothSharing);
+ userManager.setUserRestriction(UserManager.DISALLOW_BLUETOOTH_SHARING, bluetoothSharing == 0);
+
+ // 重置网络设置
+ int resetNetworkSettings = getIntField(json, "resetNetworkSettings", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_RESET_NETWORK_SETTINGS, resetNetworkSettings);
+ userManager.setUserRestriction(UserManager.DISALLOW_NETWORK_RESET, resetNetworkSettings == 0);
+
+ // 调节全局音量
+ int adjustGlobalVolume = getIntField(json, "adjustGlobalVolume", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_ADJUST_GLOBAL_VOLUME, adjustGlobalVolume);
+ userManager.setUserRestriction(UserManager.DISALLOW_ADJUST_VOLUME, adjustGlobalVolume == 0);
+
+ // 修改屏幕超时休眠时间
+ int modifyScreenTimeout = getIntField(json, "modifyScreenTimeout", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MODIFY_SCREEN_TIMEOUT, modifyScreenTimeout);
+ userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_SCREEN_TIMEOUT, modifyScreenTimeout == 0);
+
+ // 手动调整系统时间与时区
+ int manualAdjustTimeZone = getIntField(json, "manualAdjustTimeZone", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MANUAL_ADJUST_TIME_ZONE, manualAdjustTimeZone);
+ userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_DATE_TIME, manualAdjustTimeZone == 0);
+
+ // 修改系统语言与区域设置
+ int modifySystemLanguage = getIntField(json, "modifySystemLanguage", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MODIFY_SYSTEM_LANGUAGE, modifySystemLanguage);
+ userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_LOCALE, modifySystemLanguage == 0);
+
+ // 手动调整系统亮度与自动亮度开关
+ int manualAdjustBrightness = getIntField(json, "manualAdjustBrightness", 1);
+ mMMKV.encode(CommonConfig.KEY_POLICY_MANUAL_ADJUST_BRIGHTNESS, manualAdjustBrightness);
+ userManager.setUserRestriction(UserManager.DISALLOW_CONFIG_BRIGHTNESS, manualAdjustBrightness == 0);
+
Logger.e(TAG, "setDevicePolicy: 策略解析完成并落库, usbData=" + usbData
+ ", timeSetting=" + timeSetting
+ ", storageCard=" + storageCard
+ ", factoryReset=" + factoryReset
+ ", wifiHotspot=" + wifiHotspot
- + ", bluetoothSwitch=" + bluetoothSwitch
+ + ", bluetooth=" + bluetooth
+ ", wallpaper=" + wallpaper
+ ", notificationBar=" + notificationBar
+ ", statusBarPullDown=" + statusBarPullDown
+ ", systemNavBarShow=" + systemNavBarShow
+ ", systemNavBarSetting=" + systemNavBarSetting
+ ", navBarOptions=" + navBarOptions
- + ", bluetoothFunction=" + bluetoothFunction
+ + ", configBluetooth=" + configBluetooth
+ ", otaUpgrade=" + otaUpgrade
- + ", appInstall=" + appInstall
+ + ", installApps=" + installApps
+ ", autoRotate=" + autoRotate
+ ", autoBrightness=" + autoBrightness
+ ", eyeProtection=" + eyeProtection
- + ", darkMode=" + darkMode);
+ + ", darkMode=" + darkMode
+ + ", safeBoot=" + safeBoot
+ + ", adbUsbDebug=" + adbUsbDebug
+ + ", mountPhysicalStorage=" + mountPhysicalStorage
+ + ", modifySystemTimeZone=" + modifySystemTimeZone
+ + ", toggleAirplaneMode=" + toggleAirplaneMode
+ + ", modifyMobileApn=" + modifyMobileApn
+ + ", uninstallApps=" + uninstallApps
+ + ", camera=" + camera
+ + ", usbFileTransfer=" + usbFileTransfer
+ + ", modifyLocationSwitch=" + modifyLocationSwitch
+ + ", bluetoothSharing=" + bluetoothSharing
+ + ", resetNetworkSettings=" + resetNetworkSettings
+ + ", adjustGlobalVolume=" + adjustGlobalVolume
+ + ", modifyScreenTimeout=" + modifyScreenTimeout
+ + ", manualAdjustTimeZone=" + manualAdjustTimeZone
+ + ", modifySystemLanguage=" + modifySystemLanguage
+ + ", manualAdjustBrightness=" + manualAdjustBrightness);
// TODO: 针对各策略开关的 OS 级能力(导航栏/通知栏/WiFi热点/蓝牙/壁纸等)已具备落地数据,
// 接入对应 DeviceManagerService / DeviceController 能力后在此即时应用。
@@ -908,6 +1415,228 @@ public class PushExecutor {
}
}
+ // ===================== 设备绑定(contentType = 20/21) =====================
+
+ /**
+ * 设备绑定请求(contentType = 20,家属端发起)。
+ *
+ * extra 携带:
+ * - phone : 发起绑定的家属手机号
+ * - remark : 家属填写的备注名(可选)
+ *
+ * 设备端弹出绑定确认窗口:60 秒倒计时,展示绑定手机号,提供
+ * 「不再接收该手机的请求」「拒绝」「同意」三个交互。同意后调用
+ * {@code /api/v1/sn/bind_phone} 完成绑定。
+ */
+ private void bindRequest(String extra) {
+ Logger.e(TAG, "bindRequest: 收到设备绑定请求推送, extra=" + extra);
+ if (TextUtils.isEmpty(extra)) {
+ Logger.e(TAG, "bindRequest: extra 为空");
+ return;
+ }
+ try {
+ JsonObject json = JsonParser.parseString(extra).getAsJsonObject();
+ final String phone = json.has("phone") ? json.get("phone").getAsString() : "";
+ final String remark = json.has("remark") ? json.get("remark").getAsString() : "";
+ if (TextUtils.isEmpty(phone)) {
+ Logger.e(TAG, "bindRequest: phone 为空");
+ return;
+ }
+
+ // 该手机号已被标记「不再接收」→ 直接忽略,不再弹窗
+ if (isPhoneRejected(phone)) {
+ Logger.e(TAG, "bindRequest: 手机号 " + phone + " 已被拒绝,忽略本次请求");
+ return;
+ }
+
+ showBindRequestDialog(phone, remark);
+ } catch (Exception e) {
+ Logger.e(TAG, "bindRequest: 解析绑定请求失败: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 设备绑定成功(contentType = 21,绑定完成后服务端回推)。
+ *
+ * extra 携带:phone(绑定成功的手机号)。弹出提示告知老人设备已绑定。
+ */
+ private void bindSuccess(String extra) {
+ Logger.e(TAG, "bindSuccess: 收到设备绑定成功推送, extra=" + extra);
+ String phone = "";
+ if (!TextUtils.isEmpty(extra)) {
+ try {
+ JsonObject json = JsonParser.parseString(extra).getAsJsonObject();
+ phone = json.has("phone") ? json.get("phone").getAsString() : "";
+ } catch (Exception e) {
+ Logger.e(TAG, "bindSuccess: 解析失败: " + e.getMessage());
+ }
+ }
+ showBindSuccessDialog(phone);
+ }
+
+ /**
+ * 弹出设备绑定确认窗口。
+ *
+ * 窗口包含:绑定手机号提示、60 秒倒计时、「不再接收该手机的请求」选项、
+ * 「拒绝」与「同意」按钮。倒计时结束自动关闭(视为拒绝)。
+ */
+ private void showBindRequestDialog(final String phone, final String remark) {
+ new Handler(Looper.getMainLooper()).post(() -> {
+ try {
+ ContextThemeWrapper wrapper = new ContextThemeWrapper(
+ mContext, android.R.style.Theme_DeviceDefault_Light_Dialog_Alert);
+ // 复选框:不再接收该手机的请求
+ CheckBox rejectCheckBox = new CheckBox(wrapper);
+ rejectCheckBox.setText(mContext.getString(R.string.bind_request_reject_phone));
+
+ String msg = mContext.getString(R.string.bind_request_message, phone);
+ if (!TextUtils.isEmpty(remark)) {
+ msg += "\n" + remark;
+ }
+
+ AlertDialog dialog = new AlertDialog.Builder(wrapper)
+ .setTitle(R.string.bind_request_title)
+ .setMessage(msg)
+ .setView(rejectCheckBox)
+ .setPositiveButton(R.string.bind_request_agree, (d, which) -> {
+ if (rejectCheckBox.isChecked()) {
+ rejectPhone(phone);
+ }
+ d.dismiss();
+ confirmBindPhone(phone);
+ })
+ .setNegativeButton(R.string.bind_request_refuse, (d, which) -> {
+ if (rejectCheckBox.isChecked()) {
+ rejectPhone(phone);
+ }
+ d.dismiss();
+ })
+ .setCancelable(false)
+ .create();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
+ } else {
+ dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
+ }
+
+ // 60 秒倒计时:每秒更新消息文本,归零自动关闭(视为拒绝)
+ final long[] remaining = {BIND_REQUEST_COUNTDOWN_SECONDS};
+ Handler countdownHandler = new Handler(Looper.getMainLooper());
+ Runnable countdownRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!dialog.isShowing()) {
+ return;
+ }
+ remaining[0]--;
+ if (remaining[0] <= 0) {
+ dialog.dismiss();
+ Toast.makeText(mContext, R.string.bind_request_timeout, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ String countdownMsg = mContext.getString(R.string.bind_request_message, phone);
+ if (!TextUtils.isEmpty(remark)) {
+ countdownMsg += "\n" + remark;
+ }
+ countdownMsg += "\n" + mContext.getString(R.string.bind_request_countdown, remaining[0]);
+ dialog.setMessage(countdownMsg);
+ countdownHandler.postDelayed(this, 1000);
+ }
+ };
+ dialog.setOnShowListener(d -> countdownHandler.postDelayed(countdownRunnable, 1000));
+ dialog.show();
+ } catch (Exception e) {
+ Logger.e(TAG, "showBindRequestDialog error: " + e.getMessage());
+ }
+ });
+ }
+
+ /**
+ * 弹出绑定成功提示。
+ */
+ private void showBindSuccessDialog(String phone) {
+ new Handler(Looper.getMainLooper()).post(() -> {
+ try {
+ AlertDialog dialog = new AlertDialog.Builder(
+ new ContextThemeWrapper(mContext, android.R.style.Theme_DeviceDefault_Light_Dialog_Alert))
+ .setTitle(R.string.bind_success_title)
+ .setMessage(mContext.getString(R.string.bind_success_message, phone))
+ .setPositiveButton(android.R.string.ok, (d, which) -> d.dismiss())
+ .setCancelable(false)
+ .create();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
+ } else {
+ dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
+ }
+ dialog.show();
+ } catch (Exception e) {
+ Logger.e(TAG, "showBindSuccessDialog error: " + e.getMessage());
+ }
+ });
+ }
+
+ /**
+ * 同意绑定:调用 {@code /api/v1/sn/bind_phone} 完成 SN 与手机号绑定。
+ */
+ private void confirmBindPhone(String phone) {
+ mSnRepository.bindPhone(phone)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(new BaseObserver> getAlarms() {
+ return ApiResponseMapper.unwrap(mApi.getAlarms());
+ }
+
+ /**
+ * 整表同步设备闹钟
+ */
+ @NonNull
+ public Observable
> getAlarms() {
+ return mRemoteDataSource.getAlarms()
+ .subscribeOn(Schedulers.io());
+ }
+
+ /**
+ * 整表同步设备闹钟(本地增删改后上传)。
+ */
+ @NonNull
+ public Observable
+ *
+ *
+ *
+ * 算法 : ECDSA P-256 (secp256r1)
+ * 签名 : SHA256withECDSA
+ * 公钥 : X.509 SubjectPublicKeyInfo, Base64
+ * 签名值 : Base64
+ *
+ */
+public class DeviceKeyManager {
+
+ private static final String TAG = "DeviceKeyManager";
+
+ private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
+
+ /** 固定密钥别名(按设备,稳定复用,避免重复生成导致公钥漂移) */
+ public static final String KEY_ALIAS = "ttstd_device_sign_key";
+
+ /** 签名算法(与后端 SHA256withECDSA 对应) */
+ private static final String SIGNATURE_ALGORITHM = "SHA256withECDSA";
+
+ private static volatile DeviceKeyManager sInstance;
+
+ private KeyStore mKeyStore;
+
+ public static DeviceKeyManager getInstance() {
+ if (sInstance == null) {
+ synchronized (DeviceKeyManager.class) {
+ if (sInstance == null) {
+ sInstance = new DeviceKeyManager();
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ private DeviceKeyManager() {
+ initKeyStore();
+ }
+
+ private void initKeyStore() {
+ try {
+ mKeyStore = KeyStore.getInstance(ANDROID_KEYSTORE);
+ mKeyStore.load(null);
+ } catch (Exception e) {
+ Log.e(TAG, "initKeyStore error", e);
+ }
+ }
+
+ /**
+ * 确保 ECDSA P-256 密钥对存在(不存在则生成)。
+ */
+ public void ensureKey() {
+ if (mKeyStore == null) {
+ initKeyStore();
+ }
+ try {
+ if (!mKeyStore.containsAlias(KEY_ALIAS)) {
+ generateKeyPair();
+ Log.i(TAG, "ECDSA P-256 key generated, alias=" + KEY_ALIAS);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "ensureKey error", e);
+ }
+ }
+
+ private void generateKeyPair() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE);
+ kpg.initialize(new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_SIGN)
+ .setAlgorithmParameterSpec(new ECGenParameterSpec("secp256r1"))
+ .setDigests(KeyProperties.DIGEST_SHA256)
+ .setUserAuthenticationRequired(false)
+ .build());
+ kpg.generateKeyPair();
+ }
+
+ /**
+ * 导出公钥(X.509 SubjectPublicKeyInfo,Base64 编码),用于注册时上传服务端。
+ *
+ * @return Base64 公钥,失败返回 null
+ */
+ public String getPublicKeyBase64() {
+ try {
+ if (!mKeyStore.containsAlias(KEY_ALIAS)) {
+ ensureKey();
+ }
+ java.security.cert.Certificate cert = mKeyStore.getCertificate(KEY_ALIAS);
+ if (cert == null) {
+ return null;
+ }
+ return Base64.encodeToString(cert.getPublicKey().getEncoded(), Base64.NO_WRAP);
+ } catch (Exception e) {
+ Log.e(TAG, "getPublicKeyBase64 error", e);
+ return null;
+ }
+ }
+
+ /**
+ * 用设备私钥对 canonical 原文做 SHA256withECDSA 签名。
+ *
+ * @param canonical 待签名原文(METHOD|path|bodyHash|ts|nonce)
+ * @return Base64 签名,失败返回 null
+ */
+ public String sign(String canonical) {
+ try {
+ if (canonical == null || canonical.isEmpty()) {
+ return null;
+ }
+ if (!mKeyStore.containsAlias(KEY_ALIAS)) {
+ ensureKey();
+ }
+ PrivateKey privateKey = (PrivateKey) mKeyStore.getKey(KEY_ALIAS, null);
+ if (privateKey == null) {
+ return null;
+ }
+ Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
+ signature.initSign(privateKey);
+ signature.update(canonical.getBytes(StandardCharsets.UTF_8));
+ return Base64.encodeToString(signature.sign(), Base64.NO_WRAP);
+ } catch (Exception e) {
+ Log.e(TAG, "sign error", e);
+ return null;
+ }
+ }
+
+ /**
+ * 删除设备签名密钥(恢复出厂 / 解绑时调用,重新注册会生成新密钥对)。
+ */
+ public void deleteKey() {
+ try {
+ if (mKeyStore != null && mKeyStore.containsAlias(KEY_ALIAS)) {
+ mKeyStore.deleteEntry(KEY_ALIAS);
+ Log.i(TAG, "device sign key deleted");
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "deleteKey error", e);
+ }
+ }
+}
diff --git a/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java b/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java
index 4a2dd69..5d2370d 100644
--- a/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java
+++ b/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java
@@ -13,16 +13,25 @@ import com.ttstd.iconloader.utils.FileUtils;
import org.jetbrains.annotations.NotNull;
import java.io.File;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import okhttp3.Cache;
import okhttp3.ConnectionPool;
import okhttp3.Dispatcher;
+import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
+import okhttp3.Request;
import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
import okhttp3.logging.HttpLoggingInterceptor;
+import okio.Buffer;
+import okio.BufferedSource;
import retrofit2.Retrofit;
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory;
import retrofit2.converter.gson.GsonConverterFactory;
@@ -127,6 +136,7 @@ public class OkHttpManager {
Logger.e("HttpLoggingInterceptor", "log: " + s);
}
}).setLevel(HttpLoggingInterceptor.Level.BASIC))
+ .addInterceptor(new ResponseErrorLogInterceptor()) // 打印非200响应的详细信息
.retryOnConnectionFailure(true);// 连接失败重试
@@ -275,4 +285,153 @@ public class OkHttpManager {
RequestBody requestBody = RequestBody.create(MediaType.parse("text/plain"), String.valueOf(param));
return requestBody;
}
+
+ /**
+ * 拦截器:当 HTTP 状态码 != 200 时,打印关键请求/响应信息。
+ * 过滤掉图片等二进制内容,只打印可读文本,避免刷屏。
+ */
+ private static class ResponseErrorLogInterceptor implements Interceptor {
+
+ private static final String TAG = "HttpErrorInterceptor";
+ private static final long MAX_BODY_LOG_BYTES = 4096; // body 最大打印长度,防止超大响应刷屏
+
+ @Override
+ public @NonNull Response intercept(@NonNull Chain chain) throws IOException {
+ Request request = chain.request();
+ Response response = chain.proceed(request);
+ int code = response.code();
+
+// if (code != 200) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n===== HTTP ERROR ").append(code).append(" ").append(response.message())
+ .append(" | ").append(request.method()).append(" ").append(request.url()).append(" =====\n");
+
+ // 请求体:仅文本类型且可读时打印,图片/二进制只打大小
+ String reqBody = readTextBody(request.body());
+ if (reqBody != null) {
+ sb.append("Req Body: ").append(truncate(reqBody)).append("\n");
+ }
+
+ // 响应体:仅文本类型且可读时打印,图片/二进制只打大小
+ ResponseBody responseBody = response.body();
+ if (responseBody != null) {
+ String respBody = readTextBody(responseBody);
+ if (respBody != null) {
+ sb.append("Resp Body: ").append(truncate(respBody)).append("\n");
+ } else {
+ sb.append("Resp Body: [binary/not-logged] size=")
+ .append(responseBody.contentLength()).append("\n");
+ }
+ }
+
+ sb.append("==============================================");
+ Logger.e(TAG, sb.toString());
+// }
+ return response;
+ }
+
+ /**
+ * 从请求体中读取文本;若是二进制/不可读内容则返回 null。
+ */
+ private String readTextBody(RequestBody body) {
+ if (body == null) {
+ return null;
+ }
+ // 过滤二进制/图片等类型
+ MediaType contentType = body.contentType();
+ if (contentType != null && !isTextContentType(contentType)) {
+ return null;
+ }
+ try {
+ Buffer buffer = new Buffer();
+ body.writeTo(buffer);
+ if (!isReadableText(buffer)) {
+ return null;
+ }
+ return buffer.readUtf8();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * 从响应体中读取文本(克隆 body 避免影响后续解析);二进制返回 null。
+ */
+ private String readTextBody(ResponseBody body) {
+ MediaType contentType = body.contentType();
+ if (contentType != null && !isTextContentType(contentType)) {
+ return null;
+ }
+ try {
+ BufferedSource source = body.source();
+ source.request(Long.MAX_VALUE);
+ Buffer buffer = source.getBuffer().clone();
+ if (!isReadableText(buffer)) {
+ return null;
+ }
+ Charset charset = contentType != null
+ && contentType.charset(StandardCharsets.UTF_8) != null
+ ? contentType.charset(StandardCharsets.UTF_8)
+ : StandardCharsets.UTF_8;
+ return buffer.readString(charset);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * 判断是否为可读文本内容类型(文本/JSON/XML/表单等)。
+ */
+ private boolean isTextContentType(MediaType contentType) {
+ String type = contentType.type();
+ String subtype = contentType.subtype();
+ if (type == null) {
+ return false;
+ }
+ if (type.equals("text")) {
+ return true;
+ }
+ if (type.equals("application")) {
+ return "json".equals(subtype)
+ || "xml".equals(subtype)
+ || "x-www-form-urlencoded".equals(subtype)
+ || subtype != null && subtype.endsWith("+json")
+ || subtype != null && subtype.endsWith("+xml");
+ }
+ return false;
+ }
+
+ /**
+ * 判断 buffer 内容是否为可读文本(排除图片等二进制乱码)。
+ */
+ private boolean isReadableText(Buffer buffer) {
+ // 只抽样前若干字节判断是否含大量不可打印字符
+ int checked = 0;
+ int nonPrintable = 0;
+ long size = Math.min(buffer.size(), 512);
+ for (long i = 0; i < size; i++) {
+ byte b = buffer.getByte(i);
+ checked++;
+ // 允许常见的可打印 ASCII 与常见中文 UTF-8 首字节(>0xC0)
+ if ((b < 0x09) || (b > 0x1F && b < 0x7F) || (b >= 0xC0)) {
+ continue;
+ }
+ nonPrintable++;
+ }
+ return checked == 0 || nonPrintable * 1.0 / checked < 0.3;
+ }
+
+ /**
+ * 截断超长文本,避免刷屏。
+ */
+ private String truncate(String text) {
+ if (text == null) {
+ return null;
+ }
+ if (text.length() <= MAX_BODY_LOG_BYTES) {
+ return text;
+ }
+ return text.substring(0, (int) MAX_BODY_LOG_BYTES) + "...[truncated, total=" + text.length() + "]";
+ }
+ }
}
diff --git a/app/src/main/java/com/ttstd/dialer/network/SecurityUtils.java b/app/src/main/java/com/ttstd/dialer/network/SecurityUtils.java
index 4a7aa9f..86a78f7 100644
--- a/app/src/main/java/com/ttstd/dialer/network/SecurityUtils.java
+++ b/app/src/main/java/com/ttstd/dialer/network/SecurityUtils.java
@@ -7,7 +7,19 @@ import com.ttstd.dialer.utils.HashUtils;
import java.util.SortedMap;
import java.util.function.BiConsumer;
+/**
+ * 签名工具类。
+ *
+ *
+ * X-Device-SN : 设备序列号
+ * X-Nonce : 随机数(防重放)
+ * X-Timestamp : 时间戳(毫秒)
+ * X-Sign : Base64(ECDSA-SHA256(METHOD|path|bodyHash|ts|nonce))
+ *
+ *
+ *