mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
[fix] align Android privacy consent prompts with iOS
This commit is contained in:
@@ -21,6 +21,7 @@ on:
|
||||
- ".github/workflows/sign-android.yml"
|
||||
- ".gitmodules"
|
||||
- "LICENSE"
|
||||
- "PRIVACY.md"
|
||||
- "apps/android/**"
|
||||
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
|
||||
- "apps/ios/licenses/**"
|
||||
|
||||
+25
-13
@@ -1,18 +1,18 @@
|
||||
# CrossDesk Privacy Policy
|
||||
|
||||
Last updated: September 28, 2026
|
||||
Last updated: October 2, 2026
|
||||
|
||||
[中文](#中文) | [English](#english)
|
||||
|
||||
## 中文
|
||||
|
||||
更新日期:2026 年 9 月 28 日
|
||||
更新日期:2026 年 10 月 2 日
|
||||
|
||||
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
|
||||
|
||||
### 一、适用范围
|
||||
|
||||
本政策适用于 CrossDesk 桌面客户端、iOS 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
||||
本政策适用于 CrossDesk 桌面客户端、iOS 与 Android 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
||||
|
||||
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
|
||||
|
||||
@@ -24,7 +24,7 @@ CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提
|
||||
|
||||
**远程会话内容。** 您发起或接受远程连接后,软件根据使用的功能在参与会话的设备之间传输画面、音频、键盘和鼠标操作、剪贴板文本及文件。会话内容可能包含个人或工作信息,请仅连接您拥有或已获授权使用的设备,并按需管理共享内容。
|
||||
|
||||
**版本检查。** 桌面客户端启动后会获取版本信息;iOS 客户端在连接成功并收到远端版本信息后,会获取最新桌面版本信息,用于提示升级。该请求不携带设备 ID 或远端版本号,但网络服务会处理完成请求所必需的网络信息。
|
||||
**版本检查。** 桌面客户端启动后会获取版本信息;iOS 与 Android 客户端在连接成功并收到远端版本信息后,会获取最新桌面版本信息,用于提示升级。该请求不携带设备 ID 或远端版本号,但网络服务会处理完成请求所必需的网络信息。
|
||||
|
||||
CrossDesk 未集成广告投放、广告追踪或用户行为分析功能。客户端不会主动上传诊断日志、崩溃报告或用户行为分析数据。您主动向我们提交问题或日志时,请先移除与问题无关的个人信息及连接凭据。
|
||||
|
||||
@@ -40,7 +40,9 @@ CrossDesk 未集成广告投放、广告追踪或用户行为分析功能。客
|
||||
|
||||
**客户端本地数据。** 软件会在本机保存连接设置、用户偏好、最近连接记录及必要的运行日志,以支持后续使用和故障排查。iOS 中的本机登录凭据及您选择记住的远程连接密码保存在系统钥匙串中,不通过 iCloud 钥匙串同步;旧版偏好中的本机登录凭据在成功迁入钥匙串后删除。
|
||||
|
||||
**远程画面预览。** iOS 默认不保存远程画面预览。只有您在“设置 → 隐私”单独开启并确认后,才会从下一次连接起,每次连接保存一张远程画面,用于最近连接预览。预览仅保存在本机,不上传,并设置为不纳入设备备份。您可以单独清除预览;关闭保存或撤回隐私授权也会清除已有预览。旧版本未经此选择保存的预览会在升级后清理。
|
||||
Android 中的设备身份及您选择保存的访问密码使用 Android Keystore 密钥加密后保存在本机,应用本地数据不纳入设备备份。
|
||||
|
||||
**远程画面预览。** iOS 与 Android 默认不保存远程画面预览。只有您在“设置 → 隐私”单独开启并确认后,才会从下一次连接起,每次连接保存一张远程画面,用于最近连接预览。预览仅保存在本机,不上传,并设置为不纳入设备备份。您可以单独清除预览;关闭保存或撤回隐私授权也会清除已有预览。旧版本未经此选择保存的预览会在升级后清理。
|
||||
|
||||
**接收的文件。** iOS 接收文件保存在本应用的文件目录中,您可通过系统“文件”应用管理或删除。清除画面预览不会删除接收文件或最近连接记录。
|
||||
|
||||
@@ -48,14 +50,18 @@ CrossDesk 未集成广告投放、广告追踪或用户行为分析功能。客
|
||||
|
||||
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
|
||||
|
||||
### 五、iOS 权限与授权选择
|
||||
Android 默认信令连接同样进行 TLS 证书校验。
|
||||
|
||||
**隐私授权。** 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。
|
||||
### 五、移动端权限与授权选择
|
||||
|
||||
**隐私授权。** iOS 与 Android 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
|
||||
|
||||
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
|
||||
|
||||
**系统权限与会话功能。** iOS 客户端作为控制端接收远程电脑的画面与音频,不采集本机屏幕、摄像头或麦克风。局域网访问权限用于连接本地网络中的设备,您可在系统设置中管理。发送文件通过系统文件选择器选择,不要求开放整个文件库。当前 iOS 界面不读取或发送本机剪贴板文本;远端发送的剪贴板文本可在会话中写入本机剪贴板。
|
||||
|
||||
Android 客户端同样作为控制端接收远程画面与音频,不采集本机屏幕、摄像头或麦克风,当前不支持文件传输。剪贴板文本仅在您主动发送或复制远端文本时传输或写入本机,会话结束时清除应用内缓存的远端剪贴板文本。
|
||||
|
||||
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
|
||||
|
||||
### 六、查询与删除请求
|
||||
@@ -78,13 +84,13 @@ iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认
|
||||
|
||||
## English
|
||||
|
||||
Updated: September 28, 2026
|
||||
Updated: October 2, 2026
|
||||
|
||||
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
|
||||
|
||||
### 1. Scope
|
||||
|
||||
This policy applies to the CrossDesk desktop and iOS clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
|
||||
This policy applies to the CrossDesk desktop, iOS and Android clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
|
||||
|
||||
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
|
||||
|
||||
@@ -96,7 +102,7 @@ If you deploy your own server or choose a server provided by another operator, t
|
||||
|
||||
**Remote-session content.** After you initiate or accept a remote connection, the software transfers content between participating devices according to the features used. This includes screens, audio, keyboard and mouse input, clipboard text and files. Such content may contain personal or work information. Connect only to devices you own or are authorized to access, and manage shared content accordingly.
|
||||
|
||||
**Version checks.** The desktop client retrieves release information after startup. After connecting and receiving the remote host's version, the iOS client retrieves the latest desktop release information to suggest updates. These requests do not contain device IDs or the remote host's version, but the service processes network information necessary to complete the request.
|
||||
**Version checks.** The desktop client retrieves release information after startup. After connecting and receiving the remote host's version, the iOS and Android clients retrieve the latest desktop release information to suggest updates. These requests do not contain device IDs or the remote host's version, but the service processes network information necessary to complete the request.
|
||||
|
||||
CrossDesk does not integrate advertising, advertising tracking or behavioral analytics. The clients do not proactively upload diagnostic logs, crash reports or behavioral analytics data. If you choose to submit an issue or logs, remove unrelated personal information and connection credentials first.
|
||||
|
||||
@@ -112,7 +118,9 @@ Self-hosted servers are managed by the operator you choose. If you change the co
|
||||
|
||||
**Local client data.** The software stores connection settings, preferences, recent connections and necessary operational logs on the device for subsequent use and troubleshooting. On iOS, local login credentials and remote connection passwords you choose to remember are stored in the system Keychain without iCloud Keychain synchronization. Legacy local login credentials in preferences are removed after successful migration to the Keychain.
|
||||
|
||||
**Remote desktop previews.** Saving previews is off by default on iOS. Only after you separately enable and confirm it in Settings → Privacy will the app save one remote frame per connection, starting with the next connection, for recent-connection previews. Previews remain local, are not uploaded and are marked as excluded from device backups. You can clear them separately. Disabling saving or withdrawing network consent also clears them. Previews saved by older versions without this choice are cleared on upgrade.
|
||||
On Android, device identities and access passwords you choose to save are encrypted with Android Keystore keys and stored locally. The app's local data is excluded from device backups.
|
||||
|
||||
**Remote desktop previews.** Saving previews is off by default on iOS and Android. Only after you separately enable and confirm it in Settings → Privacy will the app save one remote frame per connection, starting with the next connection, for recent-connection previews. Previews remain local, are not uploaded and are marked as excluded from device backups. You can clear them separately. Disabling saving or withdrawing network consent also clears them. Previews saved by older versions without this choice are cleared on upgrade.
|
||||
|
||||
**Received files.** Received files on iOS are stored in the app's file directory and can be managed or deleted through Files. Clearing previews does not delete received files or recent-connection records.
|
||||
|
||||
@@ -120,14 +128,18 @@ Self-hosted servers are managed by the operator you choose. If you change the co
|
||||
|
||||
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
|
||||
|
||||
### 5. iOS permissions and consent choices
|
||||
Default signaling connections on Android also validate TLS certificates.
|
||||
|
||||
**Privacy authorization.** On first use, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable.
|
||||
### 5. Mobile permissions and consent choices
|
||||
|
||||
**Privacy authorization.** On first use of iOS or Android, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
|
||||
|
||||
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
|
||||
|
||||
**System permissions and session features.** The iOS client receives remote computer screens and audio as a controller; it does not capture the local screen, camera or microphone. Local network access supports connections to devices on your network and can be managed in system settings. File sending uses the system document picker without requiring access to the entire file library. The current iOS interface does not read or send local clipboard text. Text sent by the remote device may be written to the local clipboard during a session.
|
||||
|
||||
The Android client also receives remote screens and audio as a controller and does not capture the local screen, camera or microphone. It currently does not support file transfer. Clipboard text is transmitted or written locally only when you explicitly send text or copy remote text. The app clears cached remote clipboard text when the session ends.
|
||||
|
||||
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
|
||||
|
||||
### 6. Access and deletion requests
|
||||
|
||||
@@ -148,6 +148,10 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
|
||||
|
||||
采集时间的有效性在接收时检查,本机解码和显示等待计入画面延时;真实延时超过 5 秒也会继续显示,不会当成无效时间戳过滤。显示线程复用解码器的 CPU NV12 缓冲区,只保留一帧待显示画面,切换显示器或 Surface 时清除旧帧,会话结束时先停止显示线程再销毁原生连接。
|
||||
|
||||
## 隐私授权
|
||||
|
||||
未完成隐私授权时,首次打开或从后台返回应用会自动显示离线隐私政策;需要主动勾选阅读并同意后才能联网,与 iOS 一致。暂不同意后,本次停留不会反复弹窗,仍可浏览设置和本地连接记录。设置 → 隐私 → 隐私与授权可离线查看政策或撤回授权;撤回后下次进入应用会再次提示。
|
||||
|
||||
## 远程画面预览
|
||||
|
||||
设置 → 隐私中的“保存远程画面预览”默认关闭,完成联网授权后可开启。与 iOS 一致,开启前说明预览可能包含个人或工作信息;每次连接只在实际显示画面后保存一张预览,居中裁剪为 640×360、JPEG 质量 78。截图来自远程视频的 Surface,不包含悬浮菜单、键盘、本地光标或画面外的黑边;压缩、读写和解码在串行后台线程执行,不复制完整 4K 位图,也不连续截图。
|
||||
|
||||
@@ -63,7 +63,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private boolean foreground;
|
||||
private RemoteVideoView video;
|
||||
private SessionControls controls;
|
||||
private Dialog passwordDialog, progressDialog;
|
||||
private Dialog passwordDialog, progressDialog, privacyDialog;
|
||||
private AlertDialog disconnectDialog;
|
||||
private AlertDialog remoteUpdateDialog;
|
||||
private RemoteVersionCheck remoteVersionCheck;
|
||||
@@ -152,7 +152,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
LinearLayout toolbar=row();toolbar.setPadding(dp(20),0,dp(20),0);root.addView(toolbar,ui.size(-1,56));
|
||||
signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true);
|
||||
signalBadge.setOnClickListener(v->{
|
||||
if(!preferences.getBoolean("networkConsent",false))privacyPage();
|
||||
if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);
|
||||
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));
|
||||
@@ -218,8 +218,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
passwordSheet(id,remember?"已保存的密码未能保留,请重新输入一次":"",remember);
|
||||
}
|
||||
private void consent(Runnable accepted){
|
||||
new AlertDialog.Builder(this).setTitle("隐私与授权").setMessage("连接时会向所选服务器发送设备身份和认证信息,并传输远程画面、声音与操作指令。设备身份加密保存在本机;仅在你开启“保存密码”时加密保存访问密码。剪贴板内容仅在你操作时发送或写入本机。仅连接你有权访问的电脑。")
|
||||
.setNegativeButton("暂不同意",null).setPositiveButton("同意并继续",(d,w)->{preferences.edit().putBoolean("networkConsent",true).apply();ensureSignaling();accepted.run();}).show();
|
||||
if(privacyDialog!=null&&privacyDialog.isShowing())return;
|
||||
Dialog dialog=new PrivacyConsentDialog(this,ui,()->{
|
||||
preferences.edit().putBoolean("networkConsent",true).apply();renderSignaling();ensureSignaling();accepted.run();
|
||||
});
|
||||
privacyDialog=dialog;dialog.setOnDismissListener(d->{if(privacyDialog==dialog)privacyDialog=null;});dialog.show();
|
||||
}
|
||||
private void passwordSheet(String id,String error,boolean rememberDefault){
|
||||
Dialog dialog=new Dialog(this);passwordDialog=dialog;LinearLayout sheet=column();sheet.setPadding(dp(22),dp(10),dp(22),dp(22));sheet.setBackground(ui.background(BACKGROUND,24));
|
||||
@@ -355,10 +358,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
@Override public void announcementFailed(String requestId){announcementInbox.failed(requestId);}
|
||||
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,"隐私说明","",()->consent(this::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)));
|
||||
}
|
||||
private void privacyPolicyPage(){
|
||||
installRoot("privacy-policy",false);navigation("隐私政策",this::privacyPage);
|
||||
root.addView(new PrivacyPolicyDocument(this).view(ui),new LinearLayout.LayoutParams(-1,0,1));
|
||||
}
|
||||
private void about(){
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings);aboutPages.show();
|
||||
}
|
||||
@@ -499,7 +506,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
|
||||
@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);}
|
||||
@Override protected void onStart(){super.onStart();foreground=true;ensureSignaling();}
|
||||
@Override protected void onStop(){foreground=false;pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
||||
@Override protected void onDestroy(){pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);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();}
|
||||
// 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();}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Dialog;
|
||||
import android.view.Gravity;
|
||||
import android.view.Window;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Explicit agreement is required; refusal dismisses only the current visit. */
|
||||
final class PrivacyConsentDialog extends Dialog {
|
||||
PrivacyConsentDialog(Activity activity,MobileUi ui,Runnable accepted){
|
||||
this(activity,ui,new PrivacyPolicyDocument(activity),accepted);
|
||||
}
|
||||
PrivacyConsentDialog(Activity activity,MobileUi ui,PrivacyPolicyDocument policy,Runnable accepted){
|
||||
super(activity);requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false);
|
||||
LinearLayout sheet=ui.column();sheet.setBackground(ui.background(android.graphics.Color.WHITE,20));sheet.setClipToOutline(true);
|
||||
TextView title=ui.text("隐私政策",17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet);
|
||||
sheet.addView(policy.view(ui),new LinearLayout.LayoutParams(-1,0,1));ui.divider(sheet);
|
||||
LinearLayout actions=ui.column();actions.setPadding(ui.dp(20),ui.dp(12),ui.dp(20),ui.dp(12));sheet.addView(actions);
|
||||
CheckBox hasReadPolicy=new CheckBox(activity);hasReadPolicy.setText("我已阅读并同意《隐私政策》");hasReadPolicy.setTextColor(INK);hasReadPolicy.setTextSize(14);hasReadPolicy.setMinHeight(ui.dp(44));actions.addView(hasReadPolicy,ui.size(-1,-2));ui.gap(actions,12);
|
||||
TextView agree=ui.primary("同意并继续",()->{
|
||||
if(!hasReadPolicy.isChecked()||!policy.available)return;
|
||||
dismiss();accepted.run();
|
||||
});agree.setEnabled(false);agree.setAlpha(.45f);actions.addView(agree,ui.size(-1,48));
|
||||
hasReadPolicy.setOnCheckedChangeListener((button,checked)->{boolean enabled=checked&&policy.available;agree.setEnabled(enabled);agree.setAlpha(enabled?1:.45f);});
|
||||
ui.gap(actions,12);actions.addView(ui.action("暂不同意",SECONDARY,this::dismiss),ui.size(-1,44));
|
||||
setContentView(sheet);Window window=getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.22f);}
|
||||
}
|
||||
@Override public void show(){
|
||||
super.show();Window window=getWindow();if(window!=null){window.setLayout(-1,Math.round(getContext().getResources().getDisplayMetrics().heightPixels*.9f));window.setGravity(Gravity.BOTTOM);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import java.io.IOException;
|
||||
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** The same bundled policy as iOS, readable before any network consent. */
|
||||
final class PrivacyPolicyDocument {
|
||||
final boolean available;
|
||||
final String text;
|
||||
|
||||
PrivacyPolicyDocument(Context context){this(load(context));}
|
||||
PrivacyPolicyDocument(String document){
|
||||
available=document!=null&&!document.trim().isEmpty();
|
||||
String value=available?document:"隐私政策暂时无法读取,请拒绝联网并重新安装应用。";
|
||||
int start=value.indexOf("## 中文\n"),end=start<0?-1:value.indexOf("## English",start);
|
||||
text=start>=0&&end>start?value.substring(start+"## 中文\n".length(),end).trim():value;
|
||||
}
|
||||
private static String load(Context context){
|
||||
try{return LicenseCatalog.read(context,"PRIVACY.md");}catch(IOException error){return null;}
|
||||
}
|
||||
ScrollView view(MobileUi ui){
|
||||
ScrollView scroll=new ScrollView(ui.context);scroll.setBackgroundColor(android.graphics.Color.WHITE);
|
||||
LinearLayout content=ui.column();content.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));scroll.addView(content);
|
||||
for(String paragraph:text.split("\n\n")){
|
||||
paragraph=paragraph.trim();if(paragraph.isEmpty()||paragraph.equals("---"))continue;
|
||||
boolean heading=paragraph.startsWith("#"),date=paragraph.startsWith("更新日期:");
|
||||
String value=heading?paragraph.replaceFirst("^#+\\s*",""):paragraph.replace("**","");
|
||||
TextView label=ui.text(value,date?12:16,heading);label.setTextIsSelectable(true);label.setLineSpacing(ui.dp(4),1);
|
||||
if(date)label.setTextColor(SECONDARY);
|
||||
LinearLayout.LayoutParams spacing=new LinearLayout.LayoutParams(-1,-2);spacing.bottomMargin=ui.dp(14);content.addView(label,spacing);
|
||||
}
|
||||
return scroll;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ def write_pages(output, catalog, additional, revision, minirtc):
|
||||
def main():
|
||||
output = ANDROID / 'app/build/generated/notices'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / 'PRIVACY.md').write_text((ROOT / 'PRIVACY.md').read_text(encoding='utf-8'), encoding='utf-8')
|
||||
catalog = json.loads((ROOT / 'apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json').read_text())
|
||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
|
||||
minirtc = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT / 'deps/submodules/minirtc', text=True).strip()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class PrivacyConsentTest {
|
||||
private SharedPreferences preferences;
|
||||
private Map<String,?> saved;
|
||||
|
||||
@Before public void prepare(){
|
||||
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
preferences=context.getSharedPreferences("settings",Context.MODE_PRIVATE);saved=preferences.getAll();
|
||||
preferences.edit().remove("networkConsent").putString("host","127.0.0.1").putInt("port",1).commit();
|
||||
}
|
||||
@After public void restore(){
|
||||
SharedPreferences.Editor edit=preferences.edit();
|
||||
for(String key:new String[]{"networkConsent","host","port"}){
|
||||
edit.remove(key);Object value=saved.get(key);
|
||||
if(value instanceof Boolean)edit.putBoolean(key,(Boolean)value);
|
||||
else if(value instanceof String)edit.putString(key,(String)value);
|
||||
else if(value instanceof Integer)edit.putInt(key,(Integer)value);
|
||||
}
|
||||
edit.commit();
|
||||
}
|
||||
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 Dialog notice(MainActivity activity){
|
||||
Dialog dialog=(Dialog)field(activity,"privacyDialog");assertNotNull("An unconsented visit must prompt",dialog);assertTrue(dialog.isShowing());return dialog;
|
||||
}
|
||||
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){ViewGroup parent=(ViewGroup)view;for(int i=0;i<parent.getChildCount();i++){TextView found=findText(parent.getChildAt(i),text);if(found!=null)return found;}}
|
||||
return null;
|
||||
}
|
||||
private static TextView action(Dialog dialog,String text){
|
||||
TextView view=findText(dialog.getWindow().getDecorView(),text);assertNotNull(text,view);return view;
|
||||
}
|
||||
private static void decline(MainActivity activity){action(notice(activity),"暂不同意").performClick();}
|
||||
private static void agree(MainActivity activity){
|
||||
Dialog dialog=notice(activity);action(dialog,"我已阅读并同意《隐私政策》").performClick();action(dialog,"同意并继续").performClick();
|
||||
}
|
||||
|
||||
@Test public void coldLaunchRequiresExplicitAgreementBeforeNetworking(){
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(activity->{
|
||||
Dialog dialog=notice(activity);
|
||||
assertFalse(((CheckBox)action(dialog,"我已阅读并同意《隐私政策》")).isChecked());
|
||||
assertFalse(action(dialog,"同意并继续").isEnabled());
|
||||
action(dialog,"同意并继续").performClick();
|
||||
assertTrue(dialog.isShowing());assertFalse(preferences.getBoolean("networkConsent",false));
|
||||
assertNull(field(activity,"signaling"));
|
||||
decline(activity);assertFalse(dialog.isShowing());assertNull(field(activity,"signaling"));
|
||||
assertNotNull(findText(activity.getWindow().getDecorView(),"等待隐私授权"));
|
||||
});
|
||||
scenario.onActivity(activity->assertNull(field(activity,"privacyDialog")));
|
||||
}
|
||||
}
|
||||
@Test public void refusalPromptsOnBackgroundReturnButNotTransientInactivity(){
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(PrivacyConsentTest::decline);
|
||||
scenario.moveToState(Lifecycle.State.STARTED).moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity->{assertNull(field(activity,"privacyDialog"));assertNull(field(activity,"signaling"));});
|
||||
for(int visit=0;visit<3;visit++){
|
||||
scenario.moveToState(Lifecycle.State.CREATED).moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity->{notice(activity);assertNull(field(activity,"signaling"));decline(activity);});
|
||||
}
|
||||
}
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(activity->{notice(activity);assertNull(field(activity,"signaling"));});
|
||||
}
|
||||
}
|
||||
@Test public void backgroundDismissesOldDialogAndClearsTheCheckbox(){
|
||||
Dialog[] original={null};
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(activity->{original[0]=notice(activity);action(original[0],"我已阅读并同意《隐私政策》").performClick();assertTrue(action(original[0],"同意并继续").isEnabled());});
|
||||
scenario.moveToState(Lifecycle.State.CREATED);
|
||||
assertFalse(original[0].isShowing());
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity->{Dialog next=notice(activity);assertNotSame(original[0],next);assertFalse(action(next,"同意并继续").isEnabled());assertNull(field(activity,"signaling"));});
|
||||
}
|
||||
}
|
||||
@Test public void agreementPersistsAndStopsAutomaticPrompts(){
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(activity->{Dialog dialog=notice(activity);agree(activity);assertTrue(preferences.getBoolean("networkConsent",false));assertFalse(dialog.isShowing());assertNotNull(field(activity,"signaling"));});
|
||||
scenario.onActivity(activity->assertNull(field(activity,"privacyDialog")));
|
||||
scenario.moveToState(Lifecycle.State.CREATED).moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity->assertNull(field(activity,"privacyDialog")));
|
||||
}
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(activity->assertNull(field(activity,"privacyDialog")));
|
||||
scenario.moveToState(Lifecycle.State.CREATED);
|
||||
preferences.edit().putBoolean("networkConsent",false).commit();
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity->{notice(activity);assertNull(field(activity,"signaling"));});
|
||||
}
|
||||
}
|
||||
@Test public void existingLocalRecordsDoNotImplyConsent(){
|
||||
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
SharedPreferences identities=context.getSharedPreferences("identities",Context.MODE_PRIVATE);
|
||||
String key="privacy-upgrade.invalid:1";
|
||||
try{
|
||||
identities.edit().putString(key,"existing-device-identity").commit();
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(activity->{notice(activity);assertNull(field(activity,"signaling"));});
|
||||
}
|
||||
}finally{identities.edit().remove(key).commit();}
|
||||
}
|
||||
@Test public void bundledChinesePolicyIsOfflineAndMissingPolicyCannotAuthorize(){
|
||||
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
PrivacyPolicyDocument policy=new PrivacyPolicyDocument(context);
|
||||
assertTrue(policy.available);assertTrue(policy.text.contains("更新日期:"));assertTrue(policy.text.contains("查询与删除请求"));assertFalse(policy.text.contains("## English"));
|
||||
assertFalse(new PrivacyPolicyDocument(" \n").available);
|
||||
try(ActivityScenario<MainActivity> scenario=ActivityScenario.launch(MainActivity.class)){
|
||||
scenario.onActivity(activity->{
|
||||
decline(activity);int[] accepted={0};
|
||||
Dialog missing=new PrivacyConsentDialog(activity,new MobileUi(activity),new PrivacyPolicyDocument((String)null),()->accepted[0]++);
|
||||
try{
|
||||
missing.show();action(missing,"我已阅读并同意《隐私政策》").performClick();
|
||||
assertFalse(action(missing,"同意并继续").isEnabled());action(missing,"同意并继续").performClick();
|
||||
assertEquals(0,accepted[0]);assertFalse(preferences.getBoolean("networkConsent",false));assertNull(field(activity,"signaling"));
|
||||
action(missing,"暂不同意").performClick();assertFalse(missing.isShowing());
|
||||
}finally{missing.dismiss();}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ def verify(path):
|
||||
raise ValueError('APK native library entry is not 16 KB aligned')
|
||||
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
|
||||
raise ValueError('Missing third-party notices')
|
||||
if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes():
|
||||
raise ValueError('Missing or outdated bundled privacy policy')
|
||||
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
|
||||
source = json.loads(archive.read('assets/SourceMetadata.json'))
|
||||
components = catalog['components']
|
||||
@@ -53,7 +55,7 @@ def verify(path):
|
||||
for document in source['buildDocuments']:
|
||||
if not archive.read('assets/'+document['asset']):
|
||||
raise ValueError('Missing source build instructions')
|
||||
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, notices, About catalog and documents')
|
||||
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy, notices, About catalog and documents')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user