5 Commits

Author SHA1 Message Date
0856c22ce8 fix(time): 系统应用直接放行,无需受时间管控
- 在 appCanRun 中优先判断系统应用并返回 true
2026-08-24 10:48:43 +08:00
265108252e feat(statistics): add usage statistics and device info features 2026-08-19 15:32:14 +08:00
6e995e0795 feat(cache): 重构缓存架构并新增多级缓存及统计接口 2026-07-27 11:42:58 +08:00
a7c6e1a00a feat: 增强配置刷新机制并优化时间管控
增加跨天配置自动重试、推送防抖节流与定时轮询兜底,修复连接节流失效问题;
优化锁屏状态检测与应用强杀逻辑,升级版本号至 3.5.3。
2026-07-27 09:32:23 +08:00
e5c4829e7e fix: 修复应用安装,升级之后无法获取配置,增加MainService获取应用时间配置 2026-07-02 17:12:12 +08:00
59 changed files with 3880 additions and 460 deletions

View File

@@ -1,4 +1,6 @@
apply plugin: 'com.android.application'
plugins {
id 'com.android.application'
}
static def appName() {
return "FLYSN"
@@ -12,7 +14,7 @@ android {
namespace "com.fuying.sn"
compileSdkVersion 29
buildToolsVersion "29.0.3"
buildToolsVersion "36.0.0"
defaultConfig {
minSdkVersion 26
@@ -59,17 +61,25 @@ android {
buildConfigField "String", "ROOT_URL", '"https://fxyapi.17hxg.com/android/"'
buildConfigField "String", "SCREEN_URL", '"https://fxyapi.17hxg.com:3018/wm/is_online"'
// buildConfigField "String", "WEBSOCKET_URL", '"wss://47.111.23.154:3018"'
manifestPlaceholders = [
ALIYUN_PUSH_KEY: "335685739",
ALIYUN_PUSH_SECRET: "713e04b35a6249fc979e3d1f74ad59fd"
]
}
official {
flavorDimensions "default"
versionCode 89
versionName "3.4.9"
versionCode 100
versionName "3.5.9"
applicationId "com.fuying.sn"
buildConfigField "String", "ROOT_URL", '"https://as.fuyingy.com/android/"'
buildConfigField "String", "SCREEN_URL", '"https://as.fuyingy.com:3018/wm/is_online"'
// buildConfigField "String", "WEBSOCKET_URL", '"wss://47.111.23.154:3018"'
manifestPlaceholders = [
ALIYUN_PUSH_KEY: "335596359",
ALIYUN_PUSH_SECRET: "9995cad535ee42e99d299b3f0e18fcc6"
]
}
}
@@ -469,15 +479,12 @@ android {
applicationVariants.all { variant ->
variant.outputs.each { output ->
def buildType = variant.buildType.name
def fileName = ""
if (buildType.contains("debug")) {
fileName = "${appName()}_V${defaultConfig.versionName}_${releaseTime()}.apk"
// fileName = "${appName()}_V${defaultConfig.versionName}_${releaseTime()}.apk"
output.outputFileName = "app_debug.apk"
} else {
fileName = "${appName()}_${variant.versionCode}_V${variant.versionName}_${releaseTime()}_${buildType}.apk"
output.outputFileName = "${appName()}_${variant.versionCode}_V${variant.versionName}_${releaseTime()}_${buildType}.apk"
}
output.outputFileName = fileName
}
}
@@ -495,6 +502,12 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.3.1'
implementation 'androidx.lifecycle:lifecycle-livedata:2.3.1'
implementation 'androidx.lifecycle:lifecycle-runtime:2.3.1'
// 可选RxJava3 Observable → LiveData 的桥接
implementation 'androidx.lifecycle:lifecycle-reactivestreams:2.3.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
@@ -526,13 +539,15 @@ dependencies {
annotationProcessor 'com.github.bumptech.glide:compiler:4.13.2'
//磁盘缓存
implementation 'com.jakewharton:disklrucache:2.0.2'
//图表
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
//Aria
implementation 'com.arialyy.aria:core:3.8.15'
annotationProcessor 'com.arialyy.aria:compiler:3.8.15'
//Java WebSocket
implementation "org.java-websocket:Java-WebSocket:1.5.3"
//MMKV
implementation 'com.tencent:mmkv-static:1.2.13'
implementation 'com.tencent:mmkv-static:2.4.0'
//bugly
implementation 'com.tencent.bugly:crashreport:4.1.9.2'
// //腾讯移动推送 TPNS
@@ -545,9 +560,8 @@ dependencies {
implementation 'com.amap.api:location:5.1.0'
//工具类
implementation 'com.blankj:utilcodex:1.31.0'
//autosize会改变第三方view的大小
//https://github.com/JessYanCoding/AndroidAutoSize
//implementation 'me.jessyan:autosize:1.2.1'
//指示器
implementation 'com.github.hackware1993:MagicIndicator:1.7.0'
implementation 'com.github.chrisbanes:PhotoView:2.0.0'
//沉浸状态栏
implementation 'com.gitee.zackratos:UltimateBarX:0.8.0'
@@ -582,7 +596,7 @@ tasks.register('deleteAppSourceWhiteList', Exec) {
ignoreExitValue = true
doLast {
println "ADB command executed: settings delete system app_source_white_list"
println "ADB command executed: adb shell settings delete system app_source_white_list"
}
}

View File

@@ -126,6 +126,9 @@
android:name=".activity.update.UpdateActivity"
android:launchMode="singleTask"
android:theme="@style/DialogCloseOnTouchOutside" />
<activity
android:name=".activity.usage.UsageDetailActivity"
android:launchMode="singleTask" />
<service
android:name=".service.ControlPanelService"
@@ -269,12 +272,12 @@
<!-- 请填写你自己的- appKey -->
<meta-data
android:name="com.alibaba.app.appkey"
android:value="335685739" />
android:value="${ALIYUN_PUSH_KEY}" />
<!-- 请填写你自己的appSecret -->
<meta-data
android:name="com.alibaba.app.appsecret"
android:value="713e04b35a6249fc979e3d1f74ad59fd" />
android:value="${ALIYUN_PUSH_SECRET}" />
</application>
</manifest>

View File

@@ -24,6 +24,7 @@ import com.fuying.sn.databinding.ActivityHomeBinding;
import com.fuying.sn.dialog.UserDebugDialog;
import com.fuying.sn.fragment.BaseFragmentPagerAdapter;
import com.fuying.sn.fragment.device.DeviceFragment;
import com.fuying.sn.fragment.usage.UsageFragment;
import com.fuying.sn.manager.ControlManager;
import com.fuying.sn.manager.DeviceManager;
import com.fuying.sn.manager.time.TimeManager;
@@ -38,7 +39,7 @@ public class HomeActivity extends BaseMvvmActivity<HomeViewModel, ActivityHomeBi
private static final String TAG = "MainActivity";
private String[] mAllTitle = new String[]{
// "使用统计",
"使用统计",
"设备信息",};
private List<Fragment> mFragments;
@@ -78,7 +79,7 @@ public class HomeActivity extends BaseMvvmActivity<HomeViewModel, ActivityHomeBi
@Override
public void initView() {
mFragments = new ArrayList<>();
// mFragments.add(new UsageFragment());
mFragments.add(new UsageFragment());
mFragments.add(new DeviceFragment());
mFragmentManager = getSupportFragmentManager();
mBaseFragmentPagerAdapter = new BaseFragmentPagerAdapter(mFragmentManager, mFragments);

View File

@@ -0,0 +1,196 @@
package com.fuying.sn.activity.usage;
import android.app.Activity;
import android.content.Context;
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.GridLayoutManager;
import com.fuying.sn.R;
import com.fuying.sn.adapter.AppUseTimeAdapter;
import com.fuying.sn.base.mvvm.BaseMvvmActivity;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.data.model.AppUseTimeData;
import com.fuying.sn.data.model.AppUseTimeInfo;
import com.fuying.sn.databinding.ActivityUsageDetailBinding;
import com.fuying.sn.utils.ScreenUtils;
import com.fuying.sn.utils.ToastUtil;
import net.lucode.hackware.magicindicator.MagicIndicator;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.CommonNavigator;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.CommonNavigatorAdapter;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerIndicator;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerTitleView;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.indicators.LinePagerIndicator;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.titles.SimplePagerTitleView;
import java.util.Collections;
import java.util.List;
/**
* 应用使用时长详情页。
* 对应 fragment_usage 中 cl_use_time 模块,进入时 mIsAll = true展示全部应用数据。
*/
public class UsageDetailActivity extends BaseMvvmActivity<UsageDetailViewModel, ActivityUsageDetailBinding> {
private Activity mContext;
private MagicIndicator mMagicIndicator;
private int mSelectedTab = 0;
private AppUseTimeAdapter mAdapter;
private int mQueryType = 1;
// 详情页固定展示全部数据
private boolean mIsAll = true;
@Override
public boolean setNightMode() {
return true;
}
@Override
public int getLayoutId() {
return R.layout.activity_usage_detail;
}
@Override
protected void initDataBinding() {
mContext = this;
mViewModel.setCtx(this);
mViewModel.setLifecycle(getLifecycleSubject());
mViewModel.setVDBinding(mViewDataBinding);
mViewDataBinding.setClick(new BtnClick());
}
@Override
protected void initView() {
initTabs();
initRecyclerView();
selectTab(0);
}
@Override
protected void initData() {
mViewModel.appUseTime.observe(this, this::onAppUseTime);
mViewModel.loading.observe(this, this::onLoading);
mViewModel.error.observe(this, this::onError);
loadAppUseTime();
}
private void loadAppUseTime() {
mViewModel.loadAppUseTime(mQueryType, mIsAll ? 1 : 0);
}
private void onAppUseTime(BaseResponse<AppUseTimeData> resp) {
if (resp == null || resp.data == null || resp.data.getApp_list().isEmpty()) {
return;
}
List<AppUseTimeInfo> list = resp.data.getApp_list();
Collections.sort(list, (a, b) -> Long.compare(b.getUsage_time(), a.getUsage_time()));
long totalSeconds = 0;
for (AppUseTimeInfo info : list) {
totalSeconds += info.getUsage_time();
}
mViewDataBinding.tvTotalTime.setText(formatTotalTime(totalSeconds));
mAdapter.setData(list);
}
private void initTabs() {
mMagicIndicator = mViewDataBinding.magicIndicator;
final String[] titles = {
getString(R.string.usage_today),
getString(R.string.usage_week),
getString(R.string.usage_month)
};
CommonNavigator commonNavigator = new CommonNavigator(mContext);
commonNavigator.setAdjustMode(false);
commonNavigator.setAdapter(new CommonNavigatorAdapter() {
@Override
public int getCount() {
return titles.length;
}
@Override
public IPagerTitleView getTitleView(Context context, final int index) {
SimplePagerTitleView titleView = new SimplePagerTitleView(context);
titleView.setText(titles[index]);
titleView.setTextSize(ScreenUtils.sp2px(mContext, 18f));
titleView.setNormalColor(ContextCompat.getColor(mContext, R.color.gray));
titleView.setSelectedColor(Color.BLACK);
titleView.setOnClickListener(v -> selectTab(index));
return titleView;
}
@Override
public IPagerIndicator getIndicator(Context context) {
return new LinePagerIndicator(context);
}
});
mMagicIndicator.setNavigator(commonNavigator);
}
private void initRecyclerView() {
mAdapter = new AppUseTimeAdapter();
// 使用 rv_left 作为列表容器,设置 GridLayoutManager 2 列实现双列效果
mViewDataBinding.rvLeft.setLayoutManager(new GridLayoutManager(mContext, 2));
mViewDataBinding.rvLeft.setAdapter(mAdapter);
// 隐藏右侧 rv统一用左侧 rv + GridLayoutManager 实现
mViewDataBinding.rvRight.setVisibility(View.GONE);
}
private void selectTab(int position) {
mSelectedTab = position;
CommonNavigator navigator = (CommonNavigator) mMagicIndicator.getNavigator();
if (navigator != null) {
navigator.onPageSelected(position);
navigator.notifyDataSetChanged();
}
int newQueryType = position + 1;
if (mQueryType != newQueryType) {
mQueryType = newQueryType;
loadAppUseTime();
}
}
private void onLoading(Boolean visible) {
// 页面暂无 loading 视图,可按需要扩展
}
private void onError(Throwable e) {
ToastUtil.show(e != null && e.getMessage() != null ? e.getMessage() : getString(R.string.open_app_fail));
}
@Override
public void onBackPressed() {
finish();
}
public class BtnClick {
public void onBackClick(View view) {
finish();
}
}
private static String formatTotalTime(long totalSeconds) {
if (totalSeconds <= 0) {
return "0" + "分钟";
}
long hours = totalSeconds / 3600;
long minutes = (totalSeconds % 3600) / 60;
if (hours > 0 && minutes > 0) {
return hours + "小时" + minutes + "分钟";
} else if (hours > 0) {
return hours + "小时";
} else {
return minutes + "分钟";
}
}
}

View File

@@ -0,0 +1,72 @@
package com.fuying.sn.activity.usage;
import android.util.Log;
import androidx.lifecycle.MutableLiveData;
import com.fuying.sn.base.mvvm.BaseViewModel;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.data.model.AppUseTimeData;
import com.fuying.sn.data.repository.StatisticsRepository;
import com.fuying.sn.databinding.ActivityUsageDetailBinding;
import com.trello.rxlifecycle4.RxLifecycle;
import com.trello.rxlifecycle4.android.ActivityEvent;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
/**
* UsageDetailActivity 对应的 ViewModel。
* 仅负责"应用使用时长"这一模块的数据获取(复用 StatisticsRepository
* 不持有任何图表/UI 绘制逻辑,结果通过 LiveData 暴露给 Activity。
*/
public class UsageDetailViewModel extends BaseViewModel<ActivityUsageDetailBinding, ActivityEvent> {
private static final String TAG = "UsageDetailViewModel";
private final CompositeDisposable mDisposables = new CompositeDisposable();
private final StatisticsRepository mRepository = StatisticsRepository.getInstance();
public final MutableLiveData<BaseResponse<AppUseTimeData>> appUseTime = new MutableLiveData<>();
public final MutableLiveData<Boolean> loading = new MutableLiveData<>();
public final MutableLiveData<Throwable> error = new MutableLiveData<>();
@Override
public ActivityUsageDetailBinding getVDBinding() {
return binding;
}
/**
* 加载指定周期的应用使用记录。
*
* @param queryType 查询类型1 今天、2 本周、3 本月
* @param isAll 0 返回使用时长最高的 6 条1 返回全部有使用的记录
*/
public void loadAppUseTime(int queryType, int isAll) {
loading.setValue(true);
Disposable disposable = mRepository.getAppUseTime(queryType, isAll)
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribe(
resp -> {
Log.e(TAG, "loadAppUseTime: " + (resp == null ? "null" : resp.data));
appUseTime.setValue(resp);
loading.setValue(false);
},
err -> {
error.setValue(err);
loading.setValue(false);
}
);
mDisposables.add(disposable);
}
@Override
public void onDestroy() {
mDisposables.clear();
}
@Override
protected void onCleared() {
mDisposables.clear();
super.onCleared();
}
}

View File

@@ -0,0 +1,101 @@
package com.fuying.sn.adapter;
import android.content.Context;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.fuying.sn.R;
import com.fuying.sn.bean.PartTime;
import com.fuying.sn.config.CommonConfig;
import com.fuying.sn.data.model.AppControlDetail;
import com.fuying.sn.utils.GlideLoadUtils;
import com.fuying.sn.utils.TimeUtils;
import com.tencent.mmkv.MMKV;
import java.util.List;
public class AppControlDetailAdapter extends RecyclerView.Adapter<AppControlDetailAdapter.ViewHolder> {
private static final String TAG = "DesktopItemAdapter";
private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE);
private Context mContext;
private List<AppControlDetail> mAppTimeControlInfos;
public void setAppTimeControlInfos(List<AppControlDetail> appTimeControlInfos) {
mAppTimeControlInfos = appTimeControlInfos;
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
mContext = parent.getContext();
return new ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_app_time_detail, parent, false));
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
AppControlDetail appControlDetail = mAppTimeControlInfos.get(position);
holder.tv_name.setText(appControlDetail.getApp_name());
GlideLoadUtils.getInstance().glideLoad(mContext, appControlDetail.getApp_img(), holder.iv_icon, R.mipmap.ic_launcher);
if (appControlDetail.getIs_control() == 0) {
holder.tv_type.setText("禁用");
holder.tv_type.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_disable_background));
holder.tv_today_time.setText("");
holder.tv_remaining_time.setVisibility(View.GONE);
} else {
holder.tv_today_time.setText("");
holder.tv_remaining_time.setVisibility(View.GONE);
switch (appControlDetail.getControl_type()) {
case 0:
holder.tv_type.setText("不限制");
holder.tv_type.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_unlimited_background));
break;
case 1:
holder.tv_type.setText("限制时长");
holder.tv_type.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_duration_background));
holder.tv_today_time.setText("每天" + TimeUtils.formatTime(appControlDetail.getToday_time_total()));
holder.tv_remaining_time.setText("剩余" + TimeUtils.formatTime(appControlDetail.getRemaining_time()));
holder.tv_remaining_time.setVisibility(View.VISIBLE);
break;
case 2:
holder.tv_type.setText("限制时间段");
holder.tv_type.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_period_background));
holder.tv_today_time.setText(TimeUtils.getPartTimeString(appControlDetail));
break;
default:
holder.tv_type.setText("禁用");
holder.tv_type.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_disable_background));
}
}
}
@Override
public int getItemCount() {
return mAppTimeControlInfos == null ? 0 : mAppTimeControlInfos.size();
}
public class ViewHolder extends RecyclerView.ViewHolder {
ImageView iv_icon;
TextView tv_name, tv_type, tv_today_time, tv_remaining_time;
public ViewHolder(View itemView) {
super(itemView);
iv_icon = itemView.findViewById(R.id.iv_icon);
tv_name = itemView.findViewById(R.id.tv_name);
tv_type = itemView.findViewById(R.id.tv_type);
tv_today_time = itemView.findViewById(R.id.tv_today_time);
tv_remaining_time = itemView.findViewById(R.id.tv_remaining_time);
}
}
}

View File

@@ -0,0 +1,83 @@
package com.fuying.sn.adapter;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.fuying.sn.R;
import com.fuying.sn.data.model.AppUseTimeInfo;
import com.fuying.sn.utils.GlideLoadUtils;
import java.util.List;
public class AppUseTimeAdapter extends RecyclerView.Adapter<AppUseTimeAdapter.ViewHolder> {
private Context mContext;
private List<AppUseTimeInfo> mList;
public void setData(List<AppUseTimeInfo> list) {
mList = list;
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
mContext = parent.getContext();
return new ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_app_use_time, parent, false));
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
AppUseTimeInfo info = mList.get(position);
holder.tv_name.setText(info.getApp_name());
holder.tv_time.setText(formatUsageTime(info.getUsage_time()));
GlideLoadUtils.getInstance().glideLoad(mContext, info.getApp_img(), holder.iv_icon, R.mipmap.ic_launcher);
}
@Override
public int getItemCount() {
return mList == null ? 0 : mList.size();
}
/**
* 将秒数格式化为可读时长,精确到秒,如 "1小时20分30秒"、"20分30秒"、"30秒"
*/
private String formatUsageTime(long totalSeconds) {
if (totalSeconds <= 0) {
return "0秒";
}
long hours = totalSeconds / 3600;
long minutes = (totalSeconds % 3600) / 60;
long seconds = totalSeconds % 60;
StringBuilder sb = new StringBuilder();
if (hours > 0) {
sb.append(hours).append("小时");
}
if (minutes > 0) {
sb.append(minutes).append("");
}
if (seconds > 0) {
sb.append(seconds).append("");
}
return sb.toString();
}
static class ViewHolder extends RecyclerView.ViewHolder {
ImageView iv_icon;
TextView tv_name, tv_time;
ViewHolder(View itemView) {
super(itemView);
iv_icon = itemView.findViewById(R.id.iv_icon);
tv_name = itemView.findViewById(R.id.tv_name);
tv_time = itemView.findViewById(R.id.tv_time);
}
}
}

View File

@@ -17,6 +17,9 @@ public class AppTimeControlInfo implements Serializable {
* 0不管控1时长管控2时间段管控
*/
int control_type;
/**
* 0 禁用1 未禁用
*/
int is_control;
long work_time;
long rest_time;

View File

@@ -0,0 +1,26 @@
package com.fuying.sn.bean;
import java.io.Serializable;
public class DeviceInfo implements Serializable {
private static final long serialVersionUID = 1410550828670262801L;
String parent_mobile;
String bind_time;
public String getParent_mobile() {
return parent_mobile;
}
public void setParent_mobile(String parent_mobile) {
this.parent_mobile = parent_mobile;
}
public String getBind_time() {
return bind_time;
}
public void setBind_time(String bind_time) {
this.bind_time = bind_time;
}
}

View File

@@ -32,6 +32,9 @@ public class CommonConfig {
public static final String BROWSER_APP_KEY = "browser_app_key";
public static final String TYPEWRITING_APP_KEY = "typewriting_app_key";
/*上次 loadData 请求成功的时间戳(毫秒)*/
public static final String LAST_SYNC_TIME_KEY = "last_sync_time_key";
public static final String isLogined = "isLogined";
public static final String AES_KEY = "0123456789ABCDEF";
public static final String HTTP_KEY = "YTM3YTAxNTJmMmZmNzkyM2E2YzIwZjlhZTc0NzNmMGI=";

View File

@@ -0,0 +1,39 @@
package com.fuying.sn.data.local;
/**
* 本地数据源(伪代码 / 待实现)。
*
* 职责:统一封装所有本地存储读写,供 Repository 调用,
* 使 Repository 不感知具体存储实现(内存缓存 / 磁盘缓存 / SP / MMKV / 数据库)。
*
* 当前 StatisticsRepository 直接使用 NetInterfaceManager.withMultiLevelCache
* 完成缓存后续可将缓存读写、SP/MMKV 操作、业务副作用收敛到本类,
* 让 Repository 只做"取网络还是取本地"的决策。
*
* 以下均为示意骨架,尚未实现具体逻辑。
*/
public class LocalDataSource {
// ========== 1. 内存 + 磁盘缓存(承接 RxCacheManager / CacheHelper==========
// public <T> T getCache(String key) { /* TODO */ return null; }
// public <T> void saveCache(String key, T data, long expireMillis) { /* TODO */ }
// public void removeCache(String key) { /* TODO */ }
// ========== 2. SharedPreferences 存储(承接 SPUtils==========
// public void putString(String key, String value) { /* TODO */ }
// public String getString(String key, String def) { /* TODO */ return def; }
// public void putInt(String key, int value) { /* TODO */ }
// public int getInt(String key, int def) { /* TODO */ return def; }
// ========== 3. MMKV 存储(承接 MMKV==========
// public void putMMKV(String key, String value) { /* TODO */ }
// public String getMMKV(String key, String def) { /* TODO */ return def; }
// ========== 4. 业务副作用收敛(原散落在 NetInterfaceManager.onNext / Activity 中)==========
// public void saveLoginState(int code) { /* TODO 原 SPUtils.put(isLogined, ...) */ }
// public void markControlState(int state) { /* TODO 原 Settings.System.putInt(KEY_IS_CONTROL, ...) */ }
// ========== 5. 设备/统计信息缓存示例 ==========
// public void saveSnInfo(BaseResponse<SnInfo> resp) { /* TODO */ }
// public BaseResponse<SnInfo> getSnInfoCache() { /* TODO */ return null; }
}

View File

@@ -0,0 +1,85 @@
package com.fuying.sn.data.model;
import androidx.annotation.NonNull;
import com.fuying.sn.bean.PartTime;
import com.google.gson.Gson;
import com.google.gson.JsonParser;
import java.io.Serializable;
import java.util.List;
public class AppControlDetail implements Serializable {
private static final long serialVersionUID = 4052368488850538033L;
String app_name;
String app_img;
int control_type;
int is_control;
int today_time_total;
int remaining_time;
List<PartTime> time_period;
public String getApp_name() {
return app_name;
}
public void setApp_name(String app_name) {
this.app_name = app_name;
}
public String getApp_img() {
return app_img;
}
public void setApp_img(String app_img) {
this.app_img = app_img;
}
public int getControl_type() {
return control_type;
}
public void setControl_type(int control_type) {
this.control_type = control_type;
}
public int getIs_control() {
return is_control;
}
public void setIs_control(int is_control) {
this.is_control = is_control;
}
public int getToday_time_total() {
return today_time_total;
}
public void setToday_time_total(int today_time_total) {
this.today_time_total = today_time_total;
}
public int getRemaining_time() {
return remaining_time;
}
public void setRemaining_time(int remaining_time) {
this.remaining_time = remaining_time;
}
public List<PartTime> getTime_period() {
return time_period;
}
public void setTime_period(List<PartTime> time_period) {
this.time_period = time_period;
}
@NonNull
@Override
public String toString() {
return JsonParser.parseString(new Gson().toJson(this)).getAsJsonObject().toString();
}
}

View File

@@ -0,0 +1,39 @@
package com.fuying.sn.data.model;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.JsonParser;
import java.io.Serializable;
import java.util.List;
public class AppUseTimeData implements Serializable {
private static final long serialVersionUID = -6067838846112447777L;
long total_time;
List<AppUseTimeInfo> app_list;
public long getTotal_time() {
return total_time;
}
public void setTotal_time(long total_time) {
this.total_time = total_time;
}
public List<AppUseTimeInfo> getApp_list() {
return app_list;
}
public void setApp_list(List<AppUseTimeInfo> app_list) {
this.app_list = app_list;
}
@NonNull
@Override
public String toString() {
return JsonParser.parseString(new Gson().toJson(this)).getAsJsonObject().toString();
}
}

View File

@@ -0,0 +1,56 @@
package com.fuying.sn.data.model;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.JsonParser;
import java.io.Serializable;
public class AppUseTimeInfo implements Serializable {
private static final long serialVersionUID = 7957352661798901963L;
String app_package;
String app_name;
long usage_time;
String app_img;
public String getApp_package() {
return app_package;
}
public void setApp_package(String app_package) {
this.app_package = app_package;
}
public String getApp_name() {
return app_name;
}
public void setApp_name(String app_name) {
this.app_name = app_name;
}
public long getUsage_time() {
return usage_time;
}
public void setUsage_time(long usage_time) {
this.usage_time = usage_time;
}
public String getApp_img() {
return app_img;
}
public void setApp_img(String app_img) {
this.app_img = app_img;
}
@NonNull
@Override
public String toString() {
return JsonParser.parseString(new Gson().toJson(this)).getAsJsonObject().toString();
}
}

View File

@@ -0,0 +1,27 @@
package com.fuying.sn.data.model;
import com.fuying.sn.bean.BaseResponse;
import java.io.Serializable;
/**
* zip 合并后的组合结果:管控状态 + 剩余时长。
*/
public class ControlStatusCombined implements Serializable {
private static final long serialVersionUID = 1L;
public final BaseResponse<ControlStatusInfo> controlStatus;
public final BaseResponse<RemainingTimeBean> remainingTime;
public ControlStatusCombined(BaseResponse<ControlStatusInfo> controlStatus,
BaseResponse<RemainingTimeBean> remainingTime) {
this.controlStatus = controlStatus;
this.remainingTime = remainingTime;
}
/** 两个请求都成功code==200才为 true */
public boolean isBothSuccess() {
return controlStatus != null && controlStatus.code == 200
&& remainingTime != null && remainingTime.code == 200;
}
}

View File

@@ -0,0 +1,78 @@
package com.fuying.sn.data.model;
import androidx.annotation.NonNull;
import com.fuying.sn.bean.PartTime;
import com.google.gson.Gson;
import com.google.gson.JsonParser;
import java.io.Serializable;
import java.util.List;
public class ControlStatusInfo implements Serializable {
private static final long serialVersionUID = -4241643521947293549L;
String sn;
/**
* 0 默认 1 限制时长 2 时间段限制
*/
int control_type;
int work_time;
int rest_time;
int is_week_end;
List<PartTime> time_period;
public String getSn() {
return sn;
}
public void setSn(String sn) {
this.sn = sn;
}
public int getControl_type() {
return control_type;
}
public void setControl_type(int control_type) {
this.control_type = control_type;
}
public int getWork_time() {
return work_time;
}
public void setWork_time(int work_time) {
this.work_time = work_time;
}
public int getRest_time() {
return rest_time;
}
public void setRest_time(int rest_time) {
this.rest_time = rest_time;
}
public int getIs_week_end() {
return is_week_end;
}
public void setIs_week_end(int is_week_end) {
this.is_week_end = is_week_end;
}
public List<PartTime> getTime_period() {
return time_period;
}
public void setTime_period(List<PartTime> time_period) {
this.time_period = time_period;
}
@NonNull
@Override
public String toString() {
return JsonParser.parseString(new Gson().toJson(this)).getAsJsonObject().toString();
}
}

View File

@@ -0,0 +1,37 @@
package com.fuying.sn.data.model;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.JsonParser;
import java.io.Serializable;
public class RemainingTimeBean implements Serializable {
private static final long serialVersionUID = 5470771600877982771L;
String sn;
long remaining_time;
public String getSn() {
return sn;
}
public void setSn(String sn) {
this.sn = sn;
}
public long getRemaining_time() {
return remaining_time;
}
public void setRemaining_time(long remaining_time) {
this.remaining_time = remaining_time;
}
@NonNull
@Override
public String toString() {
return JsonParser.parseString(new Gson().toJson(this)).getAsJsonObject().toString();
}
}

View File

@@ -0,0 +1,117 @@
package com.fuying.sn.data.repository;
import com.fuying.sn.bean.AppTimeControlInfo;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.bean.SystemSettingsSet;
import com.fuying.sn.data.model.AppControlDetail;
import com.fuying.sn.data.model.AppUseTimeData;
import com.fuying.sn.data.model.AppUseTimeInfo;
import com.fuying.sn.data.model.ControlStatusCombined;
import com.fuying.sn.data.model.ControlStatusInfo;
import com.fuying.sn.data.model.RemainingTimeBean;
import com.fuying.sn.network.NetInterfaceManager;
import com.fuying.sn.network.UrlAddress;
import com.fuying.sn.utils.Utils;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.List;
import io.reactivex.rxjava3.core.Observable;
/**
* 使用统计数据仓库Repository
* <p>
* 职责:作为使用统计相关数据的唯一入口,向 ViewModel 屏蔽数据来源细节
* (网络 / 缓存),并统一套用 NetInterfaceManager 提供的多级缓存策略。
* <p>
* 类型策略:每个方法的返回类型与 StatisticsApi 接口完全一致并带具体泛型,
* 这样 withMultiLevelCache 才能精确反序列化缓存,且调用方拿到强类型、无需强转。
* 暂无具体 Bean 的接口暂用 BaseResponse<Object> 过渡,有 Bean 时直接替换即可。
*/
public class StatisticsRepository {
// 缓存过期时间3秒钟NetInterfaceManager 内部常量为 private这里自定义
private static final long CACHE_EXPIRE_ONE_MINUTE = 3 * 1000L;
private static volatile StatisticsRepository INSTANCE;
public static StatisticsRepository getInstance() {
if (INSTANCE == null) {
synchronized (StatisticsRepository.class) {
if (INSTANCE == null) {
INSTANCE = new StatisticsRepository();
}
}
}
return INSTANCE;
}
private StatisticsRepository() {
// 单例:依赖 NetInterfaceManager已初始化的单例提供 Api 与缓存能力
}
public Observable<BaseResponse<SystemSettingsSet>> getSetting() {
return NetInterfaceManager.withMultiLevelCache(
NetInterfaceManager.getInstance().getSettingsApi().getSetting(Utils.getSerial()),
UrlAddress.GET_SETTINGS,
new TypeToken<BaseResponse<SystemSettingsSet>>() {
}.getType(),
CACHE_EXPIRE_ONE_MINUTE);
}
public Observable<BaseResponse<ControlStatusInfo>> getControlStatus() {
return NetInterfaceManager.withMultiLevelCache(
NetInterfaceManager.getInstance().getStatisticsApi().getControlStatus(Utils.getSerial()),
UrlAddress.CONTROL_STATUS,
new TypeToken<BaseResponse<ControlStatusInfo>>() {
}.getType(),
CACHE_EXPIRE_ONE_MINUTE);
}
public Observable<BaseResponse<RemainingTimeBean>> getRemainingTime() {
return NetInterfaceManager.withMultiLevelCache(
NetInterfaceManager.getInstance().getStatisticsApi().getRemainingTime(Utils.getSerial()),
UrlAddress.REMAINING_TIME,
new TypeToken<BaseResponse<RemainingTimeBean>>() {
}.getType(),
CACHE_EXPIRE_ONE_MINUTE);
}
/**
* 将管控状态(controlStatus)与剩余时长(remainingTime)两个请求 zip 合并,
* 两个都返回后一次性给出组合结果,便于上层根据两个值联动判断。
* 任意一方网络失败都会触发 onError上层按整体失败处理即可。
*/
public Observable<ControlStatusCombined> getControlStatusCombined() {
return Observable.zip(
getControlStatus(),
getRemainingTime(),
(statusResp, remainResp) -> new ControlStatusCombined(statusResp, remainResp)
);
}
/**
* 获取应用使用记录
*
* @param query_type 查询类型 1 今天 2 本周 3 本月
* @param is_all 0 最高时长的6条 ,1 全部有使用的记录
*/
public Observable<BaseResponse<AppUseTimeData>> getAppUseTime(int query_type, int is_all) {
return NetInterfaceManager.withMultiLevelCache(
NetInterfaceManager.getInstance().getStatisticsApi().getAppUseTime(Utils.getSerial(), query_type, is_all),
UrlAddress.APP_USE_TIME,
new TypeToken<BaseResponse<AppUseTimeData>>() {
}.getType(),
CACHE_EXPIRE_ONE_MINUTE);
}
public Observable<BaseResponse<List<AppControlDetail>>> getAppControlDetail() {
return NetInterfaceManager.withMultiLevelCache(
NetInterfaceManager.getInstance().getStatisticsApi().getAppControlDetail(Utils.getSerial()),
UrlAddress.APP_CONTROL_DETAIL,
new TypeToken<BaseResponse<List<AppControlDetail>>>() {
}.getType(),
CACHE_EXPIRE_ONE_MINUTE);
}
}

View File

@@ -6,9 +6,7 @@ import android.graphics.drawable.Drawable;
import android.os.Environment;
import android.util.Log;
import com.fuying.sn.config.CommonConfig;
import com.jakewharton.disklrucache.DiskLruCache;
import com.tencent.mmkv.MMKV;
import org.json.JSONArray;
import org.json.JSONException;
@@ -19,20 +17,21 @@ import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
/**
* 磁盘缓存帮助类
* https://www.cnblogs.com/aademeng/articles/6817058.html
*/
public class CacheHelper {
private static final String TAG = "DiskLruCacheHelper";
private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE);
private static final String DIR_NAME = "diskCache";
private static final int MAX_COUNT = 1024 * 1024 * 1024;
private static final int DEFAULT_APP_VERSION = 1;
@@ -72,9 +71,8 @@ public class CacheHelper {
int appVersion = context == null ? DEFAULT_APP_VERSION : Utils.getAppVersion(context);
DiskLruCache diskLruCache = null;
try {
diskLruCache = DiskLruCache.open(
return DiskLruCache.open(
dir,
appVersion,
DEFAULT_APP_VERSION,
@@ -82,13 +80,12 @@ public class CacheHelper {
} catch (IOException e) {
e.printStackTrace();
}
return diskLruCache;
return null;
}
private DiskLruCache generateCache(Context context, String dirName, int maxCount) {
DiskLruCache diskLruCache = null;
try {
diskLruCache = DiskLruCache.open(
return DiskLruCache.open(
getDiskCacheDir(context, dirName),
Utils.getAppVersion(context),
DEFAULT_APP_VERSION,
@@ -96,7 +93,7 @@ public class CacheHelper {
} catch (IOException e) {
e.printStackTrace();
}
return diskLruCache;
return null;
}
// =======================================
@@ -104,9 +101,7 @@ public class CacheHelper {
// =======================================
public void put(String key, String value) {
// Log.e(TAG, "put: key = " + key + " value = " + value);
mMMKV.encode(key + "_time", System.currentTimeMillis());
mMMKV.encode(key + "_mmkv", value);
Log.e(TAG, "put: key = " + key + " value = " + value);
DiskLruCache.Editor edit = null;
BufferedWriter bw = null;
@@ -116,15 +111,16 @@ public class CacheHelper {
return;
}
OutputStream os = edit.newOutputStream(0);
bw = new BufferedWriter(new OutputStreamWriter(os));
bw = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8));
bw.write(value);
edit.commit();//write CLEAN
} catch (IOException e) {
e.printStackTrace();
Log.e(TAG, "put: " + e.getMessage());
try {
//s
edit.abort();//write REMOVE
if (edit != null) {
edit.abort();//write REMOVE
}
} catch (IOException e1) {
e1.printStackTrace();
Log.e(TAG, "put: " + e1.getMessage());
@@ -142,30 +138,30 @@ public class CacheHelper {
}
public String getAsString(String key) {
// Log.e(TAG, "getAsString: " + key);
Log.e(TAG, "getAsString: " + key);
InputStream inputStream = null;
try {
//write READ
inputStream = get(key);
if (inputStream == null) {
return mMMKV.decodeString(key + "_mmkv", null);
return null;
}
StringBuilder sb = new StringBuilder();
int len = 0;
byte[] buf = new byte[128];
while ((len = inputStream.read(buf)) != -1) {
sb.append(new String(buf, 0, len));
InputStreamReader isr = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
char[] buf = new char[1024];
int len;
while ((len = isr.read(buf)) != -1) {
sb.append(buf, 0, len);
}
return sb.toString();
} catch (IOException e) {
e.printStackTrace();
Log.e(TAG, "getAsString: " + e.getMessage());
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e1) {
e1.printStackTrace();
Log.e(TAG, "getAsString: " + e1.getMessage());
} catch (IOException e) {
e.printStackTrace();
}
}
}
@@ -199,9 +195,9 @@ public class CacheHelper {
public JSONArray getAsJSONArray(String key) {
String JSONString = getAsString(key);
if (JSONString == null) return null;
try {
JSONArray obj = new JSONArray(JSONString);
return obj;
return new JSONArray(JSONString);
} catch (Exception e) {
e.printStackTrace();
return null;
@@ -233,7 +229,9 @@ public class CacheHelper {
} catch (Exception e) {
e.printStackTrace();
try {
editor.abort();//write REMOVE
if (editor != null) {
editor.abort();//write REMOVE
}
} catch (IOException e1) {
e1.printStackTrace();
}
@@ -256,8 +254,8 @@ public class CacheHelper {
if (is == null) {
return null;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[256];
int len = 0;
while ((len = is.read(buf)) != -1) {
@@ -266,6 +264,12 @@ public class CacheHelper {
res = baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return res;
}
@@ -289,7 +293,9 @@ public class CacheHelper {
} catch (IOException e) {
e.printStackTrace();
try {
editor.abort();
if (editor != null) {
editor.abort();
}
} catch (IOException e1) {
e1.printStackTrace();
}
@@ -365,8 +371,8 @@ public class CacheHelper {
// =======================================
public boolean remove(String key) {
try {
key = Utils.hashKeyForDisk(key);
return mDiskLruCache.remove(key);
String hashKey = Utils.hashKeyForDisk(key);
return mDiskLruCache.remove(hashKey);
} catch (IOException e) {
e.printStackTrace();
}
@@ -379,7 +385,6 @@ public class CacheHelper {
public void delete() throws IOException {
mDiskLruCache.delete();
mMMKV.clearAll();
}
public void flush() throws IOException {
@@ -413,12 +418,12 @@ public class CacheHelper {
//basic editor
public DiskLruCache.Editor editor(String key) {
try {
key = Utils.hashKeyForDisk(key);
String hashKey = Utils.hashKeyForDisk(key);
//wirte DIRTY
DiskLruCache.Editor edit = mDiskLruCache.edit(key);
DiskLruCache.Editor edit = mDiskLruCache.edit(hashKey);
//edit maybe null :the entry is editing
if (edit == null) {
Log.w(TAG, "the entry spcified key:" + key + " is editing by other . ");
Log.w(TAG, "the entry spcified key:" + hashKey + " is editing by other . ");
}
return edit;
} catch (IOException e) {
@@ -436,7 +441,7 @@ public class CacheHelper {
DiskLruCache.Snapshot snapshot = mDiskLruCache.get(Utils.hashKeyForDisk(key));
if (snapshot == null) //not find entry , or entry.readable = false
{
// Log.e(TAG, "not find entry , or entry.readable = false");
Log.e(TAG, "not find entry , or entry.readable = false");
return null;
}
//write READ
@@ -455,20 +460,17 @@ public class CacheHelper {
// =======================================
private File getDiskCacheDir(Context context, String uniqueName) {
String cachePath;
File cacheDir;
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
|| !Environment.isExternalStorageRemovable()) {
if (context.getExternalCacheDir() != null) {
cachePath = context.getExternalCacheDir().getPath();
} else if (context.getExternalFilesDir("cache") != null) {
cachePath = context.getExternalFilesDir("cache").getPath();
} else {
cachePath = context.getCacheDir().getPath();
cacheDir = context.getExternalCacheDir();
if (cacheDir == null) {
cacheDir = context.getCacheDir();
}
} else {
cachePath = context.getCacheDir().getPath();
cacheDir = context.getCacheDir();
}
return new File(cachePath + File.separator + uniqueName);
return new File(cacheDir, uniqueName);
}
}

View File

@@ -18,6 +18,7 @@ import com.fuying.sn.R;
import com.fuying.sn.activity.update.UpdateActivity;
import com.fuying.sn.base.mvvm.fragment.BaseMvvmFragment;
import com.fuying.sn.bean.AppInfo;
import com.fuying.sn.bean.DeviceInfo;
import com.fuying.sn.bean.SnInfo;
import com.fuying.sn.config.CommonConfig;
import com.fuying.sn.databinding.FragmentDeviceBinding;
@@ -88,19 +89,36 @@ public class DeviceFragment extends BaseMvvmFragment<DeviceViewModel, FragmentDe
@Override
public void onChanged(SnInfo snInfo) {
if (snInfo != null) {
if (!TextUtils.isEmpty(snInfo.getMobile())) {
mViewDataBinding.tvPhoneNumber.setText(snInfo.getMobile());
}
mViewDataBinding.tvBindTime.setText(TimeUtils.getDate(0));
} else {
mViewDataBinding.tvPhoneNumber.setText("未绑定");
mViewDataBinding.tvBindTime.setText("未绑定");
}
}
});
mViewModel.getSnInfo();
mViewModel.mDeviceInfoMutableLiveData.observe(this, new Observer<DeviceInfo>() {
@Override
public void onChanged(DeviceInfo deviceInfo) {
if (deviceInfo != null) {
if (!TextUtils.isEmpty(deviceInfo.getBind_time())) {
mViewDataBinding.tvBindTime.setText(deviceInfo.getBind_time());
} else {
mViewDataBinding.tvBindTime.setText("获取失败");
}
if (!TextUtils.isEmpty(deviceInfo.getParent_mobile())) {
mViewDataBinding.tvPhoneNumber.setText(deviceInfo.getParent_mobile());
} else {
mViewDataBinding.tvPhoneNumber.setText("获取失败");
}
} else {
mViewDataBinding.tvBindTime.setText("未绑定");
mViewDataBinding.tvPhoneNumber.setText("未绑定");
}
}
});
mViewModel.getDeviceInfo();
mViewModel.mAppInfoMutableLiveData.observe(this, new Observer<AppInfo>() {
@Override
public void onChanged(AppInfo appInfo) {

View File

@@ -8,6 +8,7 @@ import com.fuying.sn.BuildConfig;
import com.fuying.sn.base.mvvm.BaseViewModel;
import com.fuying.sn.bean.AppInfo;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.bean.DeviceInfo;
import com.fuying.sn.bean.SnInfo;
import com.fuying.sn.databinding.FragmentDeviceBinding;
import com.fuying.sn.network.NetInterfaceManager;
@@ -64,6 +65,38 @@ public class DeviceViewModel extends BaseViewModel<FragmentDeviceBinding, Fragme
});
}
public MutableLiveData<DeviceInfo> mDeviceInfoMutableLiveData = new MutableLiveData<>();
public void getDeviceInfo() {
NetInterfaceManager.getInstance().getDeviceInfoObservable()
.subscribe(new Observer<BaseResponse<DeviceInfo>>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
}
@Override
public void onNext(BaseResponse<DeviceInfo> baseResponse) {
if (baseResponse.code == 200) {
DeviceInfo deviceInfo = baseResponse.data;
mDeviceInfoMutableLiveData.setValue(deviceInfo);
}else {
mDeviceInfoMutableLiveData.setValue(null);
}
}
@Override
public void onError(@NonNull Throwable e) {
}
@Override
public void onComplete() {
}
});
}
public MutableLiveData<AppInfo> mAppInfoMutableLiveData = new MutableLiveData<>();
public void checkUpdate(boolean response) {

View File

@@ -1,20 +1,95 @@
package com.fuying.sn.fragment.usage;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.fuying.sn.R;
import com.fuying.sn.activity.usage.UsageDetailActivity;
import com.fuying.sn.adapter.AppControlDetailAdapter;
import com.fuying.sn.base.mvvm.fragment.BaseMvvmFragment;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.bean.SystemSettings;
import com.fuying.sn.bean.SystemSettingsSet;
import com.fuying.sn.config.CommonConfig;
import com.fuying.sn.data.model.AppControlDetail;
import com.fuying.sn.data.model.AppUseTimeData;
import com.fuying.sn.data.model.AppUseTimeInfo;
import com.fuying.sn.data.model.ControlStatusCombined;
import com.fuying.sn.data.model.ControlStatusInfo;
import com.fuying.sn.data.model.RemainingTimeBean;
import com.fuying.sn.databinding.FragmentUsageBinding;
import com.fuying.sn.manager.AmapManager;
import com.fuying.sn.utils.ScreenUtils;
import com.fuying.sn.utils.TimeUtils;
import com.fuying.sn.utils.ToastUtil;
import com.fuying.sn.utils.Utils;
import com.github.mikephil.charting.charts.HorizontalBarChart;
import com.github.mikephil.charting.components.XAxis;
import com.github.mikephil.charting.components.YAxis;
import com.github.mikephil.charting.data.BarData;
import com.github.mikephil.charting.data.BarDataSet;
import com.github.mikephil.charting.data.BarEntry;
import com.github.mikephil.charting.formatter.ValueFormatter;
import com.github.mikephil.charting.interfaces.datasets.IBarDataSet;
import com.jeremyliao.liveeventbus.LiveEventBus;
import com.tencent.mmkv.MMKV;
import net.lucode.hackware.magicindicator.MagicIndicator;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.CommonNavigator;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.CommonNavigatorAdapter;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerIndicator;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerTitleView;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.model.PositionData;
import net.lucode.hackware.magicindicator.buildins.commonnavigator.titles.SimplePagerTitleView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class UsageFragment extends BaseMvvmFragment<UsageViewModel, FragmentUsageBinding> {
private static final String TAG = "UsageFragment";
private Activity mContext;
private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE);
private HorizontalBarChart mBarChart;
private TextView mTvTotalTime;
private MagicIndicator mMagicIndicator;
private int mSelectedTab = 0;
private int mQueryType = 1;
private boolean mIsAll = false;
private AppControlDetailAdapter mAppControlDetailAdapter;
// 一次 loadData 周期内登记的请求集合,用于判断整体是否全部请求完成。
// 取代原先硬编码的 TOTAL_REQUESTS因为实际请求数量取决于 onSettingData 的结果
// (限制场景会额外发出 controlStatus / remainingTime / appControlDetail 三个请求)。
private static final String REQ_SETTING = "setting";
private static final String REQ_USE_TIME = "useTime";
private static final String REQ_CONTROL_STATUS = "controlStatus";
private static final String REQ_REMAINING_TIME = "remainingTime";
private static final String REQ_APP_CONTROL_DETAIL = "appControlDetail";
private final Set<String> mPendingRequests = new HashSet<>();
// 本次 loadData 周期登记的总请求数(含根据 onSettingData 动态补充的请求),
// 用于判断整体是否全部成功
private int mTotalRequests = 0;
private int mSuccessCount = 0;
@Override
protected int getLayoutId() {
@@ -32,20 +107,553 @@ public class UsageFragment extends BaseMvvmFragment<UsageViewModel, FragmentUsag
@Override
protected void initView(Bundle bundle) {
mViewDataBinding.tvSn.setText("SN码:" + Utils.getSerial());
mAppControlDetailAdapter = new AppControlDetailAdapter();
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getCtx());
linearLayoutManager.setOrientation(LinearLayoutManager.VERTICAL);
mViewDataBinding.recyclerView.setAdapter(mAppControlDetailAdapter);
mViewDataBinding.recyclerView.setLayoutManager(linearLayoutManager);
mBarChart = mViewDataBinding.barChart;
mTvTotalTime = mViewDataBinding.tvTotalTime;
initChart();
initTabs();
// 进入页面时先展示上一次缓存的同步时间
updateLastSyncTime();
selectTab(0);
}
@Override
protected void initData(Bundle savedInstanceState) {
String address = mMMKV.decodeString(CommonConfig.MAP_ADDRESS_KEY, "正在定位");
if (!TextUtils.isEmpty(address)) {
mViewDataBinding.tvAddress.setText(address);
}
LiveEventBus.get(CommonConfig.MAP_ADDRESS_KEY, String.class)
.observe(this, new Observer<String>() {
@Override
public void onChanged(String s) {
if (!TextUtils.isEmpty(s)) {
mViewDataBinding.tvAddress.setText(s);
}
}
});
mViewModel.settingData.observe(this, this::onSettingData);
mViewModel.controlCombined.observe(getViewLifecycleOwner(), this::onControlCombined);
mViewModel.appUseTime.observe(getViewLifecycleOwner(), this::onAppUseTime);
mViewModel.mAppInfosLiveData.observe(getViewLifecycleOwner(), this::onAppControlDetail);
mViewModel.loading.observe(getViewLifecycleOwner(), this::onLoading);
mViewModel.error.observe(getViewLifecycleOwner(), this::onError);
loadData();
}
private void loadData() {
// 重置请求登记,统计本次 loadData 是否全部请求成功。
// 先登记一定会发出的两个请求:设置信息与使用时长。
mPendingRequests.clear();
mSuccessCount = 0;
mPendingRequests.add(REQ_SETTING);
mPendingRequests.add(REQ_USE_TIME);
mTotalRequests = mPendingRequests.size();
mViewModel.loadSettingData();
loadAppUseTime();
AmapManager.getInstance().startLocation();
}
/**
* 登记一个整体请求。仅 loadData 周期内发出的请求需要登记,
* 由 onSettingData 根据数据动态补充登记额外请求。
*/
private void registerRequest(String tag) {
if (mPendingRequests.add(tag)) {
mTotalRequests++;
}
}
/**
* 统计本次 loadData 内各请求的完成情况。
* 当全部已登记的请求都完成且都成功时,保存当前时间戳到 MMKV 并刷新同步时间显示。
* 注意:本方法仅用于 loadData 周期登记的请求;切换 Tab / fetchData 触发的单独
* loadAppUseTime 不会登记,因此不会计入整体同步时间。
*/
private void onRequestFinished(String tag, boolean success) {
if (!mPendingRequests.remove(tag)) {
// 该请求未被登记(如非 loadData 触发的单独请求),不计入整体同步时间
return;
}
if (success) {
mSuccessCount++;
}
if (mPendingRequests.isEmpty()) {
// 所有已登记的请求都已完成,全部成功才算本次同步成功
if (mSuccessCount >= mTotalRequests) {
long now = System.currentTimeMillis();
mMMKV.encode(CommonConfig.LAST_SYNC_TIME_KEY, now);
updateLastSyncTime();
}
mSuccessCount = 0;
}
}
/**
* 读取 MMKV 中缓存的同步时间戳并展示到 tv_last_sync_time。
*/
private void updateLastSyncTime() {
long ts = mMMKV.decodeLong(CommonConfig.LAST_SYNC_TIME_KEY, 0);
if (ts > 0) {
mViewDataBinding.tvLastSyncTime.setText("最新数据同步时间:" + TimeUtils.transferLongToDate(ts));
} else {
mViewDataBinding.tvLastSyncTime.setText("最新数据同步时间:暂无");
}
}
private void onSettingData(BaseResponse<SystemSettingsSet> systemSettingsSetBaseResponse) {
onRequestFinished(REQ_SETTING, systemSettingsSetBaseResponse.code == 200);
if (systemSettingsSetBaseResponse.code == 200) {
SystemSettings systemSettings = systemSettingsSetBaseResponse.data.getUser();
int is_control = systemSettings.getIs_control();
if (is_control == 1) {
mViewDataBinding.tvTodayTime.setText("");
mViewDataBinding.tvRemainingTime.setText("");
mViewDataBinding.tvNoData.setVisibility(View.GONE);
mViewDataBinding.recyclerView.setVisibility(View.VISIBLE);
// 限制场景下动态补充登记额外请求,使其计入整体同步时间统计
registerRequest(REQ_CONTROL_STATUS);
registerRequest(REQ_REMAINING_TIME);
registerRequest(REQ_APP_CONTROL_DETAIL);
mViewModel.loadControlCombined();
mViewModel.loadAppControlDetail();
} else {
mViewDataBinding.tvControlType.setText("不限制");
mViewDataBinding.tvControlType.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_unlimited_background));
mViewDataBinding.tvTodayTime.setText("当前pad无管控限制");
mViewDataBinding.tvRemainingTime.setText("");
mViewDataBinding.tvNoData.setVisibility(View.VISIBLE);
mViewDataBinding.recyclerView.setVisibility(View.GONE);
}
}
}
/**
* controlStatus 与 remainingTime 经 zip 合并后的统一回调。
* 此时两个结果都已就绪,可安全根据 control_type 与 remaining_time 组合判断。
*/
private void onControlCombined(ControlStatusCombined combined) {
boolean statusOk = combined.controlStatus != null && combined.controlStatus.code == 200;
boolean remainOk = combined.remainingTime != null && combined.remainingTime.code == 200;
onRequestFinished(REQ_CONTROL_STATUS, statusOk);
onRequestFinished(REQ_REMAINING_TIME, remainOk);
if (!statusOk) {
// 管控状态获取失败:整体置为“不支持”
mViewDataBinding.tvControlType.setText("不支持");
mViewDataBinding.tvControlType.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_disable_background));
mViewDataBinding.tvTodayTime.setText("无可用时间");
mViewDataBinding.tvRemainingTime.setText("");
return;
}
ControlStatusInfo info = combined.controlStatus.data;
// 根据 control_type 与 remaining_time 组合判断
int type = info.getControl_type();
long remain = remainOk && combined.remainingTime.data != null
? combined.remainingTime.data.getRemaining_time() : -1;
if (type == 2) {
mViewDataBinding.tvTodayTime.setText(TimeUtils.getPartTimeStringLine(info.getTime_period()));
} else {
// 每日可用时长文案(与星期相关)
if (TimeUtils.inWeekDay()) {
mViewDataBinding.tvTodayTime.setText("每天" + TimeUtils.formatTime(info.getRest_time()));
} else {
mViewDataBinding.tvTodayTime.setText("每天" + TimeUtils.formatTime(info.getWork_time()));
}
}
switch (type) {
case 0:
mViewDataBinding.tvControlType.setText("不限制");
mViewDataBinding.tvControlType.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_unlimited_background));
mViewDataBinding.tvRemainingTime.setText("");
mViewDataBinding.tvTodayTime.setText("不限制整机时长");
break;
case 1: // 限制时长:根据剩余时间组合判断
mViewDataBinding.tvControlType.setText("限制时长");
mViewDataBinding.tvControlType.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_duration_background));
if (remainOk && remain > 0) {
mViewDataBinding.tvRemainingTime.setText("剩余" + TimeUtils.formatTime(remain));
} else if (remainOk && remain == 0) {
mViewDataBinding.tvRemainingTime.setText("已用尽");
} else {
mViewDataBinding.tvRemainingTime.setText("");
}
break;
case 2: // 限制时间段:按需求不展示剩余时长
mViewDataBinding.tvControlType.setText("限制时间段");
mViewDataBinding.tvControlType.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_period_background));
mViewDataBinding.tvRemainingTime.setText("");
break;
default:
mViewDataBinding.tvControlType.setText("不限制");
mViewDataBinding.tvControlType.setBackground(mContext.getResources().getDrawable(R.drawable.tv_time_unlimited_background));
mViewDataBinding.tvRemainingTime.setText("");
}
}
private void onAppControlDetail(BaseResponse<List<AppControlDetail>> response) {
onRequestFinished(REQ_APP_CONTROL_DETAIL, response.code == 200);
if (response.code == 200) {
List<AppControlDetail> appTimeControlInfos = response.data;
mAppControlDetailAdapter.setAppTimeControlInfos(appTimeControlInfos);
} else {
mAppControlDetailAdapter.setAppTimeControlInfos(null);
}
}
private void initChart() {
// 关闭图表右下角的描述文字(标题说明)
mBarChart.getDescription().setEnabled(false);
// 没有数据显示的提示文字
mBarChart.setNoDataText("没有数据");
// 设置每条柱子的数值显示
mBarChart.setDrawValueAboveBar(true);
// 不绘制图表背景网格的填充底色
mBarChart.setDrawGridBackground(false);
// 不绘制图表边框
mBarChart.setDrawBorders(false);
// 禁用图表整体的自由缩放(拖拽缩放)
mBarChart.setScaleEnabled(false);
//分别在x轴和y轴上进行缩放
mBarChart.setPinchZoom(false);
//禁用双击缩放
mBarChart.setDoubleTapToZoomEnabled(false);
// 禁用图例(避免显示多余的系列说明)
mBarChart.getLegend().setEnabled(false);
// 让柱子自动适配 X 轴范围,使两端不留空隙
mBarChart.setFitBars(true);
// 使用 setViewPortOffsets 固定绘图区域偏移防止起始轴0刻度线随标签长度变化而抖动
// 参数依次为:左、上、右、下(单位 px。左侧预留足够空间显示应用名底部预留空间显示 Y 轴刻度。
mBarChart.setViewPortOffsets(ScreenUtils.dp2px(80f), ScreenUtils.dp2px(8f), ScreenUtils.dp2px(12f), ScreenUtils.dp2px(28f));
// 设置图表四周额外留白(左/上/右/下dp 转 px避免标签被边缘裁剪
mBarChart.setExtraOffsets(ScreenUtils.dp2px(4f), ScreenUtils.dp2px(8f), ScreenUtils.dp2px(4f), ScreenUtils.dp2px(8f));
// 获取 X 轴(分类轴)对象,用于后续配置
XAxis xAxis = mBarChart.getXAxis();
// 横向柱状图的分类轴在左侧垂直显示,避免与底部数值刻度重叠
xAxis.setPosition(XAxis.XAxisPosition.BOTTOM);
// 不绘制 X 轴线
xAxis.setDrawAxisLine(false);
// 不绘制 X 轴方向的网格线
xAxis.setDrawGridLines(false);
// 设置 X 轴刻度最小间隔为 1避免标签重复/重叠
xAxis.setGranularity(1f);
// 设置 X 轴分类标签文字大小
xAxis.setTextSize(ScreenUtils.sp2px(mContext, 10f));
// 设置 X 轴分类标签文字颜色为灰色(#666666
xAxis.setTextColor(Color.parseColor("#666666"));
// 获取左侧 Y 轴(数值轴)对象,用于后续配置
YAxis leftAxis = mBarChart.getAxisLeft();
// 锁定数值轴最小值恒为 0确保 0 刻度始终贴着绘图区左边缘
leftAxis.setAxisMinimum(0f);
// 底部数值轴:绘制轴线、网格刻度和标签,作为柱状图底部的刻度尺
leftAxis.setDrawAxisLine(true);
// 设置左侧 Y 轴轴线颜色为浅灰(#CCCCCC
leftAxis.setAxisLineColor(Color.parseColor("#CCCCCC"));
// 设置左侧 Y 轴轴线宽度为 0.5(细线)
leftAxis.setAxisLineWidth(0.5f);
// 绘制左侧 Y 轴方向的网格线
leftAxis.setDrawGridLines(true);
// 设置网格线颜色为浅灰(#EEEEEE
leftAxis.setGridColor(Color.parseColor("#EEEEEE"));
// 缩小刻度线宽度,使刻度更细
leftAxis.setGridLineWidth(0.5f);
// 缩小刻度文字,让底部刻度更紧凑
leftAxis.setTextSize(ScreenUtils.sp2px(mContext, 10f));
// 设置左侧 Y 轴刻度文字颜色为浅灰(#999999
leftAxis.setTextColor(Color.parseColor("#999999"));
// 设置数值格式化器,将刻度值按"小时"格式显示
leftAxis.setValueFormatter(new HourAxisValueFormatter());
// 禁用右侧 Y 轴(右侧不显示数值轴)
mBarChart.getAxisRight().setEnabled(false);
}
private void initTabs() {
mMagicIndicator = mViewDataBinding.magicIndicator;
final String[] titles = {
getString(R.string.usage_today),
getString(R.string.usage_week),
getString(R.string.usage_month)
};
CommonNavigator commonNavigator = new CommonNavigator(mContext);
commonNavigator.setAdjustMode(false);
commonNavigator.setAdapter(new CommonNavigatorAdapter() {
@Override
public int getCount() {
return titles.length;
}
@Override
public IPagerTitleView getTitleView(Context context, final int index) {
SimplePagerTitleView titleView = new SimplePagerTitleView(context);
titleView.setText(titles[index]);
titleView.setTextSize(ScreenUtils.sp2px(mContext, 12));
titleView.setNormalColor(ContextCompat.getColor(mContext, R.color.gray));
titleView.setSelectedColor(Color.WHITE);
titleView.setOnClickListener(v -> selectTab(index));
return titleView;
}
@Override
public IPagerIndicator getIndicator(Context context) {
return new RoundedRectIndicator(context,
ContextCompat.getColor(mContext, R.color.default_blue),
ScreenUtils.dp2px(16f),
ScreenUtils.dp2px(12f),
ScreenUtils.dp2px(4f));
}
});
mMagicIndicator.setNavigator(commonNavigator);
}
private void selectTab(int position) {
mSelectedTab = position;
CommonNavigator navigator = (CommonNavigator) mMagicIndicator.getNavigator();
if (navigator != null) {
navigator.onPageSelected(position);
navigator.notifyDataSetChanged();
}
int newQueryType = position + 1;
if (mQueryType != newQueryType) {
mQueryType = newQueryType;
loadAppUseTime();
}
}
private void loadAppUseTime() {
// Tab 切换 / fetchData 触发的单独请求,不计入 loadData 整体同步时间
mViewModel.loadAppUseTime(mQueryType, mIsAll ? 1 : 0);
}
private void onAppUseTime(BaseResponse<AppUseTimeData> resp) {
onRequestFinished(REQ_USE_TIME, resp != null && resp.code == 200
&& resp.data != null && !resp.data.getApp_list().isEmpty());
if (resp == null || resp.data == null || resp.data.getApp_list().isEmpty()) {
// 数据为空时清空图表已有内容
mBarChart.clear();
// 强制刷新图表(触发重绘,使清空生效)
mBarChart.invalidate();
mTvTotalTime.setText(formatTotalTime(0));
return;
}
List<AppUseTimeInfo> list = resp.data.getApp_list();
Collections.sort(list, (a, b) -> Long.compare(b.getUsage_time(), a.getUsage_time()));
List<AppUseTimeInfo> displayList = mIsAll ? list : list.subList(0, Math.min(6, list.size()));
// 横向柱状图默认从底部向上递增,升序排列让最长的柱子显示在最上方
Collections.sort(displayList, (a, b) -> Long.compare(a.getUsage_time(), b.getUsage_time()));
long totalSeconds = 0;
for (AppUseTimeInfo info : list) {
totalSeconds += info.getUsage_time();
}
mTvTotalTime.setText(formatTotalTime(totalSeconds));
List<String> labels = new ArrayList<>();
List<BarEntry> entries = new ArrayList<>();
float maxHours = 0f;
for (int i = 0; i < displayList.size(); i++) {
AppUseTimeInfo info = displayList.get(i);
labels.add(info.getApp_name());
float hours = secondsToHours(info.getUsage_time());
entries.add(new BarEntry(i, hours));
if (hours > maxHours) {
maxHours = hours;
}
}
// 创建柱状图数据集(第二个参数为图例名称,留空)
BarDataSet dataSet = new BarDataSet(entries, "");
// 不在柱子顶部绘制具体数值文本
dataSet.setDrawValues(false);
// 设置柱子填充颜色为绿色(#1DBF73
dataSet.setColor(Color.parseColor("#1DBF73"));
// 设置选中高亮时的颜色(同样为绿色)
dataSet.setHighLightColor(Color.parseColor("#1DBF73"));
List<IBarDataSet> dataSets = new ArrayList<>();
dataSets.add(dataSet);
// 用数据集构建柱状图数据对象
BarData data = new BarData(dataSets);
// 设置柱子宽度为 0.5(占单个刻度区间的一半,留出间隙)
data.setBarWidth(0.5f);
// 将构建好的数据设置给图表
mBarChart.setData(data);
// 获取 X 轴,配置分类标签显示
XAxis xAxis = mBarChart.getXAxis();
// 使用自定义格式化器,将索引映射为应用名称标签
xAxis.setValueFormatter(new AppNameValueFormatter(labels));
// 按标签数量均分 X 轴刻度(不强制等距,避免标签被压缩)
xAxis.setLabelCount(labels.size(), false);
// 获取左侧 Y 轴,配置数值范围与刻度
YAxis leftAxis = mBarChart.getAxisLeft();
// 计算 Y 轴上限:取最大小时数向上取整,且至少显示到 1
float axisMax = Math.max((float) Math.ceil(maxHours), 1f);
leftAxis.setAxisMaximum(axisMax);
// 减少刻度数量,使底部刻度尺更稀疏、更小
int labelCount = Math.min(4, Math.max((int) axisMax + 1, 2));
leftAxis.setLabelCount(labelCount, true);
// 通知图表数据已更新,重新绘制
mBarChart.invalidate();
}
private void onLoading(Boolean visible) {
// 页面暂无 loading 视图,可按需要扩展
}
private void onError(Throwable e) {
ToastUtil.show(e != null && e.getMessage() != null ? e.getMessage() : getString(R.string.open_app_fail));
}
@Override
public void fetchData() {
loadAppUseTime();
}
public class BtnClick {
public void onViewAllClick(View view) {
startActivity(new Intent(getActivity(), UsageDetailActivity.class));
}
public void onRefreshClick(View view) {
loadData();
}
}
/**
* 服务端返回的 usage_time 单位为秒,这里统一换算成小时。
*/
private static float secondsToHours(long seconds) {
return seconds / 3600f;
}
private static String formatTotalTime(long totalSeconds) {
if (totalSeconds <= 0) {
return "0" + "分钟";
}
long hours = totalSeconds / 3600;
long minutes = (totalSeconds % 3600) / 60;
if (hours > 0 && minutes > 0) {
return hours + "小时" + minutes + "分钟";
} else if (hours > 0) {
return hours + "小时";
} else {
return minutes + "分钟";
}
}
private static class HourAxisValueFormatter extends ValueFormatter {
@Override
public String getFormattedValue(float value) {
return (int) value + "小时";
}
}
private static class AppNameValueFormatter extends ValueFormatter {
private final List<String> labels;
AppNameValueFormatter(List<String> labels) {
this.labels = labels;
}
@Override
public String getFormattedValue(float value) {
int index = (int) value;
if (index < 0 || index >= labels.size()) {
return "";
}
String name = labels.get(index);
return name.length() > 8 ? name.substring(0, 8) + "..." : name;
}
}
/**
* 圆角矩形指示器:在选中标题(或滑动过程中)背后绘制一个带圆角的矩形高亮背景。
*/
private static class RoundedRectIndicator extends View implements IPagerIndicator {
private final Paint mPaint;
private final RectF mRect = new RectF();
private final int mRoundRadius;
private final int mPaddingHorizontal;
private final int mPaddingVertical;
private List<PositionData> mPositionDataList;
private int mSelectedPosition = 0;
RoundedRectIndicator(Context context, int color, int roundRadius,
int paddingHorizontal, int paddingVertical) {
super(context);
mRoundRadius = roundRadius;
mPaddingHorizontal = paddingHorizontal;
mPaddingVertical = paddingVertical;
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(color);
}
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
mSelectedPosition = position;
updateRect(position, positionOffset);
}
@Override
public void onPageSelected(int position) {
mSelectedPosition = position;
updateRect(position, 0f);
}
@Override
public void onPageScrollStateChanged(int state) {
}
@Override
public void onPositionDataProvide(List<PositionData> dataList) {
mPositionDataList = dataList;
// 布局完成后位置数据就绪,重新计算当前选中态,保证首屏也能正确绘制
updateRect(mSelectedPosition, 0f);
}
private void updateRect(int position, float offset) {
if (mPositionDataList == null || mPositionDataList.isEmpty()) {
return;
}
position = Math.max(0, Math.min(position, mPositionDataList.size() - 1));
PositionData current = mPositionDataList.get(position);
PositionData next = mPositionDataList.get(Math.min(position + 1, mPositionDataList.size() - 1));
int left = (int) (current.mContentLeft + (next.mContentLeft - current.mContentLeft) * offset) - mPaddingHorizontal;
int right = (int) (current.mContentRight + (next.mContentRight - current.mContentRight) * offset) + mPaddingHorizontal;
int top = (int) (current.mContentTop + (next.mContentTop - current.mContentTop) * offset) - mPaddingVertical;
int bottom = (int) (current.mContentBottom + (next.mContentBottom - current.mContentBottom) * offset) + mPaddingVertical;
mRect.set(left, top, right, bottom);
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawRoundRect(mRect, mRoundRadius, mRoundRadius, mPaint);
}
}
}

View File

@@ -1,18 +1,192 @@
package com.fuying.sn.fragment.usage;
import android.util.Log;
import androidx.lifecycle.MutableLiveData;
import com.fuying.sn.base.mvvm.BaseViewModel;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.bean.SystemSettings;
import com.fuying.sn.bean.SystemSettingsSet;
import com.fuying.sn.data.model.AppControlDetail;
import com.fuying.sn.data.model.AppUseTimeData;
import com.fuying.sn.data.model.ControlStatusCombined;
import com.fuying.sn.data.model.ControlStatusInfo;
import com.fuying.sn.data.model.RemainingTimeBean;
import com.fuying.sn.data.repository.StatisticsRepository;
import com.fuying.sn.databinding.FragmentUsageBinding;
import com.hjq.toast.Toaster;
import com.trello.rxlifecycle4.RxLifecycle;
import com.trello.rxlifecycle4.android.FragmentEvent;
import java.util.List;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Consumer;
/**
* UsageFragment 对应的 ViewModel。
* <p>
* 职责:持有 UI 状态LiveData、调用 StatisticsRepository 获取数据,
* 不持有网络/IO 细节,不直接依赖 NetInterfaceManager。
* 用 CompositeDisposable 管理 RxJava 订阅,在 onCleared / onDestroy 时释放。
*/
public class UsageViewModel extends BaseViewModel<FragmentUsageBinding, FragmentEvent> {
private final CompositeDisposable mDisposables = new CompositeDisposable();
private final StatisticsRepository mRepository = StatisticsRepository.getInstance();
// ===== UI 状态(对外暴露 LiveData类型与 Repository 返回类型一致)=====
public final MutableLiveData<BaseResponse<SystemSettingsSet>> settingData = new MutableLiveData<>();
public final MutableLiveData<BaseResponse<ControlStatusInfo>> controlStatus = new MutableLiveData<>();
public final MutableLiveData<BaseResponse<RemainingTimeBean>> remainingTime = new MutableLiveData<>();
/** zip 合并后的管控状态 + 剩余时长组合结果 */
public final MutableLiveData<ControlStatusCombined> controlCombined = new MutableLiveData<>();
public final MutableLiveData<BaseResponse<AppUseTimeData>> appUseTime = new MutableLiveData<>();
public final MutableLiveData<BaseResponse<List<AppControlDetail>>> mAppInfosLiveData = new MutableLiveData<>();
public final MutableLiveData<Boolean> loading = new MutableLiveData<>();
public final MutableLiveData<Throwable> error = new MutableLiveData<>();
@Override
public FragmentUsageBinding getVDBinding() {
return binding;
}
public void loadSettingData() {
Disposable disposable = mRepository.getSetting()
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.DESTROY_VIEW))
.subscribe(new Consumer<BaseResponse<SystemSettingsSet>>() {
@Override
public void accept(BaseResponse<SystemSettingsSet> resp) throws Throwable {
Log.e("UsageViewModel", "loadSettingData: " + resp.data);
settingData.setValue(resp);
loading.setValue(false);
Toaster.show("刷新成功");
}
}, new Consumer<Throwable>() {
@Override
public void accept(Throwable err) throws Throwable {
error.setValue(err);
loading.setValue(false);
Toaster.show("网络错误");
}
});
mDisposables.add(disposable);
}
public void loadControlStatus() {
loading.setValue(true);
Disposable disposable = mRepository.getControlStatus()
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.DESTROY_VIEW))
.subscribe(
resp -> {
Log.e("UsageViewModel", "loadControlStatus: " + resp.data);
controlStatus.setValue(resp);
loading.setValue(false);
},
err -> {
error.setValue(err);
loading.setValue(false);
}
);
mDisposables.add(disposable);
}
public void loadRemainingTime() {
Disposable disposable = mRepository.getRemainingTime()
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.DESTROY_VIEW))
.subscribe(
resp -> {
Log.e("UsageViewModel", "loadRemainingTime: " + resp.data);
remainingTime.setValue(resp);
loading.setValue(false);
},
err -> {
error.setValue(err);
loading.setValue(false);
}
);
mDisposables.add(disposable);
}
/**
* 同时请求管控状态与剩余时长zip 合并后一次性回调。
* Fragment 只需观察 controlCombined即可根据两个值联动判断。
*/
public void loadControlCombined() {
loading.setValue(true);
Disposable disposable = mRepository.getControlStatusCombined()
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.DESTROY_VIEW))
.subscribe(
combined -> {
Log.e("UsageViewModel", "loadControlCombined: " + combined);
// 保留原有独立 LiveData兼容仍单独观察的地方
controlStatus.setValue(combined.controlStatus);
remainingTime.setValue(combined.remainingTime);
controlCombined.setValue(combined);
loading.setValue(false);
},
err -> {
error.setValue(err);
loading.setValue(false);
}
);
mDisposables.add(disposable);
}
/**
* 加载指定周期的应用使用记录。
*
* @param queryType 查询类型1 今天、2 本周、3 本月
* @param isAll 0 返回使用时长最高的 6 条1 返回全部有使用的记录
*/
public void loadAppUseTime(int queryType, int isAll) {
loading.setValue(true);
Disposable disposable = mRepository.getAppUseTime(queryType, isAll)
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.DESTROY_VIEW))
.subscribe(
resp -> {
appUseTime.setValue(resp);
loading.setValue(false);
},
err -> {
error.setValue(err);
loading.setValue(false);
}
);
mDisposables.add(disposable);
}
public void loadAppControlDetail() {
Disposable disposable = mRepository.getAppControlDetail()
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.DESTROY_VIEW))
.subscribe(
resp -> {
mAppInfosLiveData.setValue(resp);
// loading.setValue(false);
},
err -> {
// error.setValue(err);
// loading.setValue(false);
}
);
mDisposables.add(disposable);
}
@Override
public void onDestroy() {
// BaseViewModel 抽象方法:由页面销毁时调用
mDisposables.clear();
}
@Override
protected void onCleared() {
// ViewModel 系统回调:确保订阅一定被释放(比 onDestroy 更可靠)
mDisposables.clear();
super.onCleared();
}
}

View File

@@ -95,6 +95,14 @@ public class ConnectManager {
return nowTime - lastTime > intervalTime && nowTime - lastTime > ONE_SECOND_TIME;
}
/**
* 请求成功后记录本次连接时间,供 {@link #isNeedConnect(String, ConnectMode)} 做节流判断。
* 注意:原实现从未写入该时间戳,导致节流机制形同虚设,这里补齐。
*/
public void updateConnectTime(String key) {
mMMKV.encode(key, System.currentTimeMillis());
}
/**
* @return 重启后是否连接

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint;
import android.app.ActivityManager;
import android.app.ActivityManagerNative;
import android.app.IActivityManager;
import android.app.KeyguardManager;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
@@ -32,6 +33,7 @@ import com.google.gson.reflect.TypeToken;
import com.hjq.toast.Toaster;
import com.tencent.mmkv.MMKV;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.time.DayOfWeek;
import java.time.LocalDate;
@@ -75,6 +77,8 @@ public class TimeManager {
private SnTimeControlInfo mSnTimeControlInfo;
private boolean mGetSnTimeControlInfoSuccessful = false;
private boolean mGetAppTimeControlInfoSuccessful = false;
/**
* 保存所有应用的管控配置Key 为包名)
@@ -91,6 +95,19 @@ public class TimeManager {
*/
private Map<String, Long> mRemainingTimeMap = new HashMap<>();
/**
* 记录上一次已处理过的日期用于检测跨天0点后刷新配置
*/
private LocalDate mLastProcessedDate;
/**
* 跨天刷新配置时的重试定时器,网络异常时用于周期性重试直至成功
*/
private Disposable mConfigRetryDisposable;
private static final long CONFIG_RETRY_INTERVAL = 60; // 重试间隔(秒)
private static final int MAX_CONFIG_RETRY_COUNT = 10; // 跨天刷新配置的最大重试次数,超过后停止重试
private int mConfigRetryCount = 0; // 已执行重试的次数
/**
* 免管控白名单,包含系统核心组件及特定工具应用
*/
@@ -127,6 +144,7 @@ public class TimeManager {
initActivityController();
initUidImportanceListener();
loadLocalConfig();
mLastProcessedDate = LocalDate.now();
getTimeControl(); // 异步同步服务端配置
startInIntervalTask(); // 启动每秒检测任务
@@ -249,19 +267,35 @@ public class TimeManager {
*/
private void checkForegroundAppName() {
String pkg = getTopActivityPackageName();
if (BuildConfig.DEBUG) {
Log.d(TAG, "checkForegroundAppName: " + pkg);
}
if (TextUtils.isEmpty(pkg)) return;
//如果应用没有退出锁屏,获取到的还是应用的包名
if (isDeviceLocked(mContext)) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "checkForegroundAppName: DeviceLocked ");
}
// if (!allowPackage.contains(pkg)) {
// killBackgroundProcesses(pkg);
// }
return;
}
// 1. 豁免条件:白名单或家长管控总开关已关闭
if (isPackageExempted(pkg)) return;
// 2. 检查基本配置是否就绪
if (mSnTimeControlInfo == null) {
if (!mGetSnTimeControlInfoSuccessful) getSnTimeControl();
if (!mGetAppTimeControlInfoSuccessful) getAppTimeControlInfo();
return;
}
// 3. 运行权限动态校验
if (!appCanRun(pkg)) {
killBackgroundProcesses(pkg);
gotoLauncher();
return;
}
@@ -286,13 +320,14 @@ public class TimeManager {
* @return true 允许运行false 触发拦截
*/
public boolean appCanRun(String pkg) {
if (ApkUtils.isSystemApp(mContext, pkg)) return true;
if (isPackageExempted(pkg)) return true;
if (mSnTimeControlInfo == null) return false;
AppTimeControlInfo appInfo = mAppTimeControlMap.get(pkg);
if (appInfo == null) {
Log.w(TAG, "appCanRun: Application [" + pkg + "] is not in the controlled list. Blocking by default.");
Toaster.show("应用已被禁用");
Toaster.show("获取应用设置失败");
return false;
}
@@ -382,6 +417,18 @@ public class TimeManager {
}
}
/**
* 判断当前是否处于锁屏状态
*/
public boolean isDeviceLocked(Context context) {
KeyguardManager km = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE);
if (km != null) {
// isKeyguardLocked() 返回 true 表示当前屏幕是锁定的(无论是有安全锁还是滑动锁)
return km.isKeyguardLocked();
}
return false;
}
/**
* 获取最顶层的应用包名
*/
@@ -514,6 +561,7 @@ public class TimeManager {
@Override
public void onNext(@NonNull BaseResponse<SnTimeControlInfo> response) {
Log.e(TAG, "getSnTimeControlObservable onNext: " + response);
mGetSnTimeControlInfoSuccessful = true;
if (response.code == 200 && response.data != null) {
mSnTimeControlInfo = response.data;
@@ -525,6 +573,7 @@ public class TimeManager {
@Override
public void onError(@NonNull Throwable e) {
mGetSnTimeControlInfoSuccessful = false;
Log.e(TAG, "getSnTimeControl failed: " + e.getMessage());
}
@@ -543,6 +592,8 @@ public class TimeManager {
@Override
public void onNext(@NonNull BaseResponse<List<AppTimeControlInfo>> response) {
mGetAppTimeControlInfoSuccessful = true;
Log.e(TAG, "getAppTimeControlInfo onNext: " + response);
if (response.code == 200 && response.data != null) {
List<AppTimeControlInfo> list = response.data;
mAppTimeControlMap = list.stream().collect(Collectors.toMap(AppTimeControlInfo::getApp_package, Function.identity(), (v1, v2) -> v1));
@@ -552,6 +603,7 @@ public class TimeManager {
@Override
public void onError(@NonNull Throwable e) {
mGetAppTimeControlInfoSuccessful = false;
Log.e(TAG, "getAppTimeControlInfo failed: " + e.getMessage());
}
@@ -572,15 +624,100 @@ public class TimeManager {
mContext.registerReceiver(new TimeChangedReceiver(), filter);
}
private static class TimeChangedReceiver extends BroadcastReceiver {
private class TimeChangedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "System time/date changed: " + intent.getAction());
handleDateChanged();
}
}
/**
* 检测是否跨天0点。跨天后先用本地缓存配额乐观重置当日剩余时长
* 避免旧值(可能已耗尽)继续拦截设备;随后重新从服务端拉取最新配置。
*/
private void handleDateChanged() {
LocalDate today = LocalDate.now();
Log.d(TAG, "handleDateChanged: today = " + today);
Log.d(TAG, "handleDateChanged: mLastProcessedDate = " + mLastProcessedDate);
if (today.equals(mLastProcessedDate)) {
// 日期未变化(如仅时间微调、每分钟的 TIME_TICK
// 若两级配置尚未成功拉取(如启动时拉取失败),则尝试重新请求以恢复管控配置。
// 仅在当前无重试任务在跑时触发,避免重复创建定时器。
if ((!mGetSnTimeControlInfoSuccessful || !mGetAppTimeControlInfoSuccessful)
&& (mConfigRetryDisposable == null || mConfigRetryDisposable.isDisposed())) {
Log.d(TAG, "Date unchanged but config not fully fetched, trying to fetch config.");
fetchConfigWithRetry();
}
return;
}
mLastProcessedDate = today;
Log.d(TAG, "Date changed to " + today + ", resetting daily quota and fetching latest config.");
// 日期变化:先将两级配置成功标记置为 false强制跨天后重新校验与拉取
mGetSnTimeControlInfoSuccessful = false;
mGetAppTimeControlInfoSuccessful = false;
// 乐观重置:使用本地缓存的当日配额重置剩余时长,保证跨天后立即可用
if (mSnTimeControlInfo != null) {
mGlobalRemainingTime = mSnTimeControlInfo.getToday_time();
mMMKV.encode(GLOBAL_REMAINING_TIME_KEY, mGlobalRemainingTime);
}
if (mAppTimeControlMap != null && !mAppTimeControlMap.isEmpty()) {
mRemainingTimeMap = mAppTimeControlMap.values().stream()
.collect(Collectors.toMap(AppTimeControlInfo::getApp_package,
AppTimeControlInfo::getToday_time, (v1, v2) -> v1));
}
// 重新拉取服务端最新配置(含当日最新配额与时间管控规则),
// 若因网络问题获取失败则自动重试,直至两级配置均成功获取
mConfigRetryCount = 0;
fetchConfigWithRetry();
}
/**
* 跨天刷新配置:先发起一次请求,若 SN 或应用任一级配置未成功获取(网络异常等),
* 则按固定间隔重试,直到两级均成功,确保设备最终能拿到最新配置。
*/
private void fetchConfigWithRetry() {
getTimeControl();
if (mConfigRetryDisposable != null && !mConfigRetryDisposable.isDisposed()) {
mConfigRetryDisposable.dispose();
}
// 已达最大重试次数则停止重试,避免无限占用资源
if (mConfigRetryCount >= MAX_CONFIG_RETRY_COUNT) {
Log.w(TAG, "Config fetch reached max retry count (" + MAX_CONFIG_RETRY_COUNT + "), stop retrying.");
return;
}
mConfigRetryCount++;
mConfigRetryDisposable = Observable.timer(CONFIG_RETRY_INTERVAL, TimeUnit.SECONDS)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(aLong -> {
if (mGetSnTimeControlInfoSuccessful && mGetAppTimeControlInfoSuccessful) {
Log.d(TAG, "Latest config fetched successfully after retry.");
mConfigRetryDisposable = null;
mConfigRetryCount = 0;
return;
}
Log.d(TAG, "Config fetch incomplete (sn=" + mGetSnTimeControlInfoSuccessful
+ ", app=" + mGetAppTimeControlInfoSuccessful + "), retrying... ("
+ mConfigRetryCount + "/" + MAX_CONFIG_RETRY_COUNT + ")");
fetchConfigWithRetry();
}, throwable -> Log.e(TAG, "Config retry timer error: " + throwable.getMessage()));
}
public void killBackgroundProcesses(String processName) {
Log.e(TAG, "killBackgroundProcesses: " + processName);
ActivityManager activityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
activityManager.forceStopPackage(processName);
}
public void onDestroy() {
// 必要时清理资源
if (mConfigRetryDisposable != null && !mConfigRetryDisposable.isDisposed()) {
mConfigRetryDisposable.dispose();
mConfigRetryDisposable = null;
}
}
// --- Getters & Setters ---
@@ -604,4 +741,22 @@ public class TimeManager {
public void setGetSnTimeControlInfoSuccessful(boolean successful) {
this.mGetSnTimeControlInfoSuccessful = successful;
}
public Map<String, AppTimeControlInfo> getAppTimeControlMap() {
return mAppTimeControlMap;
}
public void setAppTimeControlMap(Map<String, AppTimeControlInfo> appTimeControlMap) {
mAppTimeControlMap = appTimeControlMap;
}
public Map<String, Long> getRemainingTimeMap() {
return mRemainingTimeMap;
}
public void setRemainingTimeMap(Map<String, Long> remainingTimeMap) {
mRemainingTimeMap = remainingTimeMap;
}
}

View File

@@ -32,6 +32,7 @@ import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.bean.BatchID;
import com.fuying.sn.bean.BlacklistBean;
import com.fuying.sn.bean.CloudLessonSetting;
import com.fuying.sn.bean.DeviceInfo;
import com.fuying.sn.bean.Label;
import com.fuying.sn.bean.MyAppUsageBean;
import com.fuying.sn.bean.OverallAppBean;
@@ -67,6 +68,9 @@ import com.fuying.sn.network.api.SnControlApi;
import com.fuying.sn.network.api.SnInfoApi;
import com.fuying.sn.network.api.SnLogApi;
import com.fuying.sn.network.api.newly.ControlApi;
import com.fuying.sn.network.api.newly.DeviceInfoApi;
import com.fuying.sn.network.api.newly.StatisticsApi;
import com.fuying.sn.network.cache.RxCacheManager;
import com.fuying.sn.network.interceptor.RepeatRequestInterceptor;
import com.fuying.sn.service.LogcatService;
import com.fuying.sn.utils.ApkUtils;
@@ -125,19 +129,39 @@ import retrofit2.converter.gson.GsonConverterFactory;
public class NetInterfaceManager {
private static final String TAG = "NetInterfaceManager";
private static final RxCacheManager CACHE_MANAGER = RxCacheManager.getInstance();
public static void clearAllCache() {
Log.e(TAG, "clearAllCache: ");
CACHE_MANAGER.clearAllCache();
}
public static void removeCache(String key) {
Log.e(TAG, "removeCache: key = " + key);
CACHE_MANAGER.removeCache(key);
}
//缓存过期时间1秒
private static final long CACHE_EXPIRE_ONE_SECOND = 1000;
//默认 缓存过期时间10秒
private static final long DEFAULT_CACHE_EXPIRE = 10 * CACHE_EXPIRE_ONE_SECOND;
//缓存过期时间1分钟
private static final long CACHE_EXPIRE_ONE_MINUTE = 60 * CACHE_EXPIRE_ONE_SECOND;
//超时时间
private static final int TIME_OUT = 30;
// 缓存文件最大限制大小20M
private static final long CACHE_SIZE = 1024 * 1024 * 64;
private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE);
@SuppressLint("StaticFieldLeak")
private static NetInterfaceManager INSTANCE;
private Context mContext;
private ContentResolver crv;
private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE);
private Retrofit mRetrofit;
private OkHttpClient okHttpClient;
private CacheHelper mCacheHelper;
//超时时间
private static final int timeOut = 5;
// 缓存文件最大限制大小20M
private static final long cacheSize = 1024 * 1024 * 64;
private Retrofit mRetrofit;
private OkHttpClient mOkHttpClient;
private NetInterfaceManager(Context context) {
if (context == null) {
@@ -148,23 +172,23 @@ public class NetInterfaceManager {
this.mCacheHelper = new CacheHelper(mContext);
if (null == mRetrofit) {
if (okHttpClient == null) {
if (mOkHttpClient == null) {
//如果无法生存缓存文件目录,检测权限使用已经加上,检测手机是否把文件读写权限禁止了
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.connectTimeout(timeOut, TimeUnit.SECONDS); // 设置连接超时时间
builder.writeTimeout(timeOut, TimeUnit.SECONDS);// 设置写入超时时间
builder.readTimeout(timeOut, TimeUnit.SECONDS);// 设置读取数据超时时间
builder.connectTimeout(TIME_OUT, TimeUnit.SECONDS); // 设置连接超时时间
builder.writeTimeout(TIME_OUT, TimeUnit.SECONDS);// 设置写入超时时间
builder.readTimeout(TIME_OUT, TimeUnit.SECONDS);// 设置读取数据超时时间
builder.retryOnConnectionFailure(true);// 设置进行连接失败重试
builder.addInterceptor(new RepeatRequestInterceptor());
// 设置缓存文件路径
String cacheDirectory = getCacheDir() + "/OkHttpCache";
Cache cache = new Cache(new File(cacheDirectory), cacheSize);
Cache cache = new Cache(new File(cacheDirectory), CACHE_SIZE);
builder.cache(cache);// 设置缓存
okHttpClient = builder.build();
mOkHttpClient = builder.build();
}
mRetrofit = new Retrofit.Builder()
.client(okHttpClient)
.client(mOkHttpClient)
.baseUrl(BuildConfig.ROOT_URL)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
@@ -206,10 +230,132 @@ public class NetInterfaceManager {
}
public OkHttpClient getOkHttpClient() {
return okHttpClient;
return mOkHttpClient;
}
/**
* 极简调用:仅需 网络请求Observable + 缓存过期时间
*
* @param networkObservable 原始网络请求
* @param expireTimeMillis 缓存有效期(毫秒)
* @return 带自动缓存的Observable
*/
public static <T> Observable<T> withCache(Observable<T> networkObservable, String cacheKey, long expireTimeMillis) {
return Observable.defer(() -> {
// Log.e(TAG, "withCache: cacheKey = " + cacheKey);
// 1. 读取缓存
T cacheData = CACHE_MANAGER.getCache(cacheKey);
if (cacheData != null) {
Log.e(TAG, "withCache: getCache " + cacheKey);
return Observable.just(cacheData);
}
Log.e(TAG, "withCache: request " + cacheKey);
// 2. 无缓存则发起网络请求,成功后缓存
return networkObservable
.subscribeOn(Schedulers.io())
.doOnNext(data -> CACHE_MANAGER.saveCache(cacheKey, data, expireTimeMillis));
})
.subscribeOn(Schedulers.io()) // 缓存IO切到子线程
.observeOn(AndroidSchedulers.mainThread());
}
/**
* 通用缓存策略:优先从内存缓存获取,其次从磁盘缓存获取,最后请求网络
* 统一使用 cacheKey 同时作为内存和磁盘缓存的键,且支持 Stale-While-Revalidate 策略
*
* @param networkObservable 网络请求Observable
* @param cacheKey 缓存键(内存与磁盘通用)
* @param type 数据类型(通过 TypeToken 获取,支持泛型)
* @param expireTimeMillis 内存缓存过期时间(毫秒)
* @return 带三级缓存策略的Observable可能会发射两次缓存 + 最新网络数据)
*/
public static <T> Observable<BaseResponse<T>> withMultiLevelCache(
Observable<BaseResponse<T>> networkObservable,
String cacheKey,
Type type,
long expireTimeMillis) {
return Observable.<BaseResponse<T>>create(emitter -> {
Log.e(TAG, "withMultiLevelCache: cacheKey = " + cacheKey);
// 1. 优先从内存缓存获取
BaseResponse<T> memoryCache = CACHE_MANAGER.getCache(cacheKey);
if (memoryCache != null) {
Log.e(TAG, "withMultiLevelCache: hit memory cache");
emitter.onNext(memoryCache);
} else {
// 2. 内存未命中,从磁盘缓存获取
try {
String diskCacheJson = INSTANCE.mCacheHelper.getAsString(cacheKey);
if (!TextUtils.isEmpty(diskCacheJson)) {
Log.e(TAG, "withMultiLevelCache: hit disk cache");
BaseResponse<T> diskCache = GsonUtils.toJavaObject(diskCacheJson, type);
if (diskCache != null) {
emitter.onNext(diskCache);
}
}
} catch (Exception e) {
Log.e(TAG, "withMultiLevelCache: read disk cache error", e);
}
}
// 3. 始终请求网络数据以同步更新
Log.e(TAG, "withMultiLevelCache: request from network");
networkObservable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<BaseResponse<T>>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
emitter.setDisposable(d);
}
@Override
public void onNext(BaseResponse<T> response) {
Log.e(TAG, "withMultiLevelCache: network success code = " + (response != null ? response.code : "null"));
if (response != null && response.code == 200) {
// 保存到内存和磁盘缓存
CACHE_MANAGER.saveCache(cacheKey, response, expireTimeMillis);
try {
String json = GsonUtils.toJSONString(response);
INSTANCE.mCacheHelper.put(cacheKey, json);
} catch (Exception e) {
Log.e(TAG, "withMultiLevelCache: save disk cache error", e);
}
} else {
// 状态码不为 200清除缓存
Log.e(TAG, "withMultiLevelCache: code != 200, clear cache");
CACHE_MANAGER.removeCache(cacheKey);
INSTANCE.mCacheHelper.remove(cacheKey);
}
emitter.onNext(response);
}
@Override
public void onError(@NonNull Throwable e) {
Log.e(TAG, "withMultiLevelCache: network error", e);
// 网络错误,清除缓存
CACHE_MANAGER.removeCache(cacheKey);
INSTANCE.mCacheHelper.remove(cacheKey);
if (!emitter.isDisposed()) {
emitter.onError(e);
}
}
@Override
public void onComplete() {
if (!emitter.isDisposed()) {
emitter.onComplete();
}
}
});
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/*
*
* API
@@ -257,236 +403,165 @@ public class NetInterfaceManager {
return mRetrofit.create(ControlApi.class);
}
public StatisticsApi getStatisticsApi() {
return mRetrofit.create(StatisticsApi.class);
}
public DeviceInfoApi getDeviceInfoApi() {
return mRetrofit.create(DeviceInfoApi.class);
}
/*
*
* Observable
*
* */
public <T> Observable<T> schedule(Observable<T> observable, BehaviorSubject<ActivityEvent> lifecycle) {
return observable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.compose(RxLifecycle.bindUntilEvent(lifecycle, ActivityEvent.DESTROY));
}
public Observable<BaseResponse<SnInfo>> getSnInfoControl() {
return getSnApi()
.getsninfo(Utils.getSerial())
public <T> Observable<T> schedule(Observable<T> observable) {
return observable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Observable<BaseResponse<SnInfo>> getSnInfoControl() {
return schedule(getSnApi().getsninfo(Utils.getSerial()));
}
public Observable<BaseResponse<SystemSettingsSet>> getSettingControl() {
return getSettingsApi()
.getSetting(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSettingsApi().getSetting(Utils.getSerial()));
}
public Observable<BaseResponse<List<AppInfo>>> getForceInstallControl() {
return getAppApi()
.getForceInstall(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getAppApi().getForceInstall(Utils.getSerial()));
}
public Observable<BaseResponse<BrowserApiData>> getBrowserControl() {
return getSettingsApi()
.getBrowser(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSettingsApi().getBrowser(Utils.getSerial()));
}
public Observable<BaseResponse<Label>> getLabelControl() {
return getSettingsApi()
.getLabel(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSettingsApi().getLabel(Utils.getSerial()));
}
public Observable<BaseResponse<List<AppStart>>> getAppStartControl() {
return getSnControlApi()
.getAppStatu(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnControlApi().getAppStatu(Utils.getSerial()));
}
public Observable<BaseResponse<List<AppInfo>>> getQueryAllAppControl() {
return getAppApi()
.getAllApp(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getAppApi().getAllApp(Utils.getSerial()));
}
public Observable<BaseResponse> getTimeControlObservable() {
return getSnControlApi()
.getTimeControl(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnControlApi().getTimeControl(Utils.getSerial()));
}
public Observable<BaseResponse<UserAvatarInfo>> getUserAvatarInfoControl() {
return getSnApi()
.getUserAvatarInfo(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnApi().getUserAvatarInfo(Utils.getSerial()));
}
public Observable<BaseResponse> getRunningAppObservable(String json) {
return getAppApi()
.sendAppInfo(Utils.getSerial(), json)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getAppApi().sendAppInfo(Utils.getSerial(), json));
}
public Observable<BaseResponse<AppletQRCode>> getQRCodeApiControl() {
return getFileApi()
.getQRCodeApi()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getFileApi().getQRCodeApi());
}
public Observable<BaseResponse> getGetGuideControl() {
return getFileApi()
.getGuidePic(2)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getFileApi().getGuidePic(2));
}
public Observable<BaseResponse> getAppInsideControl() {
return getSettingsApi()
.getAppInside(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSettingsApi().getAppInside(Utils.getSerial()));
}
public Observable<BaseResponse<BatchID>> getBatchApiControl() {
return getSnApi()
.getBatch(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnApi().getBatch(Utils.getSerial()));
}
public Observable<BaseResponse<CloudLessonSetting>> getCloudLessonSettingApiControl() {
return getSettingsApi()
.getCloudLessonSetting(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSettingsApi().getCloudLessonSetting(Utils.getSerial()));
}
public Observable<BaseResponse<AppInfo>> getUpdateObservable(String packageName) {
return getAppApi()
.getAppUpdate(packageName, "0", JgyUtils.getInstance().checkAppPlatform())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getAppApi().getAppUpdate(packageName, "0", JgyUtils.getInstance().checkAppPlatform()));
}
public Observable<BaseResponse<List<OverallAppBean>>> getOverallAppObservable() {
int platformCode = JgyUtils.getInstance().checkAppPlatform();
return getAppApi()
.getOverallApp(platformCode)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getAppApi().getOverallApp(platformCode));
}
public Observable<BaseResponse<List<AppTimeControl>>> getAppTimeControlObservable() {
return getSettingsApi()
.getAppTimeControl(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSettingsApi().getAppTimeControl(Utils.getSerial()));
}
@Deprecated
public Observable<BaseResponse<MachineControl>> getMachineTimeControlObservable() {
return getSettingsApi()
.getSnTimeControl(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSettingsApi().getSnTimeControl(Utils.getSerial()));
}
public Observable<BaseResponse> getAppUsageRecordObservable(String pkg, String pkgName, Long open_time, Long close_time) {
return getCommonApi()
.sendAppUsageRecord(Utils.getSerial(), pkg, pkgName, open_time, close_time)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getCommonApi().sendAppUsageRecord(Utils.getSerial(), pkg, pkgName, open_time, close_time));
}
public Observable<BaseResponse<List<MyAppUsageBean>>> getMyAppListObservable() {
return getAppApi()
.getMyAppList(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getAppApi().getMyAppList(Utils.getSerial()));
}
public Observable<BaseResponse<List<AppJump>>> getAppJumpObservable() {
return getSnControlApi()
.getSNJump(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnControlApi().getSNJump(Utils.getSerial()));
}
public Observable<BaseResponse<BlacklistBean>> getBlacklistObservable() {
return getCommonApi()
.getBlacklist()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getCommonApi().getBlacklist());
}
public Observable<BaseResponse<List<AppInfo>>> getAdminAppObservable() {
return getCommonApi()
.getAdminApp(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getCommonApi().getAdminApp(Utils.getSerial()));
}
public Observable<BaseResponse<ScreenPassword>> getLockScreenPwdObservable() {
return getSnControlApi()
.getLockScreenPwd(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnControlApi().getLockScreenPwd(Utils.getSerial()));
}
public Observable<BaseResponse> getUpdateLockScreenObservable() {
return getSnControlApi()
.updateLockScreen(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnControlApi().updateLockScreen(Utils.getSerial()));
}
public Observable<BaseResponse<List<AppAndWhiteBean>>> getAppAndWhiteApiObservable() {
return getCommonApi()
.getAppAndWhite(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getCommonApi().getAppAndWhite(Utils.getSerial()));
}
public Observable<BaseResponse<SnBindState>> getSnBindObservable() {
return getSnApi()
.getSnBind(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnApi().getSnBind(Utils.getSerial()));
}
public Observable<BaseResponse<SnRunLog>> getSnRunLogControl() {
return getSnLogApi()
.getSnRunLog(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnLogApi().getSnRunLog(Utils.getSerial()));
}
public Observable<BaseResponse<SnTag>> getSnTag() {
return getSnApi()
.getTag(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnApi().getTag(Utils.getSerial()));
}
public Observable<BaseResponse<SnTimeControlInfo>> getSnTimeControlObservable() {
return getControlApi()
.getSnTimeControl(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getControlApi().getSnTimeControl(Utils.getSerial()));
}
public Observable<BaseResponse<List<AppTimeControlInfo>>> getAppTimeControlInfoObservable() {
return getControlApi()
.getAppTimeControl(Utils.getSerial())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getControlApi().getAppTimeControl(Utils.getSerial()));
}
public Observable<BaseResponse<DeviceInfo>> getDeviceInfoObservable() {
return schedule(getDeviceInfoApi().getDeviceInfo(Utils.getSerial()));
}
/*
@@ -2292,23 +2367,18 @@ public class NetInterfaceManager {
}
private Observable<Integer> getScreenshot(String filePath) {
Observable<Integer> screenshotObservable = Observable.create(new ObservableOnSubscribe<Integer>() {
@Override
public void subscribe(ObservableEmitter<Integer> e) {
int code = CmdUtil.execute("screencap -p " + filePath).code;
e.onNext(code);
e.onComplete();
}
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return screenshotObservable;
return schedule(Observable.create(new ObservableOnSubscribe<Integer>() {
@Override
public void subscribe(ObservableEmitter<Integer> e) {
int code = CmdUtil.execute("screencap -p " + filePath).code;
e.onNext(code);
e.onComplete();
}
}));
}
private Observable<BaseResponse> getSendFile(String path, MultipartBody.Part body) {
return getSnInfoApi()
.getControlScreenshot(Utils.getSerial(), body)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
return schedule(getSnInfoApi().getControlScreenshot(Utils.getSerial(), body));
}
public void screenshot() {
@@ -2487,17 +2557,20 @@ public class NetInterfaceManager {
return mCacheHelper.getAsString(UrlAddress.GET_SN_TIME_CONTROL);
}
@Deprecated
public void getMachineTimeControl(BehaviorSubject<ActivityEvent> lifecycle, CompleteCallback callback) {
getMachineTimeControlObservable()
.compose(RxLifecycle.bindUntilEvent(lifecycle, ActivityEvent.DESTROY))
.subscribe(getMachineTimeControlObserver(callback));
}
@Deprecated
public void getMachineTimeControl(CompleteCallback callback) {
getMachineTimeControlObservable()
.subscribe(getMachineTimeControlObserver(callback));
}
@Deprecated
public void getMachineTimeControl() {
getMachineTimeControlObservable()
.subscribe(getMachineTimeControlObserver(null));
@@ -2578,6 +2651,7 @@ public class NetInterfaceManager {
});
}
@Deprecated
public void sendCloseApp(String packageName, CompleteCallback completeCallback) {
int isLogined = (int) SPUtils.get(mContext, CommonConfig.isLogined, 2);
if (isLogined != 1) {
@@ -3350,18 +3424,78 @@ public class NetInterfaceManager {
TimeManager.getInstance().setSnTimeControlInfo(null);
}
TimeManager.getInstance().setGetSnTimeControlInfoSuccessful(true);
}
@Override
public void onError(@NonNull Throwable e) {
Log.e("getSnTimeControl", "onError: ");
onComplete();
}
@Override
public void onComplete() {
Log.e("getSnTimeControlObserver", "onComplete: ");
if (callback != null) {
callback.onComplete();
}
}
};
}
public void getAppTimeControlInfo(BehaviorSubject<ActivityEvent> lifecycle, CompleteCallback callback) {
getAppTimeControlInfoObservable()
.compose(RxLifecycle.bindUntilEvent(lifecycle, ActivityEvent.DESTROY))
.subscribe(getAppTimeControlInfoObserver(callback));
}
public void getAppTimeControlInfo(CompleteCallback callback) {
getAppTimeControlInfoObservable()
.subscribe(getAppTimeControlInfoObserver(callback));
}
public void getAppTimeControlInfo() {
getAppTimeControlInfoObservable()
.subscribe(getAppTimeControlInfoObserver(null));
}
private Observer<BaseResponse<List<AppTimeControlInfo>>> getAppTimeControlInfoObserver(CompleteCallback callback) {
return new Observer<BaseResponse<List<AppTimeControlInfo>>>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Log.e("getAppTimeControlInfo", "onSubscribe: ");
}
@Override
public void onNext(@NonNull BaseResponse<List<AppTimeControlInfo>> response) {
Log.e("getAppTimeControlInfo", "onNext: " + response);
if (response.code == 200) {
List<AppTimeControlInfo> list = response.data;
Map<String, AppTimeControlInfo> appTimeControlMap = list.stream().collect(Collectors.toMap(AppTimeControlInfo::getApp_package, java.util.function.Function.identity(), (v1, v2) -> v1));
Map<String, Long> remainingTimeMap = list.stream().collect(Collectors.toMap(AppTimeControlInfo::getApp_package, AppTimeControlInfo::getToday_time, (v1, v2) -> v1));
TimeManager.getInstance().setAppTimeControlMap(appTimeControlMap);
TimeManager.getInstance().setRemainingTimeMap(remainingTimeMap);
} else if (response.code == 400) {
Log.e("getSnTimeControl", "onNext: Too frequent");
} else {
}
}
@Override
public void onError(@NonNull Throwable e) {
Log.e("getAppTimeControlInfo", "onError: ");
onComplete();
}
@Override
public void onComplete() {
Log.e("getAppTimeControlInfo", "onComplete: ");
if (callback != null) {
callback.onComplete();
}
}
};
}
}

View File

@@ -91,6 +91,7 @@ public class UrlAddress {
public final static String DEVICE_INFO = "device/info";
/*检查设备守护更新*/
public final static String CHECK_UPDATE = "device/check-update";
/*使用统计*/
/*获取整机控制配置*/
public final static String CONTROL_STATUS = "statistics/control-status";

View File

@@ -1,6 +1,7 @@
package com.fuying.sn.network.api.newly;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.bean.DeviceInfo;
import com.fuying.sn.network.UrlAddress;
import io.reactivex.rxjava3.core.Observable;
@@ -9,7 +10,7 @@ import retrofit2.http.Query;
public interface DeviceInfoApi {
@GET(UrlAddress.DEVICE_INFO)
Observable<BaseResponse> getDeviceInfo(
Observable<BaseResponse<DeviceInfo>> getDeviceInfo(
@Query("sn") String sn
);

View File

@@ -1,32 +1,40 @@
package com.fuying.sn.network.api.newly;
import com.fuying.sn.bean.AppTimeControlInfo;
import com.fuying.sn.bean.BaseResponse;
import com.fuying.sn.data.model.AppControlDetail;
import com.fuying.sn.data.model.AppUseTimeData;
import com.fuying.sn.data.model.AppUseTimeInfo;
import com.fuying.sn.data.model.ControlStatusInfo;
import com.fuying.sn.data.model.RemainingTimeBean;
import com.fuying.sn.network.UrlAddress;
import java.util.List;
import io.reactivex.rxjava3.core.Observable;
import retrofit2.http.GET;
import retrofit2.http.Query;
public interface StatisticsApi {
@GET(UrlAddress.CONTROL_STATUS)
Observable<BaseResponse> getControlStatus(
Observable<BaseResponse<ControlStatusInfo>> getControlStatus(
@Query("sn") String sn
);
@GET(UrlAddress.REMAINING_TIME)
Observable<BaseResponse> getRemainingTime(
Observable<BaseResponse<RemainingTimeBean>> getRemainingTime(
@Query("sn") String sn
);
@GET(UrlAddress.APP_USE_TIME)
Observable<BaseResponse> getAppUseTime(
Observable<BaseResponse<AppUseTimeData>> getAppUseTime(
@Query("sn") String sn,
@Query("query_type") int query_type,
@Query("is_all") int is_all
);
@GET(UrlAddress.APP_CONTROL_DETAIL)
Observable<BaseResponse> getAppControlDetail(
Observable<BaseResponse<List<AppControlDetail>>> getAppControlDetail(
@Query("sn") String sn
);
}

View File

@@ -0,0 +1,30 @@
package com.fuying.sn.network.cache;
/**
* 缓存实体:存储请求结果、创建时间、有效期
*
* @param <T> 接口返回数据类型
*/
public class CacheEntry<T> {
// 缓存的成功数据
private final T data;
// 缓存创建时间(毫秒)
private final long createTime;
// 缓存有效期(毫秒)
private final long expireTimeMillis;
public CacheEntry(T data, long createTime, long expireTimeMillis) {
this.data = data;
this.createTime = createTime;
this.expireTimeMillis = expireTimeMillis;
}
// 判断缓存是否过期
public boolean isExpired() {
return System.currentTimeMillis() - createTime > expireTimeMillis;
}
public T getData() {
return data;
}
}

View File

@@ -0,0 +1,63 @@
package com.fuying.sn.network.cache;
import java.util.concurrent.ConcurrentHashMap;
/**
* 全局缓存管理器:线程安全,存储所有接口缓存
*/
public class RxCacheManager {
// 单例
private static volatile RxCacheManager INSTANCE;
// 缓存容器key=请求唯一标识value=缓存实体
private final ConcurrentHashMap<String, CacheEntry<?>> cacheMap;
private RxCacheManager() {
cacheMap = new ConcurrentHashMap<>();
}
public static RxCacheManager getInstance() {
if (INSTANCE == null) {
synchronized (RxCacheManager.class) {
if (INSTANCE == null) {
INSTANCE = new RxCacheManager();
}
}
}
return INSTANCE;
}
/**
* 保存缓存
*/
public <T> void saveCache(String key, T data, long expireTimeMillis) {
if (key == null || data == null) return;
cacheMap.put(key, new CacheEntry<>(data, System.currentTimeMillis(), expireTimeMillis));
}
/**
* 获取缓存(自动判断过期)
*/
public <T> T getCache(String key) {
CacheEntry<?> entry = cacheMap.get(key);
if (entry == null || entry.isExpired()) {
// 缓存不存在/已过期移除并返回null
cacheMap.remove(key);
return null;
}
return (T) entry.getData();
}
/**
* 清空所有缓存
*/
public void clearAllCache() {
cacheMap.clear();
}
/**
* 删除指定缓存
*/
public void removeCache(String key) {
cacheMap.remove(key);
}
}

View File

@@ -0,0 +1,38 @@
package com.fuying.sn.network.cache;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* 优化后无需手动传CacheKey自动从请求获取
*/
public class RxCacheRequest {
private static final RxCacheManager CACHE_MANAGER = RxCacheManager.getInstance();
/**
* 极简调用:仅需 网络请求Observable + 缓存过期时间
*
* @param networkObservable 原始网络请求
* @param expireTimeMillis 缓存有效期(毫秒)
* @return 带自动缓存的Observable
*/
public static <T> Observable<T> withCache(Observable<T> networkObservable, String cacheKey, long expireTimeMillis) {
return Observable.defer(() -> {
// 2. 读取缓存
T cacheData = CACHE_MANAGER.getCache(cacheKey);
if (cacheData != null) {
return Observable.just(cacheData);
}
// 3. 无缓存则发起网络请求,成功后缓存
return networkObservable
.subscribeOn(Schedulers.io())
.doOnNext(data -> CACHE_MANAGER.saveCache(cacheKey, data, expireTimeMillis));
})
.subscribeOn(Schedulers.io()) // 缓存IO切到子线程
.observeOn(AndroidSchedulers.mainThread());
}
}

View File

@@ -0,0 +1,66 @@
package com.fuying.sn.network.cache;
import java.util.concurrent.ConcurrentHashMap;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.ObservableSource;
import io.reactivex.rxjava3.core.ObservableTransformer;
public class RxTimeCacheManager {
// Thread-safe memory cache to hold our data and its timestamp
private static final ConcurrentHashMap<String, CacheEntry<?>> cacheMap = new ConcurrentHashMap<>();
/**
* Industry Standard RxJava Cache Transformer.
* * @param cacheKey Unique key for this specific request (e.g., URL or Method Name)
*
* @param validTimeMillis How long the data is valid before a new network request is allowed
*/
public static <T> ObservableTransformer<T, T> applyCache(final String cacheKey, final long validTimeMillis) {
return new ObservableTransformer<T, T>() {
@Override
public ObservableSource<T> apply(Observable<T> upstream) {
// defer() ensures this logic runs EVERY time someone subscribes, not at initialization
return Observable.defer(() -> {
CacheEntry<?> entry = cacheMap.get(cacheKey);
long currentTime = System.currentTimeMillis();
// 1. Check if cache exists and is within the time limit
if (entry != null && (currentTime - entry.timestamp) < validTimeMillis) {
// Cache HIT -> Return cached data immediately, do not trigger network
@SuppressWarnings("unchecked")
T cachedData = (T) entry.data;
return Observable.just(cachedData);
}
// 2. Cache MISS or EXPIRED -> Trigger upstream (network) and save result
return upstream.doOnNext(data -> {
// Save the fresh data and current timestamp back to the cache
cacheMap.put(cacheKey, new CacheEntry<>(data, System.currentTimeMillis()));
});
});
}
};
}
/**
* Clears a specific cache key if you need to force a refresh manually.
*/
public static void clearCache(String cacheKey) {
cacheMap.remove(cacheKey);
}
/**
* Internal wrapper to hold the payload and the time it was fetched.
*/
private static class CacheEntry<T> {
final T data;
final long timestamp;
CacheEntry(T data, long timestamp) {
this.data = data;
this.timestamp = timestamp;
}
}
}

View File

@@ -72,7 +72,7 @@ public class RepeatRequestInterceptor implements Interceptor {
private void log(String action, String requestKey, Request request) {
if (BuildConfig.DEBUG) {
Log.e("REPEAT-REQUEST", action + requestKey + " Method @" + request.method() + " --- " + " URL = " + request.url().encodedPath() + "\t" + bodyToString(request));
Log.e("REPEAT-REQUEST", action + requestKey + " Method @" + request.method() + " --- " + " URL = " + request.url() + "\t" + bodyToString(request));
} else {
Log.e("REPEAT-REQUEST", action + requestKey + " Method @" + request.method());
}

View File

@@ -28,9 +28,12 @@ import com.fuying.sn.dialog.CustomDialog;
import com.fuying.sn.disklrucache.CacheHelper;
import com.fuying.sn.gson.GsonUtils;
import com.fuying.sn.manager.ControlManager;
import com.fuying.sn.manager.ConnectManager;
import com.fuying.sn.manager.ConnectMode;
import com.fuying.sn.manager.DeviceManager;
import com.fuying.sn.manager.time.TimeManager;
import com.fuying.sn.network.NetInterfaceManager;
import com.fuying.sn.network.UrlAddress;
import com.fuying.sn.receiver.BootReceiver;
import com.fuying.sn.service.LogcatService;
import com.fuying.sn.service.ManagerService;
@@ -94,10 +97,31 @@ public class PushManager {
this.mCacheHelper = new CacheHelper(context);
}
/**
* 定时轮询兜底间隔:推送不可靠时,靠它保证最终能拿到最新配置。
* 15 分钟一次,配合下方短节流,不会频繁请求接口。
*/
private static final long POLLING_INTERVAL = ConnectManager.FIFTEEN_MINUTES_TIME;
/**
* 推送防抖窗口:短时间内连续多条推送,合并为一次刷新。
*/
private static final long DEBOUNCE_WINDOW = 3 * 1000L;
/**
* 推送触发刷新的短节流 key1 分钟内因推送触发的多次刷新只真正请求一次)。
*/
private static final String KEY_PUSH_REFRESH = "push_config_refresh";
private Handler mSyncHandler;
private Runnable mPollRunnable;
private Runnable mDebounceRunnable;
private boolean mPollingStarted = false;
public static void init(Context context) {
if (sInstance == null) {
sInstance = new PushManager(context);
}
// 启动定时轮询兜底(推送可能收不到,轮询保证最终一致)
sInstance.startPolling();
}
public static PushManager getInstance() {
@@ -566,6 +590,8 @@ public class PushManager {
break;
default:
}
// 无论收到何种推送,都触发一次配置刷新(防抖+短节流,保证最新且不过频)
scheduleRefresh();
}
@@ -580,11 +606,91 @@ public class PushManager {
NetInterfaceManager.getInstance().getMachineTimeControl();
NetInterfaceManager.getInstance().getSnTimeControl();
TimeManager.getInstance().getTimeControl();
// 记录本次刷新时间,供短节流判断
ConnectManager.getInstance().updateConnectTime(KEY_PUSH_REFRESH);
}
}, 2345);
}
/**
* 启动定时轮询兜底。进程存活期间,每隔 {@link #POLLING_INTERVAL} 主动拉取一次关键配置,
* 解决"推送收不到导致配置永远不更新"的问题。轮询间隔(15分钟)本身即天然限频。
*/
public void startPolling() {
if (mPollingStarted) {
return;
}
mPollingStarted = true;
if (mSyncHandler == null) {
mSyncHandler = new Handler(Looper.getMainLooper());
}
mPollRunnable = new Runnable() {
@Override
public void run() {
Log.e(TAG, "polling tick -> updateUserSettings");
updateUserSettings();
if (mSyncHandler != null) {
mSyncHandler.postDelayed(this, POLLING_INTERVAL);
}
}
};
// 首次轮询延迟一个间隔再执行,避免与启动/网络恢复时的即时刷新叠加
mSyncHandler.postDelayed(mPollRunnable, POLLING_INTERVAL);
}
public void stopPolling() {
mPollingStarted = false;
if (mSyncHandler != null && mPollRunnable != null) {
mSyncHandler.removeCallbacks(mPollRunnable);
}
}
/**
* 推送触发:防抖合并 + 短节流。
* 多条推送在 {@link #DEBOUNCE_WINDOW} 内到达只合并为一次刷新;
* 且 1 分钟内的多次推送触发只真正请求一次,避免推送风暴导致频繁请求接口。
*/
public void scheduleRefresh() {
if (mSyncHandler == null) {
mSyncHandler = new Handler(Looper.getMainLooper());
}
if (mDebounceRunnable == null) {
mDebounceRunnable = new Runnable() {
@Override
public void run() {
if (ConnectManager.getInstance().isNeedConnect(KEY_PUSH_REFRESH, ConnectMode.ONE_MINUTE)) {
Log.e(TAG, "scheduleRefresh -> updateUserSettings");
updateUserSettings();
} else {
Log.e(TAG, "scheduleRefresh skipped (throttled)");
}
}
};
}
mSyncHandler.removeCallbacks(mDebounceRunnable);
mSyncHandler.postDelayed(mDebounceRunnable, DEBOUNCE_WINDOW);
}
/**
* 立即刷新(绕过防抖,但仍受短节流保护),用于网络恢复、开机完成等场景。
*/
public void triggerRefresh() {
if (mSyncHandler == null) {
mSyncHandler = new Handler(Looper.getMainLooper());
}
mSyncHandler.post(new Runnable() {
@Override
public void run() {
if (ConnectManager.getInstance().isNeedConnect(KEY_PUSH_REFRESH, ConnectMode.ONE_MINUTE)) {
Log.e(TAG, "triggerRefresh -> updateUserSettings");
updateUserSettings();
} else {
Log.e(TAG, "triggerRefresh skipped (throttled)");
}
}
});
}
private void addApkToWhiteList(String extras) {
Random random = new Random();
//避免同时请求

View File

@@ -9,6 +9,7 @@ import android.util.Log;
import com.fuying.sn.config.CommonConfig;
import com.fuying.sn.manager.ControlManager;
import com.fuying.sn.manager.time.TimeManager;
import com.fuying.sn.network.NetInterfaceManager;
import com.fuying.sn.utils.CacheUtils;
import com.fuying.sn.utils.JgyUtils;
@@ -107,6 +108,7 @@ public class APKinstallReceiver extends BroadcastReceiver {
}
NetInterfaceManager.getInstance().SendAppInstallInfo();
// NetInterfaceManager.getInstance().getAppAndWhite();
TimeManager.getInstance().getTimeControl();
}
@Override

View File

@@ -28,6 +28,7 @@ import com.fuying.sn.config.CommonConfig;
import com.fuying.sn.gson.GsonUtils;
import com.fuying.sn.network.NetInterfaceManager;
import com.fuying.sn.network.UrlAddress;
import com.fuying.sn.push.PushManager;
import com.fuying.sn.receiver.APKinstallReceiver;
import com.fuying.sn.receiver.BootReceiver;
import com.fuying.sn.utils.SPUtils;
@@ -74,6 +75,12 @@ public class ManagerService extends Service implements NetworkUtils.OnNetworkSta
@Override
public void onConnected(NetworkUtils.NetworkType networkType) {
// getScreenLockState();
// 网络恢复后立即兜底刷新一次配置(推送可能在此期间丢失)
try {
PushManager.getInstance().triggerRefresh();
} catch (Exception e) {
Log.e(TAG, "onConnected triggerRefresh: " + e.getMessage());
}
}
@@ -208,10 +215,10 @@ public class ManagerService extends Service implements NetworkUtils.OnNetworkSta
public static final String LOCK_STATE = "SCRENN_LOOCKED_STATE";
private void showFloatingWindow(String name) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "showFloatingWindow: debug mode skip");
return;
}
// if (BuildConfig.DEBUG) {
// Log.e(TAG, "showFloatingWindow: debug mode skip");
// return;
// }
if (Settings.canDrawOverlays(this)) {
// 获取WindowManager服务
if (null == windowManager) {

View File

@@ -326,6 +326,13 @@ public class MainSPresenter implements MainSContact.Presenter {
mView.setAppTimeControl();
}
});
NetInterfaceManager.getInstance().getAppTimeControlInfo(getLifecycle(), new NetInterfaceManager.CompleteCallback() {
@Override
public void onComplete() {
mView.setAppTimeControl();
}
});
}
@Override
@@ -336,6 +343,12 @@ public class MainSPresenter implements MainSContact.Presenter {
mView.setSnTimeControl();
}
});
NetInterfaceManager.getInstance().getSnTimeControl(getLifecycle(), new NetInterfaceManager.CompleteCallback() {
@Override
public void onComplete() {
mView.setSnTimeControl();
}
});
}
@Override

View File

@@ -28,6 +28,7 @@ import com.fuying.sn.config.CommonConfig;
import com.fuying.sn.desktop.RunningAppManager;
import com.fuying.sn.manager.AmapManager;
import com.fuying.sn.manager.ControlManager;
import com.fuying.sn.manager.time.TimeManager;
import com.fuying.sn.network.NetInterfaceManager;
import com.fuying.sn.network.UrlAddress;
import com.fuying.sn.rlog.LogDBManager;
@@ -96,6 +97,7 @@ public class MainService extends BaseRxService implements MainSContact.MainView,
NetInterfaceManager.getInstance().getAppWhiteList(() -> {
Log.e("onConnected", "onNext: getAppWhiteList");
});
TimeManager.getInstance().getTimeControl();
mPresenter.getSnInfo();
mPresenter.getAppAndWhite();
NetInterfaceManager.getInstance().updateAdminInfo();

View File

@@ -14,6 +14,7 @@ import android.content.pm.PackageInfo;
import android.content.pm.PackageInstaller;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Binder;
import android.os.Build;
@@ -1048,4 +1049,26 @@ public class ApkUtils {
return "unknown";
}
/**
* 通过包名获取应用图标
*
* @param context 上下文
* @param packageName 包名
* @return 应用图标 Drawable未找到时返回 null
*/
public static Drawable getAppIcon(Context context, String packageName) {
if (context == null || TextUtils.isEmpty(packageName)) {
return null;
}
PackageManager pm = context.getPackageManager();
try {
ApplicationInfo applicationInfo = pm.getApplicationInfo(packageName, 0);
return applicationInfo.loadIcon(pm);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
Log.e(TAG, "getAppIcon: " + packageName + " : " + e.getMessage());
}
return context.getDrawable(R.mipmap.ic_launcher);
}
}

View File

@@ -0,0 +1,152 @@
package com.fuying.sn.utils;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.util.Log;
import android.widget.ImageView;
import androidx.fragment.app.Fragment;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.RequestOptions;
import java.io.File;
/**
* Glide 加载 简单判空封装 防止异步加载数据时调用Glide 抛出异常
* Created by Li_Xavier on 2017/6/20 0020.
*/
public class GlideLoadUtils {
private String TAG = "ImageLoader";
/**
* 借助内部类 实现线程安全的单例模式
* 属于懒汉式单例因为Java机制规定内部类SingletonHolder只有在getInstance()
* 方法第一次调用的时候才会被加载实现了lazy而且其加载过程是线程安全的。
* 内部类加载的时候实例化一次instance。
*/
public GlideLoadUtils() {
}
private static class GlideLoadUtilsHolder {
private final static GlideLoadUtils INSTANCE = new GlideLoadUtils();
}
public static GlideLoadUtils getInstance() {
return GlideLoadUtilsHolder.INSTANCE;
}
/**
* Glide 加载 简单判空封装 防止异步加载数据时调用Glide 抛出异常
*
* @param context
* @param url 加载图片的url地址 String
* @param imageView 加载图片的ImageView 控件
* @param defaultImage 图片展示错误的本地图片 id
*/
public void glideLoad(Context context, String url, ImageView imageView, int defaultImage) {
if (context != null) {
Glide.with(context).load(url).centerCrop().error(defaultImage).placeholder(imageView.getDrawable()).dontAnimate().into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoadRound(Context context, String url, ImageView imageView, int defaultImage) {
if (context != null) {
Glide.with(context).load(url).centerCrop().error(defaultImage)
.placeholder(imageView.getDrawable()).dontAnimate()
.apply(RequestOptions.bitmapTransform(new RoundedCorners(ScreenUtils.dp2px(context, 16f))))
.into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoad(Context context, int resId, ImageView imageView, int defaultImage) {
if (context != null) {
Glide.with(context).load(resId).centerCrop().placeholder(imageView.getDrawable()).error(defaultImage).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoad(Context context, String url, ImageView imageView) {
if (context != null) {
Glide.with(context).load(url).centerCrop().dontAnimate().placeholder(imageView.getDrawable()).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoad(Context context, Bitmap bitmap, ImageView imageView) {
if (context != null) {
Glide.with(context).load(bitmap).centerCrop().dontAnimate().placeholder(imageView.getDrawable()).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoad(Context context, Drawable drawable, ImageView imageView) {
if (context != null) {
Glide.with(context).load(drawable).centerCrop().dontAnimate().placeholder(imageView.getDrawable()).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoad(Context context, Drawable drawable, ImageView imageView,int resId) {
if (context != null) {
Glide.with(context).load(drawable).centerCrop().dontAnimate().placeholder(imageView.getDrawable()).error(resId).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoad(Context context, File file, ImageView imageView) {
if (context != null) {
Glide.with(context).load(file).centerCrop().into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
public void glideLoad(Context context, File file, ImageView imageView, int defaultRes) {
if (context != null) {
Glide.with(context).load(file).centerCrop().error(defaultRes).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,context is null");
}
}
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
public void glideLoad(Activity activity, String url, ImageView imageView, int default_image) {
if (!activity.isDestroyed()) {
Glide.with(activity).load(url).centerCrop().error(default_image).placeholder(imageView.getDrawable()).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,activity is Destroyed");
}
}
public void glideLoad(Fragment fragment, String url, ImageView imageView, int default_image) {
if (fragment != null && fragment.getActivity() != null) {
Glide.with(fragment).load(url).centerCrop().error(default_image).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,fragment is null");
}
}
public void glideLoad(android.app.Fragment fragment, String url, ImageView imageView, int default_image) {
if (fragment != null && fragment.getActivity() != null) {
Glide.with(fragment).load(url).centerCrop().error(default_image).into(imageView);
} else {
Log.i(TAG, "Picture loading failed,android.app.Fragment is null");
}
}
}

View File

@@ -160,6 +160,7 @@ public class JgyUtils {
this.add(xiyouread);
this.add("com.fuying.middle.english");
this.add("com.qijuqiyi");
this.add("com.fuyingedu.fylxy_test");
}};
public static final String fxyywgj = "com.fuying.chinese";
@@ -855,7 +856,7 @@ public class JgyUtils {
}
}
ComponentName preActivity = new ComponentName(defPackageName, defClassName);
context.getPackageManager().addPreferredActivity(filter, bestMatch, set, preActivity);
context.getPackageManager().replacePreferredActivity(filter, bestMatch, set, preActivity);
}
} catch (java.lang.SecurityException e) {
e.printStackTrace();
@@ -864,29 +865,48 @@ public class JgyUtils {
}
public void setRoleHolderAsUser(Context context, String packageName) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
String roleName = "android.app.role.HOME";
boolean add = true;
int flags = 0;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.e(TAG, "setRoleHolderAsUser: RoleManager requires Android 10 or higher.");
return;
}
if (JgyUtils.getInstance().checkAppPlatform() == JgyUtils.MTKPlatform) {
Log.e(TAG, "setRoleHolderAsUser: MTK platform, skip");
return;
}
RoleManager roleManager = context.getSystemService(RoleManager.class);
if (roleManager == null) {
Log.e(TAG, "setRoleHolderAsUser: roleManager is null");
return;
}
String roleName = RoleManager.ROLE_HOME;
try {
UserHandle user = Process.myUserHandle();
Log.i("settingssssssstemf", (add ? "Adding" : "Removing") + " package as role holder, role: "
+ roleName + ", package: " + packageName);
if (JgyUtils.getInstance().checkAppPlatform() != JgyUtils.MTKPlatform) {
RoleManager roleManager = context.getSystemService(RoleManager.class);
Executor executor = context.getMainExecutor();
Consumer<Boolean> callback = successful -> {
if (successful) {
Log.d("settingssssssstemf", "Package " + (add ? "added" : "removed")
+ " as role holder, role: " + roleName + ", package: " + packageName);
} else {
Log.d("settingssssssstemf", "Failed to " + (add ? "add" : "remove")
+ " package as role holder, role: " + roleName + ", package: "
+ packageName);
}
};
roleManager.addRoleHolderAsUser(roleName, packageName, flags, user, executor, callback);
Log.i("settingssssssstemf", "addRoleHolderAsUser done");
}
Executor executor = context.getMainExecutor();
// 定义回调
Consumer<Boolean> callback = successful -> {
if (successful) {
Log.i(TAG, "setRoleHolderAsUser: Successfully set " + packageName + " as " + roleName);
} else {
Log.e(TAG, "setRoleHolderAsUser: Failed to set " + packageName + " as " + roleName + ". Check system logs.");
}
};
// 2. 使用反射调用隐藏方法 addRoleHolderAsUser
// 方法签名: addRoleHolderAsUser(String, String, int, UserHandle, Executor, Consumer<Boolean>)
Method method = RoleManager.class.getMethod("addRoleHolderAsUser",
String.class, String.class, int.class, UserHandle.class, Executor.class, Consumer.class);
Log.d(TAG, "setRoleHolderAsUser: Invoking addRoleHolderAsUser for package: " + packageName);
method.invoke(roleManager, roleName, packageName, 0, user, executor, callback);
// roleManager.addRoleHolderAsUser(roleName, packageName, 0, user, executor, callback);
} catch (NoSuchMethodException e) {
Log.e(TAG, "setRoleHolderAsUser: Method not found. Is this a non-standard ROM?", e);
} catch (Exception e) {
Log.e(TAG, "setRoleHolderAsUser: Error invoking addRoleHolderAsUser", e);
}
}
@@ -958,42 +978,26 @@ public class JgyUtils {
setRoleHolderAsUser(mContext, pkg);
Log.e(TAG, "setDefaultDesktop: setRoleHolderAsUser");
} else {
//爱华设置,暂时屏蔽
// setDefaultLauncher(mContext, pkg, className);
setDefaultLauncher(mContext, pkg, className);
Log.e(TAG, "setDefaultDesktop: setDefaultLauncher");
}
// String oldDesktop = (String) SPUtils.get(mContext, "default_launcher", "");
// if (Objects.equals(oldDesktop, pkg)) {
// Log.e(TAG, "setDefaultDesktop: " + "数据一致");
// return;
// }
Intent intent = new Intent("setDefaultLauncher");
intent.putExtra("package", pkg);
intent.putExtra("className", className);
if (JgyUtils.getInstance().checkAppPlatform() == MTKPlatform) {
Log.e(TAG, "setDefaultDesktop: MTK");
//爱华定制
intent.setComponent(new ComponentName("com.android.settings", "com.android.settings.AoleReceiver"));
// TODO: 2022/7/6 有问题
setDefaultLauncher(mContext, "com.android.transfer", "com.android.transfer.MainActivity");
SystemProperties.set("persist.sys.launcher.pkgname", pkg);
SystemProperties.set("persist.sys.launcher.classname", className);
}
intent.setPackage("com.android.settings");
mContext.sendBroadcast(intent);
// ApkUtils.openPackage(mContext, pkg);
Log.e(TAG, "setDefaultDesktop: " + pkg + ":" + className);
Log.e(TAG, "setDefaultDesktop: " + "persist.sys.launcher.pkgname = " + SystemProperties.get("persist.sys.launcher.pkgname"));
Log.e(TAG, "setDefaultDesktop: " + "persist.sys.launcher.classname = " + SystemProperties.get("persist.sys.launcher.classname"));
}
// public void setDefaultDesktop(String pkg, String className) {
// Intent intent = new Intent("setDefaultLauncher");
// intent.putExtra("package", pkg);
// intent.putExtra("className", className);
// if (JgyUtils.getInstance().checkAppPlatform() == MTKPlatform) {
// Log.e(TAG, "setDefaultDesktop: MTK");
// //爱华定制
// intent.setComponent(new ComponentName("com.android.settings", "com.android.settings.AoleReceiver"));
// // TODO: 2022/7/6 有问题
// setDefaultLauncher(mContext, "com.android.transfer", "com.android.transfer.MainActivity");
// SystemProperties.set("persist.sys.launcher.pkgname", pkg);
// SystemProperties.set("persist.sys.launcher.classname", className);
// }
// // TODO: 2026/8/11 发送setDefaultLauncher会强制到桌面暂时去掉
// intent.setPackage("com.android.settings");
// mContext.sendBroadcast(intent);
// }
}
public ArrayList<String> getForbidList() {
ArrayList<String> disableApp = new ArrayList<>();
@@ -1188,6 +1192,7 @@ public class JgyUtils {
mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0);
}
@Deprecated
public void setDefaultLauncher(String pkg) {
Intent intent = new Intent("setDefaultLauncher");
intent.putExtra("package", "com.uiui.os");
@@ -1349,26 +1354,26 @@ public class JgyUtils {
return;
}
Observable.create(new ObservableOnSubscribe<String>() {
@Override
public void subscribe(ObservableEmitter<String> e) throws Exception {
Log.e("RemoveTask", "subscribe: " + packageName);
List<DownloadEntity> list = Aria.download(context).getTaskList();
for (DownloadEntity entity : list) {
long id = entity.getId();
String extendField = Aria.download(this).load(id).getExtendField();
try {
JsonObject jsonObject = JsonParser.parseString(extendField).getAsJsonObject();
if (packageName.equals(jsonObject.get("app_package").getAsString())) {
Log.e("RemoveTask", "subscribe: " + "删除文件:" + entity.getFilePath());
Aria.download(this).load(id).cancel(true);
@Override
public void subscribe(ObservableEmitter<String> e) throws Exception {
Log.e("RemoveTask", "subscribe: " + packageName);
List<DownloadEntity> list = Aria.download(context).getTaskList();
for (DownloadEntity entity : list) {
long id = entity.getId();
String extendField = Aria.download(this).load(id).getExtendField();
try {
JsonObject jsonObject = JsonParser.parseString(extendField).getAsJsonObject();
if (packageName.equals(jsonObject.get("app_package").getAsString())) {
Log.e("RemoveTask", "subscribe: " + "删除文件:" + entity.getFilePath());
Aria.download(this).load(id).cancel(true);
}
} catch (Exception exception) {
Log.e("RemoveTask", "subscribe: " + exception.getMessage());
}
}
} catch (Exception exception) {
Log.e("RemoveTask", "subscribe: " + exception.getMessage());
e.onComplete();
}
}
e.onComplete();
}
}).subscribeOn(Schedulers.io())
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<String>() {
@Override

View File

@@ -0,0 +1,33 @@
package com.fuying.sn.utils;
import android.content.Context;
import android.content.res.Resources;
public class ScreenUtils {
public static int dp2px(final float dpValue) {
final float scale = Resources.getSystem().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
public static int dp2px(Context context, final float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
public static int px2dp(Context context, final float pxValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (pxValue / scale + 0.5f);
}
public static int sp2px(Context context, final float spValue) {
final float scale = context.getResources().getDisplayMetrics().scaledDensity;
return (int) (spValue * scale + 0.5f);
}
public static int px2sp(Context context, final float pxValue) {
final float fontScale = context.getResources().getDisplayMetrics().scaledDensity;
return (int) (pxValue / fontScale + 0.5f);
}
}

View File

@@ -10,6 +10,8 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import com.fuying.sn.bean.PartTime;
import com.fuying.sn.data.model.AppControlDetail;
import com.fuying.sn.service.ManagerService;
import java.net.DatagramPacket;
@@ -17,12 +19,15 @@ import java.net.DatagramSocket;
import java.net.InetAddress;
import java.text.DateFormat;
import java.text.ParseException;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.Duration;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
@@ -240,6 +245,56 @@ public class TimeUtils {
private String[] weekDays = {"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"};
public static String formatTime(long timeSecond) {
if (timeSecond <= 0) {
return "0分钟";
}
Duration duration = Duration.ofSeconds(timeSecond);
long hours = duration.toHours();
long minutes = duration.toMinutes() - hours * 60;
if (hours > 0) {
if (minutes > 0) {
return hours + "小时" + minutes + "分钟";
}
return hours + "小时";
}
return minutes + "分钟";
}
public static String getPartTimeString(AppControlDetail appControlDetail) {
if (appControlDetail == null) return "";
List<PartTime> time_period = appControlDetail.getTime_period();
return getPartTimeString(time_period);
}
public static String getPartTimeString(List<PartTime> time_period) {
if (time_period == null || time_period.isEmpty()) return "";
StringBuilder sbWork = new StringBuilder();
for (int i = 0; i < time_period.size(); i++) {
PartTime partTime = time_period.get(i);
sbWork.append(partTime.getStart_time()).append("~").append(partTime.getEnd_time());
if (i < time_period.size() - 1) {
sbWork.append("\n");
}
}
Log.e(TAG, "getPartTimeString: " + sbWork);
return sbWork.toString();
}
public static String getPartTimeStringLine(List<PartTime> time_period) {
if (time_period == null || time_period.isEmpty()) return "";
StringBuilder sbWork = new StringBuilder();
for (int i = 0; i < time_period.size(); i++) {
PartTime partTime = time_period.get(i);
sbWork.append(partTime.getStart_time()).append("~").append(partTime.getEnd_time());
if (i < time_period.size() - 1) {
sbWork.append("\t");
}
}
Log.e(TAG, "getPartTimeString: " + sbWork);
return sbWork.toString();
}
/**
* 获取星期几
*

View File

@@ -557,32 +557,6 @@ public class Utils {
return "";
}
/**
* 根据手机的分辨率从 dp 的单位 转成为 px(像素)
*/
public static int dip2px(Context context, float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
/**
* 根据手机的分辨率从 px(像素) 的单位 转成为 dp
*/
public static int px2dip(Context context, float pxValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (pxValue / scale + 0.5f);
}
public static float dp2px(Resources resources, float dp) {
final float scale = resources.getDisplayMetrics().density;
return dp * scale + 0.5f;
}
public static float sp2px(Resources resources, float sp) {
final float scale = resources.getDisplayMetrics().scaledDensity;
return sp * scale;
}
public static String getTime() {
long time = System.currentTimeMillis();//long now = android.os.SystemClock.uptimeMillis();
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 内部颜色 -->
<solid android:color="@color/default_blue" />
<!-- 圆角的幅度 -->
<corners
android:bottomLeftRadius="@dimen/dp_16"
android:bottomRightRadius="@dimen/dp_16"
android:topLeftRadius="@dimen/dp_16"
android:topRightRadius="@dimen/dp_16" />
<padding
android:bottom="@dimen/dp_4"
android:left="@dimen/dp_16"
android:right="@dimen/dp_16"
android:top="@dimen/dp_4" />
</shape>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M702.93,777.58c-11.69,-3.16 -23.74,3.76 -26.9,15.45 -3.16,11.7 3.76,23.74 15.45,26.9 73.03,19.75 105.71,47.55 105.71,66.33 0,14.49 -19.8,37.05 -75.4,56.91 -55.7,19.9 -130.21,30.84 -209.79,30.84 -79.59,0 -154.1,-10.95 -209.8,-30.84 -55.59,-19.85 -75.4,-42.41 -75.4,-56.91 0,-18.79 32.7,-46.6 105.76,-66.35 11.7,-3.16 18.62,-15.2 15.46,-26.9 -3.16,-11.69 -15.2,-18.62 -26.9,-15.45 -89.12,24.08 -138.2,62.69 -138.2,108.7 0,26.87 17.36,47.17 31.92,59.46 17.48,14.76 41.9,27.81 72.59,38.77 60.32,21.54 140.06,33.41 224.55,33.41 84.49,0 164.24,-11.87 224.56,-33.41 30.69,-10.96 55.12,-24 72.6,-38.77 14.56,-12.3 31.92,-32.59 31.92,-59.46 0,-46 -49.06,-84.6 -138.14,-108.69v0zM422.05,826.72c23.42,26.84 56.22,42.22 90.12,42.22h0c34.03,0 66.8,-15.51 90.08,-42.57 7.06,-7.7 67.09,-73.79 127.92,-160.35 88.15,-125.43 132.84,-229.37 132.84,-308.92 0,-47.37 -9.28,-93.34 -27.6,-136.64 -17.68,-41.8 -42.99,-79.34 -75.21,-111.56 -32.22,-32.22 -69.75,-57.53 -111.55,-75.2 -43.3,-18.32 -89.28,-27.6 -136.64,-27.6 -47.37,0 -93.34,9.29 -136.64,27.6 -41.8,17.68 -79.34,42.99 -111.56,75.2 -32.22,32.23 -57.53,69.76 -75.2,111.56 -18.32,43.3 -27.6,89.27 -27.6,136.64 0,79.55 44.72,183.52 132.93,309.03 61.25,87.15 121.68,153.57 128.12,160.59v0zM375.91,347.29c0,-77.42 62.99,-140.4 140.4,-140.4 77.42,0 140.4,62.99 140.4,140.4 0,77.42 -62.98,140.4 -140.4,140.4 -77.41,0 -140.4,-62.99 -140.4,-140.4v0zM375.91,347.29z"
android:fillColor="#3884FF" />
</vector>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 内部颜色 -->
<solid android:color="#DD5052" />
<!-- 圆角的幅度 -->
<corners
android:bottomLeftRadius="@dimen/dp_16"
android:bottomRightRadius="@dimen/dp_16"
android:topLeftRadius="@dimen/dp_16"
android:topRightRadius="@dimen/dp_16" />
<padding
android:bottom="@dimen/dp_2"
android:left="@dimen/dp_8"
android:right="@dimen/dp_8"
android:top="@dimen/dp_2" />
</shape>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 内部颜色 -->
<solid android:color="#FF9E61" />
<!-- 圆角的幅度 -->
<corners
android:bottomLeftRadius="@dimen/dp_16"
android:bottomRightRadius="@dimen/dp_16"
android:topLeftRadius="@dimen/dp_16"
android:topRightRadius="@dimen/dp_16" />
<padding
android:bottom="@dimen/dp_2"
android:left="@dimen/dp_4"
android:right="@dimen/dp_4"
android:top="@dimen/dp_2" />
</shape>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 内部颜色 -->
<solid android:color="#3ACEFD" />
<!-- 圆角的幅度 -->
<corners
android:bottomLeftRadius="@dimen/dp_16"
android:bottomRightRadius="@dimen/dp_16"
android:topLeftRadius="@dimen/dp_16"
android:topRightRadius="@dimen/dp_16" />
<padding
android:bottom="@dimen/dp_2"
android:left="@dimen/dp_4"
android:right="@dimen/dp_4"
android:top="@dimen/dp_2" />
</shape>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 内部颜色 -->
<solid android:color="#34CE85" />
<!-- 圆角的幅度 -->
<corners
android:bottomLeftRadius="@dimen/dp_16"
android:bottomRightRadius="@dimen/dp_16"
android:topLeftRadius="@dimen/dp_16"
android:topRightRadius="@dimen/dp_16" />
<padding
android:bottom="@dimen/dp_2"
android:left="@dimen/dp_4"
android:right="@dimen/dp_4"
android:top="@dimen/dp_2" />
</shape>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.home.HomeActivity">
<data>
<variable
name="click"
type="com.fuying.sn.activity.home.HomeActivity.BtnClick" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:background="@drawable/main_background"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="@dimen/dp_32"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="@dimen/dp_20"
android:layout_height="@dimen/dp_20"
android:layout_marginStart="16dp"
android:adjustViewBounds="true"
android:onClick="@{click::exit}"
android:scaleType="centerCrop"
android:src="@drawable/icon_blue_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.flyco.tablayout.SlidingTabLayout
android:id="@+id/main_sliding_tab_layout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tl_indicator_color="@color/default_blue"
app:tl_indicator_height="@dimen/dp_2"
app:tl_indicator_style="NORMAL"
app:tl_indicator_width_equal_title="true"
app:tl_tab_gravity="Center"
app:tl_tab_horizontal_gravity="Center"
app:tl_tab_space_equal="false"
app:tl_textBold="SELECT"
app:tl_textSelectColor="@color/black"
app:tl_textSelectSize="@dimen/sp_13"
app:tl_textSize="@dimen/sp_13"
app:tl_textUnSelectColor="@color/gray"
app:tl_textUnSelectSize="@dimen/sp_14" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout">
<androidx.viewpager.widget.ViewPager
android:id="@+id/viewPager"
android:layout_marginStart="@dimen/dp_32"
android:layout_marginEnd="@dimen/dp_32"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.usage.UsageDetailActivity">
<data>
<variable
name="click"
type="com.fuying.sn.activity.usage.UsageDetailActivity.BtnClick" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/main_background">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_top_bar"
android:layout_width="match_parent"
android:layout_height="@dimen/dp_32"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="@dimen/dp_20"
android:layout_height="@dimen/dp_20"
android:layout_marginStart="16dp"
android:adjustViewBounds="true"
android:onClick="@{click::onBackClick}"
android:scaleType="centerCrop"
android:src="@drawable/icon_blue_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<net.lucode.hackware.magicindicator.MagicIndicator
android:id="@+id/magic_indicator"
android:layout_width="wrap_content"
android:layout_height="@dimen/dp_20"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_use_time"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="@dimen/dp_8"
android:background="@drawable/card_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/cl_top_bar">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_use_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="整机使用时长:"
android:textColor="@color/black"
android:textSize="@dimen/sp_12"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_total_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:text="@string/unknown"
android:textColor="@color/black"
android:textSize="@dimen/sp_9"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/tv_use_time"
app:layout_constraintStart_toEndOf="@id/tv_use_time"
app:layout_constraintTop_toTopOf="@id/tv_use_time" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:id="@+id/view"
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
android:background="@color/gray"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout1" />
<LinearLayout
android:id="@+id/barChart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginBottom="@dimen/dp_8"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/view">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_left"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/dp_8"
android:layout_weight="1"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_right"
android:layout_width="0dp"
android:layout_marginStart="@dimen/dp_8"
android:layout_height="match_parent"
android:layout_weight="1"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -592,10 +592,10 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_update"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:id="@+id/cl_update">
android:layout_weight="1">
<TextView
android:layout_width="wrap_content"

View File

@@ -15,169 +15,413 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
android:layout_height="match_parent"
android:layout_margin="@dimen/dp_8"
android:orientation="horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/dp_4"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="3"
android:layout_height="0dp"
android:layout_marginBottom="@dimen/dp_4"
android:layout_weight="4"
android:background="@drawable/card_background"
android:orientation="vertical">
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/textView5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:text="设备信息"
android:textColor="@color/black"
android:textSize="@dimen/sp_12"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout4"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_4"
android:layout_marginEnd="@dimen/dp_8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView5">
<TextView
android:id="@+id/textView11"
android:layout_width="wrap_content"
android:id="@+id/tv_sn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_16"
android:text="设备码"
android:text="SN码"
android:textColor="@color/black"
android:textSize="@dimen/sp_10"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="@dimen/sp_9"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginStart="@dimen/dp_16" />
<TextView
android:id="@+id/tv_imei"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/dp_16"
android:ellipsize="end"
android:maxLength="30"
android:singleLine="true"
android:text="@string/unknown"
android:textColor="@color/text_gray"
android:textSize="@dimen/sp_10"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:layout_width="0dp"
android:layout_height="1px"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
android:background="@color/text_gray"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_number"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_16"
android:text="软件版本"
android:textColor="@color/black"
android:textSize="@dimen/sp_10"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginStart="@dimen/dp_16" />
<TextView
android:id="@+id/tv_app_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/dp_16"
android:ellipsize="end"
android:maxLength="30"
android:singleLine="true"
android:text="@string/unknown"
android:textColor="@color/text_gray"
android:textSize="@dimen/sp_10"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:layout_width="0dp"
android:layout_height="1px"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
android:background="@color/text_gray"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_class"
android:id="@+id/constraintLayout6"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_4"
android:layout_marginEnd="@dimen/dp_8"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/tv_sn"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout4">
<TextView
android:id="@+id/textView9"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_16"
android:text="系统版本"
android:textColor="@color/black"
android:textSize="@dimen/sp_10"
<ImageView
android:id="@+id/imageView4"
android:layout_width="@dimen/dp_10"
android:layout_height="@dimen/dp_10"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
android:src="@drawable/ic_map_location"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginStart="@dimen/dp_16" />
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_build_version"
android:id="@+id/tv_address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="1"
android:singleLine="true"
android:textColor="@color/gray"
android:textSize="@dimen/sp_9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView4"
app:layout_constraintTop_toTopOf="parent"
tools:text="地址" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:id="@+id/view2"
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_4"
android:layout_marginEnd="@dimen/dp_8"
android:background="@color/gray"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout6"
tools:layout_editor_absoluteX="9dp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/view2">
<TextView
android:id="@+id/tv_last_sync_time"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/dp_8"
android:text="最新数据同步时间"
android:textColor="@color/black"
android:textSize="@dimen/sp_7"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView6"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/dp_16"
android:ellipsize="end"
android:maxLength="30"
android:singleLine="true"
android:text="@string/unknown"
android:background="@drawable/bt_refresh_normnl"
android:onClick="@{click::onRefreshClick}"
android:text="刷新"
android:textColor="@color/white"
android:textSize="@dimen/sp_8"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_use_time"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="@dimen/dp_4"
android:layout_weight="7"
android:background="@drawable/card_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_use_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/usage_statistics_title"
android:textColor="@color/black"
android:textSize="@dimen/sp_12"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_view_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{click::onViewAllClick}"
android:text="@string/usage_view_all"
android:textColor="@color/gray"
android:textSize="@dimen/sp_9"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:id="@+id/view"
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
android:background="@color/gray"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout1" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/view">
<TextView
android:id="@+id/tv_total_time_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/usage_duration_label"
android:textColor="@color/text_gray"
android:textSize="@dimen/sp_10"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:layout_width="0dp"
android:layout_height="1px"
<TextView
android:id="@+id/tv_total_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
android:background="@color/text_gray"
android:visibility="gone"
android:text="@string/unknown"
android:textColor="@color/black"
android:textSize="@dimen/sp_9"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/tv_total_time_label"
app:layout_constraintStart_toEndOf="@id/tv_total_time_label"
app:layout_constraintTop_toTopOf="@id/tv_total_time_label" />
<net.lucode.hackware.magicindicator.MagicIndicator
android:id="@+id/magic_indicator"
android:layout_width="wrap_content"
android:layout_height="@dimen/dp_20"
app:layout_constraintBottom_toBottomOf="@+id/constraintLayout3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<com.github.mikephil.charting.charts.HorizontalBarChart
android:id="@+id/barChart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginBottom="@dimen/dp_8"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/dp_4"
android:layout_weight="1"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="@dimen/dp_4"
android:layout_weight="2"
android:background="@drawable/card_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="整机使用"
android:textColor="@color/black"
android:textSize="@dimen/sp_12"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_control_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_12"
android:gravity="center"
android:textColor="@color/white"
android:textSize="@dimen/sp_8"
app:layout_constraintBottom_toBottomOf="@+id/textView7"
app:layout_constraintStart_toEndOf="@+id/textView7"
app:layout_constraintTop_toTopOf="@+id/textView7"
tools:text="地址" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout5">
<TextView
android:id="@+id/tv_today_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="@dimen/sp_9"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="SN码" />
<TextView
android:id="@+id/tv_remaining_time"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:textColor="@color/gray"
android:textSize="@dimen/sp_9"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/tv_today_time"
app:layout_constraintTop_toTopOf="parent"
tools:text="剩余" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="@dimen/dp_4"
android:layout_weight="7"
android:background="@drawable/card_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/textView12"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:text="应用控制"
android:textColor="@color/black"
android:textSize="@dimen/sp_12"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="@dimen/dp_8"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView12" />
<TextView
android:id="@+id/tv_no_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:singleLine="true"
android:textSize="@dimen/sp_11"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:text="当前pad无管控限制" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</layout>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="@dimen/dp_40"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/iv_icon"
android:layout_width="@dimen/dp_28"
android:layout_height="@dimen/dp_28"
android:layout_marginStart="@dimen/dp_4"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_4"
android:layout_marginEnd="@dimen/dp_4"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="@dimen/sp_9"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/tv_type"
app:layout_constraintStart_toEndOf="@+id/iv_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="AppName" />
<TextView
android:id="@+id/tv_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/tv_time_disable_background"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="@dimen/sp_6"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.464"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.516"
tools:text="AppName" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_4"
android:layout_marginEnd="@dimen/dp_4"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/tv_type"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_today_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:ellipsize="end"
android:maxLines="2"
android:gravity="center"
android:textColor="@color/black"
android:textSize="@dimen/sp_8"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="每天" />
<TextView
android:id="@+id/tv_remaining_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:textColor="@color/gray"
android:textSize="@dimen/sp_8"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="剩余" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginStart="@dimen/dp_8"
android:layout_marginTop="@dimen/dp_8"
android:layout_marginEnd="@dimen/dp_8"
android:background="@color/lightGray"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/dp_12"
android:paddingVertical="@dimen/dp_8">
<ImageView
android:id="@+id/iv_icon"
android:layout_width="@dimen/dp_28"
android:layout_height="@dimen/dp_28"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/tv_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_8"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#333333"
android:textSize="@dimen/sp_12"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/tv_time"
app:layout_constraintStart_toEndOf="@+id/iv_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="AI 问答" />
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#666666"
android:textSize="@dimen/sp_12"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="1小时20分" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -20,4 +20,14 @@
<string name="unknown"></string>
<string name="usage_statistics_title">使用时长统计</string>
<string name="usage_view_all">查看全部></string>
<string name="usage_duration_label">使用时长</string>
<string name="usage_today">今日</string>
<string name="usage_week">本周</string>
<string name="usage_month">本月</string>
<string name="usage_hour">小时</string>
<string name="usage_minute">分钟</string>
<string name="usage_no_data">暂无数据</string>
</resources>