[feat] add mobile update badges and iOS simulator support

This commit is contained in:
dijunkun
2026-10-04 01:38:08 +08:00
parent 115533cd62
commit b8f704abb9
25 changed files with 766 additions and 61 deletions
+6
View File
@@ -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;
+12
View File
@@ -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;
}
}