mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 08:43:17 +08:00
[feat] add mobile update badges and iOS simulator support
This commit is contained in:
@@ -23,6 +23,12 @@
|
||||
|
||||
当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
|
||||
|
||||
## 本机更新提示
|
||||
|
||||
完成联网授权后,每次启动或从后台返回时检查一次公共版本文件,前台远程连接结束后再检查一次;同时触发时复用正在进行的请求。不进行周期性检查或自动重试,失败时保留已发现的新版本提示,等待下一次触发或手动检查;进入后台或撤回授权时取消请求并忽略过期回复。
|
||||
|
||||
本机只读取 `downloads.android-arm64`,复用 PC 端的版本号及补丁比较规则;仅构建日期变化不提示升级,平台版本字段缺失按检查失败处理。发现新版本后,首页设置按钮右上角显示红底白色感叹号角标,大小和位置与通知角标一致,设置页“关于”显示“新版本”文字和红点;“关于”展示新版本号、官网更新入口,并提供“检查更新”。该功能独立于被控电脑的版本升级提示。
|
||||
|
||||
## 构建
|
||||
|
||||
在 macOS 或 Linux 安装:
|
||||
|
||||
@@ -17,15 +17,22 @@ final class AboutPages {
|
||||
private final MobileUi ui;
|
||||
private final Runnable settings;
|
||||
private final LicenseCatalog catalog;
|
||||
private final AppUpdateChecker updates;
|
||||
private TextView updateStatus, updateLink, checkButton;
|
||||
private LinearLayout updateRow;
|
||||
|
||||
AboutPages(MainActivity activity,MobileUi ui,Runnable settings){
|
||||
this.activity=activity;this.ui=ui;this.settings=settings;
|
||||
AboutPages(MainActivity activity,MobileUi ui,Runnable settings,AppUpdateChecker updates){
|
||||
this.activity=activity;this.ui=ui;this.settings=settings;this.updates=updates;
|
||||
LicenseCatalog loaded;try{loaded=new LicenseCatalog(activity);}catch(Exception error){loaded=null;}catalog=loaded;
|
||||
}
|
||||
void show(){
|
||||
LinearLayout content=activity.formPage("about","关于 CrossDesk",settings);
|
||||
LinearLayout version=activity.section(content,"");value(version,"版本",applicationVersion());
|
||||
updateRow=ui.row();updateRow.setPadding(ui.dp(16),0,ui.dp(16),0);updateRow.addView(activity.updateDot(),ui.size(8,8));
|
||||
updateLink=ui.action("",BLUE,()->open("https://crossdesk.cn"));updateLink.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);updateLink.setPadding(ui.dp(8),ui.dp(12),0,ui.dp(12));updateRow.addView(updateLink,new LinearLayout.LayoutParams(0,-2,1));version.addView(updateRow);
|
||||
updateStatus=ui.text("",13,false);updateStatus.setPadding(ui.dp(16),ui.dp(8),ui.dp(16),ui.dp(8));version.addView(updateStatus);
|
||||
checkButton=ui.action("检查更新",BLUE,activity::checkAppUpdates);version.addView(checkButton);renderUpdates();ui.gap(content,28);
|
||||
if(catalog==null){unavailable(content);return;}
|
||||
LinearLayout version=activity.section(content,"");value(version,"版本",applicationVersion());ui.gap(content,28);
|
||||
LinearLayout links=activity.section(content,"");
|
||||
activity.link(links,"软件许可","",()->component(catalog.find("crossdesk"),this::show));ui.divider(links);
|
||||
activity.link(links,"开源组件","",this::components);ui.divider(links);
|
||||
@@ -33,7 +40,22 @@ final class AboutPages {
|
||||
activity.footer(content,"许可与版权声明可离线查阅。");
|
||||
}
|
||||
private String applicationVersion(){
|
||||
try{return activity.getPackageManager().getPackageInfo(activity.getPackageName(),0).versionName;}catch(android.content.pm.PackageManager.NameNotFoundException error){return "—";}
|
||||
return activity.applicationVersion();
|
||||
}
|
||||
void renderUpdates(){
|
||||
if(updateStatus==null)return;
|
||||
updateRow.setVisibility(updates.updateAvailable()?View.VISIBLE:View.GONE);
|
||||
updateLink.setText("新版本可用:v"+updates.availableVersion);
|
||||
String status=!activity.hasNetworkConsent()?"完成隐私授权后可检查更新":switch(updates.status){
|
||||
case CHECKING -> "正在检查更新…";
|
||||
case UP_TO_DATE -> "当前已是最新版本";
|
||||
case FAILED -> "检查失败,请稍后重试";
|
||||
default -> "";
|
||||
};
|
||||
updateStatus.setText(status);updateStatus.setVisibility(status.isEmpty()?View.GONE:View.VISIBLE);
|
||||
updateStatus.setTextColor(updates.status==AppUpdateChecker.Status.FAILED?RED:SECONDARY);
|
||||
checkButton.setEnabled(updates.status!=AppUpdateChecker.Status.CHECKING);
|
||||
checkButton.setAlpha(checkButton.isEnabled()?1f:.5f);
|
||||
}
|
||||
private void components(){
|
||||
LinearLayout content=activity.formPage("open-source","开源组件",this::show);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/** Main-thread local-app checks triggered by visits, session endings and manual requests. */
|
||||
final class AppUpdateChecker {
|
||||
enum Status { IDLE, CHECKING, UP_TO_DATE, AVAILABLE, FAILED }
|
||||
private final String currentVersion;
|
||||
private final RemoteVersionCheck.Fetcher fetcher;
|
||||
private final BiFunction<String, byte[], String> compare;
|
||||
private final Runnable changed;
|
||||
private RemoteVersionCheck.Cancellation request;
|
||||
private boolean enabled;
|
||||
private long generation;
|
||||
Status status = Status.IDLE;
|
||||
String availableVersion = "";
|
||||
|
||||
AppUpdateChecker(String currentVersion, Runnable changed) {
|
||||
this(currentVersion, RemoteVersionCheck.ReleaseRequest::start,
|
||||
NativeSession::nCheckMobileUpdate, changed);
|
||||
}
|
||||
|
||||
AppUpdateChecker(String currentVersion, RemoteVersionCheck.Fetcher fetcher,
|
||||
BiFunction<String, byte[], String> compare, Runnable changed) {
|
||||
this.currentVersion = currentVersion; this.fetcher = fetcher;
|
||||
this.compare = compare;
|
||||
this.changed = changed;
|
||||
}
|
||||
|
||||
boolean updateAvailable() { return !availableVersion.isEmpty(); }
|
||||
|
||||
void setEnabled(boolean value) {
|
||||
if (enabled == value) return;
|
||||
enabled = value;
|
||||
if (enabled) { checkNow(); return; }
|
||||
++generation;
|
||||
if (request != null) request.cancel();
|
||||
request = null;
|
||||
if (status == Status.CHECKING) {
|
||||
status = updateAvailable() ? Status.AVAILABLE : Status.IDLE;
|
||||
}
|
||||
changed.run();
|
||||
}
|
||||
|
||||
void checkNow() {
|
||||
if (!enabled || status == Status.CHECKING) return;
|
||||
status = Status.CHECKING;
|
||||
changed.run();
|
||||
long token = ++generation;
|
||||
request = fetcher.fetch(data -> {
|
||||
if (!enabled || token != generation) return;
|
||||
request = null;
|
||||
String result = data == null ? null : compare.apply(currentVersion, data);
|
||||
if (result == null) {
|
||||
status = Status.FAILED;
|
||||
} else {
|
||||
availableVersion = result;
|
||||
status = updateAvailable() ? Status.AVAILABLE : Status.UP_TO_DATE;
|
||||
}
|
||||
changed.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private MobileUi ui;
|
||||
private PageHost pageHost;
|
||||
private AboutPages aboutPages;
|
||||
private AppUpdateChecker appUpdates;
|
||||
private View settingsUpdateDot, aboutUpdateDot, settingsUpdateButton, aboutUpdateRow;
|
||||
private TextView aboutUpdateLabel;
|
||||
private boolean navigatingBack;
|
||||
private FrameLayout canvas;
|
||||
private LinearLayout root;
|
||||
@@ -84,6 +87,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);};
|
||||
recentPresence.changed=this::renderRecentConnections;
|
||||
remoteVersionCheck=new RemoteVersionCheck(this::showRemoteUpdate);
|
||||
appUpdates=new AppUpdateChecker(applicationVersion(),this::renderAppUpdates);
|
||||
previews=RemotePreviewStore.get(this);previews.addListener(previewChanged);previews.enforceConsent();
|
||||
announcementInbox=new AnnouncementInbox(new java.io.File(getNoBackupFilesDir(),"AnnouncementReads"));
|
||||
announcementInbox.sender=(request,id)->{if(signaling!=null&&!signaling.isClosed())signaling.announcementRequest(request,id);else announcementInbox.failed(id);};
|
||||
@@ -107,6 +111,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
if(root!=null)hideKeyboard();page=nextPage;
|
||||
signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null;
|
||||
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
|
||||
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
|
||||
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
|
||||
root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1));
|
||||
@@ -144,10 +149,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
LinearLayout card=column();ui.card(card,radius);content.addView(card);return card;
|
||||
}
|
||||
void footer(LinearLayout content,String title){TextView v=text(title,12,false);v.setTextColor(SECONDARY);v.setLineSpacing(dp(3),1);v.setPadding(dp(16),dp(8),dp(16),dp(16));content.addView(v);}
|
||||
void link(LinearLayout parent,String title,String detail,Runnable action){
|
||||
LinearLayout link(LinearLayout parent,String title,String detail,Runnable action){
|
||||
LinearLayout item=row();item.setPadding(dp(16),dp(14),dp(12),dp(14));item.setMinimumHeight(dp(48));
|
||||
item.addView(text(title,16,false),new LinearLayout.LayoutParams(0,-2,1));TextView value=text(detail,15,false);value.setTextColor(SECONDARY);item.addView(value);
|
||||
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
|
||||
return item;
|
||||
}
|
||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Match the iOS portrait home; sessions support both orientations.
|
||||
private void home(String message){
|
||||
@@ -159,10 +165,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
else if(signalState==1)toast("已连接 "+serverKey());
|
||||
else {stopSignaling();ensureSignaling();}
|
||||
});renderSignaling();toolbar.addView(signalBadge,ui.size(-2,34));toolbar.addView(new View(this),new LinearLayout.LayoutParams(0,1,1));
|
||||
FrameLayout bell=(FrameLayout)ui.iconButton("bell","通知公告",this::announcements);bell.setClipChildren(false);toolbar.setClipChildren(false);
|
||||
FrameLayout bell=(FrameLayout)ui.iconButton("bell","通知公告",this::announcements);bell.setClipChildren(false);toolbar.setClipChildren(false);toolbar.setClipToPadding(false);
|
||||
announcementBadge=text("",10,true);announcementBadge.setTextColor(Color.WHITE);announcementBadge.setGravity(Gravity.CENTER);announcementBadge.setMinWidth(dp(16));announcementBadge.setPadding(dp(4),0,dp(4),0);announcementBadge.setBackground(ui.background(RED,12));announcementBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
FrameLayout.LayoutParams badgePosition=new FrameLayout.LayoutParams(-2,dp(16),Gravity.TOP|Gravity.END);badgePosition.topMargin=-dp(3);badgePosition.setMarginEnd(-dp(5));bell.addView(announcementBadge,badgePosition);renderAnnouncements();
|
||||
toolbar.addView(bell,ui.size(40,40));View spacer=new View(this);toolbar.addView(spacer,ui.size(12,1));toolbar.addView(ui.iconButton("settings","设置",this::settings),ui.size(40,40));
|
||||
toolbar.addView(bell,ui.size(40,40));View spacer=new View(this);toolbar.addView(spacer,ui.size(12,1));
|
||||
FrameLayout settingsButton=(FrameLayout)ui.iconButton("settings","设置",this::settings);settingsButton.setClipChildren(false);settingsUpdateButton=settingsButton;
|
||||
TextView updateBadge=text("!",10,true);updateBadge.setTextColor(Color.WHITE);updateBadge.setGravity(Gravity.CENTER);updateBadge.setIncludeFontPadding(false);updateBadge.setBackground(ui.background(RED,8));updateBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);settingsUpdateDot=updateBadge;
|
||||
FrameLayout.LayoutParams updatePosition=new FrameLayout.LayoutParams(dp(16),dp(16),Gravity.TOP|Gravity.END);updatePosition.topMargin=-dp(3);updatePosition.setMarginEnd(-dp(5));settingsButton.addView(settingsUpdateDot,updatePosition);
|
||||
toolbar.addView(settingsButton,ui.size(40,40));renderAppUpdates();
|
||||
LinearLayout content=column();content.setPadding(dp(20),dp(24),dp(20),dp(12));content.setClipChildren(false);content.setClipToPadding(false);root.addView(content,new LinearLayout.LayoutParams(-1,0,1));
|
||||
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));content.addView(ui.shadowCard(connection,20));
|
||||
connection.addView(text("远程桌面",20,true));ui.gap(connection,16);
|
||||
@@ -223,7 +233,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void consent(Runnable accepted){
|
||||
if(privacyDialog!=null&&privacyDialog.isShowing())return;
|
||||
Dialog dialog=new PrivacyConsentDialog(this,ui,()->{
|
||||
preferences.edit().putBoolean("networkConsent",true).apply();renderSignaling();ensureSignaling();accepted.run();
|
||||
preferences.edit().putBoolean("networkConsent",true).apply();appUpdates.setEnabled(foreground);renderSignaling();ensureSignaling();accepted.run();
|
||||
});
|
||||
privacyDialog=dialog;dialog.setOnDismissListener(d->{if(privacyDialog==dialog)privacyDialog=null;});dialog.show();
|
||||
}
|
||||
@@ -273,6 +283,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
resetVideoSettings();
|
||||
previewCapture=null;previewCopying=false;
|
||||
if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}if(session!=null){releaseKeys();session.close();session=null;}stopSignaling();pendingPassword="";remoteClipboard="";hostPlatform="";displays=new JSONArray();ready=false;video=null;controls=null;getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);home(reason);
|
||||
appUpdates.checkNow();
|
||||
}
|
||||
private void settings(){
|
||||
LinearLayout content=formPage("settings","设置",()->home(""));
|
||||
@@ -294,7 +305,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
previewSwitch.setOnCheckedChangeListener((button,checked)->{if(updatingPreviewSwitch)return;if(checked){renderPreviews();confirmPreviewSaving();}else previews.setEnabled(false);});
|
||||
ui.divider(privacy);clearPreviewsButton=ui.action("清除预览图",RED,this::confirmClearPreviews);clearPreviewsButton.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);clearPreviewsButton.setTypeface(Typeface.DEFAULT);clearPreviewsButton.setPadding(dp(16),0,dp(16),0);privacy.addView(clearPreviewsButton,ui.size(-1,48));
|
||||
previewCleanupError=text("",12,false);previewCleanupError.setTextColor(RED);previewCleanupError.setPadding(dp(16),dp(8),dp(16),dp(12));privacy.addView(previewCleanupError);renderPreviews();
|
||||
footer(content,"画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和密码不受影响。");LinearLayout about=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);link(about,"关于","",this::about);
|
||||
footer(content,"画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和密码不受影响。");LinearLayout about=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout aboutLink=link(about,"关于","新版本",this::about);aboutUpdateRow=aboutLink;
|
||||
aboutUpdateLabel=(TextView)aboutLink.getChildAt(1);aboutUpdateLabel.setPadding(0,0,dp(8),0);
|
||||
aboutUpdateDot=updateDot();LinearLayout.LayoutParams dotPosition=new LinearLayout.LayoutParams(dp(8),dp(8));dotPosition.setMarginEnd(dp(8));aboutLink.addView(aboutUpdateDot,2,dotPosition);renderAppUpdates();
|
||||
}
|
||||
private void confirmPreviewSaving(){
|
||||
previewDialog=new AlertDialog.Builder(this).setTitle("保存远程画面预览?")
|
||||
@@ -363,14 +376,32 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void privacyPage(){
|
||||
LinearLayout content=formPage("privacy","隐私与授权",this::settings);LinearLayout card=section(content,"");boolean consent=preferences.getBoolean("networkConsent",false);link(card,"授权状态",consent?"已授权":"未授权",()->{});ui.divider(card);link(card,"隐私政策","",this::privacyPolicyPage);
|
||||
footer(content,"设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用会结束当前会话。");
|
||||
if(consent)content.addView(ui.action("撤回联网授权",RED,()->new AlertDialog.Builder(this).setTitle("撤回联网授权?").setMessage("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。").setNegativeButton("取消",null).setPositiveButton("撤回",(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary("阅读并授权",()->consent(this::privacyPage)));
|
||||
if(consent)content.addView(ui.action("撤回联网授权",RED,()->new AlertDialog.Builder(this).setTitle("撤回联网授权?").setMessage("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。").setNegativeButton("取消",null).setPositiveButton("撤回",(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();appUpdates.setEnabled(false);previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary("阅读并授权",()->consent(this::privacyPage)));
|
||||
}
|
||||
private void privacyPolicyPage(){
|
||||
installRoot("privacy-policy",false);navigation("隐私政策",this::privacyPage);
|
||||
root.addView(new PrivacyPolicyDocument(this).view(ui),new LinearLayout.LayoutParams(-1,0,1));
|
||||
}
|
||||
String applicationVersion(){
|
||||
try{return getPackageManager().getPackageInfo(getPackageName(),0).versionName;}catch(android.content.pm.PackageManager.NameNotFoundException error){return "";}
|
||||
}
|
||||
View updateDot(){View dot=new View(this);dot.setBackground(ui.background(RED,4));dot.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);return dot;}
|
||||
boolean hasNetworkConsent(){return preferences.getBoolean("networkConsent",false);}
|
||||
void checkAppUpdates(){
|
||||
if(hasNetworkConsent())appUpdates.checkNow();
|
||||
else consent(appUpdates::checkNow);
|
||||
}
|
||||
private void renderAppUpdates(){
|
||||
boolean available=appUpdates.updateAvailable();
|
||||
if(settingsUpdateDot!=null)settingsUpdateDot.setVisibility(available?View.VISIBLE:View.GONE);
|
||||
if(aboutUpdateDot!=null)aboutUpdateDot.setVisibility(available?View.VISIBLE:View.GONE);
|
||||
if(aboutUpdateLabel!=null)aboutUpdateLabel.setVisibility(available?View.VISIBLE:View.GONE);
|
||||
if(settingsUpdateButton!=null)settingsUpdateButton.setContentDescription(available?"设置,有新版本":"设置");
|
||||
if(aboutUpdateRow!=null)aboutUpdateRow.setContentDescription(available?"关于,有新版本":"关于");
|
||||
if(aboutPages!=null&&page.equals("about"))aboutPages.renderUpdates();
|
||||
}
|
||||
private void about(){
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings);aboutPages.show();
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings,appUpdates);aboutPages.show();
|
||||
}
|
||||
private void sendText(String value){if(session==null)return;if(value.length()>2048){toast("单次最多 2048 个字符");return;}for(char ch:value.toCharArray())if(KeyMap.ascii(ch)==0){toast("中文等文本请通过剪贴板发送,再点击粘贴");return;}for(char ch:value.toCharArray()){int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;if(shift)session.key(0x10,true);tapKey(code&0xFF);if(shift)session.key(0x10,false);}}
|
||||
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
|
||||
@@ -510,7 +541,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
||||
@Override public void onConfigurationChanged(Configuration config){super.onConfigurationChanged(config);if(controls!=null)controls.post(controls::constrainPanels);}
|
||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
||||
@Override protected void onStart(){super.onStart();foreground=true;if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
|
||||
@Override protected void onStop(){foreground=false;if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
||||
@Override protected void onDestroy(){pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
|
||||
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
|
||||
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
||||
@Override protected void onDestroy(){appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ final class NativeSession implements AutoCloseable {
|
||||
}
|
||||
static native boolean nValidAppVersion(String version);
|
||||
static native boolean nHasAppUpdate(String version, byte[] releaseJson);
|
||||
static native String nCheckMobileUpdate(String version, byte[] releaseJson);
|
||||
private static native long nCreate(NativeSession owner, String host, int port, String identity, String logs, String roots);
|
||||
private static native boolean nConnect(long handle, String identity, String remote, String password);
|
||||
private static native void nReady(long handle);
|
||||
|
||||
@@ -61,7 +61,7 @@ final class RemoteVersionCheck {
|
||||
request = null;
|
||||
}
|
||||
|
||||
private static final class ReleaseRequest implements Cancellation, Runnable {
|
||||
static final class ReleaseRequest implements Cancellation, Runnable {
|
||||
private static final int MAX_BYTES = 256 * 1024;
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final Consumer<byte[]> completion;
|
||||
|
||||
@@ -298,6 +298,18 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nHasAppUpdate)(
|
||||
const auto latest = crossdesk::ParseVersionInfoJSON(data);
|
||||
return latest && crossdesk::AvailableAppUpdate(String(env, version), *latest).has_value();
|
||||
}
|
||||
extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nCheckMobileUpdate)(
|
||||
JNIEnv* env, jclass, jstring version, jbyteArray release_json) {
|
||||
if (!version || !release_json) return nullptr;
|
||||
const auto size = env->GetArrayLength(release_json);
|
||||
if (size <= 0 || size > 256 * 1024) return nullptr;
|
||||
std::string data(size, '\0');
|
||||
env->GetByteArrayRegion(release_json, 0, size, reinterpret_cast<jbyte*>(data.data()));
|
||||
if (env->ExceptionCheck()) return nullptr;
|
||||
const auto result = crossdesk::CheckPlatformAppUpdate(
|
||||
String(env, version), data, "android-arm64");
|
||||
return result ? env->NewStringUTF(result->c_str()) : nullptr;
|
||||
}
|
||||
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)(
|
||||
JNIEnv* env, jclass, jobject owner, jstring host, jint port, jstring identity,
|
||||
jstring log_path, jstring certificates) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class AppUpdateCheckerTest {
|
||||
private static final class Fixture implements RemoteVersionCheck.Fetcher {
|
||||
int cancelled;
|
||||
final List<Consumer<byte[]>> replies = new ArrayList<>();
|
||||
final AppUpdateChecker checker = new AppUpdateChecker("1.6.0", this,
|
||||
(version, data) -> new String(data, StandardCharsets.UTF_8), () -> {});
|
||||
public RemoteVersionCheck.Cancellation fetch(Consumer<byte[]> reply) {
|
||||
replies.add(reply); return () -> cancelled++;
|
||||
}
|
||||
void reply(String result) { replies.get(replies.size()-1).accept(result == null ? null : result.getBytes(StandardCharsets.UTF_8)); }
|
||||
}
|
||||
|
||||
@Test public void everyVisitChecksOnceAndConcurrentTriggersDeduplicate() {
|
||||
Fixture f = new Fixture();
|
||||
f.checker.checkNow(); assertTrue(f.replies.isEmpty());
|
||||
f.checker.setEnabled(true); f.checker.checkNow(); f.checker.setEnabled(true);
|
||||
assertEquals(1, f.replies.size());
|
||||
f.reply("1.7.0"); assertTrue(f.checker.updateAvailable());
|
||||
f.checker.setEnabled(true); assertEquals(1, f.replies.size());
|
||||
f.checker.setEnabled(false); f.checker.setEnabled(true);
|
||||
assertEquals(2, f.replies.size());
|
||||
f.reply(""); assertFalse(f.checker.updateAvailable());
|
||||
assertEquals(AppUpdateChecker.Status.UP_TO_DATE, f.checker.status);
|
||||
f.checker.checkNow(); assertEquals(3, f.replies.size());
|
||||
}
|
||||
|
||||
@Test public void failedChecksRetainBadgeUntilAnotherExplicitCheck() {
|
||||
Fixture f = new Fixture(); f.checker.setEnabled(true); f.reply("1.7.0");
|
||||
f.checker.checkNow(); f.reply(null);
|
||||
assertEquals(AppUpdateChecker.Status.FAILED, f.checker.status);
|
||||
assertTrue(f.checker.updateAvailable());
|
||||
f.checker.setEnabled(true); assertEquals(2, f.replies.size());
|
||||
f.checker.checkNow(); assertEquals(3, f.replies.size());
|
||||
f.reply(""); assertFalse(f.checker.updateAvailable());
|
||||
}
|
||||
|
||||
@Test public void backgroundAndRevocationCancelAndIgnoreStaleReplies() {
|
||||
Fixture f = new Fixture(); f.checker.setEnabled(true); f.checker.setEnabled(false);
|
||||
assertEquals(1, f.cancelled);
|
||||
f.reply("9.0.0"); assertFalse(f.checker.updateAvailable());
|
||||
f.checker.setEnabled(true); assertEquals(2, f.replies.size());
|
||||
f.replies.get(0).accept("9.0.0".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals(AppUpdateChecker.Status.CHECKING, f.checker.status);
|
||||
f.reply("1.7.0"); f.checker.setEnabled(false); f.checker.checkNow();
|
||||
assertEquals(2, f.replies.size()); f.checker.setEnabled(true);
|
||||
assertEquals(3, f.replies.size()); assertTrue(f.checker.updateAvailable());
|
||||
}
|
||||
|
||||
@Test public void nativeParserSeparatesMobileFromDesktopAndInvalidMetadata() {
|
||||
byte[] manifest = "{\"latest_version\":\"99.0.0\",\"patch\":99,\"downloads\":{\"ios-arm64\":{\"version\":\"0.0.2\"},\"android-arm64\":{\"version\":\"1.6.0-2-20261002\"}}}".getBytes(StandardCharsets.UTF_8);
|
||||
assertEquals("1.6.0-2", NativeSession.nCheckMobileUpdate("1.6.0", manifest));
|
||||
assertEquals("", NativeSession.nCheckMobileUpdate("1.6.0-2", manifest));
|
||||
assertEquals("", NativeSession.nCheckMobileUpdate("1.7.0", manifest));
|
||||
assertNull(NativeSession.nCheckMobileUpdate("", manifest));
|
||||
assertNull(NativeSession.nCheckMobileUpdate("1.6.0", "{\"version\":\"99.0.0\"}".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
@Test public void settingsAndAboutReflectUpdatesWithoutReopeningThePage() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
var preferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE);
|
||||
boolean hadConsent = preferences.contains("networkConsent");
|
||||
boolean consent = preferences.getBoolean("networkConsent", false);
|
||||
preferences.edit().putBoolean("networkConsent", false).commit();
|
||||
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
|
||||
scenario.onActivity(activity -> {
|
||||
((Dialog)field(activity, "privacyDialog")).dismiss();
|
||||
List<Consumer<byte[]>> replies = new ArrayList<>();
|
||||
AppUpdateChecker checker = new AppUpdateChecker("1.6.0",
|
||||
reply -> { replies.add(reply); return () -> {}; },
|
||||
(version, data) -> new String(data, StandardCharsets.UTF_8),
|
||||
() -> invoke(activity, "renderAppUpdates"));
|
||||
try {
|
||||
var field = MainActivity.class.getDeclaredField("appUpdates");
|
||||
field.setAccessible(true); field.set(activity, checker);
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
assertEquals(View.GONE, ((View)field(activity, "settingsUpdateDot")).getVisibility());
|
||||
checker.setEnabled(true);
|
||||
replies.get(0).accept("1.7.0".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals(View.VISIBLE, ((View)field(activity, "settingsUpdateDot")).getVisibility());
|
||||
assertEquals("设置,有新版本", ((View)field(activity, "settingsUpdateButton")).getContentDescription());
|
||||
((View)field(activity, "settingsUpdateButton")).performClick();
|
||||
assertEquals(View.VISIBLE, ((View)field(activity, "aboutUpdateDot")).getVisibility());
|
||||
((View)field(activity, "aboutUpdateRow")).performClick();
|
||||
assertNotNull(findText(activity.getWindow().getDecorView(), "新版本可用:v1.7.0"));
|
||||
checker.checkNow(); replies.get(1).accept(null);
|
||||
assertNotNull(findText(activity.getWindow().getDecorView(), "新版本可用:v1.7.0"));
|
||||
checker.checkNow(); replies.get(2).accept(new byte[0]);
|
||||
invoke(activity, "settings");
|
||||
assertEquals(View.GONE, ((View)field(activity, "aboutUpdateDot")).getVisibility());
|
||||
checker.setEnabled(false);
|
||||
});
|
||||
} finally {
|
||||
if (hadConsent) preferences.edit().putBoolean("networkConsent", consent).commit();
|
||||
else preferences.edit().remove("networkConsent").commit();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void activityVisitsAndConnectionTeardownTriggerChecks() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
var preferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE);
|
||||
boolean hadConsent = preferences.contains("networkConsent");
|
||||
boolean consent = preferences.getBoolean("networkConsent", false);
|
||||
preferences.edit().putBoolean("networkConsent", false).commit();
|
||||
Fixture f = new Fixture();
|
||||
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
|
||||
scenario.onActivity(activity -> {
|
||||
((Dialog)field(activity, "privacyDialog")).dismiss();
|
||||
try {
|
||||
var field = MainActivity.class.getDeclaredField("appUpdates");
|
||||
field.setAccessible(true); field.set(activity, f.checker);
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
});
|
||||
scenario.moveToState(Lifecycle.State.CREATED);
|
||||
preferences.edit().putBoolean("networkConsent", true).commit();
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity -> {
|
||||
assertEquals(1, f.replies.size()); f.reply("1.7.0");
|
||||
endConnection(activity);
|
||||
assertEquals(2, f.replies.size()); f.reply(null);
|
||||
assertTrue(f.checker.updateAvailable());
|
||||
endConnection(activity);
|
||||
assertEquals(3, f.replies.size()); f.reply("");
|
||||
assertFalse(f.checker.updateAvailable());
|
||||
});
|
||||
scenario.moveToState(Lifecycle.State.CREATED);
|
||||
scenario.onActivity(activity -> {
|
||||
endConnection(activity); assertEquals(3, f.replies.size());
|
||||
});
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity -> assertEquals(4, f.replies.size()));
|
||||
} finally {
|
||||
if (hadConsent) preferences.edit().putBoolean("networkConsent", consent).commit();
|
||||
else preferences.edit().remove("networkConsent").commit();
|
||||
}
|
||||
}
|
||||
|
||||
private static void endConnection(MainActivity activity) {
|
||||
try {
|
||||
var method = MainActivity.class.getDeclaredMethod("end", String.class);
|
||||
method.setAccessible(true); method.invoke(activity, "");
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
|
||||
private static Object field(MainActivity activity, String name) {
|
||||
try { var field = MainActivity.class.getDeclaredField(name); field.setAccessible(true); return field.get(activity); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
private static void invoke(MainActivity activity, String name) {
|
||||
try { var method = MainActivity.class.getDeclaredMethod(name); method.setAccessible(true); method.invoke(activity); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
private static TextView findText(View view, String text) {
|
||||
if (view instanceof TextView && ((TextView)view).getText().toString().equals(text)) return (TextView)view;
|
||||
if (view instanceof ViewGroup) for (int i = 0; i < ((ViewGroup)view).getChildCount(); i++) {
|
||||
TextView found = findText(((ViewGroup)view).getChildAt(i), text);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user