From d1ba51e823bb18a45cb56254f21672aea0fb32af Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 19 Oct 2026 14:31:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(alarm):=20=E6=96=B0=E5=A2=9E=E9=97=B9?= =?UTF-8?q?=E9=92=9F=E7=A6=BB=E7=BA=BF=E5=90=8C=E6=AD=A5=E4=B8=8E=E8=81=94?= =?UTF-8?q?=E7=BD=91=E8=A1=A5=E4=BC=A0=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 80 +- app/src/main/AndroidManifest.xml | 11 + .../activity/alarm/add/AlarmAddViewModel.java | 3 + .../alarm/edit/AlarmEditViewModel.java | 3 + .../alarm/list/AlarmListViewModel.java | 7 +- .../settings/home/SettingsActivity.java | 5 - .../settings/home/SettingsViewModel.java | 14 - .../dialer/alarmclock/AlarmSyncManager.java | 141 ++++ .../ttstd/dialer/base/BaseApplication.java | 2 + .../com/ttstd/dialer/bean/WallpaperInfo.java | 44 + .../com/ttstd/dialer/config/CommonConfig.java | 51 +- .../data/remote/SnRemoteDataSource.java | 43 +- .../dialer/data/repository/SnRepository.java | 59 +- .../com/ttstd/dialer/db/alarm/AlarmDao.java | 21 + .../ttstd/dialer/db/alarm/AlarmDatabase.java | 14 +- .../com/ttstd/dialer/db/alarm/AlarmInfo.java | 14 + .../dialer/db/alarm/AlarmRepository.java | 16 + .../dialer/network/DeviceKeyManager.java | 166 ++++ .../ttstd/dialer/network/OkHttpManager.java | 159 ++++ .../ttstd/dialer/network/SecurityUtils.java | 50 ++ .../ttstd/dialer/network/UrlConstants.java | 15 +- .../com/ttstd/dialer/network/api/SnApi.java | 42 +- .../network/interceptor/AuthInterceptor.java | 137 +++- .../com/ttstd/dialer/push/PushExecutor.java | 749 +++++++++++++++++- .../dialer/service/main/MainService.java | 12 + .../dialer/service/main/MainServiceModel.java | 31 + .../com/ttstd/dialer/utils/SystemUtils.java | 150 ++++ app/src/main/res/values/strings_options.xml | 16 + build.gradle | 32 +- gradle.properties | 4 + iconloader/build.gradle | 12 +- niceimageview/build.gradle | 8 +- 32 files changed, 1984 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/com/ttstd/dialer/alarmclock/AlarmSyncManager.java create mode 100644 app/src/main/java/com/ttstd/dialer/bean/WallpaperInfo.java create mode 100644 app/src/main/java/com/ttstd/dialer/network/DeviceKeyManager.java diff --git a/app/build.gradle b/app/build.gradle index 7f9a046..19a5597 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,25 +14,25 @@ static def releaseTime() { } android { - namespace "com.ttstd.dialer" + namespace = "com.ttstd.dialer" - compileSdkVersion 37 + compileSdk = 37 // buildToolsVersion "36.0.0" defaultConfig { - applicationId "com.ttstd.dialer" + applicationId = "com.ttstd.dialer" //There are no CERT files because If the mini sdk version is 23+, the AGP will ignore the V1 scheme signature. - minSdkVersion 23 - targetSdkVersion 37 - versionCode 1 - versionName "1.0" + minSdk = 23 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true - multiDexEnabled true + multiDexEnabled = true ndk { //根据需要 自行选择添加的对应cpu类型的.so库。 @@ -40,11 +40,11 @@ android { // 还可以添加 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64', 'mips', 'mips64' } - lintOptions { - checkReleaseBuilds false + lint { + checkReleaseBuilds = false // Or, if you prefer, you can continue to check for errors in release builds, // but continue the build even when errors are found: - abortOnError false + abortOnError = false } javaCompileOptions { @@ -78,9 +78,9 @@ android { } compileOptions { - coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } // 添加 Kotlin 编译选项 @@ -89,14 +89,16 @@ android { } buildFeatures { - dataBinding true - buildConfig true - aidl true + dataBinding = true + buildConfig = true + aidl = true } - packagingOptions { - exclude 'META-INF/io.netty.versions.properties' - exclude 'META-INF/INDEX.LIST' + packaging { + resources { + excludes.add('META-INF/io.netty.versions.properties') + excludes.add('META-INF/INDEX.LIST') + } } externalNativeBuild { @@ -145,49 +147,49 @@ android { buildTypes { zhanRuiDebug.initWith(debug) zhanRuiDebug { - debuggable true - signingConfig signingConfigs.zhanxun + debuggable = true + signingConfig = signingConfigs.zhanxun } zhanRuiRelease.initWith(release) zhanRuiRelease { - signingConfig signingConfigs.zhanxun + signingConfig = signingConfigs.zhanxun } CrosshatchDebug.initWith(debug) CrosshatchDebug { - debuggable true - signingConfig signingConfigs.crosshatch + debuggable = true + signingConfig = signingConfigs.crosshatch } CrosshatchRelease.initWith(release) CrosshatchRelease { - signingConfig signingConfigs.crosshatch + signingConfig = signingConfigs.crosshatch } debug { - versionNameSuffix "_debug" - debuggable true - minifyEnabled false + versionNameSuffix = "_debug" + debuggable = true + minifyEnabled = false //Zipalign优化 - zipAlignEnabled true - signingConfig signingConfigs.keypub + zipAlignEnabled = true + signingConfig = signingConfigs.keypub } release { - minifyEnabled false + minifyEnabled = false //Zipalign优化 - zipAlignEnabled true + zipAlignEnabled = true //前一部分代表系统默认的android程序的混淆文件,该文件已经包含了基本的混淆声明,后一个文件是自己的定义混淆文件 proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' //签名 - signingConfig signingConfigs.keypub + signingConfig = signingConfigs.keypub } } // Add application variant configuration here instead - applicationVariants.all { variant -> - variant.outputs.each { output -> + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { output -> def buildType = variant.buildType.name if (buildType.contains("debug")) { @@ -428,7 +430,7 @@ protobuf { artifact = 'com.google.protobuf:protoc:3.25.1' } generateProtoTasks { - all().each { task -> + all().configureEach { task -> task.builtins { java {} } @@ -438,7 +440,7 @@ protobuf { // 在 dependencies 之后添加 project.afterEvaluate { - android.applicationVariants.all { variant -> + android.applicationVariants.configureEach { variant -> variant.javaCompileProvider.get().options.bootstrapClasspath = files( file('libs/framework.jar'), android.getBootClasspath() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 27e23c5..b0d18c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,17 @@ + + + + + + + + + + + diff --git a/app/src/main/java/com/ttstd/dialer/activity/alarm/add/AlarmAddViewModel.java b/app/src/main/java/com/ttstd/dialer/activity/alarm/add/AlarmAddViewModel.java index 7d26914..ee87fb5 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/alarm/add/AlarmAddViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/activity/alarm/add/AlarmAddViewModel.java @@ -6,6 +6,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import com.ttstd.dialer.alarmclock.AlarmManagerHelper; +import com.ttstd.dialer.alarmclock.AlarmSyncManager; import com.ttstd.dialer.base.mvvm.BaseAndroidViewModel; import com.ttstd.dialer.db.alarm.AlarmInfo; import com.ttstd.dialer.db.alarm.AlarmRepository; @@ -62,6 +63,8 @@ public class AlarmAddViewModel extends BaseAndroidViewModel { .subscribe(() -> { AlarmManagerHelper.setExactAlarm(getAppContext(), alarmInfo.getId(), nextTime); mSaveResult.postValue(true); + // 本地保存成功后同步到后端;网络失败会留在本地并在联网后重试 + AlarmSyncManager.getInstance(getAppContext()).syncNow(); }, throwable -> { Logger.e(TAG, "更新下次触发时间失败: " + throwable.getMessage()); mSaveResult.postValue(true); // 依然认为保存成功,只是系统闹钟可能没设好 diff --git a/app/src/main/java/com/ttstd/dialer/activity/alarm/edit/AlarmEditViewModel.java b/app/src/main/java/com/ttstd/dialer/activity/alarm/edit/AlarmEditViewModel.java index 745036f..473e0c3 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/alarm/edit/AlarmEditViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/activity/alarm/edit/AlarmEditViewModel.java @@ -6,6 +6,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import com.ttstd.dialer.alarmclock.AlarmManagerHelper; +import com.ttstd.dialer.alarmclock.AlarmSyncManager; import com.ttstd.dialer.base.mvvm.BaseAndroidViewModel; import com.ttstd.dialer.db.alarm.AlarmInfo; import com.ttstd.dialer.db.alarm.AlarmRepository; @@ -70,6 +71,7 @@ public class AlarmEditViewModel extends BaseAndroidViewModel { AlarmManagerHelper.cancelAlarm(getAppContext(), alarmInfo.getId()); } mUpdateResult.setValue(true); + AlarmSyncManager.getInstance(getAppContext()).syncNow(); }, throwable -> { Logger.e(TAG, "闹钟更新失败: " + throwable.getMessage()); @@ -85,6 +87,7 @@ public class AlarmEditViewModel extends BaseAndroidViewModel { Logger.d(TAG, "删除闹钟完成"); AlarmManagerHelper.cancelAlarm(getAppContext(), alarmInfo.getId()); mDeleteResult.setValue(true); + AlarmSyncManager.getInstance(getAppContext()).syncNow(); }, throwable -> { Logger.e(TAG, "闹钟删除失败: " + throwable.getMessage()); diff --git a/app/src/main/java/com/ttstd/dialer/activity/alarm/list/AlarmListViewModel.java b/app/src/main/java/com/ttstd/dialer/activity/alarm/list/AlarmListViewModel.java index d3cf4ed..041cf16 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/alarm/list/AlarmListViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/activity/alarm/list/AlarmListViewModel.java @@ -6,6 +6,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import com.ttstd.dialer.alarmclock.AlarmManagerHelper; +import com.ttstd.dialer.alarmclock.AlarmSyncManager; import com.ttstd.dialer.base.mvvm.BaseAndroidViewModel; import com.ttstd.dialer.db.alarm.AlarmInfo; import com.ttstd.dialer.db.alarm.AlarmRepository; @@ -68,7 +69,10 @@ public class AlarmListViewModel extends BaseAndroidViewModel { }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> Logger.d(TAG, "updateAlarmEnabled: 更新闹钟状态成功"), + .subscribe(() -> { + Logger.d(TAG, "updateAlarmEnabled: 更新闹钟状态成功"); + AlarmSyncManager.getInstance(getAppContext()).syncNow(); + }, throwable -> Logger.e(TAG, "updateAlarmEnabled: 更新闹钟状态失败: " + throwable.getMessage()))); } @@ -80,6 +84,7 @@ public class AlarmListViewModel extends BaseAndroidViewModel { Logger.d(TAG, "删除闹钟完成"); AlarmManagerHelper.cancelAlarm(getAppContext(), alarmInfo.getId()); mDeleteLiveData.setValue(true); + AlarmSyncManager.getInstance(getAppContext()).syncNow(); }, throwable -> { Logger.e(TAG, "删除闹钟失败: " + throwable.getMessage()); diff --git a/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java b/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java index 4958f7f..2348803 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java +++ b/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java @@ -49,11 +49,6 @@ public class SettingsActivity extends BaseMvvmActivity Logger.e("snRegister", "onNext: " + baseResponse), - throwable -> Logger.e("snRegister", "onError: " + throwable.getMessage()))); } } diff --git a/app/src/main/java/com/ttstd/dialer/alarmclock/AlarmSyncManager.java b/app/src/main/java/com/ttstd/dialer/alarmclock/AlarmSyncManager.java new file mode 100644 index 0000000..17c9e27 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/alarmclock/AlarmSyncManager.java @@ -0,0 +1,141 @@ +package com.ttstd.dialer.alarmclock; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; + +import androidx.annotation.NonNull; + +import com.ttstd.dialer.data.repository.SnRepository; +import com.ttstd.dialer.db.alarm.AlarmInfo; +import com.ttstd.dialer.db.alarm.AlarmRepository; +import com.ttstd.dialer.utils.Logger; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * 闹钟同步管理器(设备端 Room ↔ 服务端 sys_sn_alarm)。 + * + *
    + *
  • 本地增删改闹钟后调用 {@link #syncNow()} 整表上传;网络失败时把本地数据标记 + * {@code SYNC_PENDING},联网恢复后自动重试,实现「断网也能本地保存闹钟」。
  • + *
  • 收到服务端推送(contentType=25)后调用 {@link #pullAndApply()}:拉取服务端整表 + * 覆盖本地,并保留本地未同步(pending)的闹钟合并后补推。
  • + *
+ * + * 服务端与本地均通过「整表覆盖」保证闹钟 id 一致。 + */ +public class AlarmSyncManager { + + /** 已同步 */ + public static final int SYNC_SYNCED = 0; + /** 待同步(网络失败时标记,联网后重试) */ + public static final int SYNC_PENDING = 1; + + private static final String TAG = "AlarmSyncManager"; + + private static volatile AlarmSyncManager sInstance; + + private final Context mContext; + private final AlarmRepository mRepository; + private final SnRepository mSnRepository; + + private AlarmSyncManager(Context context) { + mContext = context.getApplicationContext(); + mRepository = new AlarmRepository(mContext); + mSnRepository = SnRepository.getInstance(mContext); + } + + public static AlarmSyncManager getInstance(Context context) { + if (sInstance == null) { + synchronized (AlarmSyncManager.class) { + if (sInstance == null) { + sInstance = new AlarmSyncManager(context); + } + } + } + return sInstance; + } + + /** + * 注册网络恢复监听:存在待同步闹钟时自动重试上传。 + * 在 Application 启动时调用一次。 + */ + public void init() { + try { + ConnectivityManager cm = + (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) { + return; + } + cm.registerDefaultNetworkCallback(new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(@NonNull Network network) { + List pending = mRepository.getPendingAlarms(); + if (pending != null && !pending.isEmpty()) { + Logger.d(TAG, "网络恢复,重试同步闹钟,pending: " + pending.size()); + syncNow(); + } + } + }); + } catch (Exception e) { + Logger.e(TAG, "init 失败: " + e.getMessage()); + } + } + + /** + * 本地增删改后调用:整表上传服务端;成功清除 pending 标记,失败标记 pending 待联网重试。 + */ + public void syncNow() { + Observable.fromCallable(mRepository::getAllAlarms) + .flatMap(alarms -> mSnRepository.syncAlarms(alarms)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(resp -> { + if (resp != null && resp.isSuccess()) { + mRepository.updateAllSyncStatus(SYNC_SYNCED); + Logger.d(TAG, "syncNow 成功"); + } else { + mRepository.updateAllSyncStatus(SYNC_PENDING); + Logger.w(TAG, "syncNow 失败: " + (resp == null ? "null" : resp.getMsg())); + } + }, throwable -> { + mRepository.updateAllSyncStatus(SYNC_PENDING); + Logger.e(TAG, "syncNow 异常: " + throwable.getMessage()); + }); + } + + /** + * 收到推送 contentType=25 后调用:拉取服务端整表覆盖本地,保留本地 pending 未同步项, + * 覆盖后重排系统闹钟;若存在 pending 则整表补推一次。 + */ + public void pullAndApply() { + mSnRepository.getAlarms() + .observeOn(Schedulers.io()) + .flatMap(serverAlarms -> Observable.fromCallable(() -> { + List pending = mRepository.getPendingAlarms(); + List merged = new ArrayList<>(); + if (serverAlarms != null) { + merged.addAll(serverAlarms); + } + if (pending != null && !pending.isEmpty()) { + // 本地未同步的新增/修改合并进列表,后续整表上推补同步 + merged.addAll(pending); + } + mRepository.replaceAll(merged); + AlarmManagerHelper.rescheduleAllAlarms(mContext); + return Boolean.TRUE; + })) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> { + // 整表补推(含本地 pending 未同步项) + syncNow(); + Logger.d(TAG, "pullAndApply 完成"); + }, throwable -> Logger.e(TAG, "pullAndApply 失败: " + throwable.getMessage())); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java b/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java index cf21455..4ae84a4 100644 --- a/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java +++ b/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java @@ -109,6 +109,8 @@ public class BaseApplication extends Application { DeviceManagerService.init(this); OkHttpManager.init(this); PushExecutor.init(this); + // 闹钟离线同步:注册网络恢复监听,断网期间本地闹钟保存后联网自动补同步 + com.ttstd.dialer.alarmclock.AlarmSyncManager.getInstance(this).init(); initJPush(); if (BuildConfig.DEBUG) { // 这两行必须写在init之前,否则这些配置在init过程中将无效 diff --git a/app/src/main/java/com/ttstd/dialer/bean/WallpaperInfo.java b/app/src/main/java/com/ttstd/dialer/bean/WallpaperInfo.java new file mode 100644 index 0000000..af02a11 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/bean/WallpaperInfo.java @@ -0,0 +1,44 @@ +package com.ttstd.dialer.bean; + +import java.io.Serializable; +import java.util.List; + +/** + * 设备壁纸配置信息(对应后端 /api/v1/sn/get_wallpaper 返回) + */ +public class WallpaperInfo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 设备序列号 */ + private String serialno; + + /** 壁纸模式:0-单个壁纸,1-轮换壁纸 */ + private Integer mode; + + /** 壁纸图片URL列表(单个1张,轮换最多7张;可能为相对路径 /static/wallpaper/xxx) */ + private List images; + + public String getSerialno() { + return serialno; + } + + public void setSerialno(String serialno) { + this.serialno = serialno; + } + + public Integer getMode() { + return mode; + } + + public void setMode(Integer mode) { + this.mode = mode; + } + + public List getImages() { + return images; + } + + public void setImages(List images) { + this.images = images; + } +} diff --git a/app/src/main/java/com/ttstd/dialer/config/CommonConfig.java b/app/src/main/java/com/ttstd/dialer/config/CommonConfig.java index 6691560..87d9dad 100644 --- a/app/src/main/java/com/ttstd/dialer/config/CommonConfig.java +++ b/app/src/main/java/com/ttstd/dialer/config/CommonConfig.java @@ -50,6 +50,9 @@ public class CommonConfig { public static final String LOCATION_CHANGED_KEY = "map_location_changed"; public static final String LOCATION_ID_CHANGED_KEY = "map_Location_ID_changed"; + /*NTP 时间同步请求事件:由 AuthInterceptor 触发,MainService/MainServiceModel 监听并执行同步*/ + public static final String NTP_SYNC_REQUEST_KEY = "ntp_sync_request"; + /*当前定位地址*/ public static final String CURRENT_LOCATION_MAP_ADDRESS_KEY = "current_map_location_map_address"; @@ -82,6 +85,14 @@ public class CommonConfig { */ public static final String KEY_DEVICE_UNBIND_MOBILE = "device_unbind_mobile_key"; + /** + * 不再接收绑定请求的手机号名单(逗号分隔)。 + *

+ * 绑定请求弹窗(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 register(@NonNull String sn, @NonNull String model) { - return mApi.register(sn, model); + public Observable getWallpaper() { + return ApiResponseMapper.unwrap(mApi.getWallpaper()); + } + + /** + * 获取设备闹钟列表(服务端整表) + */ + @NonNull + public Observable> getAlarms() { + return ApiResponseMapper.unwrap(mApi.getAlarms()); + } + + /** + * 整表同步设备闹钟 + */ + @NonNull + public Observable> syncAlarms(@NonNull RequestBody body) { + return mApi.syncAlarms(body); + } + + /** + * 设备确认绑定手机号(绑定即注册,PoP 验签) + * + * @param sn 设备序列号 + * @param model 设备机型 + * @param publicKey Base64 设备公钥(X.509) + * @param challenge 挑战值(sn:ts:nonce),用于 PoP 验签 + * @param signature 用设备私钥对 challenge 的 ECDSA 签名(Base64) + * @param phone 同意绑定的家属手机号 + */ + @NonNull + public Observable bindPhone(@NonNull String sn, @NonNull String model, + @NonNull String publicKey, + @NonNull String challenge, + @NonNull String signature, + @NonNull String phone) { + return mApi.bindPhone(sn, model, publicKey, challenge, signature, phone); } /** diff --git a/app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java b/app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java index c171867..1f5c62e 100644 --- a/app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java +++ b/app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java @@ -1,12 +1,16 @@ package com.ttstd.dialer.data.repository; import android.content.Context; +import android.os.Build; 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.network.OkHttpManager; import com.ttstd.dialer.bean.req.SnLocationReq; import com.ttstd.dialer.bean.req.SnNetworkInfoReq; import com.ttstd.dialer.bean.req.SnOtherInfoReq; @@ -16,8 +20,12 @@ import com.ttstd.dialer.data.NetworkBoundResource; import com.ttstd.dialer.data.Resource; import com.ttstd.dialer.data.cache.CacheManager; import com.ttstd.dialer.data.remote.SnRemoteDataSource; +import com.ttstd.dialer.mdm.DeviceManagerService; +import com.ttstd.dialer.network.DeviceKeyManager; +import com.ttstd.dialer.utils.NativeUtils; import java.lang.reflect.Type; +import java.util.List; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -97,14 +105,59 @@ public class SnRepository { }.asObservable(); } + // ==================== 设备壁纸 ==================== + + /** + * 获取设备壁纸配置(推送触发拉取,不走缓存)。 + */ + @NonNull + public Observable getWallpaper() { + return mRemoteDataSource.getWallpaper() + .subscribeOn(Schedulers.io()); + } + + // ==================== 设备闹钟同步 ==================== + + /** + * 获取设备闹钟列表(服务端整表,推送触发拉取)。 + */ + @NonNull + public Observable> getAlarms() { + return mRemoteDataSource.getAlarms() + .subscribeOn(Schedulers.io()); + } + + /** + * 整表同步设备闹钟(本地增删改后上传)。 + */ + @NonNull + public Observable> syncAlarms(@NonNull List alarms) { + return mRemoteDataSource.syncAlarms(OkHttpManager.convertToRequestBodyjson(alarms)) + .subscribeOn(Schedulers.io()); + } + // ==================== 设备上报类(写操作,fire-and-forget)==================== /** - * 设备注册(sn + model)。调用方按需 subscribe。 + * 设备确认绑定手机号(绑定即注册)。 + *

+ * 设备端同意家属绑定请求后调用。设备首次绑定前可能尚未注册(服务端无公钥), + * 故本接口采用 PoP:生成设备公钥并对 {@code sn:ts:nonce} 签名, + * 服务端验签后自动完成「注册设备 + 绑定手机」两步。 + * + * @param phone 同意绑定的家属手机号 */ @NonNull - public Observable register(@NonNull String sn, @NonNull String model) { - return mRemoteDataSource.register(sn, model) + public Observable bindPhone(@NonNull String phone) { + String sn = DeviceManagerService.getInstance().getSerial(); + DeviceKeyManager keyManager = DeviceKeyManager.getInstance(); + keyManager.ensureKey(); + String publicKey = keyManager.getPublicKeyBase64(); + String ts = String.valueOf(System.currentTimeMillis()); + String nonce = NativeUtils.getNonce(); + String challenge = sn + ":" + ts + ":" + nonce; + String signature = keyManager.sign(challenge); + return mRemoteDataSource.bindPhone(sn, Build.MODEL, publicKey, challenge, signature, phone) .subscribeOn(Schedulers.io()); } diff --git a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDao.java b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDao.java index a46c08f..6894400 100644 --- a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDao.java +++ b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDao.java @@ -6,6 +6,7 @@ import androidx.room.Delete; import androidx.room.Insert; import androidx.room.OnConflictStrategy; import androidx.room.Query; +import androidx.room.Transaction; import androidx.room.Update; import java.util.List; @@ -55,6 +56,26 @@ public interface AlarmDao { @Query("SELECT COUNT(*) FROM alarm_clock") int getAlarmCount(); + @Query("SELECT * FROM alarm_clock WHERE sync_status = 1 ORDER BY hour ASC, minute ASC") + List getPendingAlarms(); + + @Query("UPDATE alarm_clock SET sync_status = :status WHERE id = :alarmId") + void updateSyncStatus(int alarmId, int status); + + @Query("UPDATE alarm_clock SET sync_status = :status") + void updateAllSyncStatus(int status); + + /** + * 整表替换(用于从服务端拉取后覆盖本地)。 + */ + @Transaction + default void replaceAll(List alarms) { + deleteAllAlarms(); + if (alarms != null && !alarms.isEmpty()) { + insertAlarms(alarms); + } + } + @Query("SELECT * FROM alarm_clock WHERE next_trigger_time > :currentTime ORDER BY next_trigger_time ASC LIMIT 1") AlarmInfo getNextAlarm(long currentTime); } diff --git a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDatabase.java b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDatabase.java index f46aead..00008ab 100644 --- a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDatabase.java +++ b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmDatabase.java @@ -2,14 +2,17 @@ package com.ttstd.dialer.db.alarm; import android.content.Context; +import androidx.annotation.NonNull; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; import androidx.room.TypeConverters; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; import java.io.File; -@Database(entities = {AlarmInfo.class, AlarmMedia.class}, version = 1, exportSchema = false) +@Database(entities = {AlarmInfo.class, AlarmMedia.class}, version = 2, exportSchema = false) @TypeConverters({IntegerListConverter.class}) public abstract class AlarmDatabase extends RoomDatabase { public abstract AlarmDao alarmDao(); @@ -18,6 +21,14 @@ public abstract class AlarmDatabase extends RoomDatabase { private static volatile AlarmDatabase INSTANCE; + /** v1 -> v2:闹钟表新增 sync_status(0-已同步 1-待同步),用于断网时离线保存、联网后重试。 */ + static final Migration MIGRATION_1_2 = new Migration(1, 2) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE alarm_clock ADD COLUMN sync_status INTEGER NOT NULL DEFAULT 0"); + } + }; + public static AlarmDatabase getDatabase(final Context context) { if (INSTANCE == null) { synchronized (AlarmDatabase.class) { @@ -26,6 +37,7 @@ public abstract class AlarmDatabase extends RoomDatabase { AlarmDatabase.class, context.getExternalFilesDir("db") + File.separator + "alarm" + File.separator + "alarm_db") .allowMainThreadQueries() + .addMigrations(MIGRATION_1_2) .build(); } } diff --git a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmInfo.java b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmInfo.java index 4b9f132..845d4c0 100644 --- a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmInfo.java +++ b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmInfo.java @@ -64,6 +64,10 @@ public class AlarmInfo implements Serializable, Parcelable { @ColumnInfo(name = "updated_at") private long updatedAt; + /** 同步状态:0-已同步,1-待同步(网络失败时标记,联网后重试) */ + @ColumnInfo(name = "sync_status") + private int syncStatus; + public AlarmInfo() { this.createdAt = System.currentTimeMillis(); this.updatedAt = System.currentTimeMillis(); @@ -85,6 +89,7 @@ public class AlarmInfo implements Serializable, Parcelable { isSnooze = in.readByte() != 0; createdAt = in.readLong(); updatedAt = in.readLong(); + syncStatus = in.readInt(); } public static final Creator CREATOR = new Creator() { @@ -219,6 +224,14 @@ public class AlarmInfo implements Serializable, Parcelable { this.updatedAt = updatedAt; } + public int getSyncStatus() { + return syncStatus; + } + + public void setSyncStatus(int syncStatus) { + this.syncStatus = syncStatus; + } + @NonNull @Override public String toString() { @@ -247,5 +260,6 @@ public class AlarmInfo implements Serializable, Parcelable { dest.writeByte((byte) (isSnooze ? 1 : 0)); dest.writeLong(createdAt); dest.writeLong(updatedAt); + dest.writeInt(syncStatus); } } diff --git a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmRepository.java b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmRepository.java index 5a46210..bfc7bfa 100644 --- a/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmRepository.java +++ b/app/src/main/java/com/ttstd/dialer/db/alarm/AlarmRepository.java @@ -55,6 +55,22 @@ public class AlarmRepository { return alarmDao.getNextAlarm(currentTime); } + public List getPendingAlarms() { + return alarmDao.getPendingAlarms(); + } + + public void updateSyncStatus(int alarmId, int status) { + alarmDao.updateSyncStatus(alarmId, status); + } + + public void updateAllSyncStatus(int status) { + alarmDao.updateAllSyncStatus(status); + } + + public void replaceAll(List alarms) { + alarmDao.replaceAll(alarms); + } + public long insertMedia(AlarmMedia media) { return alarmMediaDao.insertMedia(media); } diff --git a/app/src/main/java/com/ttstd/dialer/network/DeviceKeyManager.java b/app/src/main/java/com/ttstd/dialer/network/DeviceKeyManager.java new file mode 100644 index 0000000..9ad8a2c --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/network/DeviceKeyManager.java @@ -0,0 +1,166 @@ +package com.ttstd.dialer.network; + +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; +import android.util.Log; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; + +/** + * 设备签名密钥管理(ECDSA P-256,AndroidKeyStore 硬件级存储)。 + * + *

私钥由 AndroidKeyStore 生成并保管,不可导出,签名运算在系统级安全硬件内完成, + * 即使设备被 root 也无法提取私钥,符合「零信任设备」目标。

+ * + *

对外能力:

+ *
    + *
  • {@link #getPublicKeyBase64()}:导出公钥(X.509 SubjectPublicKeyInfo,Base64),注册时上传服务端绑定 SN
  • + *
  • {@link #sign(String)}:用私钥对 canonical 原文做 SHA256withECDSA 签名(Base64)
  • + *
  • {@link #deleteKey()}:删除密钥(恢复出厂/解绑时使用)
  • + *
+ * + *

算法契约(与服务端 {@code MobileApiSignatureFilter} 完全一致):

+ *
+ *   算法   : 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; +/** + * 签名工具类。 + * + *

保留旧的无密钥 SHA-256 签名(用于 WebSocket 信令等历史路径), + * 新增 ECDSA P-256 设备签名(用于 {@code /api/v1/sn/**} 接口,签名原文 + * {@code METHOD|path|bodyHash|ts|nonce})。

+ */ public class SecurityUtils { + + /** + * [旧] 无密钥 SHA-256 签名(仅对 header 参数排序拼接后取 SHA-256,无密钥参与)。 + * 用于 WebSocket 信令握手等历史路径,保留以兼容现有调用方。 + */ public static String generateSign(SortedMap params) { StringBuilder sb = new StringBuilder(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @@ -25,4 +37,42 @@ public class SecurityUtils { sb.setLength(sb.length() - 1); return HashUtils.getSHA256String(sb.toString()); } + + /** + * 构造签名原文 canonical = {@code METHOD|path|bodyHash|ts|nonce}。 + * 与服务端 {@code MobileApiSignatureFilter} 的 canonical 构造完全一致。 + * + * @param method HTTP 方法(大写,如 POST) + * @param path URL path(不含 query) + * @param bodyHash 请求体 SHA-256 的十六进制小写串;无 body / multipart 时传空字符串 + * @param ts 时间戳(毫秒) + * @param nonce 随机数 + */ + public static String buildCanonical(String method, String path, String bodyHash, String ts, String nonce) { + return method + "|" + path + "|" + (bodyHash == null ? "" : bodyHash) + "|" + ts + "|" + nonce; + } + + /** + * 用设备 ECDSA P-256 私钥对 canonical 原文签名(SHA256withECDSA,Base64)。 + * 私钥由 AndroidKeyStore 保管,不可导出。 + * + * @param canonical 签名原文({@link #buildCanonical} 的产物) + * @return Base64 签名;密钥不可用/签名失败返回 null + */ + public static String signWithEcdsa(String canonical) { + return DeviceKeyManager.getInstance().sign(canonical); + } + + /** + * 计算请求体的 SHA-256 十六进制小写串。 + * + * @param body 请求体字节数组(可为 null,此时返回空串) + * @return 十六进制小写 hash;body 为 null 返回空串 + */ + public static String sha256Hex(byte[] body) { + if (body == null || body.length == 0) { + return ""; + } + return HashUtils.getSHA256String(new String(body, java.nio.charset.StandardCharsets.UTF_8)); + } } diff --git a/app/src/main/java/com/ttstd/dialer/network/UrlConstants.java b/app/src/main/java/com/ttstd/dialer/network/UrlConstants.java index 5c72b95..02d5a13 100644 --- a/app/src/main/java/com/ttstd/dialer/network/UrlConstants.java +++ b/app/src/main/java/com/ttstd/dialer/network/UrlConstants.java @@ -1,10 +1,12 @@ package com.ttstd.dialer.network; public class UrlConstants { - public static final String ROOT_URL = "http://192.168.100.244:8000/api/v1/sn/"; + /** 服务器源地址(协议+主机+端口),用于拼接静态资源完整 URL(如 /static/wallpaper/xxx) */ + public static final String SERVER_ORIGIN = "http://192.168.100.244:8000"; + public static final String ROOT_URL = SERVER_ORIGIN + "/api/v1/sn/"; - //设备注册 - public static final String SN_REGISTER = "register"; + //设备确认绑定手机号(绑定即注册:设备同意家属绑定请求后调用,携带公钥 PoP,服务端自动注册+绑定) + public static final String SN_BIND_PHONE = "bind_phone"; //上传设备基本信息 public static final String UPDATE_SYSTEM_INFO = "update_system_info"; //上传设备硬件信息 @@ -25,6 +27,13 @@ public class UrlConstants { public static final String UPLOAD_INSTALL_APKS = "upload_install_apks"; /*上传应用图标(全局图标库)*/ public static final String UPLOAD_APP_ICON = "upload_app_icon"; + /*获取设备壁纸配置*/ + public static final String GET_WALLPAPER = "get_wallpaper"; + + /*获取设备闹钟列表(服务端整表)*/ + public static final String GET_ALARMS = "get_alarms"; + /*整表同步设备闹钟(本地增删改后上传)*/ + public static final String SYNC_ALARMS = "sync_alarms"; public static final String CONTACT_LIST = "contact/list"; diff --git a/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java b/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java index fd67549..99c57e0 100644 --- a/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java +++ b/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java @@ -2,8 +2,12 @@ package com.ttstd.dialer.network.api; import com.ttstd.dialer.bean.BaseResponse; import com.ttstd.dialer.bean.DeveloperOptions; +import com.ttstd.dialer.bean.WallpaperInfo; +import com.ttstd.dialer.db.alarm.AlarmInfo; import com.ttstd.dialer.network.UrlConstants; +import java.util.List; + import io.reactivex.rxjava3.core.Observable; import okhttp3.MultipartBody; import okhttp3.RequestBody; @@ -17,14 +21,24 @@ import retrofit2.http.POST; import retrofit2.http.Part; public interface SnApi { + /** + * 设备确认绑定手机号(绑定即注册)。 + *

+ * 家属端发起绑定请求、设备端同意后调用。设备首次绑定前可能尚未注册, + * 该接口与注册接口一致采用 PoP 验签:携带公钥 + challenge(sn:ts:nonce) + ECDSA 签名, + * 服务端验签后自动完成「注册设备 + 绑定手机」两步。 + */ @FormUrlEncoded - @POST(UrlConstants.SN_REGISTER) - Observable register( + @POST(UrlConstants.SN_BIND_PHONE) + Observable bindPhone( @Field("sn") String sn, - @Field("model") String model + @Field("model") String model, + @Field("publicKey") String publicKey, + @Field("challenge") String challenge, + @Field("signature") String signature, + @Field("phone") String phone ); - /** * 上传设备基本信息 */ @@ -103,4 +117,24 @@ public interface SnApi { @Part MultipartBody.Part file ); + /** + * 获取设备壁纸配置(mode + 图片URL列表) + */ + @GET(UrlConstants.GET_WALLPAPER) + Observable> getWallpaper(); + + /** + * 获取设备闹钟列表(服务端整表) + */ + @GET(UrlConstants.GET_ALARMS) + Observable>> getAlarms(); + + /** + * 整表同步设备闹钟(本地增删改后上传,body 为闹钟 JSON 数组) + */ + @POST(UrlConstants.SYNC_ALARMS) + Observable> syncAlarms( + @Body RequestBody body + ); + } diff --git a/app/src/main/java/com/ttstd/dialer/network/interceptor/AuthInterceptor.java b/app/src/main/java/com/ttstd/dialer/network/interceptor/AuthInterceptor.java index 70aeced..dfaec9f 100644 --- a/app/src/main/java/com/ttstd/dialer/network/interceptor/AuthInterceptor.java +++ b/app/src/main/java/com/ttstd/dialer/network/interceptor/AuthInterceptor.java @@ -4,25 +4,44 @@ import android.util.Log; import androidx.annotation.NonNull; +import com.jeremyliao.liveeventbus.LiveEventBus; +import com.ttstd.dialer.config.CommonConfig; import com.ttstd.dialer.mdm.DeviceManagerService; +import com.ttstd.dialer.network.DeviceKeyManager; import com.ttstd.dialer.network.SecurityUtils; +import com.ttstd.dialer.utils.Logger; import com.ttstd.dialer.utils.NativeUtils; import java.io.IOException; import java.util.Arrays; import java.util.HashSet; import java.util.Set; -import java.util.SortedMap; -import java.util.TreeMap; import okhttp3.Headers; import okhttp3.Interceptor; +import okhttp3.MediaType; import okhttp3.Request; +import okhttp3.RequestBody; import okhttp3.Response; +import okio.Buffer; /** - * 认证拦截器 - * 功能:为指定请求自动添加 Authorization Header,支持白名单过滤。 + * 认证拦截器(ECDSA P-256 设备签名)。 + * + *

为 {@code /api/v1/sn/**} 设备接口自动注入签名头:

+ *
+ *   X-Device-SN : 设备序列号
+ *   X-Nonce     : 随机数(防重放)
+ *   X-Timestamp : 时间戳(毫秒)
+ *   X-Sign      : Base64(ECDSA-SHA256(METHOD|path|bodyHash|ts|nonce))
+ * 
+ * + *

签名原文 canonical 与服务端 {@code MobileApiSignatureFilter} 完全一致, + * 用设备 AndroidKeyStore 私钥(ECDSA P-256)签名,服务端按 SN 查公钥验签。 + * 请求体参与签名(bodyHash),堵住明文篡改 body。

+ * + *

注册接口({@code /register})因服务端尚无该设备公钥,由服务端走 PoP 验签, + * 本拦截器对注册请求跳过设备签名头,避免服务端因查不到公钥而误拒。

*/ public class AuthInterceptor implements Interceptor { @@ -32,6 +51,17 @@ public class AuthInterceptor implements Interceptor { // 可以继续添加其他白名单路径 )); + // 注册接口:服务端用请求内公钥做 PoP 验签,跳过标准设备签名头 + private static final String REGISTER_PATH = "/api/v1/sn/register"; + + /** + * 距上次成功同步超过该时长时,通过事件通知 MainServiceModel 校准一次系统时间, + * 避免因设备时间不准导致服务端判定"请求已过期"(错误码 A0805 / HTTP 401)。 + */ + private static final long SYNC_INTERVAL_MILLIS = 6L * 60 * 60 * 1000; // 6 小时 + /** 上次成功同步系统时间的时刻(毫秒),0 表示从未同步过 */ + private static long lastSyncTimeMillis = 0L; + @NonNull @Override public Response intercept(@NonNull Chain chain) throws IOException { @@ -43,29 +73,106 @@ public class AuthInterceptor implements Interceptor { return chain.proceed(originalRequest); } + // 1.5 周期触发 NTP 时间同步(通知 MainServiceModel 异步执行,不阻塞本请求线程) + notifyTimeSyncIfNeeded(); + String sn = DeviceManagerService.getInstance().getSerial(); long timestamp = System.currentTimeMillis(); String nonce = NativeUtils.getNonce(); - SortedMap headersMap = new TreeMap<>(); - headersMap.put("X-Device-SN", sn); - headersMap.put("X-Nonce", nonce); - headersMap.put("X-Timestamp", String.valueOf(timestamp)); - String sign = SecurityUtils.generateSign(headersMap); - headersMap.put("X-Sign", sign); + // 2. 计算 bodyHash(multipart 或 GET 无 body 时为空串) + String bodyHash = computeBodyHash(originalRequest); + + // 3. 构造签名原文 METHOD|path|bodyHash|ts|nonce + String canonical = SecurityUtils.buildCanonical( + originalRequest.method(), path, bodyHash, String.valueOf(timestamp), nonce); + + // 4. 用设备 ECDSA 私钥签名(注册接口跳过,由服务端 PoP 验签) + String sign = null; + if (!isRegisterPath(path)) { + DeviceKeyManager.getInstance().ensureKey(); + sign = SecurityUtils.signWithEcdsa(canonical); + if (sign == null) { + Log.e("AuthInterceptor", "ECDSA sign failed, skip signature header"); + } + } + Log.e("AuthInterceptor", "intercept: X-Device-SN = " + sn); Log.e("AuthInterceptor", "intercept: X-Nonce = " + nonce); Log.e("AuthInterceptor", "intercept: X-Timestamp = " + timestamp); + Log.e("AuthInterceptor", "intercept: canonical = " + canonical); Log.e("AuthInterceptor", "intercept: X-Sign = " + sign); - // 2. 构建请求头(所有参数放header,防止URL被抓包泄露) - Headers headers = Headers.of(headersMap); + // 5. 构建请求头(所有参数放header,防止URL被抓包泄露) + Headers.Builder headerBuilder = new Headers.Builder(); + headerBuilder.add("X-Device-SN", sn); + headerBuilder.add("X-Nonce", nonce); + headerBuilder.add("X-Timestamp", String.valueOf(timestamp)); + if (sign != null) { + headerBuilder.add("X-Sign", sign); + } Request newRequest = originalRequest.newBuilder() - .headers(headers) + .headers(headerBuilder.build()) .build(); - // 3. 继续执行请求链 + // 6. 继续执行请求链 return chain.proceed(newRequest); } -} \ No newline at end of file + + /** + * 周期触发 NTP 时间同步。 + *

+ * 仅当距上次成功同步超过 {@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 mWallpaperUrls = new ArrayList<>(); + /** 轮换壁纸当前索引 */ + private int mWallpaperIndex = 0; + /** 轮换壁纸定时切换 Handler */ + private Handler mWallpaperHandler; + /** 轮换壁纸切换间隔(30 分钟) */ + private static final long WALLPAPER_ROTATION_INTERVAL_MS = 30 * 60 * 1000L; + private PushExecutor(Context context) { if (context == null) { throw new RuntimeException("Context is NULL"); @@ -206,6 +256,24 @@ public class PushExecutor { case DEVICE_POLICY: setDevicePolicy(extra); break; + case DEVICE_BIND_REQUEST: + bindRequest(extra); + break; + case DEVICE_BIND_SUCCESS: + bindSuccess(extra); + break; + case DEVICE_REVERSE_DIAL: + reverseDial(extra); + break; + case DEVICE_WALLPAPER: + applyWallpaper(); + break; + case DEVICE_PICTURE: + showPictureDialog(extra); + break; + case DEVICE_ALARM: + syncAlarmFromServer(); + break; default: Logger.e(TAG, "setPushMessage: default"); } @@ -772,6 +840,311 @@ public class PushExecutor { Logger.e(TAG, "screenLock: 收到屏幕锁定推送,功能待实现"); } + /** + * 反向拨号(contentType = 22, reverseDial)。 + *

+ * 由后端 {@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() { + @Override + public void onSuccess(WallpaperInfo info) { + if (info == null || info.getImages() == null || info.getImages().isEmpty()) { + Logger.e(TAG, "applyWallpaper: 壁纸配置为空,跳过"); + return; + } + int mode = info.getMode() == null ? 0 : info.getMode(); + List urls = new ArrayList<>(); + for (String image : info.getImages()) { + String full = resolveWallpaperUrl(image); + if (full != null) { + urls.add(full); + } + } + if (urls.isEmpty()) { + Logger.e(TAG, "applyWallpaper: 壁纸图片地址无效,跳过"); + return; + } + if (mode == 1 && urls.size() > 1) { + startWallpaperRotation(urls); + } else { + setWallpaperBitmap(urls.get(0)); + } + } + + @Override + public void onFailure(Throwable e) { + Logger.e(TAG, "applyWallpaper: 拉取壁纸配置失败 " + e.getMessage()); + } + }); + } + + /** + * 将壁纸相对路径(如 /static/wallpaper/xxx.jpg)解析为完整 URL。 + */ + private String resolveWallpaperUrl(String url) { + if (TextUtils.isEmpty(url)) { + return null; + } + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + String origin = UrlConstants.SERVER_ORIGIN; + if (TextUtils.isEmpty(origin)) { + return null; + } + return origin + (url.startsWith("/") ? url : "/" + url); + } + + /** + * 开始轮换壁纸:设置第一张并定时切换。 + */ + private void startWallpaperRotation(List urls) { + mWallpaperUrls = urls; + mWallpaperIndex = 0; + setWallpaperBitmap(urls.get(0)); + scheduleNextRotation(); + Logger.e(TAG, "startWallpaperRotation: 轮换壁纸启动, count=" + urls.size()); + } + + private void scheduleNextRotation() { + if (mWallpaperHandler == null) { + mWallpaperHandler = new Handler(Looper.getMainLooper()); + } + mWallpaperHandler.removeCallbacksAndMessages(null); + mWallpaperHandler.postDelayed(() -> { + if (mWallpaperUrls == null || mWallpaperUrls.isEmpty()) { + return; + } + mWallpaperIndex = (mWallpaperIndex + 1) % mWallpaperUrls.size(); + setWallpaperBitmap(mWallpaperUrls.get(mWallpaperIndex)); + scheduleNextRotation(); + }, WALLPAPER_ROTATION_INTERVAL_MS); + } + + /** + * 下载壁纸图片并设置为系统壁纸(主屏 + 锁屏)。 + */ + private void setWallpaperBitmap(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, "setWallpaperBitmap: 下载壁纸失败 " + 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, "setWallpaperBitmap: 下载壁纸失败 code=" + response.code() + " url=" + url); + return; + } + byte[] bytes = body.bytes(); + Bitmap bitmap = decodeSampledBitmap(bytes); + if (bitmap == null) { + Logger.e(TAG, "setWallpaperBitmap: 壁纸解码失败 " + url); + return; + } + WallpaperManager wallpaperManager = WallpaperManager.getInstance(mContext); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + wallpaperManager.setBitmap(bitmap, null, true, + WallpaperManager.FLAG_SYSTEM | WallpaperManager.FLAG_LOCK); + } else { + wallpaperManager.setBitmap(bitmap); + } + Logger.e(TAG, "setWallpaperBitmap: 壁纸设置成功 " + url); + } catch (Exception e) { + Logger.e(TAG, "setWallpaperBitmap: 设置壁纸失败 " + e.getMessage()); + } + } + }); + } + + /** + * 按屏幕尺寸降采样解码壁纸位图,避免大图 OOM。 + */ + private Bitmap decodeSampledBitmap(byte[] bytes) { + try { + int reqWidth = mContext.getResources().getDisplayMetrics().widthPixels; + int reqHeight = mContext.getResources().getDisplayMetrics().heightPixels; + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); + options.inJustDecodeBounds = false; + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + } catch (Throwable e) { + Logger.e(TAG, "decodeSampledBitmap: " + e.getMessage()); + return null; + } + } + + private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + int width = options.outWidth; + int height = options.outHeight; + int inSampleSize = 1; + if (height > reqHeight || width > reqWidth) { + int halfHeight = height / 2; + int halfWidth = width / 2; + while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2; + } + } + return inSampleSize; + } + + /** + * 闹钟同步(contentType = 25, deviceAlarm)。 + *

+ * 家属端增删改闹钟后由后端推送,设备收到后从服务端拉取整表覆盖本地 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() { + @Override + public void onSuccess(BaseResponse baseResponse) { + Logger.e(TAG, "confirmBindPhone: onSuccess: " + baseResponse); + } + + @Override + public void onFailure(Throwable e) { + Logger.e(TAG, "confirmBindPhone: onFailure: " + e.getMessage()); + } + }); + } + + /** + * 将手机号加入「不再接收绑定请求」名单(MMKV 逗号分隔)。 + */ + private void rejectPhone(String phone) { + try { + String existing = mMMKV.decodeString(CommonConfig.KEY_BIND_REJECT_PHONES, ""); + if (existing == null || existing.isEmpty()) { + mMMKV.encode(CommonConfig.KEY_BIND_REJECT_PHONES, phone); + } else if (!containsPhone(existing, phone)) { + mMMKV.encode(CommonConfig.KEY_BIND_REJECT_PHONES, existing + "," + phone); + } + Logger.e(TAG, "rejectPhone: 已加入拒绝名单, phone=" + phone); + } catch (Throwable t) { + Logger.e(TAG, "rejectPhone failed: " + t.getMessage()); + } + } + + /** + * 判断手机号是否在「不再接收」名单中。 + */ + private boolean isPhoneRejected(String phone) { + try { + String existing = mMMKV.decodeString(CommonConfig.KEY_BIND_REJECT_PHONES, ""); + return containsPhone(existing == null ? "" : existing, phone); + } catch (Throwable t) { + Logger.e(TAG, "isPhoneRejected failed: " + t.getMessage()); + return false; + } + } + + private boolean containsPhone(String phoneList, String phone) { + if (TextUtils.isEmpty(phoneList) || TextUtils.isEmpty(phone)) { + return false; + } + for (String p : phoneList.split(",")) { + if (p.trim().equals(phone.trim())) { + return true; + } + } + return false; + } + /** * 从推送 extra JSON 中读取整型字段;字段缺失或解析失败时返回默认值。 */ diff --git a/app/src/main/java/com/ttstd/dialer/service/main/MainService.java b/app/src/main/java/com/ttstd/dialer/service/main/MainService.java index 170fbc1..857dd2f 100644 --- a/app/src/main/java/com/ttstd/dialer/service/main/MainService.java +++ b/app/src/main/java/com/ttstd/dialer/service/main/MainService.java @@ -112,6 +112,8 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt mViewModel = new ViewModelProvider(this).get(MainServiceModel.class); // 注入 Repository(数据访问统一走 Data 层,避免 ViewModel 直接触网) mViewModel.setSnRepository(SnRepository.getInstance(this)); + // 启动时异步校准一次系统时间,防止设备时间不准导致签名请求被判定"请求已过期" + mViewModel.syncSystemTime(); LiveEventBus.get(CommonConfig.LOCATION_CHANGED_KEY, SnLocationReq.class) .observeSticky(this, new Observer() { @@ -122,6 +124,16 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt } }); + // 监听 AuthInterceptor 触发的时间同步请求,异步校准系统时间 + LiveEventBus.get(CommonConfig.NTP_SYNC_REQUEST_KEY, Object.class) + .observe(this, new Observer() { + @Override + public void onChanged(Object o) { + Logger.e(TAG, "onChanged: receive NTP sync request"); + mViewModel.syncSystemTime(); + } + }); + mWindowManager = (WindowManager) getSystemService(WINDOW_SERVICE); boolean enableFloatWindow = mMMKV.decodeInt(CommonConfig.FLOAT_WINDOW_ENABLE, 0) == 1; diff --git a/app/src/main/java/com/ttstd/dialer/service/main/MainServiceModel.java b/app/src/main/java/com/ttstd/dialer/service/main/MainServiceModel.java index 3ad50be..c484d52 100644 --- a/app/src/main/java/com/ttstd/dialer/service/main/MainServiceModel.java +++ b/app/src/main/java/com/ttstd/dialer/service/main/MainServiceModel.java @@ -8,9 +8,14 @@ import com.ttstd.dialer.bean.BaseResponse; import com.ttstd.dialer.bean.req.SnLocationReq; import com.ttstd.dialer.data.repository.SnRepository; import com.ttstd.dialer.gson.GsonUtils; +import com.ttstd.dialer.utils.SystemUtils; +import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.observers.DisposableObserver; +import io.reactivex.rxjava3.schedulers.Schedulers; + +import java.util.concurrent.Callable; public class MainServiceModel extends ViewModel { private static final String TAG = "MainServiceModel"; @@ -31,6 +36,32 @@ public class MainServiceModel extends ViewModel { super.onCleared(); } + /** + * 通过 NTP 同步系统时间(Service 启动时调用一次)。 + *

+ * 原因:设备签名接口(/api/v1/sn/**)的签名时间戳取自本机系统时钟, + * 若设备时间不准,服务端会判定"请求已过期"(HTTP 401 / 业务码 A0805)。 + * 这里在 Service 启动时异步校准一次系统时间,保证后续上报/请求时间戳准确。 + *

+ * NTP 为网络阻塞操作,通过 RxJava 切到 IO 线程执行,不阻塞主线程; + * 使用 CompositeDisposable 管理,ViewModel 销毁时自动取消。 + */ + public void syncSystemTime() { + mDisposables.add(Observable.fromCallable(new Callable() { + @Override + public Boolean call() throws Exception { + // 遍历多个 NTP 服务器,取到第一个成功的时间并设置系统时间 + return SystemUtils.syncSystemTimeFromNtp(2000); + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe( + success -> Log.e(TAG, "syncSystemTime: NTP sync result = " + success), + throwable -> Log.e(TAG, "syncSystemTime: " + throwable.getMessage()) + )); + } + public void uploadLocation(SnLocationReq locationReq) { // 空校验:位置信息缺失时不发起请求,避免后端收到无效数据 if (locationReq == null) { diff --git a/app/src/main/java/com/ttstd/dialer/utils/SystemUtils.java b/app/src/main/java/com/ttstd/dialer/utils/SystemUtils.java index d4ff7c4..6286c8c 100644 --- a/app/src/main/java/com/ttstd/dialer/utils/SystemUtils.java +++ b/app/src/main/java/com/ttstd/dialer/utils/SystemUtils.java @@ -54,6 +54,9 @@ import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.Constructor; import java.lang.reflect.Method; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; import java.net.NetworkInterface; import java.util.ArrayList; import java.util.Collections; @@ -1455,4 +1458,151 @@ public class SystemUtils { return false; } } + + // ================================================================== + // 系统时间同步(NTP) + // ================================================================== + + /** NTP 时间戳起始时间(1900-01-01)与 Unix 纪元(1970-01-01)之间的秒数偏移 */ + private static final long NTP_EPOCH_OFFSET_SECONDS = 2208988800L; + /** 常用公共 NTP 服务器(顺序即优先级) */ + private static final String[] DEFAULT_NTP_SERVERS = { + "pool.ntp.org", + "ntp.aliyun.com", + "time.windows.com", + "time.apple.com" + }; + + /** + * 通过 NTP 服务器同步系统时间(需要系统签名)。 + *

+ * 流程:从 NTP 服务器获取标准时间戳 → 反射调用隐藏 API {@code SystemClock.setCurrentTimeMillis(long)} + * 设置系统时间(系统签名应用可调用);反射失败时回退到 {@code date} 命令行。 + * 该方法会阻塞当前线程,建议放到子线程执行。 + * + * @param timeoutMillis 单次 NTP 请求超时时间(毫秒),<=0 时使用默认值 5000 + * @return true 表示同步成功 + */ + public static boolean syncSystemTimeFromNtp(int timeoutMillis) { + for (String server : DEFAULT_NTP_SERVERS) { + long ntpMillis = getNtpTimeMillis(server, timeoutMillis); + if (ntpMillis > 0) { + Logger.i(TAG, "syncSystemTimeFromNtp: got time from " + server + " = " + ntpMillis); + boolean ok = setSystemTime(ntpMillis); + Logger.i(TAG, "syncSystemTimeFromNtp: set result = " + ok); + if (ok) { + return true; + } + } else { + Logger.w(TAG, "syncSystemTimeFromNtp: failed to query " + server); + } + } + return false; + } + + /** + * 设置系统时间(毫秒时间戳)。需要系统签名。 + * 优先反射调用隐藏 API {@code android.os.SystemClock.setCurrentTimeMillis(long)}, + * 失败时回退到 {@code date -s} 命令行。 + * + * @param timeMillis 目标时间(Unix 毫秒时间戳) + * @return true 表示设置成功 + */ + public static boolean setSystemTime(long timeMillis) { + if (timeMillis <= 0) { + return false; + } + // 方案一:反射调用 SystemClock.setCurrentTimeMillis(隐藏 API,系统签名可用) + try { + Method setTime = Class.forName("android.os.SystemClock") + .getMethod("setCurrentTimeMillis", long.class); + boolean ret = (boolean) setTime.invoke(null, timeMillis); + if (ret) { + Logger.i(TAG, "setSystemTime: SystemClock.setCurrentTimeMillis success"); + return true; + } + Logger.w(TAG, "setSystemTime: SystemClock.setCurrentTimeMillis returned false"); + } catch (Exception e) { + Logger.w(TAG, "setSystemTime: SystemClock reflection failed: " + e.getMessage()); + } + + // 方案二:回退到 date 命令(MMddHHmmyyyy.ss 格式) + try { + String dateStr = new java.text.SimpleDateFormat("MMddHHmmyyyy.ss", + java.util.Locale.US).format(new java.util.Date(timeMillis)); + CmdUtil.Result result = CmdUtil.execute("date " + dateStr); + boolean ok = result != null && result.code == 0; + Logger.i(TAG, "setSystemTime: date cmd result = " + ok); + return ok; + } catch (Exception e) { + Logger.e(TAG, "setSystemTime: date cmd failed: " + e.getMessage()); + } + return false; + } + + /** + * 通过 SNTP(RFC 4330)协议从指定 NTP 服务器获取标准时间戳。 + * 会阻塞调用线程,建议放到子线程执行。 + * + * @param ntpServer NTP 服务器域名或 IP + * @param timeoutMillis 超时时间(毫秒),<=0 时使用默认值 5000 + * @return Unix 毫秒时间戳;失败返回 -1 + */ + public static long getNtpTimeMillis(String ntpServer, int timeoutMillis) { + if (ntpServer == null || ntpServer.isEmpty()) { + return -1L; + } + final int timeout = timeoutMillis > 0 ? timeoutMillis : 5000; + DatagramSocket socket = null; + try { + InetAddress address = InetAddress.getByName(ntpServer); + byte[] request = new byte[48]; + // 构造 NTP 请求包(SNTP v4,client mode=3) + request[0] = (byte) 0x1B; + socket = new DatagramSocket(); + socket.setSoTimeout(timeout); + socket.send(new DatagramPacket(request, request.length, address, 123)); + + byte[] response = new byte[48]; + DatagramPacket packet = new DatagramPacket(response, response.length); + socket.receive(packet); + + // 校验版本与模式(低 3 位为 server mode=4) + byte mode = (byte) (response[0] & 0x07); + if (mode != 4) { + Logger.w(TAG, "getNtpTimeMillis: invalid server mode " + mode); + return -1L; + } + + // 时间戳位于偏移 32(transmit timestamp,8 字节:秒+小数) + long transmitSeconds = getUint32(response, 32); + long transmitFraction = getUint32(response, 36); + + // 将 NTP 秒(1900 纪元)转为 Unix 毫秒(1970 纪元) + long unixMillis = (transmitSeconds - NTP_EPOCH_OFFSET_SECONDS) * 1000L; + // 加上小数部分(毫秒),提升精度 + unixMillis += (transmitFraction * 1000L) / 0x100000000L; + return unixMillis; + } catch (Exception e) { + Logger.w(TAG, "getNtpTimeMillis: " + ntpServer + " failed: " + e.getMessage()); + return -1L; + } finally { + if (socket != null) { + try { + socket.close(); + } catch (Throwable ignored) { + } + } + } + } + + /** + * 从 byte 数组指定偏移处读取 4 字节无符号大端整数(NTP 字段使用)。 + */ + private static long getUint32(byte[] data, int offset) { + return ((data[offset] & 0xFFL) << 24) + | ((data[offset + 1] & 0xFFL) << 16) + | ((data[offset + 2] & 0xFFL) << 8) + | (data[offset + 3] & 0xFFL); + } } diff --git a/app/src/main/res/values/strings_options.xml b/app/src/main/res/values/strings_options.xml index 7778ced..3c9a5e6 100644 --- a/app/src/main/res/values/strings_options.xml +++ b/app/src/main/res/values/strings_options.xml @@ -57,4 +57,20 @@ 拒绝 + + 设备绑定请求 + 手机号 %1$s 请求绑定本设备 + 请在 %1$d 秒内处理 + 不再接收该手机的请求 + 同意 + 拒绝 + 绑定请求已超时 + 绑定成功 + 手机号 %1$s 已成功绑定本设备 + + + + 家属发来的图片 + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 417aa10..b1a673e 100644 --- a/build.gradle +++ b/build.gradle @@ -7,14 +7,14 @@ buildscript { repositories { google() mavenCentral() - maven { url 'https://jitpack.io' } - maven { url 'https://maven.google.com' } - maven { url 'https://developer.huawei.com/repo/' } - maven { url 'https://developer.hihonor.com/repo' } - maven { url 'https://maven.aliyun.com/repository/central' } - maven { url 'https://maven.aliyun.com/repository/jcenter' } - maven { url 'https://maven.aliyun.com/repository/public' } - maven { url 'https://maven.aliyun.com/repository/google' } + maven { url = 'https://jitpack.io' } + maven { url = 'https://maven.google.com' } + maven { url = 'https://developer.huawei.com/repo/' } + maven { url = 'https://developer.hihonor.com/repo' } + maven { url = 'https://maven.aliyun.com/repository/central' } + maven { url = 'https://maven.aliyun.com/repository/jcenter' } + maven { url = 'https://maven.aliyun.com/repository/public' } + maven { url = 'https://maven.aliyun.com/repository/google' } } dependencies { classpath 'com.android.tools.build:gradle:8.13.2' @@ -40,14 +40,14 @@ allprojects { repositories { google() mavenCentral() - maven { url 'https://jitpack.io' } - maven { url 'https://maven.google.com' } - maven { url 'https://developer.huawei.com/repo/' } - maven { url 'https://developer.hihonor.com/repo' } - maven { url 'https://maven.aliyun.com/repository/central' } - maven { url 'https://maven.aliyun.com/repository/jcenter' } - maven { url 'https://maven.aliyun.com/repository/public' } - maven { url 'https://maven.aliyun.com/repository/google' } + maven { url = 'https://jitpack.io' } + maven { url = 'https://maven.google.com' } + maven { url = 'https://developer.huawei.com/repo/' } + maven { url = 'https://developer.hihonor.com/repo' } + maven { url = 'https://maven.aliyun.com/repository/central' } + maven { url = 'https://maven.aliyun.com/repository/jcenter' } + maven { url = 'https://maven.aliyun.com/repository/public' } + maven { url = 'https://maven.aliyun.com/repository/google' } } gradle.projectsEvaluated { diff --git a/gradle.properties b/gradle.properties index 199d16e..73b8c04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,3 +18,7 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true +# Suppress compileSdk version and Kotlin Gradle plugin deprecation warnings +android.suppressUnsupportedCompileSdk=37.0 +kotlin.suppressGradlePluginWarnings=DeprecatedGradleVersionWarning + diff --git a/iconloader/build.gradle b/iconloader/build.gradle index 574414e..f13bb91 100644 --- a/iconloader/build.gradle +++ b/iconloader/build.gradle @@ -1,16 +1,16 @@ apply plugin: 'com.android.library' android { - namespace "com.ttstd.iconloader" + namespace = "com.ttstd.iconloader" - compileSdkVersion 36 + compileSdk = 36 // buildToolsVersion "36.0.0" defaultConfig { - minSdkVersion 14 - targetSdkVersion 36 - versionCode 1 - versionName "1.0" + minSdk = 14 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles 'consumer-rules.pro' diff --git a/niceimageview/build.gradle b/niceimageview/build.gradle index 0800b55..01b5e1d 100644 --- a/niceimageview/build.gradle +++ b/niceimageview/build.gradle @@ -1,13 +1,13 @@ apply plugin: 'com.android.library' android { - namespace "com.shehuan.niv" + namespace = "com.shehuan.niv" - compileSdkVersion 28 + compileSdk = 28 defaultConfig { - minSdkVersion 14 - targetSdkVersion 28 + minSdk = 14 + targetSdk = 28 } buildTypes {