refactor(contact): 重构 ViewModel 为标准 MVVM 架构

将网络优先/本地降级策略下沉至 ContactDataRepository,ViewModel 仅负责状态分发;引入 Resource 包装类与 SWR 多级缓存机制,简化 RxJava 链式调用并移除冗余的 Observer/RxLifecycle 代码。
This commit is contained in:
2026-07-27 17:39:21 +08:00
parent 6bb9f25600
commit de1ce76fdf
22 changed files with 1497 additions and 480 deletions

View File

@@ -4,119 +4,58 @@ import android.content.Context;
import androidx.lifecycle.MutableLiveData;
import com.trello.rxlifecycle4.RxLifecycle;
import com.trello.rxlifecycle4.android.ActivityEvent;
import com.ttstd.dialer.base.mvvm.BaseViewModel;
import com.ttstd.dialer.data.repository.ContactDataRepository;
import com.ttstd.dialer.databinding.ActivityContactAddBinding;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.db.contact.ContactRepository;
import com.ttstd.dialer.network.OkHttpManager;
import com.ttstd.dialer.utils.Logger;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.ObservableOnSubscribe;
import io.reactivex.rxjava3.core.Observer;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* 新增联系人 ViewModelGoogle 标准 MVVM
* 网络优先 / 本地降级的策略已下沉到 RepositoryViewModel 只负责状态分发。
*/
public class ContactAddViewModel extends BaseViewModel<ActivityContactAddBinding, ActivityEvent> {
private static final String TAG = "ContactAddViewModel";
private ContactRepository mRepository;
private ContactDataRepository mRepository;
/**
* 服务端保存成功返回的 id
*/
public MutableLiveData<Long> mOnlineIdData = new MutableLiveData<>();
/** 网络失败降级到本地 Room 保存返回的 id */
public MutableLiveData<Long> mDbIdData = new MutableLiveData<>();
@Override
public void setContext(Context context) {
super.setContext(context);
mRepository = new ContactRepository(context);
mRepository = ContactDataRepository.getInstance(context);
}
/**
* 新增联系人:远程成功 → 自动失效列表缓存;失败 → 自动降级本地保存。
*/
public void addContact(ContactInfo contactInfo) {
OkHttpManager.getInstance().getContactInsertObservable(contactInfo, getLifecycle())
.subscribeOn(Schedulers.io())
addDisposable(mRepository.addContact(contactInfo)
.observeOn(AndroidSchedulers.mainThread())
.flatMap(baseResponse -> {
Logger.e(TAG, "addContact", "网络请求响应: " + baseResponse);
if (baseResponse.isSuccess()) {
Long id = baseResponse.getData();
mOnlineIdData.setValue(id);
return Observable.just(id);
.subscribe(result -> {
Logger.d(TAG, "addContact online=" + result.online + ", id=" + result.id);
if (result.online) {
mOnlineIdData.setValue(result.id);
} else {
Logger.w(TAG, "业务失败,降级到本地保存: " + baseResponse.getMsg());
return saveContactDbObservable(contactInfo);
mDbIdData.setValue(result.id);
}
})
.onErrorResumeNext(throwable -> {
Logger.e(TAG, "网络异常,降级到本地保存: " + throwable.getMessage());
return saveContactDbObservable(contactInfo);
})
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribe(new Observer<Long>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.d(TAG, "开始添加联系人");
}
@Override
public void onNext(@NonNull Long id) {
Logger.d(TAG, "联系人保存成功ID: " + id);
if (mOnlineIdData.getValue() == null) {
mDbIdData.setValue(id);
}
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e(TAG, "添加联系人最终失败: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.d(TAG, "添加联系人流程完成");
}
});
}
private Observable<Long> saveContactDbObservable(ContactInfo contactInfo) {
return Observable.create((ObservableOnSubscribe<Long>) emitter -> {
Logger.d(TAG, "执行本地保存: " + contactInfo);
contactInfo.setPosition(mRepository.getTotalCount() + 1);
contactInfo.setLocalId(System.currentTimeMillis());
long id = mRepository.insert(contactInfo);
Logger.d(TAG, "执行本地保存: id = " + id);
emitter.onNext(id);
emitter.onComplete();
}).subscribeOn(Schedulers.io());
}, throwable -> Logger.e(TAG, "addContact error: " + throwable.getMessage())));
}
/** 仅保存到本地 Room */
public void saveContactDb(ContactInfo contactInfo) {
saveContactDbObservable(contactInfo)
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
addDisposable(mRepository.addContactLocalOnly(contactInfo)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<Long>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.e("saveContact", "onSubscribe: ");
}
@Override
public void onNext(@NonNull Long aLong) {
Logger.e("saveContact", "onNext: " + aLong);
mDbIdData.setValue(aLong);
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("saveContact", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("saveContact", "onComplete: ");
}
});
.subscribe(mDbIdData::setValue,
throwable -> Logger.e(TAG, "saveContactDb error: " + throwable.getMessage())));
}
}

View File

@@ -1,42 +1,53 @@
package com.ttstd.dialer.activity.contact.edit;
import android.util.Log;
import android.content.Context;
import androidx.lifecycle.MutableLiveData;
import com.kongzue.dialogx.dialogs.PopTip;
import com.trello.rxlifecycle4.android.ActivityEvent;
import com.ttstd.dialer.base.mvvm.BaseViewModel;
import com.ttstd.dialer.bean.BaseResponse;
import com.ttstd.dialer.data.ApiException;
import com.ttstd.dialer.data.repository.ContactDataRepository;
import com.ttstd.dialer.databinding.ActivityContactEditBinding;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.network.BaseObserver;
import com.ttstd.dialer.network.OkHttpManager;
import com.ttstd.dialer.utils.Logger;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
/**
* 编辑联系人 ViewModelGoogle 标准 MVVM只依赖 Repository。
*/
public class ContactEditViewModel extends BaseViewModel<ActivityContactEditBinding, ActivityEvent> {
private static final String TAG = "ContactEditViewModel";
private ContactDataRepository mRepository;
public MutableLiveData<Boolean> mBooleanMutableLiveData = new MutableLiveData<>();
public void updateContact(long id, ContactInfo contactInfo) {
OkHttpManager.getInstance().getContactUpdateObservable(id, contactInfo, getLifecycle())
.safeSubscribe(new BaseObserver<BaseResponse<Void>>() {
@Override
public void onSuccess(BaseResponse<Void> baseResponse) {
Log.e("updateContact", "onSuccess: " + baseResponse);
if (baseResponse.isSuccess()) {
mBooleanMutableLiveData.postValue(true);
} else {
mBooleanMutableLiveData.postValue(false);
PopTip.show(baseResponse.getMsg()).iconError();
}
}
@Override
public void setContext(Context context) {
super.setContext(context);
mRepository = ContactDataRepository.getInstance(context);
}
@Override
public void onFailure(Throwable e) {
Log.e("updateContact", "onFailure: " + e.getMessage());
mBooleanMutableLiveData.postValue(false);
/**
* 更新联系人Repository 成功后自动失效列表/表单缓存并同步 Room
*/
public void updateContact(long id, ContactInfo contactInfo) {
addDisposable(mRepository.updateContact(id, contactInfo)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(success -> {
Logger.d(TAG, "updateContact success: " + success);
mBooleanMutableLiveData.setValue(success);
}, throwable -> {
Logger.e(TAG, "updateContact error: " + throwable.getMessage());
mBooleanMutableLiveData.setValue(false);
if (throwable instanceof ApiException) {
PopTip.show(throwable.getMessage()).iconError();
} else {
PopTip.show("网络错误,请稍后再试").iconError();
}
});
}));
}
}

View File

@@ -19,6 +19,7 @@ import com.ttstd.dialer.activity.contact.edit.ContactEditActivity;
import com.ttstd.dialer.activity.contact.test.ContactTestActivity;
import com.ttstd.dialer.adapter.ContactInfoAdapter;
import com.ttstd.dialer.base.mvvm.BaseMvvmActivity;
import com.ttstd.dialer.data.Resource;
import com.ttstd.dialer.databinding.ActivityContactListBinding;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.fragment.dialog.contact.call.CallFragment;
@@ -67,7 +68,8 @@ public class ContactListActivity extends BaseMvvmActivity<ContactListViewModel,
@Override
public void onRefresh() {
mViewDataBinding.swipeRefreshLayout.setRefreshing(true);
mViewModel.getAllContacts();
// 下拉刷新:强制 revalidate仍会先秒出缓存
mViewModel.loadContacts(true);
}
});
mContactInfoAdapter = new ContactInfoAdapter();
@@ -158,25 +160,34 @@ public class ContactListActivity extends BaseMvvmActivity<ContactListViewModel,
@Override
protected void initData() {
mViewModel.mContactListData.observe(this, new Observer<List<ContactInfo>>() {
// 观察 Resource 状态LOADING / SUCCESS(含缓存 stale 数据) / ERROR(含兜底数据)
mViewModel.getContactListData().observe(this, new Observer<Resource<List<ContactInfo>>>() {
@Override
public void onChanged(List<ContactInfo> contactInfos) {
mViewDataBinding.swipeRefreshLayout.setRefreshing(false);
if (contactInfos == null) {
public void onChanged(Resource<List<ContactInfo>> resource) {
if (resource == null) {
return;
}
// stale=true 表示当前展示的是过期缓存,后台正在 revalidate保持刷新动画
boolean refreshing = resource.isLoading() || resource.stale;
mViewDataBinding.swipeRefreshLayout.setRefreshing(refreshing);
} else {
Logger.e(TAG, "mContactListData: " + contactInfos);
mContactInfos = contactInfos;
if (resource.data != null) {
Logger.d(TAG, "contactList: source=" + resource.source
+ ", stale=" + resource.stale + ", size=" + resource.data.size());
mContactInfos = resource.data;
mContactInfoAdapter.setContactInfos(mContactInfos);
}
if (resource.isError() && resource.data == null) {
TipDialog.show("加载失败:" + resource.message, WaitDialog.TYPE.ERROR);
}
}
});
mViewModel.mDeleteLiveData.observe(this, new Observer<Boolean>() {
mViewModel.getDeleteResult().observe(this, new Observer<Boolean>() {
@Override
public void onChanged(Boolean aBoolean) {
if (aBoolean) {
TipDialog.show("删除成功", WaitDialog.TYPE.SUCCESS);
mViewModel.getAllContacts();
mViewModel.loadContacts(true);
} else {
TipDialog.show("删除失败", WaitDialog.TYPE.ERROR);
}
@@ -211,7 +222,8 @@ public class ContactListActivity extends BaseMvvmActivity<ContactListViewModel,
@Override
protected void onResume() {
super.onResume();
mViewModel.getAllContacts();
// 常规进入页面:走 SWR新鲜缓存秒出过期缓存先出再后台刷新
mViewModel.loadContacts(false);
}
public class BtnClick {

View File

@@ -2,184 +2,94 @@ package com.ttstd.dialer.activity.contact.list;
import android.content.Context;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.trello.rxlifecycle4.RxLifecycle;
import com.trello.rxlifecycle4.android.ActivityEvent;
import com.ttstd.dialer.base.mvvm.BaseViewModel;
import com.ttstd.dialer.bean.BaseResponse;
import com.ttstd.dialer.data.Resource;
import com.ttstd.dialer.data.repository.ContactDataRepository;
import com.ttstd.dialer.databinding.ActivityContactListBinding;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.db.contact.ContactRepository;
import com.ttstd.dialer.manager.ContactManager;
import com.ttstd.dialer.network.BaseObserver;
import com.ttstd.dialer.network.OkHttpManager;
import com.ttstd.dialer.utils.Logger;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.ObservableEmitter;
import io.reactivex.rxjava3.core.ObservableOnSubscribe;
import io.reactivex.rxjava3.core.Observer;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* 联系人列表 ViewModelGoogle 标准 MVVM
* 只依赖 Repository向 UI 暴露 LiveData&lt;Resource&lt;T&gt;&gt;,不感知网络/缓存/数据库细节。
*/
public class ContactListViewModel extends BaseViewModel<ActivityContactListBinding, ActivityEvent> {
private static final String TAG = "ContactListViewModel";
private ContactRepository mRepository;
private ContactManager mContactManager;
private ContactDataRepository mRepository;
private final MutableLiveData<Resource<List<ContactInfo>>> mContactList = new MutableLiveData<>();
private final MutableLiveData<Boolean> mDeleteResult = new MutableLiveData<>();
@Override
public void setContext(Context context) {
super.setContext(context);
mRepository = new ContactRepository(context);
mContactManager = ContactManager.getInstance(context);
mRepository = ContactDataRepository.getInstance(context);
}
public MutableLiveData<List<ContactInfo>> mContactListData = new MutableLiveData<>();
public void getAllContacts() {
getAllContactsFromDB();
public LiveData<Resource<List<ContactInfo>>> getContactListData() {
return mContactList;
}
private void getAllContactsFromDB() {
Observable.create(new ObservableOnSubscribe<List<ContactInfo>>() {
@Override
public void subscribe(@NonNull ObservableEmitter<List<ContactInfo>> emitter) throws Throwable {
List<ContactInfo> contactInfos = mRepository.getAllContacts();
emitter.onNext(contactInfos);
emitter.onComplete();
}
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribeOn(Schedulers.io())
public LiveData<Boolean> getDeleteResult() {
return mDeleteResult;
}
/**
* 加载联系人列表。
* 多级缓存 + SWR命中新鲜缓存秒出过期缓存先出旧数据再后台刷新
* 未命中走 Loading → 网络;网络失败自动降级缓存/Room。
*
* @param forceRefresh 下拉刷新时传 true强制 revalidate
*/
public void loadContacts(boolean forceRefresh) {
addDisposable(mRepository.getContactList(forceRefresh)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<List<ContactInfo>>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.e("getAllContacts", "onSubscribe: ");
}
@Override
public void onNext(@NonNull List<ContactInfo> contactInfos) {
Logger.e("getAllContacts", "onNext: ");
mContactListData.setValue(contactInfos);
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("getAllContacts", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("getAllContacts", "onComplete: ");
requestNetworkContacts();
}
});
.subscribe(resource -> {
Logger.d(TAG, "loadContacts: " + resource.status
+ ", source=" + resource.source + ", stale=" + resource.stale);
mContactList.setValue(resource);
}, throwable -> {
Logger.e(TAG, "loadContacts error: " + throwable.getMessage());
mContactList.setValue(Resource.error(throwable.getMessage(), null));
}));
}
private void requestNetworkContacts() {
mContactManager.getContacts(getLifecycle(), new ContactManager.ContactCallback() {
@Override
public void onSuccess(List<ContactInfo> contacts) {
Logger.e(TAG, "获取网络联系人成功: " + contacts.size());
mContactListData.setValue(contacts);
}
@Override
public void onFailure(Throwable e) {
Logger.e(TAG, "获取网络联系人失败: " + e.getMessage());
}
});
/**
* 拖动排序后仅更新本地顺序
*/
public void updateContacts(List<ContactInfo> contactInfos) {
addDisposable(mRepository.updateLocalContacts(contactInfos)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(success -> Logger.d(TAG, "updateContacts success: " + success),
throwable -> Logger.e(TAG, "updateContacts error: " + throwable.getMessage())));
}
public void updateItemPosition(ContactInfo contactInfo) {
Logger.e(TAG, "updateItemPosition: " + contactInfo);
Observable.create(new ObservableOnSubscribe<Integer>() {
@Override
public void subscribe(@NonNull ObservableEmitter<Integer> emitter) throws Throwable {
int id = mRepository.update(contactInfo);
emitter.onNext(id);
emitter.onComplete();
}
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribeOn(Schedulers.io())
addDisposable(mRepository.updateLocalContact(contactInfo)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<Integer>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.e("updateItemPosition", "onComplete: ");
}
@Override
public void onNext(@NonNull Integer integer) {
Logger.e("updateItemPosition", "onNext: " + integer);
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("updateItemPosition", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("updateItemPosition", "onComplete: ");
}
});
.subscribe(count -> Logger.d(TAG, "updateItemPosition count: " + count),
throwable -> Logger.e(TAG, "updateItemPosition error: " + throwable.getMessage())));
}
public void updateContacts(List<ContactInfo> contactInfos) {
Logger.e(TAG, "updateContacts: " + contactInfos.size());
Observable.create(new ObservableOnSubscribe<Boolean>() {
@Override
public void subscribe(@NonNull ObservableEmitter<Boolean> emitter) throws Throwable {
mRepository.update(contactInfos);
emitter.onNext(true);
emitter.onComplete();
}
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<Boolean>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
}
@Override
public void onNext(@NonNull Boolean aBoolean) {
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("updateContacts", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("updateContacts", "onComplete: ");
}
});
}
public MutableLiveData<Boolean> mDeleteLiveData = new MutableLiveData<>();
/**
* 删除联系人Repository 内部成功后会自动失效缓存
*/
public void deleteContact(long id) {
OkHttpManager.getInstance().getContactDeleteObservable(id, getLifecycle())
.safeSubscribe(new BaseObserver<BaseResponse>() {
@Override
public void onSuccess(BaseResponse baseResponse) {
Logger.e("deleteContact", "onSuccess: " + baseResponse);
mDeleteLiveData.setValue(baseResponse.isSuccess());
}
@Override
public void onFailure(Throwable e) {
Logger.e("deleteContact", "onFailure: " + e.getMessage());
mDeleteLiveData.setValue(false);
}
});
addDisposable(mRepository.deleteContact(id)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(mDeleteResult::setValue,
throwable -> {
Logger.e(TAG, "deleteContact error: " + throwable.getMessage());
mDeleteResult.setValue(false);
}));
}
}

View File

@@ -1,151 +1,69 @@
package com.ttstd.dialer.activity.contact.test;
import android.content.Context;
import android.util.Log;
import androidx.lifecycle.MutableLiveData;
import com.trello.rxlifecycle4.RxLifecycle;
import com.trello.rxlifecycle4.android.ActivityEvent;
import com.ttstd.dialer.base.mvvm.BaseViewModel;
import com.ttstd.dialer.bean.BaseResponse;
import com.ttstd.dialer.data.repository.ContactDataRepository;
import com.ttstd.dialer.databinding.ActivityContactTestBinding;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.db.contact.ContactRepository;
import com.ttstd.dialer.network.BaseObserver;
import com.ttstd.dialer.network.OkHttpManager;
import com.ttstd.dialer.utils.Logger;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.ObservableEmitter;
import io.reactivex.rxjava3.core.ObservableOnSubscribe;
import io.reactivex.rxjava3.core.Observer;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* 联系人测试页 ViewModelGoogle 标准 MVVM只依赖 Repository。
*/
public class ContactTestViewModel extends BaseViewModel<ActivityContactTestBinding, ActivityEvent> {
private static final String TAG = "ContactListViewModel";
private ContactRepository mRepository;
private static final String TAG = "ContactTestViewModel";
private ContactDataRepository mRepository;
@Override
public void setContext(Context context) {
super.setContext(context);
mRepository = new ContactRepository(context);
mRepository = ContactDataRepository.getInstance(context);
}
public MutableLiveData<List<ContactInfo>> mOnlineContactListData = new MutableLiveData<>();
public void getAllContacts() {
OkHttpManager.getInstance().getContactListObservable(getLifecycle())
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribe(new BaseObserver<BaseResponse<List<ContactInfo>>>() {
@Override
public void onSuccess(BaseResponse<List<ContactInfo>> listBaseResponse) {
Log.e("getAllContacts", "onSuccess: " + listBaseResponse);
if (listBaseResponse.isSuccess()) {
List<ContactInfo> contactInfos = listBaseResponse.getData();
List<ContactInfo> sorted = contactInfos.stream().sorted(new Comparator<ContactInfo>() {
@Override
public int compare(ContactInfo t0, ContactInfo t1) {
return Integer.compare(t0.getPosition(), t1.getPosition());
}
}).collect(Collectors.toList());
mOnlineContactListData.setValue(sorted);
}
}
@Override
public void onFailure(Throwable e) {
Log.e("getAllContacts", "onFailure: " + e.getMessage());
mOnlineContactListData.setValue(null);
}
});
}
public MutableLiveData<List<ContactInfo>> mLocalContactListData = new MutableLiveData<>();
public void getAllContactsFromDB() {
Observable.create(new ObservableOnSubscribe<List<ContactInfo>>() {
@Override
public void subscribe(@NonNull ObservableEmitter<List<ContactInfo>> emitter) throws Throwable {
List<ContactInfo> contactInfos = mRepository.getAllContacts();
emitter.onNext(contactInfos);
emitter.onComplete();
}
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribeOn(Schedulers.io())
/**
* 网络联系人(走多级缓存 + SWR数据已按 position 排序)
*/
public void getAllContacts() {
addDisposable(mRepository.getContactList(false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<List<ContactInfo>>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.e("getAllContacts", "onSubscribe: ");
.subscribe(resource -> {
Logger.d(TAG, "getAllContacts: " + resource.status
+ ", source=" + resource.source + ", stale=" + resource.stale);
if (resource.data != null) {
mOnlineContactListData.setValue(resource.data);
} else if (resource.isError()) {
mOnlineContactListData.setValue(null);
}
}, throwable -> {
Logger.e(TAG, "getAllContacts error: " + throwable.getMessage());
mOnlineContactListData.setValue(null);
}));
}
@Override
public void onNext(@NonNull List<ContactInfo> contactInfos) {
Logger.e("getAllContacts", "onNext: ");
// mLocalContactListData.setValue(contactInfos);
List<ContactInfo> sorted = contactInfos.stream().sorted(new Comparator<ContactInfo>() {
@Override
public int compare(ContactInfo o1, ContactInfo o2) {
return Integer.compare(o1.getPosition(), o2.getPosition());
}
}).collect(Collectors.toList());
mLocalContactListData.setValue(sorted);
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("getAllContacts", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("getAllContacts", "onComplete: ");
}
});
/** 本地(Room)联系人 */
public void getAllContactsFromDB() {
addDisposable(mRepository.getLocalContacts()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(mLocalContactListData::setValue,
throwable -> Logger.e(TAG, "getAllContactsFromDB error: " + throwable.getMessage())));
}
public void updateItemPosition(ContactInfo contactInfo) {
Logger.e(TAG, "updateItemPosition: " + contactInfo);
Observable.create(new ObservableOnSubscribe<Integer>() {
@Override
public void subscribe(@NonNull ObservableEmitter<Integer> emitter) throws Throwable {
int id = mRepository.update(contactInfo);
emitter.onNext(id);
emitter.onComplete();
}
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
.subscribeOn(Schedulers.io())
addDisposable(mRepository.updateLocalContact(contactInfo)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<Integer>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.e("updateItemPosition", "onComplete: ");
}
@Override
public void onNext(@NonNull Integer integer) {
Logger.e("updateItemPosition", "onNext: " + integer);
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("updateItemPosition", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("updateItemPosition", "onComplete: ");
}
});
.subscribe(count -> Logger.d(TAG, "updateItemPosition count: " + count),
throwable -> Logger.e(TAG, "updateItemPosition error: " + throwable.getMessage())));
}
}

View File

@@ -7,10 +7,31 @@ import androidx.lifecycle.ViewModel;
import java.lang.ref.WeakReference;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.subjects.BehaviorSubject;
public abstract class BaseViewModel<VDB extends ViewDataBinding, T> extends ViewModel {
/**
* Google 标准写法ViewModel 内统一管理 Rx 订阅,
* onCleared 时自动取消,防止内存泄漏。
*/
protected final CompositeDisposable mDisposables = new CompositeDisposable();
/**
* 将订阅托管给 ViewModel 生命周期
*/
protected void addDisposable(Disposable disposable) {
mDisposables.add(disposable);
}
@Override
protected void onCleared() {
mDisposables.clear();
super.onCleared();
}
/**
* 当前viewmodel对应的页面binding
*/

View File

@@ -0,0 +1,22 @@
package com.ttstd.dialer.data;
import androidx.annotation.Nullable;
/**
* 业务异常:服务端返回非成功 code 时抛出,便于 Repository 统一走 onError 通道。
*/
public class ApiException extends RuntimeException {
@Nullable
private final String code;
public ApiException(@Nullable String code, @Nullable String message) {
super(message == null ? "服务端业务异常" : message);
this.code = code;
}
@Nullable
public String getCode() {
return code;
}
}

View File

@@ -0,0 +1,161 @@
package com.ttstd.dialer.data;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.ttstd.dialer.data.cache.CacheEntry;
import com.ttstd.dialer.data.cache.CacheManager;
import com.ttstd.dialer.utils.Logger;
import java.lang.reflect.Type;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Google 官方架构组件示例中 NetworkBoundResource 模式的 RxJava3 实现,
* 内置「L1 内存 + L2 磁盘」多级缓存与 Stale-While-RevalidateSWR策略。
*
* <pre>
* 数据流(订阅后在 IO 线程执行):
*
* 查缓存(内存 → 磁盘)
* ├─ 未命中/硬过期 → 发射 Loading → 请求网络 → 写缓存 → 发射 Success(NETWORK)
* ├─ FRESH(软TTL内) → 发射 Success(缓存) → 结束(不请求网络)
* └─ STALE(软过期) → 发射 Success(缓存,stale) → 后台 revalidate → 写缓存 → 发射 Success(NETWORK)
* └─ 网络失败 → 发射 Error(携带缓存兜底数据)
* </pre>
*
* @param <T> 业务数据类型
*/
public abstract class NetworkBoundResource<T> {
private static final String TAG = "NetworkBoundResource";
/**
* 默认软 TTL1 分钟内视为新鲜数据,直接使用缓存
*/
private static final long DEFAULT_SOFT_TTL_MILLIS = 60 * 1000L;
/**
* 默认硬 TTL24 小时后缓存彻底失效
*/
private static final long DEFAULT_HARD_TTL_MILLIS = 24 * 60 * 60 * 1000L;
private final CacheManager mCacheManager;
private final boolean mForceRefresh;
protected NetworkBoundResource(@NonNull CacheManager cacheManager) {
this(cacheManager, false);
}
/**
* @param forceRefresh true 时即使缓存新鲜也会 revalidate如下拉刷新
* 但仍会先发射缓存数据保证 UI 秒出。
*/
protected NetworkBoundResource(@NonNull CacheManager cacheManager, boolean forceRefresh) {
mCacheManager = cacheManager;
mForceRefresh = forceRefresh;
}
// ---------------- 子类需实现的契约 ----------------
/**
* 缓存 key需包含区分参数如 "contact_form_" + id
*/
@NonNull
protected abstract String cacheKey();
/**
* 缓存值类型(泛型用 TypeToken如 new TypeToken&lt;List&lt;ContactInfo&gt;&gt;(){}.getType()
*/
@NonNull
protected abstract Type cacheType();
/**
* 创建网络请求(返回已剥离 BaseResponse 的业务数据)
*/
@NonNull
protected abstract Observable<T> createNetworkObservable();
/**
* 网络数据落库等额外持久化钩子IO 线程回调,可选实现,例如写入 Room
*/
@WorkerThread
protected void saveNetworkResult(@NonNull T data) {
}
/**
* 对读取到的数据统一加工(如排序),缓存数据与网络数据都会经过此方法
*/
@NonNull
protected T processResult(@NonNull T data) {
return data;
}
protected long softTtlMillis() {
return DEFAULT_SOFT_TTL_MILLIS;
}
protected long hardTtlMillis() {
return DEFAULT_HARD_TTL_MILLIS;
}
// ---------------- SWR 主流程 ----------------
/**
* @return Observable&lt;Resource&lt;T&gt;&gt;,已 subscribeOn(io),调用方只需 observeOn(mainThread)
*/
@NonNull
public final Observable<Resource<T>> asObservable() {
return Observable
.defer(() -> {
CacheManager.Hit<T> hit = mCacheManager.get(cacheKey(), cacheType());
if (hit == null) {
// 缓存未命中Loading → 网络
Logger.d(TAG, "cache MISS, key=" + cacheKey());
return Observable.concat(
Observable.just(Resource.<T>loading(null)),
fetchFromNetwork(null));
}
T cachedValue = processResult(hit.entry.getValue());
Resource.Source source = hit.fromMemory ? Resource.Source.MEMORY : Resource.Source.DISK;
CacheEntry.Freshness freshness = hit.entry.getFreshness();
if (freshness == CacheEntry.Freshness.FRESH && !mForceRefresh) {
// 新鲜缓存:直接返回,不打扰网络
Logger.d(TAG, "cache FRESH(" + source + "), key=" + cacheKey());
return Observable.just(Resource.success(cachedValue, source, false));
}
// Stale-While-Revalidate先发过期缓存再后台重新验证
Logger.d(TAG, "cache STALE(" + source + "), revalidating... key=" + cacheKey());
return Observable.concat(
Observable.just(Resource.success(cachedValue, source, true)),
fetchFromNetwork(cachedValue));
})
.subscribeOn(Schedulers.io());
}
/**
* 请求网络并双写缓存。
*
* @param fallback 网络失败时的兜底缓存数据(可为 null
*/
@NonNull
private Observable<Resource<T>> fetchFromNetwork(@Nullable T fallback) {
return createNetworkObservable()
.doOnNext(data -> {
// 双写多级缓存(内存 + 磁盘)
mCacheManager.put(cacheKey(), data, softTtlMillis(), hardTtlMillis());
// 额外持久化钩子(如同步 Room
saveNetworkResult(data);
})
.map(data -> Resource.success(processResult(data), Resource.Source.NETWORK, false))
.onErrorReturn(throwable -> {
Logger.e(TAG, "revalidate failed, key=" + cacheKey() + ", " + throwable.getMessage());
return Resource.error(throwable.getMessage(), fallback);
});
}
}

View File

@@ -0,0 +1,97 @@
package com.ttstd.dialer.data;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* UI 状态封装Google 官方架构指南推荐写法)
* <p>
* 携带 状态(LOADING/SUCCESS/ERROR) + 数据 + 错误信息 + 数据来源(内存/磁盘/网络) + 是否过期(stale)。
* ViewModel 通过 LiveData<Resource<T>> 将状态透传给 UI 层。
*
* @param <T> 业务数据类型
*/
public final class Resource<T> {
/**
* 请求状态
*/
public enum Status {
LOADING,
SUCCESS,
ERROR
}
/**
* 数据来源
*/
public enum Source {
MEMORY,
DISK,
NETWORK
}
@NonNull
public final Status status;
@Nullable
public final T data;
@Nullable
public final String message;
@Nullable
public final Source source;
/**
* SWR 标记true 表示当前数据是"过期但可用"的缓存数据,
* 后台正在重新验证revalidate随后会再发射一次最新数据。
*/
public final boolean stale;
private Resource(@NonNull Status status, @Nullable T data, @Nullable String message,
@Nullable Source source, boolean stale) {
this.status = status;
this.data = data;
this.message = message;
this.source = source;
this.stale = stale;
}
public static <T> Resource<T> loading(@Nullable T data) {
return new Resource<>(Status.LOADING, data, null, null, false);
}
public static <T> Resource<T> success(@Nullable T data, @NonNull Source source) {
return new Resource<>(Status.SUCCESS, data, null, source, false);
}
/**
* SWR 场景:发射过期缓存时 stale = true
*/
public static <T> Resource<T> success(@Nullable T data, @NonNull Source source, boolean stale) {
return new Resource<>(Status.SUCCESS, data, null, source, stale);
}
/**
* 失败时可携带兜底缓存数据UI 可继续展示旧数据
*/
public static <T> Resource<T> error(@Nullable String message, @Nullable T data) {
return new Resource<>(Status.ERROR, data, message, null, data != null);
}
public boolean isSuccess() {
return status == Status.SUCCESS;
}
public boolean isLoading() {
return status == Status.LOADING;
}
public boolean isError() {
return status == Status.ERROR;
}
@NonNull
@Override
public String toString() {
return "Resource{status=" + status + ", source=" + source + ", stale=" + stale
+ ", message=" + message + ", data=" + data + '}';
}
}

View File

@@ -0,0 +1,77 @@
package com.ttstd.dialer.data.cache;
import androidx.annotation.NonNull;
/**
* 缓存条目:值 + 写入时间 + 双 TTL。
* <p>
* Stale-While-Revalidate 语义:
* <ul>
* <li>age &lt;= softTtl → FRESH直接使用无需请求网络</li>
* <li>softTtl &lt; age &lt;= hardTtl → STALE先返回缓存同时后台重新验证revalidate</li>
* <li>age &gt; hardTtl → EXPIRED视为未命中必须等网络</li>
* </ul>
*
* @param <T> 缓存值类型
*/
public final class CacheEntry<T> {
public enum Freshness {
FRESH,
STALE,
EXPIRED
}
private final T value;
/**
* 写入时间戳(毫秒)
*/
private final long saveTimeMillis;
/**
* 软过期时长(毫秒):超过则数据视为 stale需要后台刷新
*/
private final long softTtlMillis;
/**
* 硬过期时长(毫秒):超过则数据完全失效,不可再展示
*/
private final long hardTtlMillis;
public CacheEntry(T value, long saveTimeMillis, long softTtlMillis, long hardTtlMillis) {
this.value = value;
this.saveTimeMillis = saveTimeMillis;
this.softTtlMillis = softTtlMillis;
this.hardTtlMillis = hardTtlMillis;
}
public T getValue() {
return value;
}
public long getSaveTimeMillis() {
return saveTimeMillis;
}
public long getSoftTtlMillis() {
return softTtlMillis;
}
public long getHardTtlMillis() {
return hardTtlMillis;
}
@NonNull
public Freshness getFreshness() {
long age = System.currentTimeMillis() - saveTimeMillis;
if (age <= softTtlMillis) {
return Freshness.FRESH;
}
if (age <= hardTtlMillis) {
return Freshness.STALE;
}
return Freshness.EXPIRED;
}
public boolean isExpired() {
return getFreshness() == Freshness.EXPIRED;
}
}

View File

@@ -0,0 +1,108 @@
package com.ttstd.dialer.data.cache;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import java.lang.reflect.Type;
/**
* 多级缓存统一入口L1 内存(LruCache) → L2 磁盘(文件+Gson)。
* <p>
* 读取顺序:内存 → 磁盘(命中磁盘时回填内存);
* 写入顺序:同时写内存与磁盘;
* 供 {@link com.ttstd.dialer.data.NetworkBoundResource} 做 Stale-While-Revalidate 判定。
*/
public final class CacheManager {
private static volatile CacheManager sInstance;
private final MemoryCache mMemoryCache;
private final DiskCache mDiskCache;
private CacheManager(@NonNull Context appContext) {
mMemoryCache = new MemoryCache();
mDiskCache = new DiskCache(appContext);
}
public static CacheManager getInstance(@NonNull Context context) {
if (sInstance == null) {
synchronized (CacheManager.class) {
if (sInstance == null) {
sInstance = new CacheManager(context.getApplicationContext());
}
}
}
return sInstance;
}
/**
* 缓存命中结果(带来源,便于 UI 展示与埋点)
*/
public static final class Hit<T> {
@NonNull
public final CacheEntry<T> entry;
/**
* true = 命中内存false = 命中磁盘
*/
public final boolean fromMemory;
Hit(@NonNull CacheEntry<T> entry, boolean fromMemory) {
this.entry = entry;
this.fromMemory = fromMemory;
}
}
/**
* 依次查内存、磁盘。磁盘命中会自动回填内存(下次直接内存命中)。
* 硬过期条目在各级缓存内部已被剔除,此处返回的一定是 FRESH 或 STALE。
*/
@Nullable
@WorkerThread
public <T> Hit<T> get(@NonNull String key, @NonNull Type type) {
CacheEntry<T> memory = mMemoryCache.get(key);
if (memory != null) {
return new Hit<>(memory, true);
}
CacheEntry<T> disk = mDiskCache.get(key, type);
if (disk != null) {
mMemoryCache.put(key, disk); // 回填 L1
return new Hit<>(disk, false);
}
return null;
}
/**
* 双写:内存 + 磁盘
*/
@WorkerThread
public <T> void put(@NonNull String key, @NonNull T value, long softTtlMillis, long hardTtlMillis) {
CacheEntry<T> entry = new CacheEntry<>(value, System.currentTimeMillis(), softTtlMillis, hardTtlMillis);
mMemoryCache.put(key, entry);
mDiskCache.put(key, entry);
}
/**
* 失效指定 key写操作成功后调用保证下次读取拿到最新数据
*/
@WorkerThread
public void invalidate(@NonNull String key) {
mMemoryCache.remove(key);
mDiskCache.remove(key);
}
/**
* 仅标记内存失效(轻量,可在主线程调用)
*/
public void invalidateMemory(@NonNull String key) {
mMemoryCache.remove(key);
}
@WorkerThread
public void clearAll() {
mMemoryCache.clear();
mDiskCache.clear();
}
}

View File

@@ -0,0 +1,165 @@
package com.ttstd.dialer.data.cache;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* L2 磁盘缓存:文件 + Gson 序列化App 冷启动后仍可秒出数据。
* <p>
* 存储格式JSON
* <pre>
* { "saveTime": 1710000000000, "softTtl": 60000, "hardTtl": 86400000, "value": {...} }
* </pre>
* 写入使用「临时文件 + 原子重命名」防止写一半损坏。
* 所有方法必须在工作线程调用。
*/
public final class DiskCache {
private static final String TAG = "DiskCache";
private static final String CACHE_DIR_NAME = "api_disk_cache";
private static final String TMP_SUFFIX = ".tmp";
private static final String KEY_SAVE_TIME = "saveTime";
private static final String KEY_SOFT_TTL = "softTtl";
private static final String KEY_HARD_TTL = "hardTtl";
private static final String KEY_VALUE = "value";
private final File mCacheDir;
private final Gson mGson = new Gson();
public DiskCache(@NonNull Context context) {
mCacheDir = new File(context.getCacheDir(), CACHE_DIR_NAME);
if (!mCacheDir.exists() && !mCacheDir.mkdirs()) {
Log.w(TAG, "Failed to create disk cache dir: " + mCacheDir);
}
}
/**
* 读取缓存条目。
*
* @param key 缓存 key
* @param type 值的具体类型(支持泛型,如 new TypeToken&lt;List&lt;ContactInfo&gt;&gt;(){}.getType()
*/
@Nullable
@WorkerThread
public <T> CacheEntry<T> get(@NonNull String key, @NonNull Type type) {
File file = fileFor(key);
if (!file.exists()) {
return null;
}
try (Reader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
long saveTime = root.get(KEY_SAVE_TIME).getAsLong();
long softTtl = root.get(KEY_SOFT_TTL).getAsLong();
long hardTtl = root.get(KEY_HARD_TTL).getAsLong();
T value = mGson.fromJson(root.get(KEY_VALUE), type);
if (value == null) {
return null;
}
CacheEntry<T> entry = new CacheEntry<>(value, saveTime, softTtl, hardTtl);
// 硬过期:删除文件并按未命中处理
if (entry.isExpired()) {
//noinspection ResultOfMethodCallIgnored
file.delete();
return null;
}
return entry;
} catch (Exception e) {
Log.w(TAG, "Read disk cache failed, key=" + key, e);
//noinspection ResultOfMethodCallIgnored
file.delete();
return null;
}
}
/**
* 写入缓存条目(原子写)
*/
@WorkerThread
public <T> void put(@NonNull String key, @NonNull CacheEntry<T> entry) {
File file = fileFor(key);
File tmp = new File(file.getAbsolutePath() + TMP_SUFFIX);
try {
JsonObject root = new JsonObject();
root.addProperty(KEY_SAVE_TIME, entry.getSaveTimeMillis());
root.addProperty(KEY_SOFT_TTL, entry.getSoftTtlMillis());
root.addProperty(KEY_HARD_TTL, entry.getHardTtlMillis());
root.add(KEY_VALUE, mGson.toJsonTree(entry.getValue()));
try (Writer writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(tmp), StandardCharsets.UTF_8))) {
mGson.toJson(root, writer);
}
if (!tmp.renameTo(file)) {
Log.w(TAG, "Rename tmp cache file failed, key=" + key);
}
} catch (IOException e) {
Log.w(TAG, "Write disk cache failed, key=" + key, e);
//noinspection ResultOfMethodCallIgnored
tmp.delete();
}
}
@WorkerThread
public void remove(@NonNull String key) {
//noinspection ResultOfMethodCallIgnored
fileFor(key).delete();
}
@WorkerThread
public void clear() {
File[] files = mCacheDir.listFiles();
if (files == null) {
return;
}
for (File f : files) {
//noinspection ResultOfMethodCallIgnored
f.delete();
}
}
private File fileFor(@NonNull String key) {
return new File(mCacheDir, md5(key) + ".json");
}
/**
* key 做 MD5避免非法文件名字符
*/
private static String md5(@NonNull String s) {
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] bytes = digest.digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (Exception e) {
// 理论上不会发生
return String.valueOf(s.hashCode());
}
}
}

View File

@@ -0,0 +1,55 @@
package com.ttstd.dialer.data.cache;
import android.util.LruCache;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* L1 内存缓存:基于 LruCache进程内共享读写为纳秒级。
* <p>线程安全LruCache 内部已同步)。</p>
*/
public final class MemoryCache {
/**
* 默认最多缓存 64 个接口结果条目
*/
private static final int DEFAULT_MAX_ENTRIES = 64;
private final LruCache<String, CacheEntry<?>> mLruCache;
public MemoryCache() {
this(DEFAULT_MAX_ENTRIES);
}
public MemoryCache(int maxEntries) {
mLruCache = new LruCache<>(maxEntries);
}
@Nullable
@SuppressWarnings("unchecked")
public <T> CacheEntry<T> get(@NonNull String key) {
CacheEntry<?> entry = mLruCache.get(key);
if (entry == null) {
return null;
}
// 硬过期直接剔除
if (entry.isExpired()) {
mLruCache.remove(key);
return null;
}
return (CacheEntry<T>) entry;
}
public <T> void put(@NonNull String key, @NonNull CacheEntry<T> entry) {
mLruCache.put(key, entry);
}
public void remove(@NonNull String key) {
mLruCache.remove(key);
}
public void clear() {
mLruCache.evictAll();
}
}

View File

@@ -0,0 +1,51 @@
package com.ttstd.dialer.data.remote;
import androidx.annotation.NonNull;
import com.ttstd.dialer.bean.BaseResponse;
import com.ttstd.dialer.data.ApiException;
import io.reactivex.rxjava3.core.Observable;
/**
* BaseResponse 统一解包工具:
* <ul>
* <li>业务成功 → 发射 data</li>
* <li>业务失败 → 抛 {@link ApiException} 走 onError 通道Repository 统一处理</li>
* </ul>
*/
public final class ApiResponseMapper {
private ApiResponseMapper() {
}
/**
* 剥离 BaseResponse返回业务数据data 不允许为 null
*/
@NonNull
public static <T> Observable<T> unwrap(@NonNull Observable<BaseResponse<T>> upstream) {
return upstream.map(response -> {
if (!response.isSuccess()) {
throw new ApiException(response.getCode(), response.getMsg());
}
T data = response.getData();
if (data == null) {
throw new ApiException(response.getCode(), "服务端返回数据为空");
}
return data;
});
}
/**
* 用于 data 为 Void/无意义的写操作接口:成功映射为 true
*/
@NonNull
public static <T> Observable<Boolean> unwrapAsSuccess(@NonNull Observable<BaseResponse<T>> upstream) {
return upstream.map(response -> {
if (!response.isSuccess()) {
throw new ApiException(response.getCode(), response.getMsg());
}
return true;
});
}
}

View File

@@ -0,0 +1,51 @@
package com.ttstd.dialer.data.remote;
import androidx.annotation.NonNull;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.network.OkHttpManager;
import com.ttstd.dialer.network.api.ContactApi;
import java.util.List;
import io.reactivex.rxjava3.core.Observable;
/**
* 联系人远程数据源Data Layer - Remote DataSource
* 只负责发起网络请求并剥离 BaseResponse不做线程调度、不碰缓存。
*/
public class ContactRemoteDataSource {
private final ContactApi mApi;
public ContactRemoteDataSource() {
mApi = OkHttpManager.getInstance().getApiService(ContactApi.class);
}
@NonNull
public Observable<List<ContactInfo>> getContactList() {
return ApiResponseMapper.unwrap(mApi.getContactList());
}
@NonNull
public Observable<ContactInfo> getContactForm(long id) {
return ApiResponseMapper.unwrap(mApi.getContactForm(id));
}
@NonNull
public Observable<Long> insertContact(@NonNull ContactInfo contactInfo) {
return ApiResponseMapper.unwrap(
mApi.insertContact(OkHttpManager.convertToRequestBodyjson(contactInfo)));
}
@NonNull
public Observable<Boolean> updateContact(long id, @NonNull ContactInfo contactInfo) {
return ApiResponseMapper.unwrapAsSuccess(
mApi.updateContact(id, OkHttpManager.convertToRequestBodyjson(contactInfo)));
}
@NonNull
public Observable<Boolean> deleteContact(long id) {
return ApiResponseMapper.unwrapAsSuccess(mApi.deleteContact(id));
}
}

View File

@@ -0,0 +1,26 @@
package com.ttstd.dialer.data.remote;
import androidx.annotation.NonNull;
import com.ttstd.dialer.bean.DeveloperOptions;
import com.ttstd.dialer.network.OkHttpManager;
import com.ttstd.dialer.network.api.SnApi;
import io.reactivex.rxjava3.core.Observable;
/**
* 设备(SN)远程数据源Data Layer - Remote DataSource
*/
public class SnRemoteDataSource {
private final SnApi mApi;
public SnRemoteDataSource() {
mApi = OkHttpManager.getInstance().getApiService(SnApi.class);
}
@NonNull
public Observable<DeveloperOptions> getDeveloperOptions() {
return ApiResponseMapper.unwrap(mApi.getDeveloperOptions());
}
}

View File

@@ -0,0 +1,358 @@
package com.ttstd.dialer.data.repository;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.google.gson.reflect.TypeToken;
import com.ttstd.dialer.data.NetworkBoundResource;
import com.ttstd.dialer.data.Resource;
import com.ttstd.dialer.data.cache.CacheManager;
import com.ttstd.dialer.data.remote.ContactRemoteDataSource;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.db.contact.ContactRepository;
import com.ttstd.dialer.utils.Logger;
import java.lang.reflect.Type;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* 联系人仓库Data Layer - Repository单一数据源 SSOT
* <p>
* 职责Google 架构指南):
* <ul>
* <li>对 UI 层ViewModel暴露 Observable&lt;Resource&lt;T&gt;&gt;,屏蔽数据来源细节</li>
* <li>读L1 内存缓存 → L2 磁盘缓存 → 网络Stale-While-Revalidate</li>
* <li>写:远程成功后失效缓存并同步 Room网络不可用时降级本地保存</li>
* </ul>
* 线程约定:所有返回的 Observable 均已 subscribeOn(io),调用方按需 observeOn(mainThread)。
*/
public class ContactDataRepository {
private static final String TAG = "ContactDataRepository";
/**
* 缓存 key
*/
private static final String KEY_CONTACT_LIST = "contact_list";
private static final String KEY_CONTACT_FORM_PREFIX = "contact_form_";
/**
* 列表软 TTL1 分钟内不重复请求网络
*/
private static final long LIST_SOFT_TTL = 60 * 1000L;
/**
* 列表硬 TTL7 天后彻底失效
*/
private static final long LIST_HARD_TTL = 7 * 24 * 60 * 60 * 1000L;
private static final Type CONTACT_LIST_TYPE = new TypeToken<List<ContactInfo>>() {
}.getType();
// 同步状态常量(与 SyncStatus 语义一致)
private static final int SYNC_STATUS_UNSYNCED = 0;
private static final int SYNC_STATUS_SYNCED = 1;
private static volatile ContactDataRepository sInstance;
private final ContactRemoteDataSource mRemoteDataSource;
private final ContactRepository mLocalDataSource; // Room 本地数据源
private final CacheManager mCacheManager;
private ContactDataRepository(@NonNull Context appContext) {
mRemoteDataSource = new ContactRemoteDataSource();
mLocalDataSource = new ContactRepository(appContext);
mCacheManager = CacheManager.getInstance(appContext);
}
public static ContactDataRepository getInstance(@NonNull Context context) {
if (sInstance == null) {
synchronized (ContactDataRepository.class) {
if (sInstance == null) {
sInstance = new ContactDataRepository(context.getApplicationContext());
}
}
}
return sInstance;
}
// ==================== 读操作:多级缓存 + SWR ====================
/**
* 获取联系人列表。
* <p>命中新鲜缓存 → 秒出且不请求网络;命中过期缓存 → 先出缓存再后台刷新SWR
* 未命中 → Loading + 网络;网络失败 → 降级 Room 本地数据。</p>
*
* @param forceRefresh true 强制 revalidate下拉刷新场景
*/
@NonNull
public Observable<Resource<List<ContactInfo>>> getContactList(boolean forceRefresh) {
return new NetworkBoundResource<List<ContactInfo>>(mCacheManager, forceRefresh) {
@NonNull
@Override
protected String cacheKey() {
return KEY_CONTACT_LIST;
}
@NonNull
@Override
protected Type cacheType() {
return CONTACT_LIST_TYPE;
}
@NonNull
@Override
protected Observable<List<ContactInfo>> createNetworkObservable() {
return mRemoteDataSource.getContactList();
}
@Override
protected void saveNetworkResult(@NonNull List<ContactInfo> data) {
// 网络数据同步进 Room单一事实来源的持久化备份
syncContactsToLocal(data);
}
@NonNull
@Override
protected List<ContactInfo> processResult(@NonNull List<ContactInfo> data) {
return sortByPosition(data);
}
@Override
protected long softTtlMillis() {
return LIST_SOFT_TTL;
}
@Override
protected long hardTtlMillis() {
return LIST_HARD_TTL;
}
}
.asObservable()
// 网络失败且无缓存兜底时,再降级 Room 本地数据
.map(resource -> {
if (resource.isError() && resource.data == null) {
List<ContactInfo> local = sortByPosition(mLocalDataSource.getAllContacts());
if (!local.isEmpty()) {
return Resource.error(resource.message, local);
}
}
return resource;
});
}
/**
* 获取单个联系人表单(按 id 独立缓存)
*/
@NonNull
public Observable<Resource<ContactInfo>> getContactForm(long id, boolean forceRefresh) {
return new NetworkBoundResource<ContactInfo>(mCacheManager, forceRefresh) {
@NonNull
@Override
protected String cacheKey() {
return KEY_CONTACT_FORM_PREFIX + id;
}
@NonNull
@Override
protected Type cacheType() {
return ContactInfo.class;
}
@NonNull
@Override
protected Observable<ContactInfo> createNetworkObservable() {
return mRemoteDataSource.getContactForm(id);
}
}.asObservable();
}
// ==================== 写操作:远程优先 + 缓存失效 ====================
/**
* 新增联系人结果
*/
public static final class AddResult {
public final long id;
/**
* true = 服务端成功false = 网络异常降级到本地 Room 保存
*/
public final boolean online;
AddResult(long id, boolean online) {
this.id = id;
this.online = online;
}
}
/**
* 新增联系人:远程成功 → 失效列表缓存;远程失败 → 降级保存到本地 Room待后续同步
*/
@NonNull
public Observable<AddResult> addContact(@NonNull ContactInfo contactInfo) {
return mRemoteDataSource.insertContact(contactInfo)
.map(id -> {
invalidateListCache();
return new AddResult(id, true);
})
.onErrorResumeNext(throwable -> {
Logger.w(TAG, "addContact 远程失败,降级本地保存: " + throwable.getMessage());
return Observable.fromCallable(() -> {
contactInfo.setPosition(mLocalDataSource.getTotalCount() + 1);
contactInfo.setLocalId(System.currentTimeMillis());
contactInfo.setSyncStatus(SYNC_STATUS_UNSYNCED);
long localId = mLocalDataSource.insert(contactInfo);
return new AddResult(localId, false);
});
})
.subscribeOn(Schedulers.io());
}
/**
* 仅保存到本地 Room不请求网络标记未同步待后续上报
*/
@NonNull
public Observable<Long> addContactLocalOnly(@NonNull ContactInfo contactInfo) {
return Observable.fromCallable(() -> {
contactInfo.setPosition(mLocalDataSource.getTotalCount() + 1);
contactInfo.setLocalId(System.currentTimeMillis());
contactInfo.setSyncStatus(SYNC_STATUS_UNSYNCED);
long localId = mLocalDataSource.insert(contactInfo);
invalidateListCache();
return localId;
})
.subscribeOn(Schedulers.io());
}
/**
* 更新联系人:成功后失效列表与表单缓存
*/
@NonNull
public Observable<Boolean> updateContact(long id, @NonNull ContactInfo contactInfo) {
return mRemoteDataSource.updateContact(id, contactInfo)
.doOnNext(success -> {
if (success) {
invalidateListCache();
mCacheManager.invalidate(KEY_CONTACT_FORM_PREFIX + id);
mLocalDataSource.update(contactInfo);
}
})
.subscribeOn(Schedulers.io());
}
/**
* 删除联系人:成功后失效缓存并删除本地记录
*/
@NonNull
public Observable<Boolean> deleteContact(long id) {
return mRemoteDataSource.deleteContact(id)
.doOnNext(success -> {
if (success) {
invalidateListCache();
mCacheManager.invalidate(KEY_CONTACT_FORM_PREFIX + id);
}
})
.subscribeOn(Schedulers.io());
}
// ==================== 本地(Room)操作 ====================
/**
* 仅更新本地排序等(不请求网络)
*/
@NonNull
public Observable<Boolean> updateLocalContacts(@NonNull List<ContactInfo> contactInfos) {
return Observable.fromCallable(() -> {
mLocalDataSource.update(contactInfos);
invalidateListCache();
return true;
})
.subscribeOn(Schedulers.io());
}
/**
* 更新单个本地联系人
*/
@NonNull
public Observable<Integer> updateLocalContact(@NonNull ContactInfo contactInfo) {
return Observable.fromCallable(() -> {
int count = mLocalDataSource.update(contactInfo);
invalidateListCache();
return count;
})
.subscribeOn(Schedulers.io());
}
/**
* 读取本地全部联系人Room
*/
@NonNull
public Observable<List<ContactInfo>> getLocalContacts() {
return Observable.fromCallable(() -> sortByPosition(mLocalDataSource.getAllContacts()))
.subscribeOn(Schedulers.io());
}
// ==================== 内部工具 ====================
private void invalidateListCache() {
mCacheManager.invalidate(KEY_CONTACT_LIST);
}
@NonNull
private static List<ContactInfo> sortByPosition(@NonNull List<ContactInfo> list) {
return list.stream()
.sorted(Comparator.comparingInt(ContactInfo::getPosition))
.collect(Collectors.toList());
}
/**
* 网络数据与 Room 做差量同步(新增/更新/删除)。
*/
@WorkerThread
private void syncContactsToLocal(@NonNull List<ContactInfo> networkContacts) {
try {
List<ContactInfo> localContacts = mLocalDataSource.getAllContacts();
Map<Long, ContactInfo> networkMap = new HashMap<>();
for (ContactInfo c : networkContacts) {
networkMap.put(c.getId(), c);
}
Map<Long, ContactInfo> localMap = new HashMap<>();
for (ContactInfo c : localContacts) {
localMap.put(c.getId(), c);
}
// 新增 / 更新
for (ContactInfo networkContact : networkContacts) {
ContactInfo localContact = localMap.get(networkContact.getId());
if (localContact == null) {
networkContact.setSyncStatus(SYNC_STATUS_SYNCED);
networkContact.setLocalId(null);
mLocalDataSource.insert(networkContact);
} else if (networkContact.getUpdateTime() > localContact.getUpdateTime()) {
networkContact.setSyncStatus(SYNC_STATUS_SYNCED);
networkContact.setLocalId(localContact.getLocalId());
mLocalDataSource.update(networkContact);
}
}
// 删除:网络已不存在且本地已同步过的记录
for (ContactInfo localContact : localContacts) {
if (!networkMap.containsKey(localContact.getId())
&& localContact.getSyncStatus() == SYNC_STATUS_SYNCED) {
mLocalDataSource.delete(localContact);
}
}
} catch (Exception e) {
Logger.e(TAG, "syncContactsToLocal failed: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,89 @@
package com.ttstd.dialer.data.repository;
import android.content.Context;
import androidx.annotation.NonNull;
import com.ttstd.dialer.bean.DeveloperOptions;
import com.ttstd.dialer.data.NetworkBoundResource;
import com.ttstd.dialer.data.Resource;
import com.ttstd.dialer.data.cache.CacheManager;
import com.ttstd.dialer.data.remote.SnRemoteDataSource;
import java.lang.reflect.Type;
import io.reactivex.rxjava3.core.Observable;
/**
* 设备(SN)配置仓库Data Layer - Repository
* 开发者选项等设备配置走「内存 + 磁盘缓存 + SWR」。
*/
public class SnRepository {
private static final String KEY_DEVELOPER_OPTIONS = "developer_options";
/**
* 软 TTL5 分钟;硬 TTL24 小时
*/
private static final long SOFT_TTL = 5 * 60 * 1000L;
private static final long HARD_TTL = 24 * 60 * 60 * 1000L;
private static volatile SnRepository sInstance;
private final SnRemoteDataSource mRemoteDataSource;
private final CacheManager mCacheManager;
private SnRepository(@NonNull Context appContext) {
mRemoteDataSource = new SnRemoteDataSource();
mCacheManager = CacheManager.getInstance(appContext);
}
public static SnRepository getInstance(@NonNull Context context) {
if (sInstance == null) {
synchronized (SnRepository.class) {
if (sInstance == null) {
sInstance = new SnRepository(context.getApplicationContext());
}
}
}
return sInstance;
}
/**
* 获取开发者选项配置SWR
*
* @param forceRefresh 推送触发的配置变更等场景传 true跳过新鲜度判定强制 revalidate
*/
@NonNull
public Observable<Resource<DeveloperOptions>> getDeveloperOptions(boolean forceRefresh) {
return new NetworkBoundResource<DeveloperOptions>(mCacheManager, forceRefresh) {
@NonNull
@Override
protected String cacheKey() {
return KEY_DEVELOPER_OPTIONS;
}
@NonNull
@Override
protected Type cacheType() {
return DeveloperOptions.class;
}
@NonNull
@Override
protected Observable<DeveloperOptions> createNetworkObservable() {
return mRemoteDataSource.getDeveloperOptions();
}
@Override
protected long softTtlMillis() {
return SOFT_TTL;
}
@Override
protected long hardTtlMillis() {
return HARD_TTL;
}
}.asObservable();
}
}

View File

@@ -4,94 +4,54 @@ import android.content.Context;
import androidx.lifecycle.MutableLiveData;
import com.trello.rxlifecycle4.RxLifecycle;
import com.trello.rxlifecycle4.android.FragmentEvent;
import com.ttstd.dialer.base.mvvm.BaseViewModel;
import com.ttstd.dialer.data.repository.ContactDataRepository;
import com.ttstd.dialer.databinding.FragmentContactBinding;
import com.ttstd.dialer.db.contact.ContactInfo;
import com.ttstd.dialer.db.contact.ContactRepository;
import com.ttstd.dialer.utils.Logger;
import java.util.List;
import java.util.concurrent.Callable;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Observer;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* 主页联系人 ViewModelGoogle 标准 MVVM
* 统一走 Repository 多级缓存 + SWR内存/磁盘缓存秒出,后台自动 revalidate。
*/
public class ContactViewModel extends BaseViewModel<FragmentContactBinding, FragmentEvent> {
private ContactRepository mRepository;
private static final String TAG = "ContactViewModel";
private ContactDataRepository mRepository;
private boolean isDataLoaded = false;
public void preloadData() {
if (isDataLoaded) return;
getAllContactsObservable()
.subscribe(new Consumer<List<ContactInfo>>() {
@Override
public void accept(List<ContactInfo> contactInfos) throws Throwable {
mContactListData.postValue(contactInfos);
isDataLoaded = true;
}
});
}
public MutableLiveData<List<ContactInfo>> mContactListData = new MutableLiveData<>();
@Override
public void setContext(Context context) {
super.setContext(context);
mRepository = new ContactRepository(context);
mRepository = ContactDataRepository.getInstance(context);
}
public MutableLiveData<List<ContactInfo>> mContactListData = new MutableLiveData<>();
public Observable<List<ContactInfo>> getAllContactsObservable() {
return Observable.fromCallable(new Callable<List<ContactInfo>>() {
@Override
public List<ContactInfo> call() throws Exception {
List<ContactInfo> contactInfos = mRepository.getAllContacts();
return contactInfos;
}
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.STOP))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
public void preloadData() {
if (isDataLoaded) return;
getAllContacts();
}
/**
* 获取联系人:命中新鲜缓存直接秒出;缓存过期先出旧数据再后台刷新;
* 网络失败自动降级缓存/Room 本地数据。
*/
public void getAllContacts() {
getAllContactsObservable()
.subscribe(new Observer<List<ContactInfo>>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.e("getAllContacts", "onSubscribe: ");
addDisposable(mRepository.getContactList(false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(resource -> {
Logger.d(TAG, "getAllContacts: " + resource.status
+ ", source=" + resource.source + ", stale=" + resource.stale);
if (resource.data != null) {
mContactListData.setValue(resource.data);
isDataLoaded = true;
}
@Override
public void onNext(@NonNull List<ContactInfo> contactInfos) {
Logger.e("getAllContacts", "onNext: ");
mContactListData.setValue(contactInfos);
// List<Contact> sorted = contacts.stream().sorted(new Comparator<Contact>() {
// @Override
// public int compare(Contact o1, Contact o2) {
// return Integer.compare(o1.getSort(), o2.getSort());
// }
// }).collect(Collectors.toList());
// mContactListData.setValue(sorted);
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("getAllContacts", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("getAllContacts", "onComplete: ");
}
});
}, throwable -> Logger.e(TAG, "getAllContacts error: " + throwable.getMessage())));
}
}

View File

@@ -18,8 +18,8 @@ import com.ttstd.dialer.mdm.DeviceManagerService;
import com.ttstd.dialer.network.api.ContactApi;
import com.ttstd.dialer.network.api.SnApi;
import com.ttstd.dialer.network.interceptor.AuthInterceptor;
import com.ttstd.iconloader.utils.FileUtils;
import com.ttstd.dialer.utils.Logger;
import com.ttstd.iconloader.utils.FileUtils;
import org.jetbrains.annotations.NotNull;
@@ -364,7 +364,7 @@ public class OkHttpManager {
return schedule(getApiService(ContactApi.class).updateContact(id, convertToRequestBodyjson(contactInfo)), provider);
}
public Observable<BaseResponse> getContactDeleteObservable(long id, BehaviorSubject<ActivityEvent> provider) {
public Observable<BaseResponse<Void>> getContactDeleteObservable(long id, BehaviorSubject<ActivityEvent> provider) {
return schedule(getApiService(ContactApi.class).deleteContact(id), provider);
}

View File

@@ -37,7 +37,7 @@ public interface ContactApi {
);
@DELETE(UrlConstants.CONTACT_DELETE)
Observable<BaseResponse> deleteContact(
Observable<BaseResponse<Void>> deleteContact(
@Query("id") Long id
);

View File

@@ -14,15 +14,16 @@ import com.ttstd.dialer.bean.DeveloperOptions;
import com.ttstd.dialer.bean.PushMessage;
import com.ttstd.dialer.bean.req.SnHardwareInfoReq;
import com.ttstd.dialer.config.CommonConfig;
import com.ttstd.dialer.data.repository.SnRepository;
import com.ttstd.dialer.manager.AppManager;
import com.ttstd.dialer.manager.MapManager;
import com.ttstd.dialer.mdm.DeviceManagerService;
import com.ttstd.dialer.network.BaseObserver;
import com.ttstd.dialer.network.OkHttpManager;
import com.ttstd.dialer.utils.CmdUtil;
import com.ttstd.iconloader.utils.FileUtils;
import com.ttstd.dialer.utils.Logger;
import com.ttstd.dialer.utils.RebootUtils;
import com.ttstd.iconloader.utils.FileUtils;
import java.io.File;
import java.util.List;
@@ -120,35 +121,20 @@ public class PushExecutor {
}
private void getDeviceSettings() {
OkHttpManager.getInstance().getDeveloperOptionsObservable()
.subscribe(new Observer<BaseResponse<DeveloperOptions>>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
Logger.e("getDeveloperOptions", "onSubscribe: ");
}
@Override
public void onNext(@NonNull BaseResponse<DeveloperOptions> baseResponse) {
Logger.e("getDeveloperOptions", "onNext: " + baseResponse);
if (baseResponse.isSuccess()) {
DeveloperOptions developerOptions = baseResponse.getData();
if (!BuildConfig.DEBUG) {
DeviceManagerService.getInstance().setDevelopmentOption(developerOptions.getDeveloperOptions() == 1);
DeviceManagerService.getInstance().setUsbDebugMode(developerOptions.getDeveloperOptions() == 1);
}
// 走 Repository多级缓存 + SWR推送触发说明服务端配置已变更强制 revalidate
SnRepository.getInstance(mContext).getDeveloperOptions(true)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(resource -> {
Logger.e("getDeveloperOptions", "resource: " + resource);
// 仅在拿到网络最新数据(非 stale 缓存)时应用配置
if (resource.isSuccess() && !resource.stale && resource.data != null) {
DeveloperOptions developerOptions = resource.data;
if (!BuildConfig.DEBUG) {
DeviceManagerService.getInstance().setDevelopmentOption(developerOptions.getDeveloperOptions() == 1);
DeviceManagerService.getInstance().setUsbDebugMode(developerOptions.getDeveloperOptions() == 1);
}
}
@Override
public void onError(@NonNull Throwable e) {
Logger.e("getDeveloperOptions", "onError: " + e.getMessage());
}
@Override
public void onComplete() {
Logger.e("getDeveloperOptions", "onComplete: ");
}
});
}, throwable -> Logger.e("getDeveloperOptions", "onError: " + throwable.getMessage()));
}
private void uploadDeviceInfo() {