mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 01:46:47 +08:00
[feat] add native Android controller
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDLTCCAhWgAwIBAgIUAl+BaiMSPm1zRwBr0qnyzpufOaIwDQYJKoZIhvcNAQEL
|
||||
BQAwJjEkMCIGA1UEAwwbQ3Jvc3NEZXNrIGxvY2FsIFRMUyB0ZXN0IENBMB4XDTI2
|
||||
MTAwMTA4MjYyMVoXDTM2MDkyODA4MjYyMVowJjEkMCIGA1UEAwwbQ3Jvc3NEZXNr
|
||||
IGxvY2FsIFRMUyB0ZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
|
||||
AQEAmEAURCIgoeNOGiXcII2v/0SEN8UJ3ItdQrU4A5jOQe2fD12mjOJsQAUQrq/j
|
||||
Bpz3cglAafJlOusgHqYyhFjHn58yZ1I+Z1jPa2CKeyWFYnmFUoL2yyNFmr3YMdkh
|
||||
3caTehZ0+dit6bDYwTvhUwI9CFXLacAZ60tzvnPn+IHmszNNH89mgfr/aJcuXZb/
|
||||
kSiJ/gUiiwp/PJznO9b0kjoJK6eZNoNNcqUyiVsBb0nL2+950GCbwvAA0px3PF/R
|
||||
/qtNstkMPFB6WuMvw/DsxKFDZ8/XbfjJBXi0Rpr+DEW+BOadlTH6Q+CcKxC/YG6q
|
||||
x2oxDGGr1Glz4SYE/OowCS7SaQIDAQABo1MwUTAdBgNVHQ4EFgQU9lut5DDz8k4A
|
||||
MUDIioygb+l+z64wHwYDVR0jBBgwFoAU9lut5DDz8k4AMUDIioygb+l+z64wDwYD
|
||||
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAJauLQRg5mnORh3fjenif
|
||||
nGmHMOlvuBtOFdHGcUYyzkfPuRBp2rCd2mL2rdnJfcyZJAaWQhP9ZZtThbioczTj
|
||||
Kf8UUTv2WYLKIWcZ1jyEEmukBhYxRpUfn4FqQG1yCqmF112coSCCY+7aGjl/Tcnx
|
||||
E5SKS/FhARnXwObBaiG4sZoOQ4fbC70I94xfY8uSu6h9uVnnZS05tVbUD2gATn57
|
||||
1snZUoJYTHi15QPZM3H/UViSYq2Inkw9yOcV3FRs6f8klePGVmUUDvT+I48jGjSz
|
||||
cg2zgNErBYvLEJ8FxHX5oxJZzVZAuVxugYu1Ru5kEl1XO1GRHRf4/8JIl1nyat0a
|
||||
hQ==
|
||||
-----END CERTIFICATE-----
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,119 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.text.Spanned;
|
||||
import android.text.style.URLSpan;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.json.*;
|
||||
import org.junit.*;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class AnnouncementInboxTest {
|
||||
private AnnouncementInbox inbox;
|
||||
private File directory;
|
||||
private final ArrayDeque<JSONObject> sent=new ArrayDeque<>();
|
||||
private interface Checked { void run() throws Exception; }
|
||||
private void main(Checked action)throws Exception {
|
||||
FutureTask<Void> task=new FutureTask<>(()->{action.run();return null;});
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(task);task.get();
|
||||
}
|
||||
@Before public void setup()throws Exception {
|
||||
directory=Files.createTempDirectory(InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir().toPath(),"announcements-").toFile();
|
||||
main(()->{inbox=new AnnouncementInbox(directory);inbox.sender=(message,id)->sent.add(message);inbox.configure("Example.test.",9099,"123456789");inbox.setConnected(true);});
|
||||
}
|
||||
@After public void cleanup()throws Exception {
|
||||
main(()->inbox.close());
|
||||
try(var paths=Files.walk(directory.toPath())){for(var path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);}
|
||||
}
|
||||
static JSONObject answer(JSONObject request,int count,long revision)throws Exception {
|
||||
int offset=request.getInt("offset");boolean summary=request.getBoolean("summary_only");JSONArray items=new JSONArray();
|
||||
for(int i=offset;i<Math.min(count,offset+(summary?200:20));i++){
|
||||
JSONObject item=new JSONObject().put("id",i+1).put("revision",revision);
|
||||
if(!summary)item.put("title",i==0?"欢迎使用 CrossDesk":"公告 "+(i+1)).put("body","安卓公告与 iOS 保持一致。\n\n访问 [CrossDesk 官网](https://crossdesk.cn) 了解更多。\n支持中文、换行与网页链接。").put("updated_at",1790812800L);
|
||||
items.put(item);
|
||||
}
|
||||
return new JSONObject().put("type","announcements").put("request_id",request.getString("request_id"))
|
||||
.put("summary_only",summary).put("offset",offset).put("total",count).put("catalog_revision",revision).put("items",items);
|
||||
}
|
||||
private void drain(int count,long revision)throws Exception {
|
||||
int limit=100;while(!sent.isEmpty()){assertTrue("Request loop",limit-->0);inbox.receive(answer(sent.remove(),count,revision));}
|
||||
}
|
||||
@Test public void summariesCountAllUnreadAndBodiesPageInTwenties()throws Exception {main(()->{
|
||||
JSONObject first=sent.remove();inbox.receive(answer(first,203,1));assertEquals(200,sent.peek().getInt("offset"));assertEquals(0,inbox.items.size());
|
||||
inbox.receive(answer(sent.remove(),203,1));assertEquals(203,inbox.unread);assertFalse(sent.peek().getBoolean("summary_only"));
|
||||
drain(203,1);assertEquals(20,inbox.items.size());assertEquals(203,inbox.total);assertFalse(inbox.loading);assertTrue(inbox.loaded);
|
||||
inbox.loadMore();assertEquals(20,sent.peek().getInt("offset"));drain(203,1);assertEquals(40,inbox.items.size());
|
||||
});}
|
||||
@Test public void readAndDeletePersistPerRevisionAndScope()throws Exception {main(()->{
|
||||
drain(3,1);inbox.markRead(inbox.items.get(0));inbox.dismiss(inbox.items.get(1));assertEquals(1,inbox.unread);assertEquals(2,inbox.total);
|
||||
inbox.close();inbox=new AnnouncementInbox(directory);inbox.sender=(message,id)->sent.add(message);
|
||||
inbox.configure("example.TEST",9099,"123456789");inbox.setConnected(true);drain(3,1);
|
||||
assertEquals(2,inbox.items.size());assertTrue(inbox.items.get(0).read);assertEquals(1,inbox.unread);
|
||||
inbox.refresh();drain(3,2);assertEquals(3,inbox.total);assertEquals(3,inbox.unread);assertEquals(3,inbox.items.size());
|
||||
inbox.configure("other.test",9099,"123456789");drain(3,1);assertEquals(3,inbox.unread);
|
||||
inbox.configure("example.test",9099,"different-device");drain(3,1);assertEquals(3,inbox.unread);
|
||||
inbox.configure("example.test",9999,"123456789");drain(3,1);assertEquals(3,inbox.unread);
|
||||
inbox.configure("example.test",9099,"123456789");drain(3,1);assertEquals(1,inbox.unread);
|
||||
});}
|
||||
@Test public void deletingAPageLoadsReplacementAndStaleConfirmationDoesNotDeleteRevision()throws Exception {main(()->{
|
||||
drain(45,1);AnnouncementInbox.Item old=inbox.items.get(0);
|
||||
for(int i=0;i<20;i++){inbox.dismiss(inbox.items.get(0));drain(45,1);}
|
||||
assertEquals(25,inbox.total);assertEquals(20,inbox.items.size());assertEquals(21,inbox.items.get(0).id);
|
||||
inbox.refresh();drain(45,2);inbox.dismiss(old);assertTrue(inbox.deleteFailed);assertEquals(45,inbox.total);
|
||||
});}
|
||||
@Test public void catalogChangesRestartPagingAndIgnoreSupersededRequests()throws Exception {main(()->{
|
||||
JSONObject old=sent.remove();inbox.refresh();JSONObject current=sent.remove();inbox.receive(answer(old,203,1));assertTrue(inbox.loading);assertEquals(0,inbox.total);
|
||||
inbox.receive(answer(current,203,1));inbox.receive(answer(sent.remove(),203,2));
|
||||
assertTrue(sent.peek().getBoolean("summary_only"));assertEquals(0,sent.peek().getInt("offset"));drain(203,2);assertEquals(203,inbox.unread);
|
||||
inbox.refresh();JSONObject refresh=sent.remove();inbox.setConnected(false);inbox.receive(answer(refresh,0,3));
|
||||
assertEquals(20,inbox.items.size());assertFalse(inbox.loading);
|
||||
inbox.setConnected(true);drain(0,3);assertEquals(0,inbox.items.size());assertTrue(inbox.loaded);
|
||||
});}
|
||||
@Test public void malformedResponseFailsWithoutLosingVisibleRowsAndCanRetry()throws Exception {main(()->{
|
||||
drain(2,1);inbox.refresh();JSONObject invalid=answer(sent.remove(),2,1);invalid.put("total","2");inbox.receive(invalid);
|
||||
assertTrue(inbox.failed);assertFalse(inbox.loading);assertEquals(2,inbox.items.size());
|
||||
inbox.refresh();drain(2,2);assertFalse(inbox.failed);
|
||||
inbox.refresh();JSONObject summary=answer(sent.remove(),2,3);summary.getJSONArray("items").getJSONObject(1).put("id",1);inbox.receive(summary);
|
||||
assertTrue(inbox.failed);inbox.refresh();drain(2,3);assertFalse(inbox.failed);
|
||||
inbox.refresh();inbox.receive(answer(sent.remove(),2,4));JSONObject body=answer(sent.remove(),2,4);
|
||||
body.getJSONArray("items").getJSONObject(0).put("body","中".repeat(2667));inbox.receive(body);assertTrue(inbox.failed);assertEquals(0,inbox.items.size());
|
||||
inbox.refresh();drain(2,4);assertFalse(inbox.failed);
|
||||
});}
|
||||
@Test public void duplicateBodiesAcrossPagesCannotCreateDuplicateRows()throws Exception {main(()->{
|
||||
drain(21,1);inbox.loadMore();JSONObject invalid=answer(sent.remove(),21,1);invalid.getJSONArray("items").getJSONObject(0).put("id",1);
|
||||
inbox.receive(invalid);assertTrue(inbox.failed);assertEquals(20,inbox.items.size());
|
||||
});}
|
||||
@Test public void sendFailureAndTimeoutAreMatchedToTheirRequest()throws Exception {main(()->{
|
||||
String obsolete=sent.remove().getString("request_id");
|
||||
var timeoutField=AnnouncementInbox.class.getDeclaredField("timeout");timeoutField.setAccessible(true);Runnable oldTimeout=(Runnable)timeoutField.get(inbox);
|
||||
inbox.refresh();oldTimeout.run();inbox.failed(obsolete);assertTrue(inbox.loading);assertFalse(inbox.failed);
|
||||
((Runnable)timeoutField.get(inbox)).run();assertFalse(inbox.loading);assertTrue(inbox.failed);sent.clear();
|
||||
inbox.sender=(message,id)->inbox.failed(id);inbox.refresh();assertTrue(inbox.failed);assertFalse(inbox.loading);
|
||||
inbox.setConnected(false);inbox.refresh();assertFalse(inbox.loading);assertTrue(sent.isEmpty());
|
||||
});}
|
||||
@Test public void diskFailureKeepsUnreadAndVisibleState()throws Exception {main(()->{
|
||||
inbox.close();File blocked=new File(directory,"not-a-directory");assertTrue(blocked.createNewFile());
|
||||
inbox=new AnnouncementInbox(blocked);inbox.sender=(message,id)->sent.add(message);sent.clear();
|
||||
inbox.configure("example.test",9099,"123456789");inbox.setConnected(true);drain(1,1);
|
||||
AnnouncementInbox.Item item=inbox.items.get(0);inbox.markRead(item);assertTrue(inbox.readSaveFailed);assertFalse(item.read);assertEquals(1,inbox.unread);
|
||||
inbox.dismiss(item);assertTrue(inbox.deleteFailed);assertEquals(1,inbox.items.size());
|
||||
assertTrue(blocked.delete());inbox.markRead(item);assertFalse(inbox.readSaveFailed);assertTrue(item.read);
|
||||
});}
|
||||
@Test public void resetClearsMemoryAndRejectsOldServerResponses()throws Exception {main(()->{
|
||||
drain(1,1);inbox.refresh();JSONObject old=sent.remove();inbox.reset();inbox.receive(answer(old,1,1));
|
||||
assertEquals(0,inbox.unread);assertEquals(0,inbox.items.size());assertFalse(inbox.isConnected());assertFalse(inbox.loading);
|
||||
inbox.refresh();assertTrue(sent.isEmpty());
|
||||
});}
|
||||
@Test public void textKeepsPlainContentAndOnlyCreatesWebLinks(){
|
||||
String body="<b>普通文本</b>\r\n[官网](https://crossdesk.cn/path_(one)),https://example.com/a?b=c!\n[java](javascript:alert(1))";
|
||||
Spanned formatted=(Spanned)AnnouncementText.format(body);
|
||||
assertEquals("<b>普通文本</b>\n官网,https://example.com/a?b=c!\n[java](javascript:alert(1))",formatted.toString());
|
||||
URLSpan[] links=formatted.getSpans(0,formatted.length(),URLSpan.class);assertEquals(2,links.length);
|
||||
assertEquals("https://crossdesk.cn/path_(one)",links[0].getURL());assertEquals("https://example.com/a?b=c",links[1].getURL());
|
||||
for(String bad:new String[]{"javascript:alert(1)","file:///data/test","intent://example","https://","https://example.com\\evil","https://example.com\n","https://<bad>"})assertFalse(bad,AnnouncementText.isWebURL(bad));
|
||||
assertTrue(AnnouncementText.isWebURL("HTTPS://example.com/path"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.view.KeyEvent;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ControllerTest {
|
||||
@Test public void announcementsUseIdentitySignalingAndReportNativeSendFailure()throws Exception{
|
||||
var instrumentation=InstrumentationRegistry.getInstrumentation();var reply=new java.util.concurrent.atomic.AtomicReference<JSONObject>();var failed=new java.util.concurrent.atomic.AtomicReference<String>();
|
||||
NativeSession.Listener listener=new NativeSession.Listener(){
|
||||
public void status(String value){}public void connected(){}public void ended(String reason){}public void data(JSONObject value){}public void videoSize(int width,int height){}public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){}
|
||||
public void announcement(JSONObject value){reply.set(value);}public void announcementFailed(String id){failed.set(id);}
|
||||
};
|
||||
NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"","",listener);
|
||||
Field workerField=NativeSession.class.getDeclaredField("RTC");workerField.setAccessible(true);ExecutorService worker=(ExecutorService)workerField.get(null);
|
||||
var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true);
|
||||
byte[] changed="{\"type\":\"announcements_changed\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
try{
|
||||
event.invoke(session,8,changed);worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertNull(reply.get());
|
||||
worker.submit(()->{try{Field ready=NativeSession.class.getDeclaredField("signalReady"),identity=NativeSession.class.getDeclaredField("identity");ready.setAccessible(true);identity.setAccessible(true);ready.setBoolean(session,true);identity.set(session,"test");}catch(Exception error){throw new RuntimeException(error);}}).get(5,TimeUnit.SECONDS);
|
||||
event.invoke(session,8,changed);session.announcementRequest(new JSONObject().put("type","announcements_list").put("request_id","request-test"),"request-test");
|
||||
worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertEquals("announcements_changed",reply.get().getString("type"));assertEquals("request-test",failed.get());
|
||||
reply.set(null);session.close();event.invoke(session,8,changed);worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertNull(reply.get());
|
||||
}finally{session.close();}
|
||||
}
|
||||
@Test public void videoSettingsSendFailureReturnsMatchingRejection()throws Exception{
|
||||
var instrumentation=InstrumentationRegistry.getInstrumentation();var reply=new java.util.concurrent.atomic.AtomicReference<JSONObject>();var received=new java.util.concurrent.CountDownLatch(1);
|
||||
NativeSession.Listener listener=new NativeSession.Listener(){
|
||||
public void status(String value){}public void connected(){}public void ended(String reason){}
|
||||
public void data(JSONObject value){reply.set(value);received.countDown();}public void videoSize(int width,int height){}public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){}
|
||||
};
|
||||
NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"123456789","test",listener);
|
||||
try{
|
||||
Field connected=NativeSession.class.getDeclaredField("connected");connected.setAccessible(true);connected.setBoolean(session,true);
|
||||
// JNI has no peer, so its real send failure must carry the original request to the UI.
|
||||
session.videoSettings(new RemoteVideoSettings.Values(1,30,2),0xffffffffL);
|
||||
assertTrue(received.await(5,TimeUnit.SECONDS));assertEquals(12,reply.get().getInt("type"));var settings=reply.get().getJSONObject("video_settings");
|
||||
assertEquals(1,settings.getInt("quality"));assertEquals(30,settings.getInt("frame_rate"));assertEquals(2,settings.getInt("preference"));assertEquals(0xffffffffL,settings.getLong("request_id"));assertFalse(settings.getBoolean("accepted"));
|
||||
}finally{session.close();}
|
||||
}
|
||||
@Test public void nativeReportsAndSubmittedFramesReachTheStatisticsCallback()throws Exception{
|
||||
var instrumentation=InstrumentationRegistry.getInstrumentation();var sample=new java.util.concurrent.atomic.AtomicReference<RemoteNetworkStatistics.Snapshot>();
|
||||
NativeSession.Listener listener=new NativeSession.Listener(){
|
||||
public void status(String value){}public void connected(){}public void ended(String reason){}public void data(JSONObject value){}public void videoSize(int width,int height){}public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){sample.set(value);}
|
||||
};
|
||||
NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"123456789","test",listener);
|
||||
try{
|
||||
var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true);
|
||||
event.invoke(session,7,NetworkStatisticsTest.report(1,true,40).toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
Field worker=NativeSession.class.getDeclaredField("RTC");worker.setAccessible(true);((ExecutorService)worker.get(null)).submit(()->{}).get(5,TimeUnit.SECONDS);
|
||||
var frame=NativeSession.class.getDeclaredMethod("onNativeVideoFrame",long.class,double.class,int.class,int.class);frame.setAccessible(true);frame.invoke(session,1L,25.0,1920,1080);
|
||||
Field tick=NativeSession.class.getDeclaredField("statisticsTick");tick.setAccessible(true);instrumentation.runOnMainSync((Runnable)tick.get(session));
|
||||
assertNotNull(sample.get());assertEquals("TURN 中继",sample.get().mode());assertEquals(8000000,sample.get().report.traffic[0].inbound);assertTrue(sample.get().report.srtp);assertEquals(40,sample.get().rtt,0);assertEquals(25,sample.get().latency,0);assertEquals(1,sample.get().fps);assertEquals("1920 × 1080",sample.get().resolution());
|
||||
}finally{session.close();}
|
||||
}
|
||||
@Test public void repeatedNativeConnectedEventsNotifyAndInitializeOnlyOnce()throws Exception{
|
||||
var instrumentation=InstrumentationRegistry.getInstrumentation();
|
||||
var notifications=new java.util.concurrent.atomic.AtomicInteger();
|
||||
NativeSession.Listener listener=new NativeSession.Listener(){
|
||||
public void status(String value){}public void connected(){notifications.incrementAndGet();}public void ended(String reason){}
|
||||
public void data(JSONObject value){}public void videoSize(int width,int height){}public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){}
|
||||
};
|
||||
NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"123456789","test",listener);
|
||||
Field workerField=NativeSession.class.getDeclaredField("RTC"),audioField=NativeSession.class.getDeclaredField("audio");workerField.setAccessible(true);audioField.setAccessible(true);
|
||||
ExecutorService worker=(ExecutorService)workerField.get(null);
|
||||
var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true);
|
||||
byte[] connected="{\"status\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
try{
|
||||
// Exercise the Java native-callback path without starting a network peer.
|
||||
event.invoke(session,2,connected);Object audio=worker.submit(()->audioField.get(session)).get(5,TimeUnit.SECONDS);
|
||||
instrumentation.waitForIdleSync();assertEquals(1,notifications.get());assertNotNull(audio);
|
||||
event.invoke(session,2,connected);event.invoke(session,2,connected);
|
||||
assertSame("Duplicate readiness must not replace the audio player",audio,worker.submit(()->audioField.get(session)).get(5,TimeUnit.SECONDS));
|
||||
instrumentation.waitForIdleSync();assertEquals("One desktop connection must notify the UI only once",1,notifications.get());
|
||||
}finally{session.close();worker.submit(()->{}).get(5,TimeUnit.SECONDS);}
|
||||
}
|
||||
@Test public void keyboardSupportsDesktopVirtualKeys() {
|
||||
assertEquals(0x41, KeyMap.windowsCode(KeyEvent.KEYCODE_A));
|
||||
assertEquals(0x2E, KeyMap.windowsCode(KeyEvent.KEYCODE_FORWARD_DEL));
|
||||
assertEquals(0x11, KeyMap.windowsCode(KeyEvent.KEYCODE_CTRL_LEFT));
|
||||
assertEquals(0x5B, KeyMap.windowsCode(KeyEvent.KEYCODE_META_LEFT));
|
||||
assertEquals(0xDB | 0x100, KeyMap.ascii('{'));
|
||||
assertEquals(0x31 | 0x100, KeyMap.ascii('!'));
|
||||
assertEquals(0, KeyMap.ascii('中'));
|
||||
}
|
||||
|
||||
@Test public void credentialsRemainEncryptedAndServerScoped() throws Exception {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
SecretStore store = new SecretStore(context);
|
||||
String scope = "instrumentation.invalid:9099";
|
||||
try {
|
||||
store.put(scope, "test-id@temporary-test-password");
|
||||
assertEquals("test-id@temporary-test-password", store.get(scope));
|
||||
assertEquals("", store.get("instrumentation.invalid:9100"));
|
||||
String persisted = context.getSharedPreferences("identities", Context.MODE_PRIVATE).getString(scope, "");
|
||||
assertFalse(persisted.contains("temporary-test-password"));
|
||||
} finally {
|
||||
context.getSharedPreferences("identities", Context.MODE_PRIVATE).edit().remove(scope).commit();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void nativePeerCanStartAndCancelRepeatedly() throws Exception {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
Field workerField = NativeSession.class.getDeclaredField("RTC"); workerField.setAccessible(true);
|
||||
ExecutorService worker = (ExecutorService) workerField.get(null);
|
||||
Field handleField = NativeSession.class.getDeclaredField("handle"); handleField.setAccessible(true);
|
||||
NativeSession.Listener listener = new NativeSession.Listener() {
|
||||
public void status(String value) { }
|
||||
public void connected() { fail("Loopback port 1 must not establish a session"); }
|
||||
public void ended(String reason) { }
|
||||
public void data(JSONObject data) { }
|
||||
public void videoSize(int width, int height) { }
|
||||
public void clipboard(String value) { }
|
||||
public void statistics(RemoteNetworkStatistics.Snapshot value) { }
|
||||
};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
NativeSession session = new NativeSession(context, "127.0.0.1", 1, "invalid", "test", listener);
|
||||
session.start();
|
||||
assertTrue(worker.submit(() -> handleField.getLong(session)).get(15, TimeUnit.SECONDS) != 0);
|
||||
session.surface(null);
|
||||
session.close(); session.close();
|
||||
assertEquals(0L, (long) worker.submit(() -> handleField.getLong(session)).get(15, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void homeActivityStartsWithoutNetworkConsent() throws Exception {
|
||||
var instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
Context context = instrumentation.getTargetContext();
|
||||
var preferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE);
|
||||
boolean consent = preferences.getBoolean("networkConsent", false);
|
||||
preferences.edit().putBoolean("networkConsent", false).commit();
|
||||
MainActivity activity = null;
|
||||
try {
|
||||
Intent launch = new Intent(context, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
activity = (MainActivity) instrumentation.startActivitySync(launch);
|
||||
assertNotNull(activity.findViewById(android.R.id.content));
|
||||
Field connection = MainActivity.class.getDeclaredField("signaling"); connection.setAccessible(true);
|
||||
assertNull("The home page must not start a peer without consent", connection.get(activity));
|
||||
} finally {
|
||||
if (activity != null) instrumentation.runOnMainSync(activity::finish);
|
||||
preferences.edit().putBoolean("networkConsent", consent).commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.RectF;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Switch;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.*;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/** Layout and navigation regressions, exercised without a signaling server. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class LayoutTest {
|
||||
private final android.app.Instrumentation instrumentation=InstrumentationRegistry.getInstrumentation();
|
||||
private MainActivity activity;
|
||||
private SharedPreferences settings;
|
||||
private Map<String,?> saved;
|
||||
private java.io.File announcementDirectory;
|
||||
private boolean clearTestPreviews;
|
||||
private static final String SCOPE="layout-test.invalid:1", ID="123456789";
|
||||
@Before public void setup(){
|
||||
Context context=instrumentation.getTargetContext();settings=context.getSharedPreferences("settings",Context.MODE_PRIVATE);saved=settings.getAll();
|
||||
settings.edit().clear().putBoolean("networkConsent",true).putString("host","layout-test.invalid").putInt("port",1).commit();
|
||||
activity=(MainActivity)instrumentation.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));instrumentation.waitForIdleSync();
|
||||
}
|
||||
@After public void cleanup()throws Exception{
|
||||
if(activity!=null)main(activity::finish);new RecentConnections(instrumentation.getTargetContext()).delete(SCOPE,ID);
|
||||
if(clearTestPreviews){RemotePreviewStore store=RemotePreviewStore.get(instrumentation.getTargetContext());store.setEnabled(false);store.io.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();}
|
||||
if(announcementDirectory!=null)try(var paths=java.nio.file.Files.walk(announcementDirectory.toPath())){for(var path:paths.sorted(java.util.Comparator.reverseOrder()).toList())java.nio.file.Files.delete(path);}
|
||||
SharedPreferences.Editor editor=settings.edit().clear();for(Map.Entry<String,?> entry:saved.entrySet()){Object value=entry.getValue();if(value instanceof String)editor.putString(entry.getKey(),(String)value);else if(value instanceof Boolean)editor.putBoolean(entry.getKey(),(Boolean)value);else if(value instanceof Integer)editor.putInt(entry.getKey(),(Integer)value);}editor.commit();
|
||||
}
|
||||
private void main(Runnable action)throws Exception{FutureTask<Void> task=new FutureTask<>(()->{action.run();return null;});instrumentation.runOnMainSync(task);task.get();instrumentation.waitForIdleSync();}
|
||||
private View content(){try{return (View)field("canvas");}catch(Exception error){throw new RuntimeException(error);}}
|
||||
private View find(View view,String label){
|
||||
if(label.contentEquals(view.getContentDescription()==null?"":view.getContentDescription())||(view instanceof TextView&&label.contentEquals(((TextView)view).getText())))return view;
|
||||
if(view instanceof ViewGroup){ViewGroup group=(ViewGroup)view;for(int i=0;i<group.getChildCount();i++){View match=find(group.getChildAt(i),label);if(match!=null)return match;}}return null;
|
||||
}
|
||||
private void click(String label)throws Exception{main(()->{View v=find(content(),label);assertNotNull(label,v);assertTrue(label,v.performClick());});}
|
||||
private Object field(String name)throws Exception{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);}
|
||||
private void set(String name,Object value)throws Exception{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);f.set(activity,value);}
|
||||
private void localSignaling()throws Exception{main(()->{try{NativeSession old=(NativeSession)field("signaling");if(old!=null)old.close();set("signaling",new NativeSession(activity,"layout-test.invalid",1,"","",activity));set("signalingServer",SCOPE);}catch(Exception error){throw new RuntimeException(error);}});}
|
||||
private void refreshHome()throws Exception{localSignaling();main(()->{try{var method=MainActivity.class.getDeclaredMethod("home",String.class);method.setAccessible(true);method.invoke(activity,"");}catch(ReflectiveOperationException error){throw new RuntimeException(error);}});}
|
||||
private ExecutorService rtc()throws Exception{Field f=NativeSession.class.getDeclaredField("RTC");f.setAccessible(true);return (ExecutorService)f.get(null);}
|
||||
private void snapshot(String name)throws Exception{
|
||||
instrumentation.waitForIdleSync();Thread.sleep(650); // Allow system window/rotation animations to settle before visual review.
|
||||
PageHost host=(PageHost)field("pageHost");java.util.concurrent.atomic.AtomicBoolean settled=new java.util.concurrent.atomic.AtomicBoolean();
|
||||
for(int i=0;i<100;i++){main(()->settled.set(host.getChildCount()==1));if(settled.get())break;Thread.sleep(30);}assertTrue("Page transition must finish",settled.get());Thread.sleep(50);
|
||||
Bitmap bitmap=instrumentation.getUiAutomation().takeScreenshot();assertNotNull(bitmap);
|
||||
try(java.io.FileOutputStream output=new java.io.FileOutputStream(new java.io.File(instrumentation.getTargetContext().getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,output);}finally{bitmap.recycle();}
|
||||
}
|
||||
@Test public void passwordIsSeparateAndSettingsReturnHome()throws Exception{
|
||||
assertNotNull(find(content(),"远程桌面"));assertNotNull(find(content(),"最近连接"));assertNull(find(content(),"访问密码"));snapshot("layout-home");
|
||||
main(()->((EditText)find(content(),"对端 ID")).setText(ID));assertEquals("123 456 789",((EditText)field("remote")).getText().toString());click("连接");
|
||||
Dialog sheet=(Dialog)field("passwordDialog");assertTrue(sheet.isShowing());View sheetRoot=sheet.getWindow().getDecorView();assertNotNull(find(sheetRoot,"访问密码"));assertNotNull(find(sheetRoot,"保存密码"));snapshot("layout-password");main(sheet::dismiss);
|
||||
assertEquals(0,new RecentConnections(activity).list(SCOPE).length());click("设置");assertNotNull(find(content(),"鼠标控制"));assertNotNull(find(content(),"隐私与授权,已授权"));snapshot("layout-settings");
|
||||
click("绝对位置");assertFalse(settings.getBoolean("relativeMouse",true));click("返回");assertNotNull(find(content(),"远程桌面"));
|
||||
}
|
||||
@Test public void aboutMatchesIosNavigationAndReadsCompleteOfflineDocuments()throws Exception{
|
||||
click("设置");click("关于");assertNotNull(find(content(),"关于 CrossDesk"));assertNull(find(content(),"使用说明"));
|
||||
assertNotNull(find(content(),activity.getPackageManager().getPackageInfo(activity.getPackageName(),0).versionName));snapshot("about-home");
|
||||
LicenseCatalog catalog=new LicenseCatalog(activity);assertEquals(29,catalog.components.size());
|
||||
click("软件许可");assertNotNull(find(content(),"CrossDesk"));assertNotNull(find(content(),"GPL-3.0-only"));snapshot("about-application");
|
||||
click("GNU GPL 第 3 版");assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("crossdesk").documents.get(0).asset));snapshot("about-gpl");
|
||||
click("返回");click("开源软件权利说明");assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("crossdesk").documents.get(1).asset));click("返回");click("返回");
|
||||
click("开源组件");assertNotNull(find(content(),"OpenFEC 告知"));assertNotNull(find(content(),"(c) Copyright 2009 - 2012 INRIA - All rights reserved"));snapshot("about-components");
|
||||
click("OpenFEC 许可与源码");assertNotNull(find(content(),"CeCILL-C 1.0 / BSD / CC-BY-SA-3.0"));click("LICENCE_CeCILL-C_V1-en.txt");assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("openfec").documents.get(0).asset));click("返回");
|
||||
click("构建配方与项目补丁");click("MiniRTC 构建配方");assertLicenseDocument(LicenseCatalog.read(activity,"about/build/3.txt"));click("返回");click("返回");click("返回");
|
||||
click("GLib / GObject / GIO / GModule / GThread / GVDB,LGPL-2.1-or-later and embedded notices");click("版权与许可声明");
|
||||
assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("glib").documents.get(8).asset));click("返回");click("返回");click("返回");
|
||||
click("源码与构建说明");assertNotNull(find(content(),"本版本源码与构建说明"));snapshot("about-source");
|
||||
java.util.concurrent.atomic.AtomicReference<Intent> opened=new java.util.concurrent.atomic.AtomicReference<>();
|
||||
android.app.Instrumentation.ActivityMonitor monitor=new android.app.Instrumentation.ActivityMonitor(){
|
||||
@Override public android.app.Instrumentation.ActivityResult onStartActivity(Intent intent){if(Intent.ACTION_VIEW.equals(intent.getAction())){opened.set(intent);return new android.app.Instrumentation.ActivityResult(android.app.Activity.RESULT_OK,null);}return null;}
|
||||
};
|
||||
instrumentation.addMonitor(monitor);try{click("查看源码");assertNotNull(opened.get());assertEquals(catalog.source.getString("sourceURL"),opened.get().getDataString());}finally{instrumentation.removeMonitor(monitor);}
|
||||
click("构建说明");assertLicenseDocument(LicenseCatalog.read(activity,"about/build/0.txt"));snapshot("about-build");click("返回");click("返回");click("返回");assertNotNull(find(content(),"鼠标控制"));
|
||||
}
|
||||
private void assertLicenseDocument(String expected)throws Exception{
|
||||
main(()->{
|
||||
android.widget.ListView list=(android.widget.ListView)((android.widget.LinearLayout)((ViewGroup)content()).getChildAt(0)).getChildAt(1);
|
||||
StringBuilder actual=new StringBuilder();for(int i=0;i<list.getAdapter().getCount();i++){String chunk=(String)list.getAdapter().getItem(i);assertTrue(chunk.length()<=4000);actual.append(chunk);}
|
||||
assertEquals("The complete license must be available offline",expected,actual.toString());list.setSelection(list.getAdapter().getCount()-1);
|
||||
});
|
||||
main(()->{
|
||||
android.widget.ListView list=(android.widget.ListView)((android.widget.LinearLayout)((ViewGroup)content()).getChildAt(0)).getChildAt(1);
|
||||
assertEquals(list.getAdapter().getCount()-1,list.getLastVisiblePosition());assertTrue(((TextView)list.getChildAt(0)).isTextSelectable());list.setSelection(0);
|
||||
});
|
||||
}
|
||||
@Test public void floatingControlsStayWithinPortraitAndLandscape()throws Exception{
|
||||
SessionControls overlay=connectedOverlay();click("展开远程控制菜单");assertNotNull(find(content(),"断开连接"));assertFalse(find(content(),"发送文件,暂不支持").isEnabled());snapshot("layout-session-menu");
|
||||
click("键盘");assertNotNull(find(content(),"收起键盘"));snapshot("layout-session-keyboard");
|
||||
main(()->activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE));
|
||||
for(int i=0;i<30&&overlay.getWidth()<overlay.getHeight();i++){Thread.sleep(100);instrumentation.waitForIdleSync();}
|
||||
assertTrue(overlay.getWidth()>overlay.getHeight());main(overlay::constrainPanels);snapshot("layout-session-landscape");
|
||||
main(()->{for(int i=0;i<overlay.getChildCount();i++){View child=overlay.getChildAt(i);if(child.getVisibility()!=View.VISIBLE)continue;assertTrue(child.getX()>=0);assertTrue(child.getY()>=0);assertTrue(child.getX()+child.getWidth()<=overlay.getWidth()+1);assertTrue(child.getY()+child.getHeight()<=overlay.getHeight()+1);}});
|
||||
assertMenuFirstFrame(overlay,()->find(overlay,"展开远程控制菜单").performClick());click("断开连接");assertTrue((Boolean)field("ready")); // Confirmation must not disconnect immediately.
|
||||
}
|
||||
private SessionControls connectedOverlay()throws Exception{
|
||||
// No peer is started. Only inject a local session object to exercise the connected UI.
|
||||
main(()->{try{var stop=MainActivity.class.getDeclaredMethod("stopSignaling");stop.setAccessible(true);stop.invoke(activity);}catch(ReflectiveOperationException e){throw new RuntimeException(e);}});
|
||||
NativeSession session=new NativeSession(activity,"127.0.0.1",1,ID,"test",activity);set("session",session);set("connectedRemote",ID);main(activity::connected);
|
||||
return (SessionControls)field("controls");
|
||||
}
|
||||
@Test public void remoteUpgradeNoticeMatchesIosAndKeepsTheSessionConnected()throws Exception{
|
||||
connectedOverlay();
|
||||
main(()->activity.data(new org.json.JSONObject()));
|
||||
assertNull(field("remoteUpdateDialog"));
|
||||
main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject()));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
android.app.AlertDialog dialog=(android.app.AlertDialog)field("remoteUpdateDialog");assertNotNull(dialog);assertTrue(dialog.isShowing());
|
||||
assertNotNull(find(dialog.getWindow().getDecorView(),"升级提示"));assertNotNull(find(dialog.getWindow().getDecorView(),RemoteVersionCheck.NOTICE));
|
||||
assertEquals("知道了",dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).getText().toString());snapshot("remote-upgrade-notice");
|
||||
main(()->dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());assertNull(field("remoteUpdateDialog"));assertTrue((Boolean)field("ready"));
|
||||
main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject()));activity.connected();}catch(Exception error){throw new RuntimeException(error);}});
|
||||
assertNull(field("remoteUpdateDialog"));assertTrue((Boolean)field("ready"));
|
||||
}
|
||||
@Test public void remoteVersionRequestIsCancelledWhenSessionEnds()throws Exception{
|
||||
var reply=new java.util.concurrent.atomic.AtomicReference<java.util.function.Consumer<byte[]>>();var cancelled=new java.util.concurrent.atomic.AtomicBoolean();
|
||||
main(()->{try{set("remoteVersionCheck",new RemoteVersionCheck(callback->{reply.set(callback);return ()->cancelled.set(true);},()->{throw new AssertionError("Stale result must not open a dialog");}));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
connectedOverlay();
|
||||
main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("app_version","1.0.0")));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
assertNotNull(reply.get());main(()->activity.ended(""));assertTrue(cancelled.get());
|
||||
main(()->reply.get().accept("{\"version\":\"2.0.0\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
assertNull(field("remoteUpdateDialog"));assertFalse((Boolean)field("ready"));
|
||||
}
|
||||
@Test public void remoteVersionCheckRespectsNetworkConsent()throws Exception{
|
||||
main(()->{try{set("remoteVersionCheck",new RemoteVersionCheck(callback->{throw new AssertionError("No request without consent");},()->{throw new AssertionError("No notice without consent");}));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
settings.edit().putBoolean("networkConsent",false).commit();connectedOverlay();
|
||||
main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("app_version","1.0.0")));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
assertNull(field("remoteUpdateDialog"));
|
||||
}
|
||||
@Test public void floatingMenuIsAnchoredOnItsFirstFrame()throws Exception{
|
||||
SessionControls overlay=connectedOverlay();
|
||||
assertMenuFirstFrame(overlay,()->find(overlay,"展开远程控制菜单").performClick());
|
||||
assertMenuFirstFrame(overlay,()->find(overlay,"网络状态").performClick());
|
||||
assertMenuFirstFrame(overlay,()->find(overlay,"返回控制栏").performClick());
|
||||
assertMenuFirstFrame(overlay,()->overlay.setMuted(true));
|
||||
click("关闭控制栏");
|
||||
// Reopen beside a dragged orb at the opposite, bottom edge.
|
||||
main(()->{View orb=find(overlay,"展开远程控制菜单");orb.setX(24);orb.setY(overlay.getHeight()-orb.getHeight()-24);});
|
||||
assertMenuFirstFrame(overlay,()->find(overlay,"展开远程控制菜单").performClick());
|
||||
}
|
||||
@Test public void videoSettingsFollowCapabilitiesAndRemoteAcknowledgements()throws Exception{
|
||||
SessionControls overlay=connectedOverlay();click("展开远程控制菜单");click("画面设置");
|
||||
assertNotNull(find(content(),"连接建立后可调整,需被控端支持。"));assertFalse(find(content(),"画面质量,高").isEnabled());
|
||||
main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("supports_video_settings",true)));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
main(((Dialog)field("remoteUpdateDialog"))::dismiss); // This fixture omits the legacy host's version.
|
||||
RemoteVideoSettings state=(RemoteVideoSettings)field("videoSettings");assertTrue(state.pending);assertTrue(find(content(),"画面质量,中").isSelected());assertTrue(find(content(),"画面采集帧率,30 fps").isSelected());assertTrue(find(content(),"画面偏好,画质优先").isSelected());
|
||||
main(()->activity.data(state.selection.status(state.requestId,true)));click("画面质量,低");long first=state.requestId;RemoteVideoSettings.Values accepted=state.selection;
|
||||
click("画面采集帧率,60 fps");assertTrue(state.pending);assertNull(find(content(),"正在应用…"));
|
||||
main(()->activity.data(accepted.status(first,true)));assertTrue(find(content(),"画面采集帧率,60 fps").isSelected());
|
||||
main(()->activity.data(state.selection.status(state.requestId,false)));assertTrue(find(content(),"画面采集帧率,30 fps").isSelected());assertTrue(find(content(),"画面质量,低").isSelected());assertNotNull(find(content(),"调整失败,请重试。"));
|
||||
click("画面质量,高");click("画面偏好,平衡");main(()->activity.data(state.selection.status(state.requestId,true)));snapshot("layout-video-settings");
|
||||
View panel=menuPanel(overlay);assertMenuFirstFrame(overlay,()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("supports_video_settings",false)));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
assertSame(panel,menuPanel(overlay));assertFalse(find(content(),"画面质量,高").isEnabled());assertFalse(state.pending);
|
||||
}
|
||||
@Test public void videoSettingsTimeoutRollsBackWithoutMovingMenu()throws Exception{
|
||||
SessionControls overlay=connectedOverlay();click("展开远程控制菜单");click("画面设置");
|
||||
main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("supports_video_settings",true)));}catch(Exception error){throw new RuntimeException(error);}});
|
||||
main(((Dialog)field("remoteUpdateDialog"))::dismiss);
|
||||
RemoteVideoSettings state=(RemoteVideoSettings)field("videoSettings");main(()->activity.data(state.selection.status(state.requestId,true)));
|
||||
click("画面质量,低");RectF before=bounds(menuPanel(overlay));Thread.sleep(5300);instrumentation.waitForIdleSync();
|
||||
assertNotNull(find(content(),"调整失败,请重试。"));assertTrue(find(content(),"画面质量,中").isSelected());assertEquals(before,bounds(menuPanel(overlay)));
|
||||
}
|
||||
@Test public void networkStatisticsShowIosRowsAndRefreshWithoutRebuilding()throws Exception{
|
||||
SessionControls overlay=connectedOverlay();RemoteNetworkStatistics stats=new RemoteNetworkStatistics();
|
||||
stats.receive(new RemoteNetworkStatistics.Report(NetworkStatisticsTest.report(1,true,40)),10);
|
||||
for(int i=0;i<60;i++)stats.frame(i+1,25,1920,1080,10+i/60.0);
|
||||
main(()->activity.statistics(stats.snapshot(10.99)));click("展开远程控制菜单");click("网络状态");
|
||||
// Keep the indicator visible in both screenshots so text overlap can be reviewed.
|
||||
View ancestor=find(content(),"媒体加密");while(!(ancestor instanceof android.widget.ScrollView))ancestor=(View)ancestor.getParent();
|
||||
android.widget.ScrollView scroll=(android.widget.ScrollView)ancestor;main(()->scroll.setScrollbarFadingEnabled(false));
|
||||
assertNotNull(find(content(),"视频,接收 8.0 Mbps,发送 0 bps,丢包率 2.0%"));assertNotNull(find(content(),"音频,接收 64 Kbps,发送 0 bps,丢包率 1.0%"));assertNotNull(find(content(),"数据,接收 2 Kbps,发送 3 Kbps,丢包率 3.0%"));assertNotNull(find(content(),"合计,接收 8.1 Mbps,发送 3 Kbps,丢包率 6.0%"));assertNotNull(find(content(),"60 FPS"));snapshot("layout-network-statistics");
|
||||
main(()->scroll.fullScroll(View.FOCUS_DOWN));
|
||||
assertNotNull(find(content(),"1920 × 1080"));assertNotNull(find(content(),"25 ms"));assertNotNull(find(content(),"40 ms"));assertNotNull(find(content(),"TURN 中继"));assertNotNull(find(content(),"SRTP 已启用"));snapshot("layout-network-details");
|
||||
View panel=menuPanel(overlay);RectF position=bounds(panel);stats.receive(new RemoteNetworkStatistics.Report(NetworkStatisticsTest.report(0,false,-1)),12);
|
||||
main(()->activity.statistics(stats.snapshot(12)));assertSame(panel,menuPanel(overlay));assertEquals(position,bounds(panel));assertNotNull(find(content(),"0 FPS"));assertNotNull(find(content(),"P2P 直连"));assertNotNull(find(content(),"未启用"));
|
||||
main(()->activity.statistics(stats.snapshot(15)));assertNotNull(find(content(),"合计,接收 —,发送 —,丢包率 —"));assertNull(find(content(),"P2P 直连"));
|
||||
}
|
||||
@Test public void homeVideoPreferenceSeedsNextConnection()throws Exception{
|
||||
click("设置");click("帧率优先");assertEquals(0,settings.getInt("videoPreference",1));assertNotNull(find(content(),RemoteVideoSettings.DETAILS[0]));click("返回");refreshHome();
|
||||
main(()->((EditText)find(content(),"对端 ID")).setText(ID));click("连接");Dialog sheet=(Dialog)field("passwordDialog");View sheetRoot=sheet.getWindow().getDecorView();
|
||||
main(()->{((EditText)find(sheetRoot,"访问密码")).setText("preference-test");find(sheetRoot,"确认连接").performClick();});main(activity::connected);
|
||||
RemoteVideoSettings state=(RemoteVideoSettings)field("videoSettings");assertEquals(0,state.selection.preference);
|
||||
}
|
||||
private View menuPanel(SessionControls overlay){
|
||||
View panel=find(overlay,"关闭控制栏");assertNotNull(panel);
|
||||
while(panel.getParent()!=overlay)panel=(View)panel.getParent();return panel;
|
||||
}
|
||||
private RectF bounds(View view){return new RectF(view.getX(),view.getY(),view.getX()+view.getWidth(),view.getY()+view.getHeight());}
|
||||
private void assertMenuFirstFrame(SessionControls overlay,Runnable open)throws Exception{
|
||||
RectF first=new RectF();
|
||||
main(()->{
|
||||
open.run();
|
||||
// A frame may be laid out and drawn before the message queue runs any posted positioning.
|
||||
overlay.measure(View.MeasureSpec.makeMeasureSpec(overlay.getWidth(),View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(overlay.getHeight(),View.MeasureSpec.EXACTLY));
|
||||
overlay.layout(overlay.getLeft(),overlay.getTop(),overlay.getRight(),overlay.getBottom());
|
||||
View panel=menuPanel(overlay),orb=find(overlay,"收起远程控制菜单");first.set(bounds(panel));
|
||||
float density=activity.getResources().getDisplayMetrics().density,margin=Math.round(8*density),gap=Math.round(12*density);
|
||||
float x=orb.getX()+orb.getWidth()/2f>overlay.getWidth()/2f?orb.getX()-gap-panel.getWidth():orb.getX()+orb.getWidth()+gap;
|
||||
assertEquals("First frame must already be anchored horizontally",Math.max(margin,Math.min(x,overlay.getWidth()-panel.getWidth()-margin)),first.left,.5f);
|
||||
assertEquals("First frame must already be anchored vertically",Math.max(margin,Math.min(orb.getY(),overlay.getHeight()-panel.getHeight()-margin)),first.top,.5f);
|
||||
assertTrue(first.right<=overlay.getWidth()-margin+.5f);assertTrue(first.bottom<=overlay.getHeight()-margin+.5f);
|
||||
});
|
||||
main(()->assertEquals("Posted work must not move the menu after its first frame",first,bounds(menuPanel(overlay))));
|
||||
}
|
||||
@Test public void savedPasswordsNeverEnterHistoryAndDeleteWithRecord()throws Exception{
|
||||
RecentConnections history=new RecentConnections(activity);
|
||||
try{
|
||||
history.save(SCOPE,ID,"测试电脑","windows",true,"temporary-layout-test");
|
||||
assertEquals("temporary-layout-test",history.password(SCOPE,ID));assertEquals("",history.password("different.invalid:1",ID));
|
||||
assertFalse(history.list(SCOPE).toString().contains("temporary-layout-test"));history.delete(SCOPE,ID);assertEquals("",history.password(SCOPE,ID));assertEquals(0,history.list(SCOPE).length());
|
||||
}finally{history.delete(SCOPE,ID);}
|
||||
}
|
||||
@Test public void savedPasswordConnectsFromRecentAndAfterRestart()throws Exception{
|
||||
RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test");refreshHome();
|
||||
click("连接 密码测试电脑");assertSavedAttempt("saved-flow-test");
|
||||
main(activity::finish);
|
||||
activity=(MainActivity)instrumentation.startActivitySync(new Intent(instrumentation.getTargetContext(),MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
|
||||
refreshHome();
|
||||
main(()->((EditText)find(content(),"对端 ID")).setText(ID));click("连接");assertSavedAttempt("saved-flow-test");
|
||||
Dialog progress=(Dialog)field("progressDialog");main(()->find(progress.getWindow().getDecorView(),"取消连接").performClick());
|
||||
assertEquals("Cancellation must retain saved credentials","saved-flow-test",history.password(SCOPE,ID));
|
||||
}
|
||||
@Test public void savedPasswordSurvivesRepeatedConnectedCallbacks()throws Exception{
|
||||
refreshHome();main(()->((EditText)find(content(),"对端 ID")).setText(ID));click("连接");
|
||||
Dialog sheet=(Dialog)field("passwordDialog");View sheetRoot=sheet.getWindow().getDecorView();
|
||||
main(()->{((EditText)find(sheetRoot,"访问密码")).setText("duplicate-callback-test");rememberSwitch(sheetRoot).setChecked(true);find(sheetRoot,"确认连接").performClick();});
|
||||
RecentConnections history=new RecentConnections(activity);
|
||||
main(activity::connected);assertEquals("duplicate-callback-test",history.password(SCOPE,ID));
|
||||
Object controls=field("controls");
|
||||
main(activity::connected);
|
||||
assertEquals("Repeated success must not overwrite the saved password with the cleared pending value","duplicate-callback-test",history.password(SCOPE,ID));
|
||||
assertSame("Repeated success must not rebuild the active session UI",controls,field("controls"));
|
||||
main(()->activity.ended(""));refreshHome();click("连接 "+ID);assertSavedAttempt("duplicate-callback-test");
|
||||
}
|
||||
@Test public void emptyPasswordCannotOverwriteSavedCredential()throws Exception{
|
||||
RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test");
|
||||
assertThrows(IllegalArgumentException.class,()->history.save(SCOPE,ID,"密码测试电脑","windows",true,""));
|
||||
assertEquals("saved-flow-test",history.password(SCOPE,ID));
|
||||
}
|
||||
@Test public void legacyEmptyPasswordKeepsRememberChoiceWhenRequestingRepair()throws Exception{
|
||||
new RecentConnections(activity).save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test");
|
||||
new SecretStore(activity).put("remote-password:"+SCOPE+":"+ID,""); // Reproduce the old duplicate-callback overwrite.
|
||||
refreshHome();click("连接 密码测试电脑");
|
||||
Dialog sheet=(Dialog)field("passwordDialog");assertNotNull(sheet);View sheetRoot=sheet.getWindow().getDecorView();
|
||||
assertEquals("",((EditText)find(sheetRoot,"访问密码")).getText().toString());
|
||||
assertNotNull("Explain why the previously remembered credential needs repair",((EditText)find(sheetRoot,"访问密码")).getError());
|
||||
assertTrue("Preserve the user's existing choice to remember this device",rememberSwitch(sheetRoot).isChecked());assertNull(field("session"));
|
||||
}
|
||||
private void assertSavedAttempt(String secret)throws Exception{
|
||||
assertNull("A saved password must bypass the password sheet",field("passwordDialog"));
|
||||
assertTrue(((Dialog)field("progressDialog")).isShowing());assertEquals(true,field("rememberPassword"));
|
||||
NativeSession session=(NativeSession)field("session");assertNotNull(session);
|
||||
rtc().submit(()->{try{Field remote=NativeSession.class.getDeclaredField("remote"),password=NativeSession.class.getDeclaredField("password");remote.setAccessible(true);password.setAccessible(true);assertEquals(ID,remote.get(session));assertEquals("The connection must receive the decrypted credential",secret,password.get(session));}catch(ReflectiveOperationException error){throw new RuntimeException(error);}}).get(5,TimeUnit.SECONDS);
|
||||
}
|
||||
@Test public void unreadableSavedPasswordFallsBackToInput()throws Exception{
|
||||
new RecentConnections(activity).save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test");
|
||||
// Damage this test credential only; never alter the device's Keystore key.
|
||||
activity.getSharedPreferences("identities",Context.MODE_PRIVATE).edit().putString("remote-password:"+SCOPE+":"+ID,"invalid-test-record").commit();
|
||||
refreshHome();click("连接 密码测试电脑");
|
||||
Dialog sheet=(Dialog)field("passwordDialog");assertNotNull(sheet);assertTrue(sheet.isShowing());
|
||||
assertEquals("",((EditText)find(sheet.getWindow().getDecorView(),"访问密码")).getText().toString());assertNull(field("session"));
|
||||
}
|
||||
@Test public void rejectedSavedPasswordCanBeReplacedAfterSuccessfulRetry()throws Exception{
|
||||
RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test");refreshHome();
|
||||
click("连接 密码测试电脑");assertSavedAttempt("saved-flow-test");
|
||||
NativeSession session=(NativeSession)field("session");var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true);
|
||||
event.invoke(session,2,"{\"status\":6}".getBytes(java.nio.charset.StandardCharsets.UTF_8));rtc().submit(()->{}).get(5,TimeUnit.SECONDS);main(()->{});
|
||||
Dialog sheet=(Dialog)field("passwordDialog");assertNotNull("A rejected saved password must offer correction",sheet);
|
||||
View sheetRoot=sheet.getWindow().getDecorView();EditText input=(EditText)find(sheetRoot,"访问密码");
|
||||
assertEquals("",input.getText().toString());assertNotNull(input.getError());assertTrue(rememberSwitch(sheetRoot).isChecked());assertNull(field("session"));
|
||||
localSignaling();
|
||||
main(()->{input.setText("replacement-flow-test");find(sheetRoot,"确认连接").performClick();});
|
||||
assertEquals("Failed/unconfirmed attempts must not replace stored credentials","saved-flow-test",history.password(SCOPE,ID));
|
||||
main(activity::connected);assertEquals("replacement-flow-test",history.password(SCOPE,ID));
|
||||
}
|
||||
@Test public void announcementsSupportBadgeDetailRefreshSwipeDeleteAndEmptyState()throws Exception{
|
||||
refreshHome();java.util.ArrayDeque<org.json.JSONObject> requests=new java.util.ArrayDeque<>();
|
||||
announcementDirectory=java.nio.file.Files.createTempDirectory(activity.getCacheDir().toPath(),"announcement-layout-").toFile();
|
||||
main(()->{try{
|
||||
((AnnouncementInbox)field("announcementInbox")).close();AnnouncementInbox inbox=new AnnouncementInbox(announcementDirectory);
|
||||
inbox.sender=(message,id)->requests.add(message);inbox.changed=()->{try{var render=MainActivity.class.getDeclaredMethod("renderAnnouncements");render.setAccessible(true);render.invoke(activity);}catch(Exception e){throw new RuntimeException(e);}};
|
||||
set("announcementInbox",inbox);inbox.configure("layout-test.invalid",1,ID);inbox.setConnected(true);drainAnnouncements(inbox,requests,3);
|
||||
}catch(Exception error){throw new RuntimeException(error);}});
|
||||
AnnouncementInbox inbox=(AnnouncementInbox)field("announcementInbox");assertEquals("3",((TextView)field("announcementBadge")).getText().toString());snapshot("layout-announcement-badge");
|
||||
click("通知公告");main(()->drainAnnouncements(inbox,requests,3));assertNotNull(find(content(),"共 3 条 · 3 条未读"));snapshot("layout-announcements");
|
||||
main(()->{View title=find(content(),"欢迎使用 CrossDesk");while(!title.isClickable())title=(View)title.getParent();title.performClick();});
|
||||
assertNotNull(find(content(),"公告详情"));assertEquals(2,inbox.unread);snapshot("layout-announcement-detail");
|
||||
click("返回");main(()->drainAnnouncements(inbox,requests,3));assertNotNull(find(content(),"共 3 条 · 2 条未读"));
|
||||
main(()->{try{AnnouncementView scroll=(AnnouncementView)field("announcementView");gesture(scroll,scroll.getWidth()/2f,30,scroll.getWidth()/2f,scroll.getHeight()/2f);}catch(Exception e){throw new RuntimeException(e);}});
|
||||
assertFalse("Pull to refresh must request the catalog",requests.isEmpty());main(()->drainAnnouncements(inbox,requests,3));
|
||||
main(()->{View title=find(content(),"欢迎使用 CrossDesk");while(!title.isClickable())title=(View)title.getParent();View swipe=(View)title.getParent();gesture(swipe,swipe.getWidth()-30,swipe.getHeight()/2f,30,swipe.getHeight()/2f);});snapshot("layout-announcement-swipe");
|
||||
main(()->{View delete=find(content(),"删除");assertNotNull(delete);delete.performClick();});
|
||||
var dialogField=AnnouncementView.class.getDeclaredField("deleteDialog");dialogField.setAccessible(true);android.app.AlertDialog dialog=(android.app.AlertDialog)dialogField.get(field("announcementView"));assertTrue(dialog.isShowing());
|
||||
main(()->dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());assertEquals(2,inbox.total);assertEquals(2,inbox.unread);assertNull(find(content(),"欢迎使用 CrossDesk"));
|
||||
click("刷新公告");main(()->drainAnnouncements(inbox,requests,0));assertNotNull(find(content(),"暂无公告"));snapshot("layout-announcement-empty");
|
||||
click("刷新公告");main(()->inbox.failed(requests.remove().optString("request_id")));assertNotNull(find(content(),"公告加载失败,请重试。"));click("重试");main(()->drainAnnouncements(inbox,requests,0));assertFalse(inbox.failed);
|
||||
click("返回");assertEquals(View.GONE,((View)field("announcementBadge")).getVisibility());
|
||||
}
|
||||
private void drainAnnouncements(AnnouncementInbox inbox,java.util.ArrayDeque<org.json.JSONObject> requests,int count){
|
||||
try{int limit=10;while(!requests.isEmpty()){assertTrue(limit-->0);inbox.receive(AnnouncementInboxTest.answer(requests.remove(),count,count==0?2:1));}}catch(Exception error){throw new RuntimeException(error);}
|
||||
}
|
||||
private void gesture(View view,float fromX,float fromY,float toX,float toY){
|
||||
long now=android.os.SystemClock.uptimeMillis();
|
||||
for(int i=0;i<4;i++){int action=i==0?android.view.MotionEvent.ACTION_DOWN:i==3?android.view.MotionEvent.ACTION_UP:android.view.MotionEvent.ACTION_MOVE;
|
||||
android.view.MotionEvent event=android.view.MotionEvent.obtain(now,now+i*50,action,fromX+(toX-fromX)*i/3,fromY+(toY-fromY)*i/3,0);view.dispatchTouchEvent(event);event.recycle();}
|
||||
}
|
||||
@Test public void previewOptionRequiresConfirmationAndClearingPreservesHistoryAndPassword()throws Exception{
|
||||
clearTestPreviews=true;refreshHome();awaitPreviews();RemotePreviewStore store=RemotePreviewStore.get(activity);assertFalse(store.enabled());click("设置");
|
||||
togglePreviews();assertFalse(store.enabled());main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_NEGATIVE).performClick());assertFalse(store.enabled());
|
||||
togglePreviews();main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());awaitPreviews();assertTrue(store.enabled());assertNull(field("previewCapture"));
|
||||
main(()->find(content(),"保存远程画面预览").requestRectangleOnScreen(new android.graphics.Rect(0,0,100,400),true));snapshot("layout-preview-settings");
|
||||
RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"预览测试电脑","windows",true,"preview-test-password");
|
||||
store.save(store.begin(SCOPE,ID),RemotePreviewStoreTest.solid(android.graphics.Color.GREEN));awaitPreviews();click("返回");awaitPreviews();assertPreviewCard();snapshot("layout-preview-card");
|
||||
click("设置");click("清除预览图");main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());awaitPreviews();
|
||||
assertTrue(store.enabled());assertEquals("preview-test-password",history.password(SCOPE,ID));assertTrue(history.contains(SCOPE,ID));assertFalse(store.directory.exists());
|
||||
store.save(store.begin(SCOPE,ID),RemotePreviewStoreTest.solid(android.graphics.Color.BLUE));awaitPreviews();togglePreviews();awaitPreviews();assertFalse(store.enabled());assertFalse(store.directory.exists());
|
||||
togglePreviews();main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());awaitPreviews();
|
||||
store.save(store.begin(SCOPE,ID),RemotePreviewStoreTest.solid(android.graphics.Color.RED));awaitPreviews();click("隐私与授权,已授权");click("撤回联网授权");
|
||||
var nodes=instrumentation.getUiAutomation().getRootInActiveWindow().findAccessibilityNodeInfosByText("撤回");boolean confirmed=false;
|
||||
for(var node:nodes)if("撤回".contentEquals(node.getText()==null?"":node.getText())&&node.isClickable()){confirmed=node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK);break;}
|
||||
assertTrue(confirmed);instrumentation.waitForIdleSync();awaitPreviews();assertFalse(store.enabled());assertFalse(settings.getBoolean(RemotePreviewStore.OPTION,true));assertFalse(store.directory.exists());assertTrue(history.contains(SCOPE,ID));assertEquals("preview-test-password",history.password(SCOPE,ID));
|
||||
}
|
||||
@Test public void previewCopiesOnlyVideoOncePerConnectionAndSurvivesRestart()throws Exception{
|
||||
clearTestPreviews=true;refreshHome();awaitPreviews();RemotePreviewStore store=RemotePreviewStore.get(activity);store.setEnabled(true);awaitPreviews();
|
||||
main(()->{try{var begin=MainActivity.class.getDeclaredMethod("begin",String.class,String.class);begin.setAccessible(true);begin.invoke(activity,ID,"");}catch(Exception error){throw new RuntimeException(error);}});main(activity::connected);
|
||||
RemoteVideoView video=(RemoteVideoView)field("video");RemotePreviewStore.Capture capture=(RemotePreviewStore.Capture)field("previewCapture");assertNotNull(capture);
|
||||
var surfaceField=RemoteVideoView.class.getDeclaredField("video");surfaceField.setAccessible(true);android.view.SurfaceView surface=(android.view.SurfaceView)surfaceField.get(video);
|
||||
main(()->{video.videoSize(800,600);surface.getHolder().setFixedSize(800,600);});
|
||||
for(int i=0;i<30&&surface.getHolder().getSurfaceFrame().height()!=600;i++){Thread.sleep(50);instrumentation.waitForIdleSync();}
|
||||
assertEquals(600,surface.getHolder().getSurfaceFrame().height());
|
||||
main(()->{
|
||||
android.graphics.Canvas canvas=surface.getHolder().lockCanvas();assertNotNull(canvas);android.graphics.Paint paint=new android.graphics.Paint();canvas.drawColor(android.graphics.Color.YELLOW);
|
||||
paint.setColor(android.graphics.Color.GREEN);canvas.drawRect(0,75,800,525,paint);paint.setColor(android.graphics.Color.RED);canvas.drawRect(0,75,200,525,paint);paint.setColor(android.graphics.Color.BLUE);canvas.drawRect(600,75,800,525,paint);surface.getHolder().unlockCanvasAndPost(canvas);
|
||||
View overlay=new View(activity);overlay.setBackgroundColor(android.graphics.Color.MAGENTA);video.addView(overlay,new android.widget.FrameLayout.LayoutParams(120,120,android.view.Gravity.CENTER));
|
||||
});
|
||||
RemoteNetworkStatistics.Snapshot sample=new RemoteNetworkStatistics.Snapshot(null,1,800,600,10,3);main(()->activity.statistics(sample));
|
||||
java.io.File file=new java.io.File(store.directory,capture.key+".jpg");for(int i=0;i<60&&!file.exists();i++){Thread.sleep(50);instrumentation.waitForIdleSync();}awaitPreviews();assertTrue("Rendered frame should be saved",file.isFile());
|
||||
Bitmap image=android.graphics.BitmapFactory.decodeFile(file.getAbsolutePath());assertEquals(640,image.getWidth());assertEquals(360,image.getHeight());
|
||||
assertTrue(android.graphics.Color.green(image.getPixel(320,180))>240);assertTrue(android.graphics.Color.red(image.getPixel(320,180))<20);assertTrue(android.graphics.Color.red(image.getPixel(320,10))<20);assertTrue(android.graphics.Color.red(image.getPixel(10,180))>240);assertTrue(android.graphics.Color.blue(image.getPixel(630,180))>240);image.recycle();
|
||||
byte[] first=java.nio.file.Files.readAllBytes(file.toPath());main(()->{android.graphics.Canvas canvas=surface.getHolder().lockCanvas();canvas.drawColor(android.graphics.Color.BLUE);surface.getHolder().unlockCanvasAndPost(canvas);activity.statistics(sample);});awaitPreviews();assertArrayEquals(first,java.nio.file.Files.readAllBytes(file.toPath()));
|
||||
main(()->activity.ended(""));awaitPreviews();assertPreviewCard();snapshot("layout-preview-captured");
|
||||
main(activity::finish);activity=(MainActivity)instrumentation.startActivitySync(new Intent(instrumentation.getTargetContext(),MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));awaitPreviews();assertPreviewCard();
|
||||
new RecentConnections(activity).delete(SCOPE,ID);awaitPreviews();assertFalse("Deleting history also deletes its preview",file.exists());
|
||||
}
|
||||
private void togglePreviews()throws Exception{main(()->{try{Switch toggle=(Switch)field("previewSwitch");assertTrue(toggle.isEnabled());toggle.performClick();}catch(Exception error){throw new RuntimeException(error);}});}
|
||||
private android.app.AlertDialog previewDialog()throws Exception{return (android.app.AlertDialog)field("previewDialog");}
|
||||
private android.app.AlertDialog previewDialogUnchecked(){try{return previewDialog();}catch(Exception error){throw new RuntimeException(error);}}
|
||||
private void awaitPreviews()throws Exception{RemotePreviewStore store=RemotePreviewStore.get(activity);for(int i=0;i<2;i++){store.io.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();}}
|
||||
private void assertPreviewCard(){android.widget.ImageView image=findPreviewImage(content());assertNotNull("Preview card image",image);assertEquals(View.VISIBLE,image.getVisibility());assertNotNull(image.getDrawable());}
|
||||
private android.widget.ImageView findPreviewImage(View view){if(view instanceof android.widget.ImageView)return (android.widget.ImageView)view;if(view instanceof ViewGroup){ViewGroup group=(ViewGroup)view;for(int i=0;i<group.getChildCount();i++){var image=findPreviewImage(group.getChildAt(i));if(image!=null)return image;}}return null;}
|
||||
private Switch rememberSwitch(View view){
|
||||
if(view instanceof Switch)return (Switch)view;
|
||||
if(view instanceof ViewGroup){ViewGroup group=(ViewGroup)view;for(int i=0;i<group.getChildCount();i++){Switch match=rememberSwitch(group.getChildAt(i));if(match!=null)return match;}}return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class NetworkStatisticsTest {
|
||||
static JSONObject report(int mode,boolean srtp,double rtt)throws Exception{
|
||||
return new JSONObject().put("mode",mode).put("srtp",srtp).put("rtt",rtt).put("video",traffic(8000000,0,.02)).put("audio",traffic(64000,0,.01)).put("data",traffic(2000,3000,.03)).put("total",traffic(8066000,3000,.06));
|
||||
}
|
||||
private static JSONObject traffic(long in,long out,double loss)throws Exception{return new JSONObject().put("inbound",in).put("outbound",out).put("loss",loss);}
|
||||
@Test public void channelRatesRouteAndEncryptionComeFromTransport()throws Exception{
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();assertNull(state.snapshot(10).report);assertEquals("—",state.snapshot(10).mode());
|
||||
state.receive(new RemoteNetworkStatistics.Report(report(1,true,20)),10);var snapshot=state.snapshot(10);
|
||||
assertEquals(8000000,snapshot.report.traffic[0].inbound);assertEquals(64000,snapshot.report.traffic[1].inbound);assertEquals(3000,snapshot.report.traffic[2].outbound);assertEquals(8066000,snapshot.report.traffic[3].inbound);assertEquals(.06,snapshot.report.traffic[3].loss,.0001);assertEquals("TURN 中继",snapshot.mode());assertTrue(snapshot.report.srtp);
|
||||
state.receive(new RemoteNetworkStatistics.Report(report(0,false,20)),11);assertEquals("P2P 直连",state.snapshot(11).mode());assertFalse(state.snapshot(11).report.srtp);
|
||||
}
|
||||
@Test public void staleReportsAndRttExpireIndependently()throws Exception{
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.receive(new RemoteNetworkStatistics.Report(report(0,true,20)),10);state.receive(new RemoteNetworkStatistics.Report(report(0,true,100)),11);assertEquals(40,state.snapshot(11).rtt,.0001);
|
||||
for(double invalid:new double[]{-1,2001})state.receive(new RemoteNetworkStatistics.Report(report(0,true,invalid)),12);
|
||||
assertEquals(40,state.snapshot(13.99).rtt,.0001);assertTrue(Double.isNaN(state.snapshot(14).rtt));assertNotNull(state.snapshot(14).report);assertNull(state.snapshot(15).report);
|
||||
state.receive(new RemoteNetworkStatistics.Report(report(0,true,0)),16);assertEquals(0,state.snapshot(16).rtt,0);
|
||||
}
|
||||
@Test public void submittedFramesAreCountedOnceAndIdleVideoExpires(){
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.frame(1,100,1920,1080,10);state.frame(1,100,1920,1080,10.1);state.frame(2,200,1920,1080,10.2);
|
||||
assertEquals(2,state.snapshot(10.3).fps);assertEquals(150,state.snapshot(10.3).latency,.0001);assertEquals("1920 × 1080",state.snapshot(10.3).resolution());assertEquals(1,state.snapshot(11).fps);assertEquals(0,state.snapshot(11.3).fps);assertTrue(Double.isNaN(state.snapshot(11.3).latency));
|
||||
}
|
||||
@Test public void displaySwitchResetsVideoWithoutLosingTraffic()throws Exception{
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.receive(new RemoteNetworkStatistics.Report(report(1,true,20)),10);state.frame(1,100,1920,1080,10);state.resetVideo();state.frame(1,100,1920,1080,10.1);
|
||||
assertEquals(0,state.snapshot(10.1).fps);assertEquals("—",state.snapshot(10.1).resolution());assertNotNull(state.snapshot(10.1).report);
|
||||
state.frame(2,-1,1280,720,10.2);state.frame(3,Double.NaN,1280,720,10.3);state.frame(4,Double.POSITIVE_INFINITY,1280,720,10.4);assertEquals(3,state.snapshot(10.5).fps);assertTrue(Double.isNaN(state.snapshot(10.5).latency));
|
||||
state.frame(5,50,1280,720,10.6);assertEquals(50,state.snapshot(10.6).latency,0);assertEquals("1280 × 720",state.snapshot(10.6).resolution());
|
||||
}
|
||||
@Test public void slowFramesKeepTheirMeasuredLatencyInsteadOfBecomingUnknown(){
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.frame(1,6000,1920,1080,10);state.frame(2,8000,1920,1080,10.2);
|
||||
assertEquals(7000,state.snapshot(10.3).latency,0);assertEquals("7000 ms",RemoteNetworkStatistics.latency(state.snapshot(10.3).latency));
|
||||
state.frame(3,50,1920,1080,11.3);assertEquals(50,state.snapshot(11.3).latency,0);
|
||||
}
|
||||
@Test public void formatMatchesIosAndUnknownIsNotZero(){
|
||||
assertEquals("—",RemoteNetworkStatistics.bitrate(-1));assertEquals("0 bps",RemoteNetworkStatistics.bitrate(0));assertEquals("64 Kbps",RemoteNetworkStatistics.bitrate(64000));assertEquals("8.0 Mbps",RemoteNetworkStatistics.bitrate(8000000));
|
||||
assertEquals("<1 ms",RemoteNetworkStatistics.latency(0));assertEquals("—",RemoteNetworkStatistics.latency(Double.NaN));assertEquals("0.5%",RemoteNetworkStatistics.loss(.005));assertEquals("120.0%",RemoteNetworkStatistics.loss(1.2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.app.Instrumentation;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.*;
|
||||
import org.junit.runner.RunWith;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class PageNavigationTest {
|
||||
private final Instrumentation instrumentation=InstrumentationRegistry.getInstrumentation();
|
||||
private MainActivity activity;
|
||||
private SharedPreferences settings;
|
||||
private Map<String,?> saved;
|
||||
private PageHost host;
|
||||
|
||||
@Before public void setup()throws Exception{
|
||||
Context context=instrumentation.getTargetContext();settings=context.getSharedPreferences("settings",Context.MODE_PRIVATE);saved=settings.getAll();
|
||||
settings.edit().putBoolean("networkConsent",true).putString("host","navigation-test.invalid").putInt("port",1).commit();
|
||||
activity=(MainActivity)instrumentation.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));instrumentation.waitForIdleSync();host=(PageHost)field(activity,"pageHost");
|
||||
}
|
||||
@After public void cleanup()throws Exception{
|
||||
if(activity!=null)main(activity::finish);
|
||||
SharedPreferences.Editor editor=settings.edit().clear();for(var entry:saved.entrySet()){
|
||||
Object v=entry.getValue();if(v instanceof String)editor.putString(entry.getKey(),(String)v);else if(v instanceof Boolean)editor.putBoolean(entry.getKey(),(Boolean)v);else if(v instanceof Integer)editor.putInt(entry.getKey(),(Integer)v);
|
||||
}editor.commit();
|
||||
}
|
||||
private void main(Runnable action)throws Exception{FutureTask<Void> task=new FutureTask<>(action,null);instrumentation.runOnMainSync(task);task.get();instrumentation.waitForIdleSync();}
|
||||
private static Object field(Object object,String name){try{var f=object.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(object);}catch(Exception error){throw new RuntimeException(error);}}
|
||||
private void set(String name,Object value){try{var f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);f.set(activity,value);}catch(Exception error){throw new RuntimeException(error);}}
|
||||
private void invoke(String name){try{var method=MainActivity.class.getDeclaredMethod(name);method.setAccessible(true);method.invoke(activity);}catch(Exception error){throw new RuntimeException(error);}}
|
||||
private FrameLayout page(){return (FrameLayout)field(activity,"canvas");}
|
||||
private View find(View root,String label){
|
||||
if(label.contentEquals(root.getContentDescription()==null?"":root.getContentDescription())||(root instanceof TextView&&label.contentEquals(((TextView)root).getText())))return root;
|
||||
if(root instanceof ViewGroup){var group=(ViewGroup)root;for(int i=0;i<group.getChildCount();i++){View match=find(group.getChildAt(i),label);if(match!=null)return match;}}return null;
|
||||
}
|
||||
private void click(String label){View item=find(page(),label);assertNotNull(label,item);assertTrue(item.performClick());}
|
||||
private ValueAnimator animator(){return (ValueAnimator)field(host,"animator");}
|
||||
// Stop at a real intermediate frame; no sleeps or dependence on emulator rendering speed.
|
||||
private void transition(Runnable navigation,long time)throws Exception{
|
||||
FutureTask<Void> positioned=new FutureTask<>(()->{assertNotNull("Transition must start",animator());animator().pause();animator().setCurrentPlayTime(time);return null;});
|
||||
main(()->{
|
||||
navigation.run();host.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
|
||||
@Override public boolean onPreDraw(){host.getViewTreeObserver().removeOnPreDrawListener(this);positioned.run();return true;}
|
||||
});
|
||||
});positioned.get(5,TimeUnit.SECONDS);
|
||||
}
|
||||
private void finishTransition()throws Exception{main(()->{assertNotNull(animator());animator().end();assertSettled();});}
|
||||
private void assertSettled(){
|
||||
assertEquals(1,host.getChildCount());assertSame(page(),host.getChildAt(0));assertNull(animator());
|
||||
assertEquals(0,page().getTranslationX(),.01f);assertEquals(1,page().getAlpha(),.01f);assertEquals(0,page().getElevation(),.01f);assertEquals(View.LAYER_TYPE_NONE,page().getLayerType());
|
||||
assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO,page().getImportantForAccessibility());assertNotEquals(ViewGroup.FOCUS_BLOCK_DESCENDANTS,page().getDescendantFocusability());
|
||||
}
|
||||
private void snapshot(String name)throws Exception{
|
||||
Bitmap image=instrumentation.getUiAutomation().takeScreenshot();assertNotNull(image);
|
||||
try(var output=new java.io.FileOutputStream(new java.io.File(activity.getExternalFilesDir(null),name+".png"))){image.compress(Bitmap.CompressFormat.PNG,100,output);}finally{image.recycle();}
|
||||
}
|
||||
|
||||
@Test public void pushAndPopHaveOppositeMotionAndCleanUp()throws Exception{
|
||||
main(this::assertSettled);FrameLayout home=page();
|
||||
transition(()->{click("设置");assertEquals(host.getWidth(),page().getTranslationX(),.01f);assertEquals(0,home.getTranslationX(),.01f);},140);
|
||||
main(()->{assertEquals(350,animator().getDuration());assertEquals(2,host.getChildCount());assertSame(page(),host.getChildAt(1));assertTrue(page().getTranslationX()>0);assertTrue(home.getTranslationX()<0);});
|
||||
snapshot("navigation-push-middle");finishTransition();assertNull(home.getParent());FrameLayout settingsPage=page();
|
||||
transition(()->click("返回"),140);
|
||||
main(()->{assertSame(page(),host.getChildAt(0));assertTrue(page().getTranslationX()<0);assertTrue(settingsPage.getTranslationX()>0);});
|
||||
snapshot("navigation-pop-middle");finishTransition();assertNull(settingsPage.getParent());assertNotNull(find(page(),"远程桌面"));
|
||||
}
|
||||
|
||||
@Test public void interruptedNavigationUsesNewestPageAndBackDirection()throws Exception{
|
||||
FrameLayout home=page();transition(()->click("设置"),90);FrameLayout settingsPage=page();
|
||||
transition(()->click("关于"),90);FrameLayout about=page();
|
||||
main(()->{assertNull(home.getParent());assertEquals(2,host.getChildCount());assertSame(settingsPage,host.getChildAt(0));assertTrue(about.getTranslationX()>0);});
|
||||
transition(()->invoke("back"),90);
|
||||
main(()->{assertNull(settingsPage.getParent());assertTrue(page().getTranslationX()<0);assertTrue(about.getTranslationX()>0);});finishTransition();
|
||||
transition(()->invoke("back"),90);finishTransition();assertNotNull(find(page(),"远程桌面"));
|
||||
}
|
||||
|
||||
@Test public void normalPlaybackFinishesAndRestoresInteractions()throws Exception{
|
||||
transition(()->click("设置"),0);main(()->animator().resume());
|
||||
for(int i=0;i<100;i++){
|
||||
java.util.concurrent.atomic.AtomicBoolean done=new java.util.concurrent.atomic.AtomicBoolean();main(()->done.set(host.getChildCount()==1));
|
||||
if(done.get())break;Thread.sleep(30);
|
||||
}
|
||||
main(this::assertSettled);assertNotNull(find(page(),"鼠标控制"));snapshot("navigation-settings-finished");
|
||||
}
|
||||
|
||||
@Test public void refreshAndBackgroundFinishWithoutExtraPages()throws Exception{
|
||||
transition(()->click("设置"),90);FrameLayout old=page();
|
||||
main(()->{invoke("settings");assertSettled();assertNull(old.getParent());});
|
||||
transition(()->click("关于"),90);
|
||||
main(()->{instrumentation.callActivityOnStop(activity);assertSettled();assertEquals("about",field(activity,"page"));});
|
||||
main(()->instrumentation.callActivityOnStart(activity));
|
||||
}
|
||||
|
||||
@Test public void sessionFadeKeepsSurfaceOpaqueAndUnlayered()throws Exception{
|
||||
FrameLayout home=page();
|
||||
transition(()->{set("session",new NativeSession(activity,"navigation-test.invalid",1,"","",activity));invoke("sessionScreen");},100);
|
||||
FrameLayout session=page();
|
||||
main(()->{assertEquals(200,animator().getDuration());assertSame(session,host.getChildAt(0));assertEquals(1,session.getAlpha(),.01f);assertEquals(View.LAYER_TYPE_NONE,session.getLayerType());assertEquals(0,session.getTranslationX(),.01f);assertEquals(.5f,home.getAlpha(),.02f);});finishTransition();
|
||||
transition(()->activity.ended(""),100);
|
||||
main(()->{assertEquals(200,animator().getDuration());assertSame(session,host.getChildAt(0));assertEquals(1,session.getAlpha(),.01f);assertEquals(View.LAYER_TYPE_NONE,session.getLayerType());assertEquals(.5f,page().getAlpha(),.02f);});finishTransition();assertNull(session.getParent());
|
||||
}
|
||||
|
||||
@Test public void touchIsBlockedDuringMotionAndResizeFinishesIt()throws Exception{
|
||||
transition(()->click("设置"),90);
|
||||
main(()->{
|
||||
MotionEvent event=MotionEvent.obtain(0,0,MotionEvent.ACTION_DOWN,40,100,0);
|
||||
try{assertTrue(host.onInterceptTouchEvent(event));assertTrue(host.onTouchEvent(event));}finally{event.recycle();}
|
||||
host.layout(0,0,host.getWidth()+1,host.getHeight());assertSettled();
|
||||
event=MotionEvent.obtain(0,0,MotionEvent.ACTION_DOWN,40,100,0);try{assertFalse(host.onInterceptTouchEvent(event));}finally{event.recycle();}
|
||||
});
|
||||
}
|
||||
|
||||
@Test public void disabledSystemAnimationsSwapImmediately()throws Exception{
|
||||
String scale=shell("settings get global animator_duration_scale").trim();
|
||||
try{
|
||||
shell("settings put global animator_duration_scale 0");awaitAnimations(false);
|
||||
main(()->{click("设置");assertSettled();click("返回");assertSettled();});
|
||||
}finally{
|
||||
// Deleting an unset value alone leaves WindowManager's cached scale at zero.
|
||||
shell("settings put global animator_duration_scale "+(scale.equals("null")?"1":scale));awaitAnimations(scale.equals("null")||Float.parseFloat(scale)>0);
|
||||
if(scale.equals("null"))shell("settings delete global animator_duration_scale");
|
||||
}
|
||||
}
|
||||
private String shell(String command)throws Exception{try(var input=new android.os.ParcelFileDescriptor.AutoCloseInputStream(instrumentation.getUiAutomation().executeShellCommand(command))){return new String(input.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8);}}
|
||||
private void awaitAnimations(boolean enabled)throws Exception{
|
||||
for(int i=0;i<100;i++){if(ValueAnimator.areAnimatorsEnabled()==enabled)return;Thread.sleep(30);}assertEquals(enabled,ValueAnimator.areAnimatorsEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.*;
|
||||
import android.graphics.*;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.*;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class RemotePreviewStoreTest {
|
||||
private Context context;
|
||||
private File root;
|
||||
private SharedPreferences options,index;
|
||||
private String prefix;
|
||||
private RemotePreviewStore store;
|
||||
@Before public void setup()throws Exception{
|
||||
context=InstrumentationRegistry.getInstrumentation().getTargetContext();prefix="preview-test-"+UUID.randomUUID();
|
||||
options=context.getSharedPreferences(prefix,0);index=context.getSharedPreferences(prefix+"-index",0);
|
||||
root=Files.createTempDirectory(context.getCacheDir().toPath(),"previews-").toFile();
|
||||
store=new RemotePreviewStore(new File(root,"images"),options,index);
|
||||
}
|
||||
@After public void cleanup()throws Exception{
|
||||
store.directory.setWritable(true,false);store.clear();flush();store.io.shutdown();
|
||||
try(var paths=Files.walk(root.toPath())){for(var path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);}
|
||||
context.deleteSharedPreferences(prefix);context.deleteSharedPreferences(prefix+"-index");
|
||||
}
|
||||
private void flush()throws Exception{store.io.submit(()->{}).get(5,TimeUnit.SECONDS);InstrumentationRegistry.getInstrumentation().waitForIdleSync();}
|
||||
private void enable(){options.edit().putBoolean("networkConsent",true).commit();store.setEnabled(true);}
|
||||
static Bitmap solid(int color){Bitmap image=Bitmap.createBitmap(640,360,Bitmap.Config.ARGB_8888);image.eraseColor(color);return image;}
|
||||
private Bitmap load(String server,String id)throws Exception{
|
||||
var result=new java.util.concurrent.atomic.AtomicReference<Bitmap>();var done=new CountDownLatch(1);
|
||||
store.load(server,id,image->{result.set(image);done.countDown();});assertTrue(done.await(5,TimeUnit.SECONDS));return result.get();
|
||||
}
|
||||
@Test public void offByDefaultAndConsentCannotBeBypassed()throws Exception{
|
||||
assertFalse(store.enabled());assertNull(store.begin("server","1"));store.setEnabled(true);flush();assertFalse(store.enabled());
|
||||
options.edit().putBoolean("networkConsent",true).commit();assertFalse(store.enabled());
|
||||
enable();assertNotNull(store.begin("server","1"));
|
||||
options.edit().putBoolean("networkConsent",false).commit();store.enforceConsent();flush();
|
||||
assertFalse(options.getBoolean(RemotePreviewStore.OPTION,true));assertNull(store.begin("server","1"));
|
||||
}
|
||||
@Test public void jpegPersistsAcrossStoreRecreationAndRemainsServerScoped()throws Exception{
|
||||
enable();var capture=store.begin("server:1","123456789");store.save(capture,solid(Color.GREEN));flush();
|
||||
assertTrue(new File(store.directory,capture.key+".jpg").isFile());
|
||||
assertNull(load("server:2","123456789"));assertNull(load("server:1","other"));
|
||||
store.io.shutdown();store=new RemotePreviewStore(store.directory,options,index);
|
||||
Bitmap loaded=load("server:1","123456789");assertNotNull(loaded);assertEquals(640,loaded.getWidth());assertEquals(360,loaded.getHeight());assertTrue(Color.green(loaded.getPixel(320,180))>240);loaded.recycle();
|
||||
}
|
||||
@Test public void clearRejectsQueuedAndLateCapturesWithoutTurningOffTheOption()throws Exception{
|
||||
enable();var old=store.begin("server","1");var gate=new CountDownLatch(1);var started=new CountDownLatch(1);
|
||||
store.io.execute(()->{started.countDown();try{gate.await(5,TimeUnit.SECONDS);}catch(InterruptedException e){Thread.currentThread().interrupt();}});
|
||||
assertTrue(started.await(5,TimeUnit.SECONDS));Bitmap queued=solid(Color.RED);store.save(old,queued);store.clear();
|
||||
assertFalse(store.current(old));assertNull(store.begin("server","2"));gate.countDown();flush();
|
||||
Bitmap late=solid(Color.RED);store.save(old,late);flush();assertTrue(queued.isRecycled());assertTrue(late.isRecycled());
|
||||
assertFalse(store.directory.exists());assertNull(load("server","1"));assertTrue(store.enabled());
|
||||
var next=store.begin("server","1");store.save(next,solid(Color.BLUE));flush();assertNotNull(load("server","1"));
|
||||
}
|
||||
@Test public void deletingARecordRejectsPendingSaveAndDoesNotRemoveOtherPreviews()throws Exception{
|
||||
enable();var first=store.begin("server","1");var second=store.begin("server","2");
|
||||
store.save(first,solid(Color.RED));store.save(second,solid(Color.BLUE));flush();
|
||||
store.remove("server","1");store.save(first,solid(Color.GREEN));flush();
|
||||
assertNull(load("server","1"));Bitmap other=load("server","2");assertNotNull(other);other.recycle();
|
||||
assertFalse(new File(store.directory,first.key+".jpg").exists());
|
||||
}
|
||||
@Test public void aNewConnectionSupersedesAnOlderCaptureForTheSameDevice()throws Exception{
|
||||
enable();var old=store.begin("server","1");var current=store.begin("server","1");
|
||||
store.save(current,solid(Color.GREEN));store.save(old,solid(Color.RED));flush();
|
||||
Bitmap loaded=load("server","1");assertNotNull(loaded);assertTrue(Color.green(loaded.getPixel(320,180))>240);loaded.recycle();
|
||||
}
|
||||
@Test public void disablingDeletesFilesAndInvalidatesInFlightCapture()throws Exception{
|
||||
enable();var capture=store.begin("server","1");store.save(capture,solid(Color.BLUE));flush();
|
||||
store.setEnabled(false);store.save(capture,solid(Color.RED));flush();
|
||||
assertFalse(store.enabled());assertFalse(store.directory.exists());assertTrue(index.getAll().isEmpty());assertNull(load("server","1"));
|
||||
store.setEnabled(true);assertNull(load("server","1"));
|
||||
}
|
||||
@Test public void failedCleanupHidesOldImagesAndCanBeRetried()throws Exception{
|
||||
enable();var capture=store.begin("server","1");store.save(capture,solid(Color.GREEN));flush();
|
||||
assertTrue(store.directory.setWritable(false,false));store.clear();flush();assertFalse(store.cleaning);assertFalse(store.cleanupError.isEmpty());
|
||||
assertNull(load("server","1"));assertTrue(store.directory.setWritable(true,false));store.clear();flush();
|
||||
assertEquals("",store.cleanupError);assertFalse(store.directory.exists());
|
||||
}
|
||||
@Test public void corruptImagesAndFailedWritesNeverBecomePreviewCards()throws Exception{
|
||||
enable();assertTrue(store.directory.createNewFile());var capture=store.begin("server","1");store.save(capture,solid(Color.RED));flush();assertNull(load("server","1"));
|
||||
assertTrue(store.directory.delete());store.save(capture,solid(Color.BLUE));flush();
|
||||
Files.write(new File(store.directory,capture.key+".jpg").toPath(),new byte[]{1,2,3});assertNull(load("server","1"));
|
||||
}
|
||||
@Test public void cropUsesCenteredSixteenByNineWithoutStretching(){
|
||||
assertEquals(new Rect(0,0,3840,2160),RemoteVideoView.previewCrop(3840,2160));
|
||||
assertEquals(new Rect(0,75,800,525),RemoteVideoView.previewCrop(800,600));
|
||||
assertEquals(new Rect(320,0,2240,1080),RemoteVideoView.previewCrop(2560,1080));
|
||||
assertEquals(new Rect(0,547,900,1053),RemoteVideoView.previewCrop(900,1600));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import org.json.JSONObject;
|
||||
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 RemoteVersionCheckTest {
|
||||
private static byte[] json(String value) { return value.getBytes(StandardCharsets.UTF_8); }
|
||||
private static JSONObject host(Object version) {
|
||||
try { return new JSONObject().put("app_version", version); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
private static final class Fetches implements RemoteVersionCheck.Fetcher {
|
||||
final List<Consumer<byte[]>> replies = new ArrayList<>();
|
||||
int cancellations;
|
||||
public RemoteVersionCheck.Cancellation fetch(Consumer<byte[]> reply) {
|
||||
replies.add(reply);
|
||||
return () -> cancellations++;
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void sharedNativeComparisonMatchesIosReleaseOrdering() {
|
||||
byte[] release = json("{\"latest_version\":\"v1.5.3-20260928\",\"patch\":2}");
|
||||
for (String version : new String[]{"1.5.2", "v1.5.3-20260928", "1.5.3-1-20260929", "1.5.3-20260928-1"}) {
|
||||
assertTrue(NativeSession.nValidAppVersion(version));
|
||||
assertTrue(version, NativeSession.nHasAppUpdate(version, release));
|
||||
}
|
||||
for (String version : new String[]{"1.5.3-2-20260927", "1.5.3-3-20260928", "1.6.0"})
|
||||
assertFalse(version, NativeSession.nHasAppUpdate(version, release));
|
||||
assertFalse(NativeSession.nHasAppUpdate("1.5.3-20260927", json("{\"version\":\"1.5.3-20261001\"}")));
|
||||
assertTrue(NativeSession.nHasAppUpdate("1.5.3-9", json("{\"version\":\"1.5.3\",\"patch\":\"10\"}")));
|
||||
}
|
||||
|
||||
@Test public void invalidReleaseMetadataNeverRaisesANotice() {
|
||||
for (String release : new String[]{"", "not JSON", "{}", "[]", "null", "{\"version\":false}",
|
||||
"{\"version\":\"unknown\"}", "{\"version\":\"1..2\"}", "{\"version\":\"999999999999999.2\"}"})
|
||||
assertFalse(release, NativeSession.nHasAppUpdate("1.0.0", json(release)));
|
||||
assertFalse(NativeSession.nHasAppUpdate("1.0.0", null));
|
||||
assertFalse(NativeSession.nHasAppUpdate("1.0.0", new byte[256 * 1024 + 1]));
|
||||
}
|
||||
|
||||
@Test public void legacyVersionWaitsForConnectionAndNeverFetches() {
|
||||
for (Object version : new Object[]{null, JSONObject.NULL, 123, "", " ", "unknown", "ios-native", "1..5", "1.5-"}) {
|
||||
Fetches fetches = new Fetches(); int[] notices = {0};
|
||||
RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++);
|
||||
check.hostInfo(host(version)); assertEquals(0, notices[0]);
|
||||
check.connected(); assertEquals(1, notices[0]);
|
||||
check.hostInfo(host(version)); check.connected(); assertEquals(1, notices[0]);
|
||||
assertTrue(fetches.replies.isEmpty()); check.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void bothEventOrdersCheckOncePerSession() {
|
||||
for (boolean hostFirst : new boolean[]{true, false}) {
|
||||
Fetches fetches = new Fetches(); int[] notices = {0};
|
||||
RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++);
|
||||
if (hostFirst) check.hostInfo(host("1.0.0")); else check.connected();
|
||||
assertTrue(fetches.replies.isEmpty());
|
||||
if (hostFirst) check.connected(); else check.hostInfo(host("1.0.0"));
|
||||
check.hostInfo(host("1.0.0")); check.connected(); assertEquals(1, fetches.replies.size());
|
||||
fetches.replies.get(0).accept(json("{\"latest_version\":\"2.0.0\"}")); assertEquals(1, notices[0]);
|
||||
check.hostInfo(host("1.0.0")); assertEquals(1, notices[0]);
|
||||
check.reset(); check.connected(); assertEquals(1, fetches.replies.size());
|
||||
check.hostInfo(host("1.0.0")); assertEquals(2, fetches.replies.size()); check.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void disconnectCancelsAndOldReplyCannotAffectANewPeer() {
|
||||
Fetches fetches = new Fetches(); int[] notices = {0};
|
||||
RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++);
|
||||
check.hostInfo(host("1.0.0")); check.connected(); check.reset();
|
||||
assertEquals(1, fetches.cancellations);
|
||||
fetches.replies.get(0).accept(json("{\"version\":\"2.0.0\"}")); assertEquals(0, notices[0]);
|
||||
check.connected(); check.hostInfo(host("3.0.0"));
|
||||
fetches.replies.get(0).accept(json("{\"version\":\"2.0.0\"}"));
|
||||
fetches.replies.get(1).accept(json("{\"version\":\"2.0.0\"}")); assertEquals(0, notices[0]);
|
||||
check.reset(); assertEquals(1, fetches.cancellations);
|
||||
}
|
||||
|
||||
@Test public void failedCheckIsSilentAndDoesNotRetryOnDuplicateHostInfo() {
|
||||
for (byte[] reply : new byte[][]{null, json("invalid"), json("{\"version\":\"1.0.0\"}")}) {
|
||||
Fetches fetches = new Fetches(); int[] notices = {0};
|
||||
RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++);
|
||||
check.connected(); check.hostInfo(host("1.0.0")); fetches.replies.get(0).accept(reply);
|
||||
check.hostInfo(host("1.0.0")); assertEquals(0, notices[0]); assertEquals(1, fetches.replies.size());
|
||||
check.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.widget.TextView;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.*;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.lang.reflect.*;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/** Home authorization and lifecycle behavior; uses a local closed port, no public service. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class SignalingLifecycleTest {
|
||||
private final android.app.Instrumentation instrumentation=InstrumentationRegistry.getInstrumentation();
|
||||
private MainActivity activity;
|
||||
private SharedPreferences preferences;
|
||||
private Map<String,?> saved;
|
||||
@Before public void start(){
|
||||
Context context=instrumentation.getTargetContext();preferences=context.getSharedPreferences("settings",Context.MODE_PRIVATE);saved=preferences.getAll();
|
||||
preferences.edit().clear().putString("host","127.0.0.1").putInt("port",1).putBoolean("networkConsent",false).commit();
|
||||
activity=(MainActivity)instrumentation.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
|
||||
}
|
||||
@After public void finish()throws Exception{
|
||||
main(activity::finish);
|
||||
SharedPreferences.Editor editor=preferences.edit().clear();
|
||||
for(var entry:saved.entrySet()){Object value=entry.getValue();if(value instanceof String)editor.putString(entry.getKey(),(String)value);else if(value instanceof Boolean)editor.putBoolean(entry.getKey(),(Boolean)value);else if(value instanceof Integer)editor.putInt(entry.getKey(),(Integer)value);}
|
||||
editor.commit();
|
||||
}
|
||||
private void main(Runnable action)throws Exception{
|
||||
FutureTask<Void> task=new FutureTask<>(()->{action.run();return null;});instrumentation.runOnMainSync(task);task.get();instrumentation.waitForIdleSync();
|
||||
}
|
||||
private Object field(String name){try{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);}catch(Exception error){throw new RuntimeException(error);}}
|
||||
private void call(String name){try{Method m=MainActivity.class.getDeclaredMethod(name);m.setAccessible(true);m.invoke(activity);}catch(Exception error){throw new RuntimeException(error);}}
|
||||
@Test public void authorizedHomeConnectsAndBackgroundClosesPeer()throws Exception{
|
||||
main(()->{
|
||||
assertNull(field("signaling"));
|
||||
preferences.edit().putBoolean("networkConsent",true).apply();call("ensureSignaling");
|
||||
NativeSession first=(NativeSession)field("signaling");assertNotNull(first);
|
||||
assertNull("Home startup must not create a desktop session",field("session"));
|
||||
call("ensureSignaling");assertSame("Navigation reuses the identity connection",first,field("signaling"));
|
||||
activity.onStop();assertTrue(first.isClosed());assertNull(field("signaling"));
|
||||
activity.onStart();NativeSession resumed=(NativeSession)field("signaling");assertNotNull(resumed);assertNotSame(first,resumed);
|
||||
try{Field host=MainActivity.class.getDeclaredField("host");host.setAccessible(true);host.set(activity,"127.0.0.2");}catch(ReflectiveOperationException e){throw new RuntimeException(e);}
|
||||
call("ensureSignaling");assertTrue("Server changes retire the old identity peer",resumed.isClosed());assertNotSame(resumed,field("signaling"));
|
||||
activity.onStop();preferences.edit().putBoolean("networkConsent",false).apply();activity.onStart();
|
||||
assertNull("Revoked consent must prevent foreground reconnect",field("signaling"));
|
||||
});
|
||||
}
|
||||
@Test public void badgeTracksCallbacksAndFailureWithoutOpeningDesktop()throws Exception{
|
||||
main(()->{
|
||||
preferences.edit().putBoolean("networkConsent",true).apply();
|
||||
activity.signaling(0,"");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("正在连接"));
|
||||
activity.signaling(1,"123456789");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("已连接服务器"));
|
||||
activity.signaling(4,"123456789");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("正在重连"));
|
||||
activity.ended("服务器 TLS 证书校验失败");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("证书校验失败"));
|
||||
assertNull(field("session"));assertEquals("home",field("page"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.net.InetAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.security.KeyStore;
|
||||
import java.util.concurrent.*;
|
||||
import javax.net.ssl.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/** Exercises the actual MiniRTC TLS path inside an Android app process (AT_SECURE=1).
|
||||
* The bundled PKCS12 keys are public test fixtures, only served on loopback. No
|
||||
* websocket upgrade/login or external server is involved in these tests. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class TlsTrustTest {
|
||||
@Test public void explicitlyTrustedCertificateCompletesHandshake() throws Exception {
|
||||
assertTrue("The app must load the supplied trust bundle despite AT_SECURE", handshake("valid",true));
|
||||
}
|
||||
@Test public void untrustedCertificateIsRejected() throws Exception {
|
||||
assertFalse("A test CA absent from the system store must not be trusted",handshake("valid",false));
|
||||
}
|
||||
@Test public void certificateForAnotherHostIsRejected() throws Exception {
|
||||
assertFalse("Trusting the CA must not bypass hostname verification",handshake("wrong-host",true));
|
||||
}
|
||||
private boolean handshake(String certificate,boolean trustFixture) throws Exception {
|
||||
var instrumentation=InstrumentationRegistry.getInstrumentation();Context context=instrumentation.getTargetContext();
|
||||
KeyStore keyStore=KeyStore.getInstance("PKCS12");char[] password="local-test-only".toCharArray();
|
||||
try(InputStream input=instrumentation.getContext().getAssets().open("tls/"+certificate+".p12")){keyStore.load(input,password);}
|
||||
KeyManagerFactory keys=KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());keys.init(keyStore,password);
|
||||
SSLContext tls=SSLContext.getInstance("TLS");tls.init(keys.getKeyManagers(),null,null);
|
||||
ExecutorService serverWorker=Executors.newSingleThreadExecutor();
|
||||
NativeSession session=new NativeSession(context,"127.0.0.1",1,"unused","unused",new NativeSession.Listener(){
|
||||
public void status(String value){} public void connected(){fail("Test server never opens a desktop session");}
|
||||
public void ended(String value){} public void data(JSONObject value){}public void videoSize(int w,int h){}
|
||||
public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){}
|
||||
});
|
||||
File roots=new File(context.getCacheDir(),"tls-test-roots.pem");
|
||||
Field rtcField=NativeSession.class.getDeclaredField("RTC");rtcField.setAccessible(true);ExecutorService rtc=(ExecutorService)rtcField.get(null);
|
||||
try(SSLServerSocket server=(SSLServerSocket)tls.getServerSocketFactory().createServerSocket(0,1,InetAddress.getByName("127.0.0.1"))){
|
||||
server.setSoTimeout(10000);
|
||||
if(trustFixture){try(InputStream input=instrumentation.getContext().getAssets().open("tls/root.pem")){Files.copy(input,roots.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);}}
|
||||
else{Method export=NativeSession.class.getDeclaredMethod("exportTrust");export.setAccessible(true);File systemRoots=(File)export.invoke(session);Files.copy(systemRoots.toPath(),roots.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);}
|
||||
Future<Boolean> request=serverWorker.submit(()->{
|
||||
try(SSLSocket socket=(SSLSocket)server.accept()){
|
||||
socket.setSoTimeout(10000);socket.startHandshake();
|
||||
String first=new BufferedReader(new InputStreamReader(socket.getInputStream(),java.nio.charset.StandardCharsets.US_ASCII)).readLine();
|
||||
return first!=null&&first.startsWith("GET ");
|
||||
}catch(SSLException expected){return false;}
|
||||
});
|
||||
Method create=NativeSession.class.getDeclaredMethod("nCreate",NativeSession.class,String.class,int.class,String.class,String.class,String.class);create.setAccessible(true);
|
||||
Field handle=NativeSession.class.getDeclaredField("handle");handle.setAccessible(true);
|
||||
rtc.submit(()->{try{long value=(long)create.invoke(null,session,"127.0.0.1",server.getLocalPort(),"",context.getCacheDir().getAbsolutePath(),roots.getAbsolutePath());handle.setLong(session,value);assertNotEquals(0L,value);}catch(ReflectiveOperationException error){throw new RuntimeException(error);}}).get(10,TimeUnit.SECONDS);
|
||||
return request.get(15,TimeUnit.SECONDS);
|
||||
}finally{session.close();rtc.submit(()->{}).get(10,TimeUnit.SECONDS);serverWorker.shutdownNow();roots.delete();}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class VideoSettingsTest {
|
||||
private final RemoteVideoSettings.Values first=new RemoteVideoSettings.Values(1,30,2),second=new RemoteVideoSettings.Values(0,60,1);
|
||||
private void same(RemoteVideoSettings.Values expected,RemoteVideoSettings.Values actual){assertEquals(expected.quality,actual.quality);assertEquals(expected.frameRate,actual.frameRate);assertEquals(expected.preference,actual.preference);}
|
||||
@Test public void supportedHostsAndProtocolValuesOnly(){
|
||||
RemoteVideoSettings state=new RemoteVideoSettings(0);assertEquals(-1,state.begin(first));same(new RemoteVideoSettings.Values(1,30,0),state.selection);state.setSupported(true);
|
||||
for(int q=-1;q<4;q++)for(int fps:new int[]{0,24,30,60,120})for(int p=-1;p<4;p++)assertEquals(q>=0&&q<=2&&(fps==30||fps==60)&&p>=0&&p<=2,state.begin(new RemoteVideoSettings.Values(q,fps,p))>=0);
|
||||
}
|
||||
@Test public void acknowledgementCommitsAndRejectionRollsBack(){
|
||||
RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first);assertTrue(state.pending);same(new RemoteVideoSettings.Values(1,30,1),state.applied);
|
||||
state.receive(first,id,true);same(first,state.selection);assertFalse(state.pending);assertFalse(state.failed);state.expire(id);assertFalse(state.failed);
|
||||
id=state.begin(second);state.receive(second,id,false);same(first,state.selection);assertTrue(state.failed);state.begin(second);assertFalse(state.failed);
|
||||
}
|
||||
@Test public void rapidChangesUseNewestSelectionAndLatestAcknowledgedRollback(){
|
||||
RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long a=state.begin(first),b=state.begin(second);
|
||||
state.receive(first,a,true);state.expire(a);same(second,state.selection);assertTrue(state.pending);
|
||||
state.receive(second,b,false);same(first,state.selection);assertTrue(state.failed);
|
||||
long c=state.begin(second);state.receive(second,c,true);state.receive(first,a,true);state.receive(first,c+1,true);same(second,state.selection);same(second,state.applied);
|
||||
}
|
||||
@Test public void timeoutLateReplyAndCapabilityLoss(){
|
||||
RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first);state.expire(id);assertTrue(state.failed);same(new RemoteVideoSettings.Values(1,30,1),state.selection);
|
||||
state.receive(first,id,true);same(first,state.selection);assertFalse(state.failed);
|
||||
id=state.begin(second);state.setSupported(false);state.receive(second,id,true);state.expire(id);same(first,state.selection);assertFalse(state.failed);assertFalse(state.pending);
|
||||
}
|
||||
@Test public void malformedAndOutOfRangeAcknowledgementsCannotChangeState()throws Exception{
|
||||
RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first);
|
||||
for(Object bad:new Object[]{-1L,0x100000000L,1.5,"1"}){JSONObject response=first.status(id,true);response.getJSONObject("video_settings").put("request_id",bad);state.receive(response);assertTrue(state.pending);}
|
||||
JSONObject response=first.status(id,true);response.getJSONObject("video_settings").put("quality",0x100000001L);state.receive(response);assertTrue(state.pending);
|
||||
response=first.status(id,true);response.getJSONObject("video_settings").put("accepted","true");state.receive(response);assertTrue(state.pending);
|
||||
state.receive(first.status(id,true));assertFalse(state.pending);same(first,state.applied);
|
||||
}
|
||||
@Test public void requestIdsWrapAsUnsigned32Bit(){
|
||||
RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);state.requestId=state.appliedRequestId=0xfffffffeL;
|
||||
long a=state.begin(first),b=state.begin(second);assertEquals(0xffffffffL,a);assertEquals(0,b);
|
||||
state.receive(first,a,true);same(second,state.selection);state.receive(second,b,true);state.receive(first,a,true);same(second,state.applied);
|
||||
}
|
||||
@Test public void softwareDecodeStartsWithinDisplayBudgetAndAllowsManualOverrides(){
|
||||
for(int edge:new int[]{640,720,1280})same(new RemoteVideoSettings.Values(0,30,1),new RemoteVideoSettings(1,edge).selection);
|
||||
for(int edge:new int[]{0,1281,1920,3840})same(new RemoteVideoSettings.Values(1,30,2),new RemoteVideoSettings(2,edge).selection);
|
||||
RemoteVideoSettings state=new RemoteVideoSettings(-1,640);same(new RemoteVideoSettings.Values(0,30,1),state.selection);
|
||||
state.setSupported(true);RemoteVideoSettings.Values high=new RemoteVideoSettings.Values(2,60,0);long id=state.begin(high);
|
||||
state.receive(high,id,true);same(high,state.selection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<application android:theme="@style/AppTheme" android:label="CrossDesk"
|
||||
android:icon="@mipmap/ic_launcher" android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:supportsRtl="true" android:usesCleartextTraffic="false">
|
||||
<activity android:name=".MainActivity" android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,116 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import java.util.List;
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Mirrors the iOS About / component / source / document navigation. */
|
||||
final class AboutPages {
|
||||
private final MainActivity activity;
|
||||
private final MobileUi ui;
|
||||
private final Runnable settings;
|
||||
private final LicenseCatalog catalog;
|
||||
|
||||
AboutPages(MainActivity activity,MobileUi ui,Runnable settings){
|
||||
this.activity=activity;this.ui=ui;this.settings=settings;
|
||||
LicenseCatalog loaded;try{loaded=new LicenseCatalog(activity);}catch(Exception error){loaded=null;}catalog=loaded;
|
||||
}
|
||||
void show(){
|
||||
LinearLayout content=activity.formPage("about","关于 CrossDesk",settings);
|
||||
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);
|
||||
activity.link(links,"源码与构建说明","",this::source);
|
||||
activity.footer(content,"许可与版权声明可离线查阅。");
|
||||
}
|
||||
private String applicationVersion(){
|
||||
try{return activity.getPackageManager().getPackageInfo(activity.getPackageName(),0).versionName;}catch(android.content.pm.PackageManager.NameNotFoundException error){return "—";}
|
||||
}
|
||||
private void components(){
|
||||
LinearLayout content=activity.formPage("open-source","开源组件",this::show);
|
||||
LinearLayout notice=activity.section(content,"OpenFEC 告知");
|
||||
paragraph(notice,"本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。",SECONDARY);
|
||||
ui.divider(notice);paragraph(notice,"(c) Copyright 2009 - 2012 INRIA - All rights reserved",INK);ui.divider(notice);
|
||||
activity.link(notice,"OpenFEC 许可与源码","",()->component(catalog.find("openfec"),this::components));ui.gap(content,28);
|
||||
LinearLayout list=activity.section(content,"");boolean first=true;
|
||||
for(LicenseCatalog.Component item:catalog.components){
|
||||
if(item.id.equals("crossdesk")||item.id.equals("openfec"))continue;
|
||||
if(!first)ui.divider(list);first=false;
|
||||
LinearLayout row=ui.row();row.setPadding(ui.dp(16),ui.dp(12),ui.dp(12),ui.dp(12));
|
||||
LinearLayout labels=ui.column();labels.addView(ui.text(item.name,16,false));ui.gap(labels,4);
|
||||
TextView license=ui.text(item.license,12,false);license.setTextColor(SECONDARY);labels.addView(license);
|
||||
row.addView(labels,new LinearLayout.LayoutParams(0,-2,1));TextView chevron=ui.text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);row.addView(chevron);
|
||||
row.setContentDescription(item.name+","+item.license);row.setClickable(true);row.setFocusable(true);row.setOnClickListener(v->component(item,this::components));list.addView(row);
|
||||
}
|
||||
activity.footer(content,"各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。");
|
||||
}
|
||||
private void component(LicenseCatalog.Component item,Runnable back){
|
||||
LinearLayout content=activity.formPage("license:"+item.id,item.name,back);LinearLayout info=activity.section(content,"");
|
||||
String version=item.id.equals("crossdesk")?applicationVersion():item.version.length()==40?item.version.substring(0,12):item.version;
|
||||
value(info,"版本",version);ui.divider(info);value(info,"许可",item.license);ui.divider(info);
|
||||
external(info,"查看源码",item.sourceURL);
|
||||
if(!item.buildDocuments.isEmpty()||!item.buildSourceURL.isEmpty()){
|
||||
ui.divider(info);
|
||||
if(!item.buildDocuments.isEmpty())activity.link(info,"构建配方与项目补丁","",()->buildSources(item,back));
|
||||
else external(info,"构建配方与项目补丁",item.buildSourceURL);
|
||||
}
|
||||
ui.gap(content,16);LinearLayout docs=activity.section(content,"许可与版权声明");
|
||||
for(int i=0;i<item.documents.size();i++){
|
||||
if(i>0)ui.divider(docs);LicenseCatalog.Document document=item.documents.get(i);
|
||||
activity.link(docs,item.documentTitle(document),"",()->document(item.documentTitle(document),document,()->component(item,back)));
|
||||
}
|
||||
}
|
||||
private void source(){
|
||||
LinearLayout content=activity.formPage("source","源码与构建说明",this::show);
|
||||
LinearLayout info=activity.section(content,"本版本源码与构建说明");
|
||||
if(catalog.source==null){paragraph(info,"本版本源码信息暂不可用。",SECONDARY);ui.divider(info);external(info,"项目主页","https://github.com/kunkundi/crossdesk");return;}
|
||||
String revision=catalog.source.optString("revision","");String tag=catalog.source.isNull("tag")?"":catalog.source.optString("tag","");
|
||||
value(info,"源码版本",tag.isEmpty()?revision.substring(0,Math.min(12,revision.length())):tag);ui.divider(info);
|
||||
if(catalog.source.optBoolean("isModified")){paragraph(info,"开发版本包含尚未发布的修改,以下链接为基础版本源码。",SECONDARY);ui.divider(info);}
|
||||
external(info,"查看源码",catalog.source.optString("sourceURL",catalog.find("crossdesk").sourceURL));ui.divider(info);
|
||||
try{
|
||||
List<LicenseCatalog.Document> documents=LicenseCatalog.documents(catalog.source.optJSONArray("buildDocuments"));
|
||||
if(!documents.isEmpty())actionLink(info,"构建说明",()->document("构建说明",documents.get(0),this::source));
|
||||
else external(info,"构建说明",catalog.source.optString("buildInstructionsURL","https://github.com/kunkundi/crossdesk"));
|
||||
}catch(org.json.JSONException error){paragraph(info,"本版本源码信息暂不可用。",SECONDARY);}
|
||||
}
|
||||
private void buildSources(LicenseCatalog.Component item,Runnable back){
|
||||
LinearLayout content=activity.formPage("build:"+item.id,"构建配方与项目补丁",()->component(item,back));
|
||||
if(!item.buildSourceURL.isEmpty()){LinearLayout upstream=activity.section(content,"");external(upstream,"查看源码",item.buildSourceURL);ui.gap(content,28);}
|
||||
LinearLayout docs=activity.section(content,"");
|
||||
for(int i=0;i<item.buildDocuments.size();i++){
|
||||
if(i>0)ui.divider(docs);LicenseCatalog.Document document=item.buildDocuments.get(i);
|
||||
activity.link(docs,document.title,"",()->document(document.title,document,()->buildSources(item,back)));
|
||||
}
|
||||
}
|
||||
private void document(String title,LicenseCatalog.Document document,Runnable back){
|
||||
try{activity.licenseDocument(title,LicenseCatalog.read(activity,document.asset),back);}catch(java.io.IOException error){unavailable(activity.formPage("license-error",title,back));}
|
||||
}
|
||||
private void value(LinearLayout parent,String label,String value){
|
||||
LinearLayout row=ui.row();row.setPadding(ui.dp(16),ui.dp(14),ui.dp(16),ui.dp(14));
|
||||
TextView key=ui.text(label,16,false);key.setPadding(0,0,ui.dp(16),0);row.addView(key);
|
||||
TextView content=ui.text(value,16,false);content.setTextColor(SECONDARY);content.setGravity(Gravity.END);content.setTextIsSelectable(true);row.addView(content,new LinearLayout.LayoutParams(0,-2,1));parent.addView(row);
|
||||
}
|
||||
private void paragraph(LinearLayout parent,String text,int color){TextView view=ui.text(text,13,false);view.setTextColor(color);view.setPadding(ui.dp(16),ui.dp(12),ui.dp(16),ui.dp(12));view.setLineSpacing(ui.dp(3),1);parent.addView(view);}
|
||||
private void external(LinearLayout parent,String title,String url){
|
||||
actionLink(parent,title,()->open(url));
|
||||
}
|
||||
private void actionLink(LinearLayout parent,String title,Runnable action){TextView link=ui.action(title,BLUE,action);link.setTypeface(android.graphics.Typeface.DEFAULT);link.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);link.setPadding(ui.dp(16),ui.dp(12),ui.dp(16),ui.dp(12));parent.addView(link);}
|
||||
private void open(String url){
|
||||
Uri uri=Uri.parse(url);if(!"https".equals(uri.getScheme())&&!"http".equals(uri.getScheme()))return;
|
||||
try{activity.startActivity(new Intent(Intent.ACTION_VIEW,uri).addCategory(Intent.CATEGORY_BROWSABLE));}catch(ActivityNotFoundException error){Toast.makeText(activity,"未找到可打开链接的浏览器",Toast.LENGTH_SHORT).show();}
|
||||
}
|
||||
private void unavailable(LinearLayout content){
|
||||
content.setGravity(Gravity.CENTER);ui.gap(content,32);TextView icon=ui.text("▤",36,false);icon.setTextColor(SECONDARY);content.addView(icon);ui.gap(content,12);
|
||||
content.addView(ui.text("无法读取开源许可",17,true));TextView message=ui.text("应用中的许可文件缺失或损坏,请重新安装应用。",14,false);message.setGravity(Gravity.CENTER);message.setTextColor(SECONDARY);content.addView(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.AtomicFile;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** Main-thread inbox. The server owns content; this device owns read/dismissed revisions. */
|
||||
final class AnnouncementInbox implements AutoCloseable {
|
||||
interface Sender { void send(JSONObject request, String requestId); }
|
||||
static final class Item {
|
||||
final long id, revision, updatedAt;
|
||||
final String title, body;
|
||||
boolean read;
|
||||
Item(JSONObject json) throws Exception {
|
||||
id=integer(json,"id");revision=integer(json,"revision");updatedAt=integer(json,"updated_at");
|
||||
title=string(json,"title");body=string(json,"body");
|
||||
if(id<=0||revision<=0||updatedAt<=0||updatedAt>32_503_680_000L||
|
||||
title.getBytes(StandardCharsets.UTF_8).length>240||body.getBytes(StandardCharsets.UTF_8).length>8000)
|
||||
throw new IllegalArgumentException("Invalid announcement");
|
||||
}
|
||||
}
|
||||
final List<Item> items=new ArrayList<>();
|
||||
int total, unread;
|
||||
boolean loaded, loading, failed, readSaveFailed, deleteFailed;
|
||||
Sender sender;
|
||||
Runnable changed=()->{};
|
||||
private final File directory;
|
||||
private final Handler handler=new Handler(Looper.getMainLooper());
|
||||
private final Map<Long,Long> reads=new HashMap<>(), dismissed=new HashMap<>();
|
||||
private Map<Long,Long> catalog=new LinkedHashMap<>(), pendingCatalog=new LinkedHashMap<>();
|
||||
private final Map<Integer,List<Long>> pageIds=new HashMap<>();
|
||||
private final Map<Integer,List<Item>> pageBodies=new HashMap<>();
|
||||
private long catalogRevision=-1, pendingRevision=-1;
|
||||
private int pendingTotal, pageOffset, visibleLimit=20;
|
||||
private boolean summary=true, connected;
|
||||
private String scope="", requestId;
|
||||
private AtomicFile file;
|
||||
private Runnable timeout;
|
||||
|
||||
AnnouncementInbox(File directory){this.directory=directory;}
|
||||
boolean isConnected(){return connected;}
|
||||
void configure(String host,int port,String deviceId){
|
||||
if(host.isEmpty()||port<=0||deviceId.isEmpty())return;
|
||||
String canonical=host.toLowerCase(Locale.ROOT);
|
||||
if(canonical.endsWith("."))canonical=canonical.substring(0,canonical.length()-1);
|
||||
String next=new JSONArray().put(canonical).put(port).put(deviceId).toString();
|
||||
if(scope.equals(next))return;
|
||||
boolean wasConnected=connected;reset();connected=wasConnected;scope=next;
|
||||
try{
|
||||
byte[] hash=MessageDigest.getInstance("SHA-256").digest(scope.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder name=new StringBuilder();for(byte b:hash)name.append(String.format(Locale.ROOT,"%02x",b&255));
|
||||
file=new AtomicFile(new File(directory,name+".json"));loadLocalState();
|
||||
}catch(Exception error){file=null;}
|
||||
refresh();
|
||||
}
|
||||
void reset(){
|
||||
cancelRequest();scope="";file=null;connected=false;items.clear();reads.clear();dismissed.clear();
|
||||
catalog.clear();pendingCatalog.clear();pageIds.clear();pageBodies.clear();
|
||||
catalogRevision=pendingRevision=-1;pendingTotal=pageOffset=total=unread=0;visibleLimit=20;
|
||||
loaded=failed=readSaveFailed=deleteFailed=false;changed.run();
|
||||
}
|
||||
void setConnected(boolean value){
|
||||
if(connected==value)return;connected=value;
|
||||
if(value)refresh();else{cancelRequest();changed.run();}
|
||||
}
|
||||
void refresh(){
|
||||
cancelRequest();pendingCatalog=new LinkedHashMap<>();pendingRevision=-1;pendingTotal=0;summary=true;
|
||||
request(0);changed.run();
|
||||
}
|
||||
void loadMore(){
|
||||
if(!connected||loading||failed||catalogRevision<0||visibleLimit>=total)return;
|
||||
visibleLimit+=Math.min(20,total-visibleLimit);rebuild();changed.run();
|
||||
}
|
||||
Item find(long id,long revision){
|
||||
for(Item item:items)if(item.id==id&&item.revision==revision)return item;return null;
|
||||
}
|
||||
void markRead(Item item){
|
||||
if(find(item.id,item.revision)==null||Objects.equals(reads.get(item.id),item.revision))return;
|
||||
Map<Long,Long> updated=new HashMap<>(reads);updated.put(item.id,item.revision);
|
||||
if(!saveLocalState(updated,dismissed)){readSaveFailed=true;changed.run();return;}
|
||||
reads.clear();reads.putAll(updated);readSaveFailed=deleteFailed=false;updateReadingState();changed.run();
|
||||
}
|
||||
void dismiss(Item item){
|
||||
if(find(item.id,item.revision)==null){deleteFailed=true;changed.run();return;}
|
||||
Map<Long,Long> updated=new HashMap<>(dismissed);updated.put(item.id,item.revision);
|
||||
if(!saveLocalState(reads,updated)){deleteFailed=true;changed.run();return;}
|
||||
dismissed.clear();dismissed.putAll(updated);deleteFailed=readSaveFailed=false;
|
||||
if(summary&&loading){items.removeIf(this::isDismissed);updateReadingState();}
|
||||
else{cancelRequest();rebuild();}
|
||||
changed.run();
|
||||
}
|
||||
void failed(String id){
|
||||
if(!Objects.equals(requestId,id)||id==null)return;
|
||||
cancelRequest();failed=true;changed.run();
|
||||
}
|
||||
void receive(JSONObject message){
|
||||
Object identifier=message.opt("request_id");
|
||||
if(!(identifier instanceof String)||!identifier.equals(requestId))return;
|
||||
String id=(String)identifier;
|
||||
try{
|
||||
if(!"announcements".equals(string(message,"type"))||message.has("error"))throw new IllegalArgumentException();
|
||||
Object summaryFlag=message.get("summary_only");
|
||||
long offsetValue=integer(message,"offset"), totalValue=integer(message,"total");
|
||||
long revision=integer(message,"catalog_revision");JSONArray entries=message.getJSONArray("items");
|
||||
if(!(summaryFlag instanceof Boolean)||((Boolean)summaryFlag)!=summary||offsetValue<0||
|
||||
totalValue<offsetValue||totalValue>Integer.MAX_VALUE||revision<0||
|
||||
entries.length()!=Math.min(summary?200:20,totalValue-offsetValue))throw new IllegalArgumentException();
|
||||
int offset=(int)offsetValue, count=(int)totalValue;
|
||||
LinkedHashMap<Long,Long> entriesById=new LinkedHashMap<>();
|
||||
for(int i=0;i<entries.length();i++){
|
||||
JSONObject entry=entries.getJSONObject(i);long key=integer(entry,"id"), version=integer(entry,"revision");
|
||||
if(key<=0||version<=0||entriesById.put(key,version)!=null)throw new IllegalArgumentException();
|
||||
}
|
||||
long expected=summary?pendingRevision:catalogRevision;
|
||||
if(expected>=0&&expected!=revision){refresh();return;}
|
||||
if(summary){
|
||||
if(offset!=pendingCatalog.size()||(pendingRevision>=0&&pendingTotal!=count))throw new IllegalArgumentException();
|
||||
for(Map.Entry<Long,Long> entry:entriesById.entrySet())
|
||||
if(pendingCatalog.put(entry.getKey(),entry.getValue())!=null)throw new IllegalArgumentException();
|
||||
pendingRevision=revision;pendingTotal=count;cancelRequest();
|
||||
if(pendingCatalog.size()<pendingTotal){request(pendingCatalog.size());changed.run();return;}
|
||||
if(catalogRevision!=pendingRevision){pageIds.clear();pageBodies.clear();}
|
||||
catalog=new LinkedHashMap<>(pendingCatalog);catalogRevision=pendingRevision;
|
||||
reads.keySet().retainAll(catalog.keySet());dismissed.keySet().retainAll(catalog.keySet());
|
||||
}else{
|
||||
if(offset!=pageOffset||count!=catalog.size())throw new IllegalArgumentException();
|
||||
List<Item> bodies=new ArrayList<>();
|
||||
for(int i=0;i<entries.length();i++){
|
||||
Item item=new Item(entries.getJSONObject(i));
|
||||
if(!Objects.equals(catalog.get(item.id),item.revision))throw new IllegalArgumentException();
|
||||
// A repeated ID on another body page must not create duplicate rows.
|
||||
for(Map.Entry<Integer,List<Long>> page:pageIds.entrySet())
|
||||
if(page.getKey()!=offset&&page.getValue().contains(item.id))throw new IllegalArgumentException();
|
||||
bodies.add(item);
|
||||
}
|
||||
cancelRequest();pageIds.put(offset,new ArrayList<>(entriesById.keySet()));pageBodies.put(offset,bodies);
|
||||
}
|
||||
failed=false;rebuild();changed.run();
|
||||
}catch(Exception error){failed(id);}
|
||||
}
|
||||
private void request(int offset){
|
||||
if(!connected||scope.isEmpty())return;
|
||||
String id=UUID.randomUUID().toString();
|
||||
try{
|
||||
JSONObject payload=new JSONObject().put("type","announcements_list").put("request_id",id)
|
||||
.put("summary_only",summary).put("offset",offset);
|
||||
requestId=id;loading=true;failed=false;timeout=()->failed(id);handler.postDelayed(timeout,12_000);
|
||||
if(sender!=null)sender.send(payload,id);else failed(id);
|
||||
}catch(Exception error){failed(id);}
|
||||
}
|
||||
private void cancelRequest(){
|
||||
if(timeout!=null)handler.removeCallbacks(timeout);timeout=null;requestId=null;loading=false;
|
||||
}
|
||||
private boolean isDismissed(Item item){return Objects.equals(dismissed.get(item.id),item.revision);}
|
||||
private void updateReadingState(){
|
||||
total=unread=0;
|
||||
for(Map.Entry<Long,Long> entry:catalog.entrySet()){
|
||||
if(Objects.equals(dismissed.get(entry.getKey()),entry.getValue()))continue;
|
||||
total++;if(!Objects.equals(reads.get(entry.getKey()),entry.getValue()))unread++;
|
||||
}
|
||||
for(Item item:items)item.read=Objects.equals(reads.get(item.id),item.revision);
|
||||
}
|
||||
private void rebuild(){
|
||||
updateReadingState();int wanted=Math.min(visibleLimit,total), position=0;Integer nextOffset=null;
|
||||
List<Item> visible=new ArrayList<>();Set<Integer> retainedPages=new HashSet<>();summary=false;
|
||||
if(wanted>0)for(int offset=0;offset<catalog.size();offset+=20){
|
||||
List<Long> ids=pageIds.get(offset);if(ids==null){nextOffset=offset;break;}
|
||||
Set<Long> selection=new HashSet<>();
|
||||
for(long id:ids){
|
||||
Long revision=catalog.get(id);if(revision==null||Objects.equals(dismissed.get(id),revision))continue;
|
||||
if(position<wanted)selection.add(id);position++;
|
||||
}
|
||||
if(selection.isEmpty()){pageBodies.remove(offset);continue;}
|
||||
retainedPages.add(offset);List<Item> bodies=pageBodies.get(offset);
|
||||
if(bodies==null){nextOffset=offset;break;}
|
||||
for(Item item:bodies)if(selection.contains(item.id))visible.add(item);
|
||||
if(visible.size()==wanted)break;
|
||||
}
|
||||
if(visible.size()==wanted)pageBodies.keySet().retainAll(retainedPages);
|
||||
else{
|
||||
Set<Long> retained=new HashSet<>();for(Item item:visible)retained.add(item.id);
|
||||
for(Item item:items)if(Objects.equals(catalog.get(item.id),item.revision)&&!isDismissed(item)&&retained.add(item.id)){
|
||||
visible.add(item);if(visible.size()==wanted)break;
|
||||
}
|
||||
}
|
||||
items.clear();items.addAll(visible);loaded=true;updateReadingState();
|
||||
if(nextOffset!=null){pageOffset=nextOffset;request(nextOffset);}
|
||||
}
|
||||
private void loadLocalState(){
|
||||
if(file==null||file.getBaseFile().length()>16*1024*1024)return;
|
||||
try{
|
||||
JSONObject saved=new JSONObject(new String(file.readFully(),StandardCharsets.UTF_8));
|
||||
long version=integer(saved,"version");
|
||||
if(!scope.equals(string(saved,"scope"))||(version!=1&&version!=2))return;
|
||||
loadRevisions(saved.optJSONObject("reads"),reads);loadRevisions(saved.optJSONObject("dismissed"),dismissed);
|
||||
}catch(Exception ignored){}
|
||||
}
|
||||
private static void loadRevisions(JSONObject json,Map<Long,Long> target){
|
||||
if(json==null)return;
|
||||
for(Iterator<String> keys=json.keys();keys.hasNext();){
|
||||
String key=keys.next();
|
||||
try{long id=Long.parseLong(key), revision=integer(json,key);if(id>0&&revision>0)target.put(id,revision);}catch(Exception ignored){}
|
||||
}
|
||||
}
|
||||
private boolean saveLocalState(Map<Long,Long> readValues,Map<Long,Long> dismissedValues){
|
||||
if(file==null)return false;FileOutputStream output=null;
|
||||
try{
|
||||
if(!directory.isDirectory()&&!directory.mkdirs())return false;
|
||||
JSONObject saved=new JSONObject().put("version",2).put("scope",scope)
|
||||
.put("reads",revisions(readValues)).put("dismissed",revisions(dismissedValues));
|
||||
byte[] bytes=saved.toString().getBytes(StandardCharsets.UTF_8);
|
||||
output=file.startWrite();output.write(bytes);output.getFD().sync();file.finishWrite(output);output=null;
|
||||
// AtomicFile logs some rename failures instead of throwing; verify the committed file.
|
||||
return Arrays.equals(bytes,file.readFully());
|
||||
}catch(Exception error){if(output!=null)file.failWrite(output);return false;}
|
||||
}
|
||||
private static JSONObject revisions(Map<Long,Long> values)throws Exception{
|
||||
JSONObject json=new JSONObject();for(Map.Entry<Long,Long> entry:values.entrySet())json.put(entry.getKey().toString(),entry.getValue());return json;
|
||||
}
|
||||
private static long integer(JSONObject json,String key)throws Exception{
|
||||
Object value=json.get(key);if(!(value instanceof Integer)&&!(value instanceof Long))throw new IllegalArgumentException(key);
|
||||
return ((Number)value).longValue();
|
||||
}
|
||||
private static String string(JSONObject json,String key)throws Exception{
|
||||
Object value=json.get(key);if(!(value instanceof String))throw new IllegalArgumentException(key);return (String)value;
|
||||
}
|
||||
@Override public void close(){cancelRequest();connected=false;sender=null;changed=()->{};}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.URLSpan;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Plain text plus HTTP(S) links, matching iOS; never interpret HTML or arbitrary Markdown. */
|
||||
final class AnnouncementText {
|
||||
static CharSequence format(String body){
|
||||
String text=body.replace("\r\n","\n");SpannableStringBuilder result=new SpannableStringBuilder();
|
||||
for(int position=0;position<text.length();){
|
||||
if(text.charAt(position)=='['){
|
||||
int labelEnd=text.indexOf(']',position+1), start=labelEnd+2;
|
||||
if(labelEnd>position+1&&start<text.length()&&text.charAt(labelEnd+1)=='('&&
|
||||
!text.substring(position+1,labelEnd).matches("(?s).*[\\[\\]\\r\\n].*")&&hasScheme(text,start)){
|
||||
int end=urlEnd(text,start);
|
||||
if(end<text.length()&&text.charAt(end)==')'&&
|
||||
appendLink(result,text.substring(position+1,labelEnd),text.substring(start,end))){
|
||||
position=end+1;continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(hasScheme(text,position)){
|
||||
int end=urlEnd(text,position);
|
||||
while(end>position&&".,;:!?".indexOf(text.charAt(end-1))>=0)end--;
|
||||
String address=text.substring(position,end);
|
||||
if(appendLink(result,address,address)){position=end;continue;}
|
||||
}
|
||||
result.append(text.charAt(position++));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static boolean isWebURL(String address){
|
||||
if(address==null)return false;
|
||||
for(int i=0;i<address.length();i++){char c=address.charAt(i);if(c<=0x20||c==0x7f||"\\<>\"".indexOf(c)>=0)return false;}
|
||||
Uri uri=Uri.parse(address);String scheme=uri.getScheme(),host=uri.getHost();
|
||||
return scheme!=null&&(scheme.equalsIgnoreCase("http")||scheme.equalsIgnoreCase("https"))&&host!=null&&!host.isEmpty();
|
||||
}
|
||||
private static boolean hasScheme(String text,int start){
|
||||
String prefix=text.substring(start,Math.min(text.length(),start+8)).toLowerCase(Locale.ROOT);
|
||||
return prefix.startsWith("https://")||prefix.startsWith("http://");
|
||||
}
|
||||
private static int urlEnd(String text,int start){
|
||||
int parentheses=0,brackets=0,end=start;
|
||||
for(;end<text.length();end++){
|
||||
char c=text.charAt(end);
|
||||
if(c<=0x20||c==0x7f||c=='\\'||c==96||"<>\"'{},。;:!?、()【】《》「」『』“”‘’".indexOf(c)>=0)break;
|
||||
if(c=='(')parentheses++;
|
||||
if(c==')'){if(parentheses==0)break;parentheses--;}
|
||||
if(c=='[')brackets++;
|
||||
if(c==']'){if(brackets==0)break;brackets--;}
|
||||
}
|
||||
return end;
|
||||
}
|
||||
private static boolean appendLink(SpannableStringBuilder result,String label,String destination){
|
||||
if(!isWebURL(destination))return false;
|
||||
int start=result.length();result.append(label);
|
||||
result.setSpan(new URLSpan(destination){
|
||||
@Override public void onClick(View widget){
|
||||
if(!isWebURL(getURL()))return;
|
||||
try{widget.getContext().startActivity(new Intent(Intent.ACTION_VIEW,Uri.parse(getURL())).addCategory(Intent.CATEGORY_BROWSABLE));}
|
||||
catch(android.content.ActivityNotFoundException error){Toast.makeText(widget.getContext(),"没有可打开网页的浏览器",Toast.LENGTH_SHORT).show();}
|
||||
}
|
||||
},start,result.length(),Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Color;
|
||||
import android.text.TextUtils;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Grouped announcement list and plain-text detail, sharing the iOS presentation. */
|
||||
@android.annotation.SuppressLint("ViewConstructor")
|
||||
final class AnnouncementView extends ScrollView {
|
||||
private final MobileUi ui;
|
||||
private final AnnouncementInbox inbox;
|
||||
private final LinearLayout content;
|
||||
private final Consumer<AnnouncementInbox.Item> open;
|
||||
private final Runnable authorize;
|
||||
private final boolean detail;
|
||||
private final long id,revision;
|
||||
private final SimpleDateFormat dates=new SimpleDateFormat("yyyy-MM-dd HH:mm",Locale.getDefault());
|
||||
private TextView countLabel;
|
||||
private AlertDialog deleteDialog;
|
||||
private float downX,downY;
|
||||
private boolean pulling,canPull;
|
||||
private final int slop;
|
||||
|
||||
AnnouncementView(MobileUi ui,AnnouncementInbox inbox,AnnouncementInbox.Item selected,
|
||||
Consumer<AnnouncementInbox.Item> open,Runnable authorize){
|
||||
super(ui.context);this.ui=ui;this.inbox=inbox;this.open=open;this.authorize=authorize;
|
||||
detail=selected!=null;id=detail?selected.id:0;revision=detail?selected.revision:0;
|
||||
slop=ViewConfiguration.get(getContext()).getScaledTouchSlop();
|
||||
setFillViewport(true);setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY);
|
||||
content=ui.column();content.setPadding(ui.dp(20),ui.dp(detail?20:16),ui.dp(20),ui.dp(28));
|
||||
addView(content,new ScrollView.LayoutParams(-1,-2));
|
||||
setOnScrollChangeListener((v,x,y,oldX,oldY)->{if(y>oldY)loadVisiblePage();});
|
||||
render();
|
||||
}
|
||||
void render(){
|
||||
int y=getScrollY();content.removeAllViews();countLabel=null;
|
||||
if(detail)renderDetail();else renderList();
|
||||
post(()->{if(isAttachedToWindow()){scrollTo(0,y);loadVisiblePage();}});
|
||||
}
|
||||
private String date(AnnouncementInbox.Item item){return dates.format(new Date(item.updatedAt*1000));}
|
||||
private TextView message(String value,int color){
|
||||
TextView text=ui.text(value,14,false);text.setTextColor(color);text.setLineSpacing(ui.dp(3),1);return text;
|
||||
}
|
||||
private void renderList(){
|
||||
if(inbox.deleteFailed){content.addView(message("无法删除这条公告,请重试。",RED));ui.gap(content,12);}
|
||||
if(!inbox.isConnected()){
|
||||
content.addView(message("连接服务器后可获取最新公告。",SECONDARY));
|
||||
content.addView(ui.action("连接服务器",BLUE,authorize));ui.gap(content,8);
|
||||
}else if(inbox.failed){
|
||||
LinearLayout error=ui.row();error.addView(message("公告加载失败,请重试。",SECONDARY),new LinearLayout.LayoutParams(0,-2,1));
|
||||
error.addView(ui.action("重试",BLUE,inbox::refresh),ui.size(64,48));content.addView(error);ui.gap(content,8);
|
||||
}
|
||||
countLabel=ui.text("共 "+inbox.total+" 条 · "+inbox.unread+" 条未读",13,false);
|
||||
countLabel.setTextColor(SECONDARY);countLabel.setPadding(ui.dp(16),ui.dp(8),0,ui.dp(8));content.addView(countLabel);
|
||||
LinearLayout card=ui.column();ui.card(card,12);content.addView(card);
|
||||
for(AnnouncementInbox.Item item:inbox.items){
|
||||
if(card.getChildCount()>0)ui.divider(card);
|
||||
card.addView(itemRow(item),ui.size(-1,-2));
|
||||
}
|
||||
if(inbox.loading){
|
||||
LinearLayout loading=ui.row();loading.setGravity(Gravity.CENTER);loading.setPadding(0,ui.dp(18),0,ui.dp(18));
|
||||
ProgressBar spinner=new ProgressBar(getContext());spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));loading.addView(spinner,ui.size(20,20));
|
||||
TextView label=message("加载中…",SECONDARY);label.setPadding(ui.dp(10),0,0,0);loading.addView(label);card.addView(loading);
|
||||
}else if(inbox.items.size()<inbox.total&&!inbox.failed){
|
||||
TextView more=ui.action("加载更多",BLUE,inbox::loadMore);more.setEnabled(inbox.isConnected());card.addView(more,ui.size(-1,48));
|
||||
}else if(inbox.loaded&&inbox.total==0&&!inbox.failed&&inbox.isConnected()){
|
||||
TextView empty=message("暂无公告",SECONDARY);empty.setGravity(Gravity.CENTER);card.addView(empty,ui.size(-1,120));
|
||||
}
|
||||
}
|
||||
private View itemRow(AnnouncementInbox.Item item){
|
||||
LinearLayout row=ui.row();row.setBackgroundColor(Color.WHITE);row.setPadding(ui.dp(16),ui.dp(16),ui.dp(12),ui.dp(16));
|
||||
LinearLayout words=ui.column();
|
||||
LinearLayout top=ui.row();top.setGravity(Gravity.TOP);
|
||||
View dot=new View(getContext());dot.setBackground(ui.background(item.read?Color.TRANSPARENT:BLUE,4));
|
||||
LinearLayout.LayoutParams dotLayout=ui.size(7,7);dotLayout.topMargin=ui.dp(7);dotLayout.rightMargin=ui.dp(10);top.addView(dot,dotLayout);
|
||||
TextView title=ui.text(item.title,16,!item.read);title.setMaxLines(2);title.setEllipsize(TextUtils.TruncateAt.END);
|
||||
words.addView(title,ui.size(-1,-2));ui.gap(words,7);TextView date=ui.text(date(item),12,false);date.setTextColor(SECONDARY);words.addView(date);
|
||||
top.addView(words,new LinearLayout.LayoutParams(0,-2,1));row.addView(top,new LinearLayout.LayoutParams(0,-2,1));
|
||||
TextView chevron=ui.text("›",23,false);chevron.setTextColor(0xFFB7B7BD);chevron.setPadding(ui.dp(10),0,0,0);row.addView(chevron);
|
||||
row.setContentDescription(item.title+","+date(item)+","+(item.read?"已读":"未读"));row.setFocusable(true);
|
||||
row.setOnClickListener(v->open.accept(item));row.setOnLongClickListener(v->{confirmDelete(item);return true;});
|
||||
return new SwipeRow(row,()->confirmDelete(item));
|
||||
}
|
||||
private void confirmDelete(AnnouncementInbox.Item item){
|
||||
if(deleteDialog!=null)deleteDialog.dismiss();
|
||||
deleteDialog=new AlertDialog.Builder(getContext()).setTitle("删除公告").setMessage("删除这条公告?")
|
||||
.setNegativeButton("取消",null).setPositiveButton("删除",(dialog,which)->inbox.dismiss(item)).show();
|
||||
}
|
||||
private void renderDetail(){
|
||||
AnnouncementInbox.Item item=inbox.find(id,revision);
|
||||
if(item==null){content.addView(message("这条公告已更新或删除,请返回公告列表查看。",SECONDARY));return;}
|
||||
content.addView(ui.text(item.title,22,true));ui.gap(content,16);
|
||||
TextView date=ui.text(date(item)+" · "+(item.read?"已读":"未读"),12,false);date.setTextColor(SECONDARY);content.addView(date);ui.gap(content,16);
|
||||
if(inbox.readSaveFailed){
|
||||
content.addView(message("无法保存本地阅读状态,请重试。",RED));
|
||||
content.addView(ui.action("重试保存阅读状态",BLUE,()->inbox.markRead(item)));ui.gap(content,16);
|
||||
}
|
||||
TextView body=ui.text("",17,false);body.setText(AnnouncementText.format(item.body));body.setTextIsSelectable(true);
|
||||
body.setMovementMethod(LinkMovementMethod.getInstance());body.setLinksClickable(true);body.setLinkTextColor(BLUE);body.setLineSpacing(ui.dp(4),1);
|
||||
content.addView(body,ui.size(-1,-2));
|
||||
}
|
||||
private void loadVisiblePage(){
|
||||
if(!detail&&isAttachedToWindow()&&inbox.isConnected()&&!inbox.loading&&!inbox.failed&&inbox.items.size()<inbox.total&&
|
||||
content.getHeight()>0&&getHeight()>0&&getScrollY()+getHeight()>=content.getHeight()-ui.dp(48))inbox.loadMore();
|
||||
}
|
||||
private void trackPull(MotionEvent event){
|
||||
if(!detail&&inbox.isConnected()&&!inbox.loading){
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_DOWN){downX=event.getX();downY=event.getY();pulling=false;canPull=getScrollY()==0;}
|
||||
else if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&canPull&&getScrollY()==0&&
|
||||
event.getY()-downY>slop&&event.getY()-downY>Math.abs(event.getX()-downX))pulling=true;
|
||||
}
|
||||
}
|
||||
@Override public boolean onInterceptTouchEvent(MotionEvent event){
|
||||
trackPull(event);return pulling||super.onInterceptTouchEvent(event);
|
||||
}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // Pull gesture only; toolbar refresh is the accessible action.
|
||||
@Override public boolean onTouchEvent(MotionEvent event){
|
||||
trackPull(event);
|
||||
if(!pulling)return super.onTouchEvent(event);
|
||||
boolean reached=event.getY()-downY>=ui.dp(72);
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&countLabel!=null)countLabel.setText(reached?"松开刷新":"下拉刷新");
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL){
|
||||
pulling=false;if(reached&&event.getActionMasked()==MotionEvent.ACTION_UP)inbox.refresh();else render();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@Override protected void onDetachedFromWindow(){if(deleteDialog!=null)deleteDialog.dismiss();super.onDetachedFromWindow();}
|
||||
|
||||
/** Reveal a delete action without interfering with vertical scrolling or row taps. */
|
||||
private final class SwipeRow extends FrameLayout {
|
||||
private final View front,remove;
|
||||
private float x,y,origin;
|
||||
private boolean dragging;
|
||||
SwipeRow(View front,Runnable delete){
|
||||
super(ui.context);this.front=front;setClipChildren(true);
|
||||
remove=ui.action("删除",Color.WHITE,delete);remove.setBackgroundColor(RED);
|
||||
remove.setVisibility(INVISIBLE);
|
||||
remove.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);remove.setFocusable(false);
|
||||
addView(remove,new FrameLayout.LayoutParams(ui.dp(76),-1,Gravity.END));addView(front,new FrameLayout.LayoutParams(-1,-2));
|
||||
}
|
||||
@Override public boolean onInterceptTouchEvent(MotionEvent event){
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_DOWN){front.animate().cancel();x=event.getX();y=event.getY();origin=front.getTranslationX();dragging=false;}
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&Math.abs(event.getX()-x)>slop&&Math.abs(event.getX()-x)>Math.abs(event.getY()-y)){
|
||||
dragging=true;remove.setVisibility(VISIBLE);getParent().requestDisallowInterceptTouchEvent(true);return true;
|
||||
}
|
||||
return super.onInterceptTouchEvent(event);
|
||||
}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // Taps/long presses belong to the accessible row and delete button.
|
||||
@Override public boolean onTouchEvent(MotionEvent event){
|
||||
if(!dragging)return super.onTouchEvent(event);
|
||||
int direction=getLayoutDirection()==LAYOUT_DIRECTION_RTL?1:-1;
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_MOVE)front.setTranslationX(direction*Math.max(0,Math.min(ui.dp(76),direction*(origin+event.getX()-x))));
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL){
|
||||
boolean expanded=Math.abs(front.getTranslationX())>ui.dp(38);dragging=false;
|
||||
front.animate().translationX(expanded?direction*ui.dp(76):0).setDuration(150).withEndAction(()->{if(!expanded)remove.setVisibility(INVISIBLE);}).start();
|
||||
remove.setFocusable(expanded);remove.setImportantForAccessibility(expanded?IMPORTANT_FOR_ACCESSIBILITY_YES:IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioTrack;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
|
||||
/** Bounded audio queue: network callbacks never block on the speaker. */
|
||||
final class AudioPlayer implements AutoCloseable {
|
||||
private final ArrayBlockingQueue<byte[]> queue = new ArrayBlockingQueue<>(20);
|
||||
private volatile boolean closed;
|
||||
private volatile boolean enabled = true;
|
||||
private final Thread thread = new Thread(this::play, "CrossDesk audio");
|
||||
AudioPlayer() { thread.start(); }
|
||||
void offer(byte[] pcm) {
|
||||
if (!closed && enabled && !queue.offer(pcm)) { queue.poll(); queue.offer(pcm); }
|
||||
}
|
||||
void setEnabled(boolean value) { enabled = value; queue.clear(); }
|
||||
private void play() {
|
||||
AudioTrack track = null;
|
||||
try {
|
||||
int minimum = AudioTrack.getMinBufferSize(48000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT);
|
||||
track = new AudioTrack.Builder()
|
||||
.setAudioAttributes(new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build())
|
||||
.setAudioFormat(new AudioFormat.Builder().setSampleRate(48000)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).setEncoding(AudioFormat.ENCODING_PCM_16BIT).build())
|
||||
.setBufferSizeInBytes(Math.max(minimum, 9600)).setTransferMode(AudioTrack.MODE_STREAM).build();
|
||||
track.play();
|
||||
while (!closed) {
|
||||
byte[] data = queue.take();
|
||||
if (!enabled) continue;
|
||||
int offset = 0;
|
||||
while (!closed && enabled && offset < data.length) {
|
||||
int written = track.write(data, offset, data.length - offset, AudioTrack.WRITE_BLOCKING);
|
||||
if (written <= 0) break;
|
||||
offset += written;
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (IllegalArgumentException | IllegalStateException ignored) {
|
||||
// Devices with unavailable output can still operate as a silent controller.
|
||||
} finally {
|
||||
if (track != null) { track.release(); }
|
||||
}
|
||||
}
|
||||
@Override public void close() { closed = true; queue.clear(); thread.interrupt(); }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
final class KeyMap {
|
||||
static int windowsCode(int code) {
|
||||
if (code >= KeyEvent.KEYCODE_A && code <= KeyEvent.KEYCODE_Z) return 0x41 + code - KeyEvent.KEYCODE_A;
|
||||
if (code >= KeyEvent.KEYCODE_0 && code <= KeyEvent.KEYCODE_9) return 0x30 + code - KeyEvent.KEYCODE_0;
|
||||
if (code >= KeyEvent.KEYCODE_F1 && code <= KeyEvent.KEYCODE_F12) return 0x70 + code - KeyEvent.KEYCODE_F1;
|
||||
return switch (code) {
|
||||
case KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> 0x0D;
|
||||
case KeyEvent.KEYCODE_DEL -> 0x08;
|
||||
case KeyEvent.KEYCODE_FORWARD_DEL -> 0x2E;
|
||||
case KeyEvent.KEYCODE_TAB -> 0x09;
|
||||
case KeyEvent.KEYCODE_ESCAPE -> 0x1B;
|
||||
case KeyEvent.KEYCODE_SPACE -> 0x20;
|
||||
case KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT -> 0x10;
|
||||
case KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT -> 0x11;
|
||||
case KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT -> 0x12;
|
||||
case KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT -> 0x5B;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT -> 0x25;
|
||||
case KeyEvent.KEYCODE_DPAD_UP -> 0x26;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN -> 0x28;
|
||||
case KeyEvent.KEYCODE_MOVE_HOME -> 0x24;
|
||||
case KeyEvent.KEYCODE_MOVE_END -> 0x23;
|
||||
case KeyEvent.KEYCODE_PAGE_UP -> 0x21;
|
||||
case KeyEvent.KEYCODE_PAGE_DOWN -> 0x22;
|
||||
case KeyEvent.KEYCODE_MINUS -> 0xBD;
|
||||
case KeyEvent.KEYCODE_EQUALS -> 0xBB;
|
||||
case KeyEvent.KEYCODE_LEFT_BRACKET -> 0xDB;
|
||||
case KeyEvent.KEYCODE_RIGHT_BRACKET -> 0xDD;
|
||||
case KeyEvent.KEYCODE_BACKSLASH -> 0xDC;
|
||||
case KeyEvent.KEYCODE_SEMICOLON -> 0xBA;
|
||||
case KeyEvent.KEYCODE_APOSTROPHE -> 0xDE;
|
||||
case KeyEvent.KEYCODE_COMMA -> 0xBC;
|
||||
case KeyEvent.KEYCODE_PERIOD -> 0xBE;
|
||||
case KeyEvent.KEYCODE_SLASH -> 0xBF;
|
||||
case KeyEvent.KEYCODE_GRAVE -> 0xC0;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
// High bit is the Shift modifier; zero means use the clipboard for this text.
|
||||
static int ascii(char ch) {
|
||||
if (ch >= 'a' && ch <= 'z') return ch - 'a' + 0x41;
|
||||
if (ch >= 'A' && ch <= 'Z') return ch | 0x100;
|
||||
if (ch >= '0' && ch <= '9') return ch;
|
||||
if (ch == '\n') return 0x0D;
|
||||
if (ch == '\t') return 0x09;
|
||||
if (ch == ' ') return 0x20;
|
||||
String plain = "-=[]\\;',./`";
|
||||
String shifted = "_+{}|:\"<>?~";
|
||||
int[] codes = {0xBD,0xBB,0xDB,0xDD,0xDC,0xBA,0xDE,0xBC,0xBE,0xBF,0xC0};
|
||||
int i = plain.indexOf(ch);
|
||||
if (i >= 0) return codes[i];
|
||||
i = shifted.indexOf(ch);
|
||||
if (i >= 0) return codes[i] | 0x100;
|
||||
i = ")!@#$%^&*(".indexOf(ch);
|
||||
return i < 0 ? 0 : (0x30 + i) | 0x100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Offline catalog; document bodies are loaded only when opened. */
|
||||
final class LicenseCatalog {
|
||||
static final class Document {
|
||||
final String title,asset;
|
||||
Document(JSONObject value)throws JSONException{
|
||||
title=value.getString("title");asset=value.getString("asset");
|
||||
if(!asset.startsWith("about/")||asset.contains(".."))throw new JSONException("Invalid asset");
|
||||
}
|
||||
}
|
||||
static final class Component {
|
||||
final String id,name,version,license,sourceURL,buildSourceURL;
|
||||
final List<Document> documents,buildDocuments;
|
||||
Component(JSONObject value)throws JSONException{
|
||||
id=value.getString("id");name=value.getString("name");version=value.getString("version");license=value.getString("license");
|
||||
sourceURL=value.getString("sourceURL");buildSourceURL=value.optString("buildSourceURL","");
|
||||
documents=documents(value.getJSONArray("documents"));buildDocuments=documents(value.optJSONArray("buildDocuments"));
|
||||
if(documents.isEmpty())throw new JSONException("Missing documents");
|
||||
}
|
||||
String documentTitle(Document document){
|
||||
if(document.title.equals("Source copyright and license notices"))return "版权与许可声明";
|
||||
if(document.title.equals("apps/android/licenses/OPEN_SOURCE_NOTICE.txt"))return "开源软件权利说明";
|
||||
if(id.equals("crossdesk")&&document.title.equals("LICENSE"))return "GNU GPL 第 3 版";
|
||||
return document.title;
|
||||
}
|
||||
}
|
||||
final List<Component> components=new ArrayList<>();
|
||||
final JSONObject source;
|
||||
LicenseCatalog(Context context)throws IOException,JSONException{
|
||||
JSONObject data=new JSONObject(read(context,"ThirdPartyLicenses.json"));
|
||||
if(data.getInt("schemaVersion")!=1)throw new JSONException("Unknown catalog");
|
||||
JSONArray entries=data.getJSONArray("components");
|
||||
for(int i=0;i<entries.length();i++)components.add(new Component(entries.getJSONObject(i)));
|
||||
if(find("crossdesk")==null||find("openfec")==null)throw new JSONException("Incomplete catalog");
|
||||
JSONObject metadata;
|
||||
try{metadata=new JSONObject(read(context,"SourceMetadata.json"));if(metadata.getInt("schemaVersion")!=1)metadata=null;}catch(IOException|JSONException error){metadata=null;}
|
||||
source=metadata;
|
||||
}
|
||||
Component find(String id){for(Component component:components)if(component.id.equals(id))return component;return null;}
|
||||
static List<Document> documents(JSONArray values)throws JSONException{
|
||||
List<Document> result=new ArrayList<>();if(values!=null)for(int i=0;i<values.length();i++)result.add(new Document(values.getJSONObject(i)));return result;
|
||||
}
|
||||
static String read(Context context,String asset)throws IOException{
|
||||
try(var input=context.getAssets().open(asset);var output=new ByteArrayOutputStream()){
|
||||
byte[] buffer=new byte[8192];int count;while((count=input.read(buffer))!=-1)output.write(buffer,0,count);return output.toString("UTF-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.*;
|
||||
import android.text.method.PasswordTransformationMethod;
|
||||
import android.view.*;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.*;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
public final class MainActivity extends Activity implements NativeSession.Listener {
|
||||
private MobileUi ui;
|
||||
private PageHost pageHost;
|
||||
private AboutPages aboutPages;
|
||||
private boolean navigatingBack;
|
||||
private FrameLayout canvas;
|
||||
private LinearLayout root;
|
||||
private EditText remote;
|
||||
private TextView status, signalBadge, identitySummary;
|
||||
private AnnouncementInbox announcementInbox;
|
||||
private AnnouncementView announcementView;
|
||||
private TextView announcementBadge;
|
||||
private View announcementRefresh;
|
||||
private String announcementServer="";
|
||||
private SharedPreferences preferences;
|
||||
private RecentConnections history;
|
||||
private RemotePreviewStore previews;
|
||||
private RemotePreviewStore.Capture previewCapture;
|
||||
private boolean previewCopying,updatingPreviewSwitch;
|
||||
private int previewAttempts;
|
||||
private Switch previewSwitch;
|
||||
private TextView clearPreviewsButton,previewCleanupError;
|
||||
private AlertDialog previewDialog;
|
||||
private final java.util.List<Runnable> previewCards=new java.util.ArrayList<>();
|
||||
private final Runnable previewChanged=this::renderPreviews;
|
||||
private NativeSession session, signaling;
|
||||
private String signalingServer="", localIdentity="", signalError="";
|
||||
private int signalState=-1;
|
||||
private boolean foreground;
|
||||
private RemoteVideoView video;
|
||||
private SessionControls controls;
|
||||
private Dialog passwordDialog, progressDialog;
|
||||
private AlertDialog disconnectDialog;
|
||||
private AlertDialog remoteUpdateDialog;
|
||||
private RemoteVersionCheck remoteVersionCheck;
|
||||
private String host, connectedRemote="", hostPlatform="", remoteClipboard="", pendingPassword="", page="home";
|
||||
private int port;
|
||||
private JSONArray displays=new JSONArray();
|
||||
private boolean muted,ready,rememberPassword;
|
||||
private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1);
|
||||
private final android.os.Handler mainHandler=new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private Runnable videoSettingsTimeout;
|
||||
private final Set<Integer> pressed=new HashSet<>();
|
||||
private Runnable backAction;
|
||||
|
||||
@Override public void onCreate(Bundle state){
|
||||
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);
|
||||
pageHost=new PageHost(this);setContentView(pageHost);
|
||||
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099);
|
||||
remoteVersionCheck=new RemoteVersionCheck(this::showRemoteUpdate);
|
||||
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);};
|
||||
announcementInbox.changed=this::renderAnnouncements;
|
||||
if(android.os.Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::back);
|
||||
home("");
|
||||
}
|
||||
private int dp(float value){return ui.dp(value);}
|
||||
private LinearLayout column(){return ui.column();}
|
||||
private LinearLayout row(){return ui.row();}
|
||||
private TextView text(String title,int size,boolean bold){return ui.text(title,size,bold);}
|
||||
private String serverKey(){return host+":"+port;}
|
||||
private void toast(String value){Toast.makeText(this,value,Toast.LENGTH_SHORT).show();}
|
||||
private void hideKeyboard(){((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(root.getWindowToken(),0);root.clearFocus();}
|
||||
private void installRoot(String nextPage,boolean dark){
|
||||
PageHost.Motion motion=PageHost.Motion.NONE;
|
||||
if(canvas!=null&&foreground&&!page.equals(nextPage)){
|
||||
if(nextPage.equals("session"))motion=PageHost.Motion.SESSION_IN;
|
||||
else if(page.equals("session"))motion=PageHost.Motion.SESSION_OUT;
|
||||
else motion=navigatingBack||nextPage.equals("home")?PageHost.Motion.POP:PageHost.Motion.PUSH;
|
||||
}
|
||||
if(root!=null)hideKeyboard();page=nextPage;
|
||||
signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null;
|
||||
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();
|
||||
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
|
||||
root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1));
|
||||
root.setOnApplyWindowInsetsListener((view,insets)->{
|
||||
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
|
||||
});
|
||||
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
||||
applySystemBars(dark);
|
||||
}
|
||||
private void applySystemBars(boolean dark){
|
||||
if(android.os.Build.VERSION.SDK_INT>=30){
|
||||
android.view.WindowInsetsController controller=getWindow().getInsetsController();
|
||||
if(controller!=null){int light=android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS|android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;controller.setSystemBarsAppearance(dark?0:light,light);}
|
||||
}else getWindow().getDecorView().setSystemUiVisibility(dark?0:View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR|View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
}
|
||||
private EditText field(String hint,int type){
|
||||
EditText v=new EditText(this);v.setHint(hint);v.setContentDescription(hint);v.setInputType(type);v.setSingleLine(true);v.setTextSize(17);v.setTextColor(INK);v.setHintTextColor(SECONDARY);
|
||||
v.setPadding(dp(14),0,dp(14),0);v.setBackground(ui.background(BACKGROUND,12));v.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);return v;
|
||||
}
|
||||
private LinearLayout navigation(String title,Runnable back){
|
||||
LinearLayout bar=row();bar.setPadding(dp(12),0,dp(12),0);root.addView(bar,ui.size(-1,56));
|
||||
bar.addView(ui.iconButton("back","返回",this::navigateBack),ui.size(44,44));
|
||||
TextView label=text(title,17,true);label.setGravity(Gravity.CENTER);label.setSingleLine();label.setEllipsize(TextUtils.TruncateAt.END);bar.addView(label,new LinearLayout.LayoutParams(0,-1,1));
|
||||
bar.addView(new View(this),ui.size(44,44));backAction=back;return bar;
|
||||
}
|
||||
LinearLayout formPage(String name,String title,Runnable back){
|
||||
installRoot(name,false);navigation(title,back);ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));
|
||||
LinearLayout content=column();content.setPadding(dp(20),dp(16),dp(20),dp(28));scroll.addView(content);return content;
|
||||
}
|
||||
LinearLayout section(LinearLayout content,String title){
|
||||
if(!title.isEmpty()){TextView label=text(title,13,false);label.setTextColor(SECONDARY);label.setPadding(dp(16),dp(12),0,dp(8));content.addView(label);}
|
||||
LinearLayout card=column();ui.card(card,12);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 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);
|
||||
}
|
||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Match the iOS portrait home; sessions support both orientations.
|
||||
private void home(String message){
|
||||
backAction=null;setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);installRoot("home",false);
|
||||
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();
|
||||
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);
|
||||
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));
|
||||
LinearLayout content=column();content.setPadding(dp(20),dp(24),dp(20),dp(12));root.addView(content,new LinearLayout.LayoutParams(-1,0,1));
|
||||
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));ui.card(connection,20);connection.setElevation(dp(2));content.addView(connection);
|
||||
connection.addView(text("远程桌面",20,true));ui.gap(connection,16);
|
||||
LinearLayout entry=row();connection.addView(entry,ui.size(-1,54));
|
||||
remote=field("对端 ID",InputType.TYPE_CLASS_NUMBER);remote.setTextSize(20);remote.setTypeface(Typeface.create("sans-serif-medium",Typeface.NORMAL));
|
||||
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
|
||||
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
|
||||
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
|
||||
TextView connect=ui.primary("连接 →",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
|
||||
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
|
||||
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
|
||||
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
|
||||
JSONArray records=history.list(serverKey());LinearLayout heading=row();heading.addView(text("最近连接",20,true),new LinearLayout.LayoutParams(0,-2,1));
|
||||
if(records.length()>0){TextView count=text(records.length()+" 台设备",12,false);count.setTextColor(SECONDARY);heading.addView(count);}recent.addView(heading);
|
||||
if(records.length()==0){
|
||||
LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12);
|
||||
TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint);
|
||||
}else{
|
||||
ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout grid=column();scroll.addView(grid);
|
||||
for(int i=0;i<records.length();i+=2){LinearLayout line=row();line.setGravity(Gravity.TOP);grid.addView(line);for(int j=i;j<i+2;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(grid,12);}
|
||||
}
|
||||
ensureSignaling();
|
||||
if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show();
|
||||
}
|
||||
private String id(){return remote.getText().toString().replace(" ","");}
|
||||
static String formatId(String value){String digits=value.replaceAll("[^0-9]","");if(digits.length()>9)digits=digits.substring(0,9);return digits.replaceAll("(.{3})(?=.)","$1 ");}
|
||||
private View recentCard(JSONObject item){
|
||||
if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform");
|
||||
LinearLayout card=column();ui.card(card,14);FrameLayout preview=new FrameLayout(this){@Override protected void onMeasure(int w,int h){super.onMeasure(w,MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(w)*9/16,MeasureSpec.EXACTLY));}};
|
||||
int color=platform.equals("windows")?0xFF316AB7:platform.equals("macos")?0xFF8057B9:platform.equals("linux")?0xFFAE642B:0xFF677587;preview.setBackgroundColor(color);
|
||||
TextView label=text(platform.equals("windows")?"Windows":platform.equals("macos")?"macOS":platform.equals("linux")?"Linux":"远程电脑",19,true);label.setGravity(Gravity.CENTER);label.setTextColor(Color.WHITE);preview.addView(label,new FrameLayout.LayoutParams(-1,-1));card.addView(preview,ui.size(-1,80));
|
||||
ImageView image=new ImageView(this);image.setScaleType(ImageView.ScaleType.CENTER_CROP);image.setVisibility(View.GONE);image.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);preview.addView(image,new FrameLayout.LayoutParams(-1,-1));
|
||||
String server=serverKey();Runnable load=()->{if(!image.isAttachedToWindow())return;Object binding=new Object();image.setTag(binding);image.setImageDrawable(null);image.setVisibility(View.GONE);label.setVisibility(View.VISIBLE);
|
||||
previews.load(server,id,bitmap->{if(image.getTag()!=binding||!image.isAttachedToWindow()){if(bitmap!=null)bitmap.recycle();return;}if(bitmap!=null){image.setImageBitmap(bitmap);image.setVisibility(View.VISIBLE);label.setVisibility(View.GONE);}});};previewCards.add(load);image.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener(){public void onViewAttachedToWindow(View view){load.run();}public void onViewDetachedFromWindow(View view){image.setTag(null);}});if(image.isAttachedToWindow())load.run();
|
||||
LinearLayout caption=column();caption.setPadding(dp(9),dp(9),dp(9),dp(9));TextView name=text(item.optString("name",id),12,true);name.setSingleLine();name.setEllipsize(TextUtils.TruncateAt.END);caption.addView(name);ui.gap(caption,4);
|
||||
TextView detail=text("ID "+id+(item.optBoolean("remember")?" · 已保存密码":""),10,false);detail.setTextColor(SECONDARY);caption.addView(detail);card.addView(caption);
|
||||
card.setContentDescription("连接 "+item.optString("name",id));card.setClickable(true);card.setFocusable(true);card.setOnClickListener(v->{remote.setText(formatId(id));promptConnection(id);});
|
||||
card.setOnLongClickListener(v->{new AlertDialog.Builder(this).setTitle(item.optString("name",id)).setItems(new String[]{"连接","删除记录"},(d,w)->{if(w==0)promptConnection(id);else{history.delete(serverKey(),id);home("");}}).show();return true;});return card;
|
||||
}
|
||||
private void promptConnection(String id){
|
||||
hideKeyboard();if(!id.matches("[0-9]{9}")){remote.setError("请输入 9 位设备 ID");remote.requestFocus();return;}
|
||||
if(!preferences.getBoolean("networkConsent",false)){consent(()->connectOrPrompt(id));return;}connectOrPrompt(id);
|
||||
}
|
||||
private void connectOrPrompt(String id){
|
||||
String stored;
|
||||
try{stored=history.password(serverKey(),id);}catch(Exception error){passwordSheet(id,"已保存密码无法读取,请重新输入",true);return;}
|
||||
if(!stored.isEmpty()){rememberPassword=true;begin(id,stored);return;}
|
||||
boolean remember=history.remembersPassword(serverKey(),id);
|
||||
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();
|
||||
}
|
||||
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));
|
||||
View handle=new View(this);handle.setBackground(ui.background(0xFFC6C6CB,3));LinearLayout.LayoutParams hp=ui.size(36,5);hp.gravity=Gravity.CENTER;sheet.addView(handle,hp);ui.gap(sheet,20);
|
||||
LinearLayout header=row(),titles=column();titles.addView(text("连接远程桌面",22,true));ui.gap(titles,5);TextView subtitle=text("对端 ID "+id,15,false);subtitle.setTextColor(SECONDARY);titles.addView(subtitle);header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭",dialog::dismiss),ui.size(36,36));sheet.addView(header);ui.gap(sheet,18);
|
||||
LinearLayout passwordRow=row();ui.card(passwordRow,10);EditText password=field("访问密码",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);password.setBackgroundColor(Color.TRANSPARENT);passwordRow.addView(password,new LinearLayout.LayoutParams(0,dp(50),1));
|
||||
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
|
||||
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
|
||||
remember.setChecked(rememberDefault);
|
||||
ui.gap(sheet,18);TextView connect=ui.primary("连接 →",()->{
|
||||
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
|
||||
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
||||
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
|
||||
dialog.setContentView(sheet);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();if(window!=null){window.setLayout(-1,-2);window.setGravity(Gravity.BOTTOM);}password.requestFocus();if(!error.isEmpty())password.setError(error);
|
||||
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
|
||||
}
|
||||
private void begin(String id,String secret){
|
||||
resetRemoteVersionCheck();
|
||||
resetVideoSettings();
|
||||
previewCapture=previews.begin(serverKey(),id);previewCopying=false;previewAttempts=0;
|
||||
connectedRemote=id;pendingPassword=rememberPassword?secret:"";ready=false;muted=false;hostPlatform="";remoteClipboard="";displays=new JSONArray();preferences.edit().putString("lastRemote",id).apply();
|
||||
ensureSignaling();
|
||||
if(signaling==null){pendingPassword="";toast("请先完成联网授权");return;}
|
||||
session=signaling;showProgress();session.connect(id,secret);
|
||||
}
|
||||
private void showProgress(){
|
||||
Dialog dialog=new Dialog(this);progressDialog=dialog;LinearLayout card=column();ui.card(card,24);card.setGravity(Gravity.CENTER_HORIZONTAL);LinearLayout body=column();body.setGravity(Gravity.CENTER);body.setPadding(dp(26),dp(24),dp(26),dp(20));
|
||||
ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46));
|
||||
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
|
||||
}
|
||||
private void sessionScreen(){
|
||||
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
|
||||
controls=new SessionControls(this,ui,new SessionControls.Actions(){
|
||||
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
|
||||
public void key(int code){tapKey(code);}
|
||||
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
|
||||
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
|
||||
public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);}
|
||||
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
|
||||
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
|
||||
private void end(String reason){
|
||||
resetRemoteVersionCheck();
|
||||
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);
|
||||
}
|
||||
private void settings(){
|
||||
LinearLayout content=formPage("settings","设置",()->home(""));
|
||||
LinearLayout mouse=section(content,"鼠标控制");mouse.setPadding(dp(12),dp(12),dp(12),dp(12));boolean relative=preferences.getBoolean("relativeMouse",true);
|
||||
TextView detail=text(relative?"像触控板一样滑动光标,点击时操作当前光标位置。":"触摸位置直接对应远端屏幕位置,适合快速定位。",13,false);detail.setTextColor(SECONDARY);
|
||||
mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->{preferences.edit().putBoolean("relativeMouse",index==0).apply();detail.setText(index==0?"像触控板一样滑动光标,点击时操作当前光标位置。":"触摸位置直接对应远端屏幕位置,适合快速定位。");}));ui.gap(mouse,10);mouse.addView(detail);
|
||||
LinearLayout picture=section(content,"画面偏好");picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));TextView preferenceDetail=text(RemoteVideoSettings.DETAILS[preference],13,false);preferenceDetail.setTextColor(SECONDARY);
|
||||
picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->{preferences.edit().putInt("videoPreference",index).apply();preferenceDetail.setText(RemoteVideoSettings.DETAILS[index]);}));ui.gap(picture,10);picture.addView(preferenceDetail);ui.gap(content,28);
|
||||
LinearLayout server=section(content,"");LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
|
||||
link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
|
||||
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
|
||||
LinearLayout inputs=column();inputs.setPadding(dp(12),0,dp(12),dp(12));EditText address=field("服务器地址",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_URI),number=field("服务器端口",InputType.TYPE_CLASS_NUMBER);address.setText(custom?host:"");number.setText(custom?String.valueOf(port):"");inputs.addView(address,ui.size(-1,48));ui.gap(inputs,8);inputs.addView(number,ui.size(-1,48));ui.gap(inputs,8);
|
||||
inputs.addView(ui.action("完成",BLUE,()->{if(saveServer(address,number)){hideKeyboard();settings();}}));fields.addView(inputs);inputs.setVisibility(custom?View.VISIBLE:View.GONE);
|
||||
toggle.setOnCheckedChangeListener((button,checked)->{inputs.setVisibility(checked?View.VISIBLE:View.GONE);if(!checked){host="api.crossdesk.cn";port=9099;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();} });
|
||||
backAction=()->{if(toggle.isChecked()&&!saveServer(address,number))return;hideKeyboard();home("");};
|
||||
identitySummary=text("",12,false);identitySummary.setTextColor(SECONDARY);identitySummary.setGravity(Gravity.CENTER);identitySummary.setPadding(dp(16),dp(12),dp(16),dp(16));identitySummary.setTextIsSelectable(true);content.addView(identitySummary);renderSignaling();
|
||||
LinearLayout privacy=section(content,"隐私");link(privacy,"隐私与授权",preferences.getBoolean("networkConsent",false)?"已授权":"未授权",this::privacyPage);ui.divider(privacy);
|
||||
LinearLayout preview=row();preview.setPadding(dp(16),dp(10),dp(16),dp(10));preview.addView(text("保存远程画面预览",16,false),new LinearLayout.LayoutParams(0,-2,1));previewSwitch=new Switch(this);previewSwitch.setContentDescription("保存远程画面预览");preview.addView(previewSwitch);privacy.addView(preview);
|
||||
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,"");link(about,"关于","",this::about);
|
||||
}
|
||||
private void confirmPreviewSaving(){
|
||||
previewDialog=new AlertDialog.Builder(this).setTitle("保存远程画面预览?")
|
||||
.setMessage("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。")
|
||||
.setNegativeButton("取消",null).setPositiveButton("开启保存",(d,w)->previews.setEnabled(true)).show();
|
||||
previewDialog.setOnDismissListener(d->{previewDialog=null;renderPreviews();});
|
||||
}
|
||||
private void confirmClearPreviews(){
|
||||
previewDialog=new AlertDialog.Builder(this).setTitle("清除预览图?")
|
||||
.setMessage("将删除本机保存的所有远程预览图,保留连接记录和密码。"+(previews.enabled()?"后续连接仍会保存新的预览图。":""))
|
||||
.setNegativeButton("取消",null).setPositiveButton("清除",(d,w)->previews.clear()).show();
|
||||
previewDialog.setOnDismissListener(d->previewDialog=null);
|
||||
}
|
||||
private void renderPreviews(){
|
||||
if(previews==null)return;
|
||||
if(!previews.enabled()||previews.cleaning)previewCapture=null;
|
||||
if(previewSwitch!=null){updatingPreviewSwitch=true;previewSwitch.setChecked(previews.enabled());previewSwitch.setEnabled(preferences.getBoolean("networkConsent",false)&&!previews.cleaning);updatingPreviewSwitch=false;}
|
||||
if(clearPreviewsButton!=null){clearPreviewsButton.setText(previews.cleaning?"正在清除…":"清除预览图");clearPreviewsButton.setEnabled(!previews.cleaning);}
|
||||
if(previewCleanupError!=null){previewCleanupError.setText(previews.cleanupError);previewCleanupError.setVisibility(previews.cleanupError.isEmpty()?View.GONE:View.VISIBLE);}
|
||||
for(Runnable card:previewCards)card.run();
|
||||
}
|
||||
private void capturePreviewIfNeeded(){
|
||||
if(!ready||video==null||previewCapture==null||previewCopying||!previews.current(previewCapture))return;
|
||||
if(!history.contains(serverKey(),connectedRemote)){previewCapture=null;return;}
|
||||
RemotePreviewStore.Capture capture=previewCapture;NativeSession source=session;previewCopying=true;
|
||||
video.capturePreview(bitmap->{
|
||||
if(session!=source||previewCapture!=capture||!previews.current(capture)){if(bitmap!=null)bitmap.recycle();return;}
|
||||
previewCopying=false;
|
||||
if(bitmap!=null){previewCapture=null;previews.save(capture,bitmap);}
|
||||
else if(++previewAttempts>=3)previewCapture=null;
|
||||
});
|
||||
}
|
||||
private View segments(String[] titles,int selected,boolean enabled,java.util.function.IntConsumer changed){
|
||||
return new SegmentedControl(ui,titles,selected,enabled,changed);
|
||||
}
|
||||
private boolean saveServer(EditText address,EditText number){
|
||||
String name=address.getText().toString().trim();int value;try{value=Integer.parseInt(number.getText().toString());}catch(NumberFormatException e){number.setError("请输入 1–65535 范围内的端口");return false;}
|
||||
if(name.length()>253||!name.matches("[A-Za-z0-9.-]+")||name.startsWith(".")||name.endsWith(".")){address.setError("填写主机名或 IPv4 地址,不含协议和路径");return false;}if(value<1||value>65535){number.setError("端口范围为 1–65535");return false;}host=name;port=value;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();return true;
|
||||
}
|
||||
private void announcements(){
|
||||
installRoot("announcements",false);LinearLayout bar=navigation("通知公告",()->home(""));
|
||||
bar.removeViewAt(bar.getChildCount()-1);announcementRefresh=ui.iconButton("refresh","刷新公告",announcementInbox::refresh);bar.addView(announcementRefresh,ui.size(44,44));
|
||||
announcementView=new AnnouncementView(ui,announcementInbox,null,this::announcementDetail,this::connectAnnouncements);
|
||||
root.addView(announcementView,new LinearLayout.LayoutParams(-1,0,1));announcementInbox.refresh();renderAnnouncements();
|
||||
}
|
||||
private void announcementDetail(AnnouncementInbox.Item item){
|
||||
installRoot("announcement-detail",false);navigation("公告详情",this::announcements);
|
||||
announcementView=new AnnouncementView(ui,announcementInbox,item,this::announcementDetail,this::connectAnnouncements);
|
||||
root.addView(announcementView,new LinearLayout.LayoutParams(-1,0,1));announcementInbox.markRead(item);
|
||||
}
|
||||
private void connectAnnouncements(){
|
||||
if(!preferences.getBoolean("networkConsent",false))consent(()->{ensureSignaling();renderAnnouncements();});
|
||||
else{stopSignaling();ensureSignaling();}
|
||||
}
|
||||
private void renderAnnouncements(){
|
||||
if(announcementBadge!=null){int unread=announcementInbox.unread;announcementBadge.setText(unread>99?"99+":String.valueOf(unread));announcementBadge.setVisibility(unread>0?View.VISIBLE:View.GONE);
|
||||
if(android.os.Build.VERSION.SDK_INT>=30)((View)announcementBadge.getParent()).setStateDescription(unread>0?unread+" 条未读":"无未读公告");}
|
||||
if(announcementRefresh!=null){boolean enabled=announcementInbox.isConnected()&&!announcementInbox.loading;announcementRefresh.setEnabled(enabled);announcementRefresh.setAlpha(enabled?1:.4f);}
|
||||
if(announcementView!=null)announcementView.render();
|
||||
}
|
||||
@Override public void announcement(JSONObject message){
|
||||
if(!preferences.getBoolean("networkConsent",false)||signalState!=1)return;
|
||||
if("announcements_changed".equals(message.optString("type")))announcementInbox.refresh();else announcementInbox.receive(message);
|
||||
}
|
||||
@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));
|
||||
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 about(){
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings);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);}}
|
||||
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
|
||||
private void selectDisplay(){
|
||||
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{if(session!=null){session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
|
||||
}
|
||||
private void clipboard(){
|
||||
if(!ready)return;
|
||||
EditText input=field("要发送到远端的文本",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE);input.setSingleLine(false);input.setMinLines(3);
|
||||
new AlertDialog.Builder(this).setTitle("剪贴板").setView(input)
|
||||
.setMessage("发送后,在远程键盘中点击“粘贴”。接收的远端文本可手动复制到本机。")
|
||||
.setNegativeButton("关闭",null).setNeutralButton("复制远端文本",(d,w)->{
|
||||
if(!remoteClipboard.isEmpty())((ClipboardManager)getSystemService(CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("CrossDesk",remoteClipboard));
|
||||
}).setPositiveButton("发送文本",(d,w)->{
|
||||
String value=input.getText().toString();
|
||||
if(value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>128*1024){Toast.makeText(this,"文本超过 128 KB",Toast.LENGTH_SHORT).show();return;}
|
||||
if(session!=null)session.clipboard(value);
|
||||
}).show();
|
||||
}
|
||||
void licenseDocument(String title,String value,Runnable back){
|
||||
installRoot("document:"+title,false);navigation(title,back);
|
||||
// Bound each selectable accessibility node while keeping every character of long notices.
|
||||
java.util.List<String> chunks=new java.util.ArrayList<>();
|
||||
for(int start=0;start<value.length();){int end=Math.min(start+4000,value.length());if(end<value.length()){int newline=value.lastIndexOf('\n',end-1);if(newline>start)end=newline+1;else if(Character.isHighSurrogate(value.charAt(end-1)))end--;}chunks.add(value.substring(start,end));start=end;}
|
||||
ListView paragraphs=new ListView(this);paragraphs.setBackgroundColor(Color.WHITE);paragraphs.setDivider(null);paragraphs.setClipToPadding(false);paragraphs.setPadding(0,dp(8),0,dp(8));
|
||||
paragraphs.setAdapter(new ArrayAdapter<String>(this,android.R.layout.simple_list_item_1,chunks){
|
||||
@Override public View getView(int position,View reuse,ViewGroup parent){
|
||||
TextView item=(TextView)super.getView(position,reuse,parent);item.setTextColor(INK);item.setTextSize(16);item.setAutoLinkMask(android.text.util.Linkify.WEB_URLS);item.setText(getItem(position));item.setTextIsSelectable(true);item.setLinksClickable(true);item.setLinkTextColor(BLUE);item.setPadding(dp(16),dp(8),dp(16),dp(8));return item;
|
||||
}
|
||||
});root.addView(paragraphs,new LinearLayout.LayoutParams(-1,0,1));
|
||||
}
|
||||
private void ensureSignaling(){
|
||||
if(!foreground||!preferences.getBoolean("networkConsent",false))return;
|
||||
if(signaling!=null&&!signaling.isClosed()&&signalingServer.equals(serverKey()))return;
|
||||
if(!announcementServer.equals(serverKey())){announcementInbox.reset();announcementServer=serverKey();}
|
||||
stopSignaling();signalState=0;signalError="";signalingServer=serverKey();
|
||||
signaling=new NativeSession(this,host,port,"","",this);
|
||||
renderSignaling();signaling.start();
|
||||
}
|
||||
private void stopSignaling(){
|
||||
announcementInbox.setConnected(false);
|
||||
if(signaling!=null)signaling.close();
|
||||
signaling=null;signalingServer="";localIdentity="";signalState=-1;signalError="";renderSignaling();
|
||||
}
|
||||
private void renderSignaling(){
|
||||
boolean consent=preferences.getBoolean("networkConsent",false);
|
||||
String label;int tint,background;
|
||||
if(!consent){label="等待隐私授权";tint=0xFFB76A00;background=0xFFFFEBD2;}
|
||||
else if(!signalError.isEmpty()){label=signalError.contains("TLS")?"证书校验失败 · 点击重试":"连接失败 · 点击重试";tint=RED;background=0xFFFFE8E6;}
|
||||
else if(signalState==1){label="✓ 已连接服务器";tint=GREEN;background=0xFFE5F4E8;}
|
||||
else{label=signalState==0?"正在连接服务器…":signalState==4?"正在重连服务器…":"未连接服务器 · 点击重试";tint=0xFFB76A00;background=0xFFFFEBD2;}
|
||||
if(signalBadge!=null){signalBadge.setText(label);signalBadge.setTextColor(tint);signalBadge.setBackground(ui.background(background,20));signalBadge.setContentDescription(signalError.isEmpty()?label:signalError+",点击重试");}
|
||||
if(identitySummary!=null)identitySummary.setText(!consent?"完成隐私授权后登记本机身份":!localIdentity.isEmpty()?"本机 ID "+localIdentity:!signalError.isEmpty()?signalError:"正在获取本机 ID…");
|
||||
}
|
||||
@Override public void signaling(int state,String deviceId){
|
||||
signalState=state;localIdentity=deviceId;signalError="";renderSignaling();
|
||||
if(preferences.getBoolean("networkConsent",false)){announcementInbox.configure(host,port,deviceId);announcementInbox.setConnected(state==1);}
|
||||
}
|
||||
@Override public void status(String value){if(session!=null&&status!=null)status.setText(value);}
|
||||
@Override public void connected(){
|
||||
// Transport readiness can be reported again after ICE changes its selected path.
|
||||
// The first success consumes pendingPassword; a duplicate must not save it again.
|
||||
if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}
|
||||
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen();
|
||||
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
|
||||
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
|
||||
}
|
||||
@Override public void ended(String reason){
|
||||
if(session!=null){end(reason);return;}
|
||||
stopSignaling();signalError=reason;renderSignaling();
|
||||
}
|
||||
@Override public void passwordRejected(){
|
||||
if(session==null)return;
|
||||
String id=connectedRemote;boolean remember=rememberPassword;
|
||||
end("");
|
||||
// Let the user replace an expired credential; never retry it automatically.
|
||||
if(foreground)passwordSheet(id,"访问密码错误,请重新输入",remember);
|
||||
}
|
||||
private void cancelVideoSettingsTimeout(){if(videoSettingsTimeout!=null)mainHandler.removeCallbacks(videoSettingsTimeout);videoSettingsTimeout=null;}
|
||||
private void showRemoteUpdate(){
|
||||
if(!ready||session==null||!foreground||isFinishing()||!preferences.getBoolean("networkConsent",false))return;
|
||||
remoteUpdateDialog=new AlertDialog.Builder(this).setTitle("升级提示").setMessage(RemoteVersionCheck.NOTICE).setPositiveButton("知道了",null).create();
|
||||
remoteUpdateDialog.setOnDismissListener(dialog->remoteUpdateDialog=null);
|
||||
remoteUpdateDialog.setCanceledOnTouchOutside(false);
|
||||
remoteUpdateDialog.show();
|
||||
}
|
||||
private void resetRemoteVersionCheck(){
|
||||
remoteVersionCheck.reset();
|
||||
if(remoteUpdateDialog!=null){remoteUpdateDialog.dismiss();remoteUpdateDialog=null;}
|
||||
}
|
||||
private void resetVideoSettings(){
|
||||
cancelVideoSettingsTimeout();
|
||||
android.util.DisplayMetrics metrics=getResources().getDisplayMetrics();
|
||||
videoSettings=new RemoteVideoSettings(preferences.getInt("videoPreference",1),Math.max(metrics.widthPixels,metrics.heightPixels));
|
||||
}
|
||||
private void updateVideoSettings(RemoteVideoSettings.Values value){
|
||||
if(!ready||session==null||!preferences.getBoolean("networkConsent",false))return;
|
||||
long request=videoSettings.begin(value);if(request<0)return;
|
||||
cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);session.videoSettings(value,request);
|
||||
videoSettingsTimeout=()->{videoSettings.expire(request);if(controls!=null)controls.videoSettings(videoSettings);};mainHandler.postDelayed(videoSettingsTimeout,5000);
|
||||
}
|
||||
@Override public void data(JSONObject message){
|
||||
if(session==null)return;
|
||||
if(message.optInt("type",-1)==12){if(!ready)return;videoSettings.receive(message);if(!videoSettings.pending)cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);return;}
|
||||
JSONObject info=message.optJSONObject("host_info");
|
||||
if(info!=null){
|
||||
hostPlatform=info.optString("platform","");JSONArray list=info.optJSONArray("displays");if(list!=null)displays=list;history.metadata(serverKey(),connectedRemote,info.optString("host_name",connectedRemote),hostPlatform);
|
||||
boolean wasSupported=videoSettings.supported;videoSettings.setSupported(info.optBoolean("supports_video_settings",false));
|
||||
if(!videoSettings.supported)cancelVideoSettingsTimeout();
|
||||
if(controls!=null)controls.videoSettings(videoSettings);
|
||||
if(videoSettings.supported&&!wasSupported)updateVideoSettings(videoSettings.selection);
|
||||
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
|
||||
}
|
||||
}
|
||||
@Override public void videoSize(int width,int height){if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);}
|
||||
@Override public void clipboard(String value){remoteClipboard=value;}
|
||||
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
|
||||
@Override public boolean dispatchKeyEvent(KeyEvent event){
|
||||
if(ready&&session!=null&&hasWindowFocus()&&!(getCurrentFocus() instanceof EditText)&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK){int code=KeyMap.windowsCode(event.getKeyCode());if(code!=0){boolean down=event.getAction()==KeyEvent.ACTION_DOWN;if(down)pressed.add(code);else pressed.remove(code);session.key(code,down);return true;}}return super.dispatchKeyEvent(event);
|
||||
}
|
||||
private void releaseKeys(){if(session!=null)for(int code:pressed)session.key(code,false);pressed.clear();}
|
||||
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
|
||||
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
||||
@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();}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.*;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
|
||||
/** Shared visual tokens matching the SwiftUI mobile screens. Values are in dp/sp. */
|
||||
final class MobileUi {
|
||||
static final int BACKGROUND=0xFFF2F2F7, INK=0xFF1C1C1E, SECONDARY=0xFF737378,
|
||||
LINE=0xFFE2E2E7, BLUE=0xFF2E7ADB, RED=0xFFFF3B30, GREEN=0xFF248A3D;
|
||||
final Context context;
|
||||
MobileUi(Context context) { this.context=context; }
|
||||
int dp(float value) { return Math.round(value*context.getResources().getDisplayMetrics().density); }
|
||||
GradientDrawable background(int color,int radius) {
|
||||
GradientDrawable shape=new GradientDrawable();shape.setColor(color);shape.setCornerRadius(dp(radius));return shape;
|
||||
}
|
||||
void card(View view,int radius) { GradientDrawable shape=background(Color.WHITE,radius);shape.setStroke(dp(.5f),LINE);view.setBackground(shape);view.setClipToOutline(true); }
|
||||
LinearLayout column() { LinearLayout v=new LinearLayout(context);v.setOrientation(LinearLayout.VERTICAL);return v; }
|
||||
LinearLayout row() { LinearLayout v=new LinearLayout(context);v.setGravity(Gravity.CENTER_VERTICAL);return v; }
|
||||
TextView text(String title,int size,boolean bold) {
|
||||
TextView v=new TextView(context);v.setText(title);v.setTextSize(size);v.setTextColor(INK);v.setIncludeFontPadding(false);
|
||||
if(bold)v.setTypeface(null,Typeface.BOLD);return v;
|
||||
}
|
||||
TextView action(String title,int color,Runnable click) {
|
||||
TextView v=text(title,16,true);v.setGravity(Gravity.CENTER);v.setTextColor(color);
|
||||
v.setMinHeight(dp(48));v.setClickable(true);v.setFocusable(true);v.setOnClickListener(ignored->click.run());
|
||||
v.setAccessibilityDelegate(new View.AccessibilityDelegate(){@Override public void onInitializeAccessibilityNodeInfo(View host,android.view.accessibility.AccessibilityNodeInfo info){super.onInitializeAccessibilityNodeInfo(host,info);info.setClassName("android.widget.Button");}});
|
||||
return v;
|
||||
}
|
||||
TextView primary(String title,Runnable click) {
|
||||
TextView v=action(title,Color.WHITE,click);
|
||||
GradientDrawable gradient=new GradientDrawable(GradientDrawable.Orientation.TL_BR,new int[]{BLUE,0xFF1F61C7});gradient.setCornerRadius(dp(12));v.setBackground(gradient);return v;
|
||||
}
|
||||
View iconButton(String icon,String label,Runnable click) {
|
||||
FrameLayout box=new FrameLayout(context);box.setBackground(background(Color.WHITE,10));
|
||||
Icon glyph=new Icon(context,icon,INK);FrameLayout.LayoutParams p=new FrameLayout.LayoutParams(dp(22),dp(22),Gravity.CENTER);box.addView(glyph,p);
|
||||
box.setContentDescription(label);box.setClickable(true);box.setFocusable(true);box.setOnClickListener(v->click.run());return box;
|
||||
}
|
||||
View icon(String name,int color) { return new Icon(context,name,color); }
|
||||
void gap(LinearLayout parent,int height){parent.addView(new View(context),new LinearLayout.LayoutParams(1,dp(height)));}
|
||||
void divider(LinearLayout parent){View line=new View(context);line.setBackgroundColor(LINE);parent.addView(line,new LinearLayout.LayoutParams(-1,dp(.5f)));}
|
||||
LinearLayout.LayoutParams size(int width,int height){return new LinearLayout.LayoutParams(width<0?width:dp(width),height<0?height:dp(height));}
|
||||
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
|
||||
static final class Icon extends View {
|
||||
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); private final String name;
|
||||
Icon(Context context,String name,int color){super(context);this.name=name;paint.setColor(color);setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);}
|
||||
@Override protected void onDraw(Canvas c){
|
||||
c.save();c.scale(getWidth()/24f,getHeight()/24f);paint.setStyle(Paint.Style.STROKE);paint.setStrokeWidth(1.7f);paint.setStrokeCap(Paint.Cap.ROUND);paint.setStrokeJoin(Paint.Join.ROUND);
|
||||
switch(name){
|
||||
case "settings":
|
||||
c.drawCircle(12,12,7,paint);c.drawCircle(12,12,3,paint);
|
||||
for(int i=0;i<8;i++){c.save();c.rotate(i*45,12,12);c.drawLine(12,2,12,5,paint);c.restore();}break;
|
||||
case "bell":
|
||||
path(c,5,17,6,14,6,9,7,6,10,4,14,4,17,6,18,9,18,14,19,17,5,17);c.drawArc(9,17,15,22,0,180,false,paint);c.drawLine(12,2,12,4,paint);break;
|
||||
case "history":
|
||||
c.drawArc(4,4,21,21,215,315,false,paint);path(c,3,4,3,10,9,10);path(c,12,7,12,13,16,15);break;
|
||||
case "back":path(c,15,4,7,12,15,20);break;
|
||||
case "refresh":c.drawArc(4,4,20,20,35,310,false,paint);path(c,20,3,20,9,14,9);break;
|
||||
case "close":path(c,6,6,18,18);path(c,18,6,6,18);break;
|
||||
case "arrow":path(c,4,12,20,12);path(c,14,6,20,12,14,18);break;
|
||||
case "keyboard":
|
||||
c.drawRoundRect(2,5,22,19,3,3,paint);for(int y=9;y<=12;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,16,17,16,paint);break;
|
||||
case "display":c.drawRoundRect(2,3,22,17,2,2,paint);path(c,12,17,12,21);path(c,7,21,17,21);break;
|
||||
case "sliders":
|
||||
for(int y=6;y<=18;y+=6){c.drawLine(3,y,21,y,paint);c.drawCircle(y==12?15:8,y,2,paint);}break;
|
||||
case "audio":path(c,3,9,7,9,12,4,12,20,7,15,3,15,3,9);c.drawArc(10,5,21,19,-65,130,false,paint);break;
|
||||
case "mouse":c.drawRoundRect(5,2,19,22,7,7,paint);path(c,12,3,12,10);break;
|
||||
case "folder":path(c,2,19,2,5,9,5,12,8,22,8,22,19,2,19);break;
|
||||
case "chart":path(c,3,3,3,21,22,21);path(c,7,17,7,13);path(c,12,17,12,8);path(c,18,17,18,4);break;
|
||||
case "lock":c.drawRoundRect(5,10,19,22,2,2,paint);c.drawArc(8,2,16,15,180,180,false,paint);c.drawPoint(12,16,paint);break;
|
||||
case "unlock":c.drawRoundRect(5,10,19,22,2,2,paint);c.drawArc(8,2,16,15,180,150,false,paint);c.drawPoint(12,16,paint);break;
|
||||
case "clipboard":c.drawRoundRect(4,4,20,22,2,2,paint);c.drawRoundRect(8,2,16,7,2,2,paint);break;
|
||||
case "eye":path(c,2,12,6,7,12,5,18,7,22,12,18,17,12,19,6,17,2,12);c.drawCircle(12,12,3,paint);break;
|
||||
default:c.drawCircle(12,12,9,paint);path(c,12,10,12,17);c.drawPoint(12,6,paint);
|
||||
}c.restore();
|
||||
}
|
||||
private void path(Canvas c,float... values){Path p=new Path();p.moveTo(values[0],values[1]);for(int i=2;i<values.length;i+=2)p.lineTo(values[i],values[i+1]);c.drawPath(p,paint);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.view.Surface;
|
||||
import org.json.JSONObject;
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
final class NativeSession implements AutoCloseable {
|
||||
static { System.loadLibrary("crossdesk_android"); }
|
||||
interface Listener {
|
||||
void status(String message);
|
||||
void connected();
|
||||
void ended(String reason);
|
||||
void data(JSONObject message);
|
||||
void videoSize(int width, int height);
|
||||
void clipboard(String text);
|
||||
void statistics(RemoteNetworkStatistics.Snapshot value);
|
||||
default void signaling(int state, String deviceId) { }
|
||||
default void announcement(JSONObject message) { }
|
||||
default void announcementFailed(String requestId) { }
|
||||
default void passwordRejected() { ended("远端密码错误"); }
|
||||
}
|
||||
// Serializes all peer creation/API calls/destruction, including across activities.
|
||||
private static final ExecutorService RTC = Executors.newSingleThreadExecutor(r -> new Thread(r, "CrossDesk RTC"));
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final Context context;
|
||||
private final Listener listener;
|
||||
private final SecretStore secrets;
|
||||
private final String host, scope;
|
||||
private volatile String remote;
|
||||
private String password, identity = "";
|
||||
private final int port;
|
||||
private long handle;
|
||||
private boolean signalReady, joining, connected, started;
|
||||
private int signalState;
|
||||
private volatile boolean closed;
|
||||
private Surface surface;
|
||||
private volatile AudioPlayer audio;
|
||||
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
|
||||
private final Runnable statisticsTick=new Runnable(){public void run(){
|
||||
if(closed)return;
|
||||
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
|
||||
if(!closed)main.postDelayed(this,1000);
|
||||
}};
|
||||
private final Runnable timeout = () -> fail(remote.isEmpty()
|
||||
? "服务器连接超时,请检查服务器地址和网络"
|
||||
: "连接超时,请检查设备 ID、服务器及远端在线状态");
|
||||
|
||||
NativeSession(Context context, String host, int port, String remote, String password, Listener listener) {
|
||||
this.context = context.getApplicationContext(); this.listener = listener;
|
||||
this.host = host; this.port = port; this.remote = remote; this.password = password;
|
||||
scope = host.toLowerCase(java.util.Locale.ROOT) + ":" + port;
|
||||
secrets = new SecretStore(this.context);
|
||||
}
|
||||
void start() {
|
||||
if (started || closed) return;
|
||||
started = true;
|
||||
main.postDelayed(timeout, 40000);
|
||||
execute(() -> {
|
||||
try {
|
||||
identity = secrets.get(scope);
|
||||
File certificates = exportTrust();
|
||||
File logs = new File(context.getNoBackupFilesDir(), "logs");
|
||||
if (!logs.isDirectory() && !logs.mkdirs()) throw new IllegalStateException("Log directory unavailable");
|
||||
handle = nCreate(this, host, port, identity, logs.getAbsolutePath(), certificates.getAbsolutePath());
|
||||
if (handle == 0) { fail("原生连接初始化失败"); return; }
|
||||
if (surface != null && surface.isValid()) nSurface(handle, surface);
|
||||
} catch (Exception error) { fail("安全存储或证书初始化失败,请重试或清除应用数据"); }
|
||||
});
|
||||
}
|
||||
/** Promote the already registered identity peer to an outgoing desktop session. */
|
||||
void connect(String remoteId, String secret) {
|
||||
execute(() -> {
|
||||
if (joining || connected) return;
|
||||
remote = remoteId;
|
||||
password = secret;
|
||||
// Serialize arming with identity callbacks, which cancel the idle
|
||||
// login timeout. Otherwise a just-completed login can cancel this
|
||||
// outgoing connection's timeout before tryJoin runs.
|
||||
main.removeCallbacks(timeout);
|
||||
main.postDelayed(timeout, 40000);
|
||||
tryJoin();
|
||||
});
|
||||
}
|
||||
boolean isClosed() { return closed; }
|
||||
private void publishSignaling() {
|
||||
int state = signalState;
|
||||
// Only expose the public device ID, never the identity's @password suffix.
|
||||
String deviceId = identity.split("@", 2)[0];
|
||||
ui(() -> listener.signaling(state, deviceId));
|
||||
}
|
||||
private File exportTrust() throws Exception {
|
||||
TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
factory.init((KeyStore) null);
|
||||
StringBuilder pem = new StringBuilder();
|
||||
for (TrustManager manager : factory.getTrustManagers()) {
|
||||
if (manager instanceof X509TrustManager trust) {
|
||||
for (X509Certificate certificate : trust.getAcceptedIssuers()) {
|
||||
pem.append("-----BEGIN CERTIFICATE-----\n")
|
||||
.append(Base64.encodeToString(certificate.getEncoded(), Base64.NO_WRAP))
|
||||
.append("\n-----END CERTIFICATE-----\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pem.length() == 0) throw new IllegalStateException("Empty Android trust store");
|
||||
File output = new File(context.getNoBackupFilesDir(), "trusted-roots.pem");
|
||||
Files.write(output.toPath(), pem.toString().getBytes(StandardCharsets.US_ASCII));
|
||||
return output;
|
||||
}
|
||||
private void execute(Runnable task) { RTC.execute(() -> { if (!closed) task.run(); }); }
|
||||
private void ui(Runnable task) { main.post(() -> { if (!closed) task.run(); }); }
|
||||
private void fail(String reason) { ui(() -> { listener.ended(reason); close(); }); }
|
||||
private void tryJoin() {
|
||||
if (handle == 0 || joining || remote.isEmpty() || !signalReady || identity.isEmpty()) return;
|
||||
joining = true;
|
||||
String base = identity.split("@", 2)[0];
|
||||
if (!nConnect(handle, base, remote, password)) fail("无法发起远程连接");
|
||||
password = "";
|
||||
}
|
||||
// Called on arbitrary native callback threads; borrowed payloads were copied by JNI.
|
||||
@SuppressWarnings("unused") private void onNativeEvent(int type, byte[] bytes) {
|
||||
execute(() -> {
|
||||
String value = new String(bytes, StandardCharsets.UTF_8);
|
||||
try {
|
||||
if (type == 3) {
|
||||
if (value.contains("@")) {
|
||||
identity = value;
|
||||
secrets.put(scope, identity);
|
||||
} else if (!identity.startsWith(value + "@")) identity = value;
|
||||
publishSignaling(); tryJoin(); return;
|
||||
}
|
||||
if (type == 6) { ui(() -> listener.clipboard(value)); return; }
|
||||
JSONObject event = new JSONObject(value);
|
||||
if (type == 1) {
|
||||
int status = event.getInt("status");
|
||||
if (!event.getBoolean("controller")) {
|
||||
signalState = status;
|
||||
signalReady = status == 1;
|
||||
if (signalReady && remote.isEmpty()) main.removeCallbacks(timeout);
|
||||
publishSignaling();
|
||||
tryJoin();
|
||||
}
|
||||
if (status == 6) fail("服务器 TLS 证书校验失败");
|
||||
else if (status == 2) fail("服务器登录失败,请检查服务器地址和网络");
|
||||
else if (status >= 3 && connected) fail("信令连接中断,请重新连接");
|
||||
else if (status == 0 || status == 4) ui(() -> listener.status("正在连接信令服务器…"));
|
||||
} else if (type == 2) {
|
||||
int status = event.getInt("status");
|
||||
if (status == 1) {
|
||||
if (connected) return; // Repeated transport readiness is the same desktop session.
|
||||
connected = true; main.removeCallbacks(timeout); nReady(handle);
|
||||
audio = new AudioPlayer();
|
||||
nControl(handle, 2, 1);
|
||||
ui(() -> { listener.connected(); if(!closed)statisticsTick.run(); });
|
||||
} else if (status == 0 || status == 2) ui(() -> listener.status("正在建立安全连接…"));
|
||||
else if (status == 6) ui(() -> { listener.passwordRejected(); close(); });
|
||||
else {
|
||||
String[] reasons = {"", "", "", "连接已断开", "连接失败", "远端已关闭连接",
|
||||
"远端密码错误", "找不到该设备 ID", "远端当前不可用"};
|
||||
fail(status >= 0 && status < reasons.length ? reasons[status] : "连接已结束");
|
||||
}
|
||||
} else if (type == 4) ui(() -> listener.data(event));
|
||||
else if (type == 5) {
|
||||
int width = event.getInt("width"), height = event.getInt("height");
|
||||
ui(() -> listener.videoSize(width, height));
|
||||
} else if (type == 7) networkStatistics.receive(new RemoteNetworkStatistics.Report(event),RemoteNetworkStatistics.now());
|
||||
else if (type == 8 && signalReady) ui(() -> listener.announcement(event));
|
||||
} catch (Exception error) {
|
||||
if (type == 3) fail("设备身份保存失败,请检查安全存储");
|
||||
}
|
||||
});
|
||||
}
|
||||
@SuppressWarnings("unused") private void onNativeAudio(byte[] pcm) {
|
||||
AudioPlayer player = audio;
|
||||
if (!closed && player != null) player.offer(pcm);
|
||||
}
|
||||
@SuppressWarnings("unused") private void onNativeVideoFrame(long id,double latency,int width,int height){
|
||||
if(!closed)networkStatistics.frame(id,latency,width,height,RemoteNetworkStatistics.now());
|
||||
}
|
||||
void videoSettings(RemoteVideoSettings.Values settings,long requestId){
|
||||
if(!settings.valid()||requestId<0||requestId>0xffffffffL)return;
|
||||
execute(()->{if(connected&&!nVideoSettings(handle,settings.quality,settings.frameRate,settings.preference,requestId))ui(()->listener.data(settings.status(requestId,false)));});
|
||||
}
|
||||
void announcementRequest(JSONObject request,String requestId){
|
||||
byte[] bytes=request.toString().getBytes(StandardCharsets.UTF_8);
|
||||
execute(()->{if(!signalReady||identity.isEmpty()||!nAnnouncementRequest(handle,bytes))ui(()->listener.announcementFailed(requestId));});
|
||||
}
|
||||
void surface(Surface value) { execute(() -> { surface = value; if (handle != 0) nSurface(handle, value); }); }
|
||||
void pointer(float x, float y, int flag, int wheel) { execute(() -> { if (connected) nPointer(handle, x, y, flag, wheel); }); }
|
||||
void key(int code, boolean down) { execute(() -> { if (connected) nKey(handle, code, down); }); }
|
||||
void control(int type, int value) {
|
||||
AudioPlayer player = audio;
|
||||
if (type == 2 && player != null) player.setEnabled(value != 0);
|
||||
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
|
||||
}
|
||||
void clipboard(String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
|
||||
execute(() -> { if (connected) nClipboard(handle, bytes); });
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; main.removeCallbacks(timeout);main.removeCallbacks(statisticsTick);
|
||||
RTC.execute(() -> {
|
||||
main.removeCallbacks(timeout);
|
||||
if (audio != null) { audio.close(); audio = null; }
|
||||
password = "";
|
||||
if (handle != 0) { nDestroy(handle); handle = 0; }
|
||||
surface = null;
|
||||
});
|
||||
}
|
||||
static native boolean nValidAppVersion(String version);
|
||||
static native boolean nHasAppUpdate(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);
|
||||
private static native void nPointer(long handle, float x, float y, int flag, int wheel);
|
||||
private static native void nKey(long handle, int code, boolean down);
|
||||
private static native void nControl(long handle, int type, int value);
|
||||
private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId);
|
||||
private static native boolean nAnnouncementRequest(long handle,byte[] data);
|
||||
private static native void nClipboard(long handle, byte[] data);
|
||||
private static native void nSurface(long handle, Surface surface);
|
||||
private static native void nDestroy(long handle);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.animation.PathInterpolator;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
/** Retains only the current page and, during a transition, the outgoing page. */
|
||||
final class PageHost extends FrameLayout {
|
||||
enum Motion { NONE, PUSH, POP, SESSION_IN, SESSION_OUT }
|
||||
|
||||
private FrameLayout current, outgoing;
|
||||
private Motion motion=Motion.NONE;
|
||||
private ValueAnimator animator;
|
||||
private ViewTreeObserver pendingObserver;
|
||||
private ViewTreeObserver.OnPreDrawListener pendingStart;
|
||||
private int accessibility, focusability;
|
||||
private float distance;
|
||||
private long generation;
|
||||
|
||||
PageHost(Context context){super(context);setBackgroundColor(MobileUi.BACKGROUND);}
|
||||
|
||||
void show(FrameLayout page,Motion requested){
|
||||
finish();
|
||||
FrameLayout previous=current;current=page;
|
||||
if(previous==null||requested==Motion.NONE||!ValueAnimator.areAnimatorsEnabled()||!isAttachedToWindow()){
|
||||
removeAllViews();addView(page,new LayoutParams(-1,-1));page.requestApplyInsets();return;
|
||||
}
|
||||
outgoing=previous;motion=requested;
|
||||
accessibility=page.getImportantForAccessibility();
|
||||
focusability=page.getDescendantFocusability();
|
||||
previous.clearFocus();previous.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
page.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
page.setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS);
|
||||
boolean underneath=motion==Motion.POP||motion==Motion.SESSION_IN;
|
||||
addView(page,underneath?0:getChildCount(),new LayoutParams(-1,-1));
|
||||
// SurfaceView pages stay opaque and unlayered. Fade the ordinary UI above them.
|
||||
if(motion==Motion.PUSH||motion==Motion.POP){
|
||||
previous.setLayerType(LAYER_TYPE_HARDWARE,null);page.setLayerType(LAYER_TYPE_HARDWARE,null);
|
||||
(motion==Motion.PUSH?page:previous).setElevation(8*getResources().getDisplayMetrics().density);
|
||||
}else (motion==Motion.SESSION_IN?previous:page).setLayerType(LAYER_TYPE_HARDWARE,null);
|
||||
distance=getWidth()*(getLayoutDirection()==LAYOUT_DIRECTION_RTL?-1:1);
|
||||
progress(0);page.requestApplyInsets();
|
||||
// Wait for content and insets, so the first drawn frame already has the right position.
|
||||
long transition=generation;
|
||||
pendingObserver=getViewTreeObserver();
|
||||
pendingStart=()->{
|
||||
if(transition!=generation||outgoing==null)return true;
|
||||
clearPendingStart();distance=getWidth()*(getLayoutDirection()==LAYOUT_DIRECTION_RTL?-1:1);progress(0);
|
||||
ValueAnimator next=ValueAnimator.ofFloat(0,1);animator=next;
|
||||
boolean session=motion==Motion.SESSION_IN||motion==Motion.SESSION_OUT;
|
||||
next.setDuration(session?200:350);
|
||||
next.setInterpolator(session?new PathInterpolator(.42f,0,.58f,1):new PathInterpolator(.25f,.1f,.25f,1));
|
||||
next.addUpdateListener(value->{if(animator==value)progress((float)value.getAnimatedValue());});
|
||||
next.addListener(new AnimatorListenerAdapter(){@Override public void onAnimationEnd(Animator value){if(animator==value)finish();}});
|
||||
next.start();return true;
|
||||
};
|
||||
pendingObserver.addOnPreDrawListener(pendingStart);
|
||||
}
|
||||
|
||||
private void progress(float value){
|
||||
switch(motion){
|
||||
case PUSH -> {current.setTranslationX(distance*(1-value));outgoing.setTranslationX(-distance*.3f*value);}
|
||||
case POP -> {current.setTranslationX(-distance*.3f*(1-value));outgoing.setTranslationX(distance*value);}
|
||||
case SESSION_IN -> outgoing.setAlpha(1-value);
|
||||
case SESSION_OUT -> current.setAlpha(value);
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
|
||||
void finish(){
|
||||
generation++;
|
||||
clearPendingStart();
|
||||
ValueAnimator running=animator;animator=null;if(running!=null)running.cancel();
|
||||
if(outgoing==null)return;
|
||||
reset(outgoing);removeView(outgoing);outgoing=null;
|
||||
reset(current);current.setImportantForAccessibility(accessibility);
|
||||
current.setDescendantFocusability(focusability);motion=Motion.NONE;
|
||||
}
|
||||
private void clearPendingStart(){
|
||||
if(pendingObserver!=null&&pendingObserver.isAlive()&&pendingStart!=null)pendingObserver.removeOnPreDrawListener(pendingStart);
|
||||
pendingObserver=null;pendingStart=null;
|
||||
}
|
||||
private static void reset(View page){page.setTranslationX(0);page.setAlpha(1);page.setElevation(0);page.setLayerType(LAYER_TYPE_NONE,null);}
|
||||
@Override public boolean onInterceptTouchEvent(MotionEvent event){return outgoing!=null||super.onInterceptTouchEvent(event);}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // A temporary input shield; the pages own all click actions.
|
||||
@Override public boolean onTouchEvent(MotionEvent event){return outgoing!=null||super.onTouchEvent(event);}
|
||||
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh){super.onSizeChanged(w,h,oldw,oldh);if(oldw>0&&oldh>0&&(w!=oldw||h!=oldh))finish();}
|
||||
@Override protected void onDetachedFromWindow(){finish();super.onDetachedFromWindow();}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** History and encrypted passwords are isolated by server; history never contains secrets. */
|
||||
final class RecentConnections {
|
||||
private final SharedPreferences preferences;
|
||||
private final SecretStore secrets;
|
||||
private final Context context;
|
||||
RecentConnections(Context context){this.context=context.getApplicationContext();preferences=context.getSharedPreferences("settings",Context.MODE_PRIVATE);secrets=new SecretStore(context);}
|
||||
private String key(String server){return "recentList:"+server;}
|
||||
private String credential(String server,String id){return "remote-password:"+server+":"+id;}
|
||||
JSONArray list(String server){
|
||||
try {
|
||||
if(!preferences.contains(key(server))){
|
||||
String legacy=preferences.getString("recent:"+server,"");
|
||||
if(!legacy.isEmpty())return new JSONArray().put(new JSONObject().put("id",legacy).put("name",legacy));
|
||||
}
|
||||
return new JSONArray(preferences.getString(key(server),"[]"));
|
||||
}
|
||||
catch(org.json.JSONException error){return new JSONArray();}
|
||||
}
|
||||
boolean remembersPassword(String server,String id){
|
||||
JSONArray records=list(server);
|
||||
for(int i=0;i<records.length();i++){JSONObject item=records.optJSONObject(i);if(item!=null&&id.equals(item.optString("id")))return item.optBoolean("remember");}
|
||||
return false;
|
||||
}
|
||||
String password(String server,String id)throws Exception{return secrets.get(credential(server,id));}
|
||||
boolean contains(String server,String id){JSONArray all=list(server);for(int i=0;i<all.length();i++){JSONObject item=all.optJSONObject(i);if(item!=null&&id.equals(item.optString("id")))return true;}return false;}
|
||||
void save(String server,String id,String name,String platform,boolean remember,String password)throws Exception{
|
||||
if(remember&&password.isEmpty())throw new IllegalArgumentException("Cannot remember an empty password");
|
||||
if(remember)secrets.put(credential(server,id),password);else secrets.remove(credential(server,id));
|
||||
JSONObject item=new JSONObject().put("id",id).put("name",name.isEmpty()?id:name).put("platform",platform).put("remember",remember);
|
||||
JSONArray result=new JSONArray().put(item),old=list(server);
|
||||
for(int i=0;i<old.length();i++){JSONObject entry=old.optJSONObject(i);if(entry!=null&&!id.equals(entry.optString("id"))){if(result.length()<20)result.put(entry);else RemotePreviewStore.get(context).remove(server,entry.optString("id"));}}
|
||||
preferences.edit().putString(key(server),result.toString()).apply();
|
||||
}
|
||||
void metadata(String server,String id,String name,String platform){
|
||||
JSONArray all=list(server);
|
||||
try{for(int i=0;i<all.length();i++){JSONObject item=all.optJSONObject(i);if(item!=null&&id.equals(item.optString("id"))){item.put("name",name.isEmpty()?id:name);item.put("platform",platform);}}}
|
||||
catch(org.json.JSONException ignored){return;}
|
||||
preferences.edit().putString(key(server),all.toString()).apply();
|
||||
}
|
||||
void delete(String server,String id){
|
||||
JSONArray result=new JSONArray(),old=list(server);for(int i=0;i<old.length();i++){JSONObject entry=old.optJSONObject(i);if(entry!=null&&!id.equals(entry.optString("id")))result.put(entry);}
|
||||
preferences.edit().putString(key(server),result.toString()).apply();secrets.remove(credential(server,id));
|
||||
RemotePreviewStore.get(context).remove(server,id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Monotonic, thread-safe measurements; the UI samples these once a second, as on iOS. */
|
||||
final class RemoteNetworkStatistics {
|
||||
static final class Traffic {
|
||||
final long inbound,outbound;final double loss;
|
||||
Traffic(JSONObject json){inbound=json.optLong("inbound",-1);outbound=json.optLong("outbound",-1);loss=json.optDouble("loss",Double.NaN);}
|
||||
}
|
||||
static final class Report {
|
||||
final Traffic[] traffic=new Traffic[4];final int mode;final boolean srtp;final double rtt;
|
||||
Report(JSONObject json){
|
||||
String[] keys={"video","audio","data","total"};
|
||||
for(int i=0;i<keys.length;i++){JSONObject item=json.optJSONObject(keys[i]);traffic[i]=item==null?null:new Traffic(item);}
|
||||
mode=json.optInt("mode",2);srtp=json.optBoolean("srtp");rtt=json.optDouble("rtt",Double.NaN);
|
||||
}
|
||||
}
|
||||
static final class Snapshot {
|
||||
final Report report;final int fps,width,height;final double latency,rtt;
|
||||
Snapshot(Report report,int fps,int width,int height,double latency,double rtt){this.report=report;this.fps=fps;this.width=width;this.height=height;this.latency=latency;this.rtt=rtt;}
|
||||
String mode(){return report==null?"—":report.mode==0?"P2P 直连":report.mode==1?"TURN 中继":"—";}
|
||||
String resolution(){return width>0&&height>0?width+" × "+height:"—";}
|
||||
String summary(){
|
||||
String value=mode()+(width>0&&height>0?" · "+width+"×"+height:"")+" · "+bitrate(report==null||report.traffic[3]==null?-1:report.traffic[3].inbound);
|
||||
if(report!=null&&report.traffic[0]!=null&&report.traffic[0].loss>0)value+=" · 丢包 "+loss(report.traffic[0].loss);return value;
|
||||
}
|
||||
}
|
||||
private static final class Frame {
|
||||
final double time,latency;Frame(double time,double latency){this.time=time;this.latency=latency;}
|
||||
}
|
||||
private Report report;
|
||||
private double reportTime,rttTime,averageRtt=Double.NaN;
|
||||
private long lastFrameId;
|
||||
private int width,height;
|
||||
private final ArrayDeque<Frame> frames=new ArrayDeque<>();
|
||||
static double now(){return System.nanoTime()/1_000_000_000.0;}
|
||||
synchronized void receive(Report value,double now){
|
||||
report=value;reportTime=now;
|
||||
if(!Double.isFinite(value.rtt)||value.rtt<0||value.rtt>2000)return;
|
||||
averageRtt=Double.isFinite(averageRtt)&&now-rttTime<3?averageRtt+.25*(value.rtt-averageRtt):value.rtt;rttTime=now;
|
||||
}
|
||||
synchronized void frame(long id,double latency,int width,int height,double now){
|
||||
if(id<=lastFrameId||width<=0||height<=0)return;lastFrameId=id;this.width=width;this.height=height;
|
||||
// Native code validates clock calibration before adding local decode/render waits.
|
||||
// A valid but slow frame must not disappear just because its delay exceeds 5 s.
|
||||
prune(now);frames.addLast(new Frame(now,Double.isFinite(latency)&&latency>=0?latency:Double.NaN));
|
||||
while(frames.size()>240)frames.removeFirst();
|
||||
}
|
||||
synchronized void resetVideo(){frames.clear();width=height=0;}
|
||||
synchronized Snapshot snapshot(double now){
|
||||
prune(now);double total=0;int count=0;
|
||||
for(Frame frame:frames)if(Double.isFinite(frame.latency)){total+=frame.latency;count++;}
|
||||
return new Snapshot(now-reportTime<3?report:null,frames.size(),width,height,count==0?Double.NaN:total/count,now-rttTime<3?averageRtt:Double.NaN);
|
||||
}
|
||||
private void prune(double now){while(!frames.isEmpty()&&now-frames.peekFirst().time>=1)frames.removeFirst();}
|
||||
static String bitrate(long value){return value<0?"—":value>=1_000_000?String.format(Locale.ROOT,"%.1f Mbps",value/1_000_000.0):value>=1000?String.format(Locale.ROOT,"%.0f Kbps",value/1000.0):value+" bps";}
|
||||
static String loss(double value){return Double.isFinite(value)&&value>=0?String.format(Locale.ROOT,"%.1f%%",value*100):"—";}
|
||||
static String latency(double value){return !Double.isFinite(value)||value<0?"—":value<1?"<1 ms":String.format(Locale.ROOT,"%.0f ms",value);}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.AtomicFile;
|
||||
import org.json.JSONArray;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Process-wide, serialized preview I/O. Clears invalidate even captures not yet queued for saving. */
|
||||
final class RemotePreviewStore {
|
||||
static final String OPTION="saveRemotePreviews";
|
||||
static final int WIDTH=640,HEIGHT=360;
|
||||
private static RemotePreviewStore instance;
|
||||
static synchronized RemotePreviewStore get(Context context){
|
||||
if(instance==null){
|
||||
Context app=context.getApplicationContext();
|
||||
instance=new RemotePreviewStore(new File(app.getNoBackupFilesDir(),"RecentConnectionThumbnails"),
|
||||
app.getSharedPreferences("settings",Context.MODE_PRIVATE),app.getSharedPreferences("remote-previews",Context.MODE_PRIVATE));
|
||||
instance.enforceConsent();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
static final class Capture {
|
||||
final String key;final long generation,version;
|
||||
private Capture(String key,long generation,long version){this.key=key;this.generation=generation;this.version=version;}
|
||||
}
|
||||
final File directory;
|
||||
final ExecutorService io=Executors.newSingleThreadExecutor(r->new Thread(r,"CrossDesk previews"));
|
||||
private final Handler main=new Handler(Looper.getMainLooper());
|
||||
private final SharedPreferences options,index;
|
||||
private final Set<String> files=new HashSet<>();
|
||||
private final Map<String,Long> versions=new HashMap<>();
|
||||
private final Set<Runnable> listeners=new CopyOnWriteArraySet<>();
|
||||
private long generation;
|
||||
volatile boolean cleaning;
|
||||
volatile String cleanupError="";
|
||||
RemotePreviewStore(File directory,SharedPreferences options,SharedPreferences index){
|
||||
this.directory=directory;this.options=options;this.index=index;
|
||||
for(String key:index.getStringSet("files",Collections.emptySet()))if(key.matches("[a-f0-9]{64}"))files.add(key);
|
||||
}
|
||||
boolean enabled(){return options.getBoolean("networkConsent",false)&&options.getBoolean(OPTION,false);}
|
||||
void addListener(Runnable listener){listeners.add(listener);}
|
||||
void removeListener(Runnable listener){listeners.remove(listener);}
|
||||
private void changed(){main.post(()->{for(Runnable listener:listeners)listener.run();});}
|
||||
synchronized void enforceConsent(){
|
||||
if(!options.getBoolean("networkConsent",false))options.edit().putBoolean(OPTION,false).apply();
|
||||
if(!enabled()&&!cleaning)clear();
|
||||
}
|
||||
void setEnabled(boolean enabled){
|
||||
options.edit().putBoolean(OPTION,enabled&&options.getBoolean("networkConsent",false)).apply();
|
||||
if(!enabled())clear();else changed();
|
||||
}
|
||||
synchronized Capture begin(String server,String id){
|
||||
if(!enabled()||cleaning||id.isEmpty())return null;
|
||||
String key=key(server,id);long version=versions.getOrDefault(key,0L)+1;versions.put(key,version);
|
||||
return new Capture(key,generation,version);
|
||||
}
|
||||
synchronized boolean current(Capture capture){
|
||||
return capture!=null&&enabled()&&!cleaning&&capture.generation==generation&&
|
||||
capture.version==versions.getOrDefault(capture.key,0L);
|
||||
}
|
||||
/** Takes ownership of the small bitmap; JPEG compression and writes never run on the UI thread. */
|
||||
void save(Capture capture,Bitmap bitmap){
|
||||
io.execute(()->{
|
||||
boolean saved=false;
|
||||
try{
|
||||
if(current(capture)&&bitmap.getWidth()==WIDTH&&bitmap.getHeight()==HEIGHT)saved=write(capture.key,bitmap);
|
||||
}finally{bitmap.recycle();}
|
||||
synchronized(this){
|
||||
if(!saved||!current(capture))return;
|
||||
Set<String> next=new HashSet<>(files);next.add(capture.key);
|
||||
if(!index.edit().putStringSet("files",next).commit())return;
|
||||
files.add(capture.key);
|
||||
}
|
||||
changed();
|
||||
});
|
||||
}
|
||||
private boolean write(String key,Bitmap bitmap){
|
||||
AtomicFile file=new AtomicFile(new File(directory,key+".jpg"));FileOutputStream output=null;
|
||||
try{
|
||||
ByteArrayOutputStream encoded=new ByteArrayOutputStream();
|
||||
if(!bitmap.compress(Bitmap.CompressFormat.JPEG,78,encoded))return false;
|
||||
byte[] bytes=encoded.toByteArray();
|
||||
if(!directory.isDirectory()&&!directory.mkdirs())return false;
|
||||
output=file.startWrite();output.write(bytes);
|
||||
output.getFD().sync();file.finishWrite(output);output=null;
|
||||
return Arrays.equals(bytes,file.readFully());
|
||||
}catch(IOException|RuntimeException error){if(output!=null)file.failWrite(output);return false;}
|
||||
}
|
||||
void load(String server,String id,Consumer<Bitmap> receive){
|
||||
final Capture read;
|
||||
synchronized(this){
|
||||
String key=key(server,id);
|
||||
if(!enabled()||cleaning||!files.contains(key)){main.post(()->receive.accept(null));return;}
|
||||
read=new Capture(key,generation,versions.getOrDefault(key,0L));
|
||||
}
|
||||
io.execute(()->{
|
||||
Bitmap bitmap=null;
|
||||
if(current(read)){
|
||||
File file=new File(directory,read.key+".jpg");
|
||||
if(file.isFile()&&file.length()<=2*1024*1024){
|
||||
BitmapFactory.Options bounds=new BitmapFactory.Options();bounds.inJustDecodeBounds=true;
|
||||
BitmapFactory.decodeFile(file.getAbsolutePath(),bounds);
|
||||
if(bounds.outWidth==WIDTH&&bounds.outHeight==HEIGHT)bitmap=BitmapFactory.decodeFile(file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
Bitmap image=bitmap;
|
||||
main.post(()->{
|
||||
boolean valid;synchronized(this){valid=current(read)&&files.contains(read.key);}
|
||||
if(valid)receive.accept(image);else if(image!=null)image.recycle();
|
||||
});
|
||||
});
|
||||
}
|
||||
synchronized void remove(String server,String id){
|
||||
String key=key(server,id);versions.put(key,versions.getOrDefault(key,0L)+1);files.remove(key);
|
||||
io.execute(()->{
|
||||
new AtomicFile(new File(directory,key+".jpg")).delete();
|
||||
synchronized(this){
|
||||
boolean saved=index.edit().putStringSet("files",new HashSet<>(files)).commit();
|
||||
if(!saved||new File(directory,key+".jpg").exists())cleanupError="部分预览图未能清除,请重试。";
|
||||
}
|
||||
changed();
|
||||
});changed();
|
||||
}
|
||||
synchronized void clear(){
|
||||
final long epoch=++generation;files.clear();cleaning=true;cleanupError="";changed();
|
||||
io.execute(()->{
|
||||
boolean success=index.edit().clear().commit();
|
||||
try{deleteTree(directory);}catch(IOException error){success=false;}
|
||||
synchronized(this){
|
||||
if(epoch!=generation)return;
|
||||
cleaning=false;cleanupError=success?"":"部分预览图未能清除,请重试。";
|
||||
}
|
||||
changed();
|
||||
});
|
||||
}
|
||||
private static void deleteTree(File file)throws IOException{
|
||||
if(!file.exists())return;
|
||||
if(file.isDirectory()&&!Files.isSymbolicLink(file.toPath())){
|
||||
File[] children=file.listFiles();if(children==null)throw new IOException("Cannot list previews");
|
||||
boolean failed=false;for(File child:children)try{deleteTree(child);}catch(IOException error){failed=true;}
|
||||
if(failed)throw new IOException("Cannot clear all previews");
|
||||
}
|
||||
if(!file.delete())throw new IOException("Cannot delete preview");
|
||||
}
|
||||
private static String key(String server,String id){
|
||||
try{
|
||||
byte[] bytes=new JSONArray().put(server).put(id).toString().getBytes(StandardCharsets.UTF_8);
|
||||
byte[] hash=MessageDigest.getInstance("SHA-256").digest(bytes);StringBuilder key=new StringBuilder();
|
||||
for(byte b:hash)key.append(String.format(Locale.ROOT,"%02x",b&255));return key.toString();
|
||||
}catch(Exception error){throw new IllegalStateException(error);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import org.json.JSONObject;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.function.Consumer;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
/** One check per desktop session. State and completion callbacks run on main. */
|
||||
final class RemoteVersionCheck {
|
||||
static final String NOTICE = "被控端版本过低,请升级到最新版本。";
|
||||
static final String RELEASE_URL = "https://version.crossdesk.cn/version.json";
|
||||
interface Cancellation { void cancel(); }
|
||||
interface Fetcher { Cancellation fetch(Consumer<byte[]> completion); }
|
||||
|
||||
private final Fetcher fetcher;
|
||||
private final Runnable notify;
|
||||
private boolean connected, receivedHostInfo, attempted;
|
||||
private String version = "";
|
||||
private long generation;
|
||||
private Cancellation request;
|
||||
|
||||
RemoteVersionCheck(Runnable notify) { this(ReleaseRequest::start, notify); }
|
||||
RemoteVersionCheck(Fetcher fetcher, Runnable notify) {
|
||||
this.fetcher = fetcher;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
void connected() { connected = true; check(); }
|
||||
|
||||
void hostInfo(JSONObject info) {
|
||||
receivedHostInfo = true;
|
||||
Object value = info.opt("app_version");
|
||||
version = value instanceof String && NativeSession.nValidAppVersion((String)value)
|
||||
? (String)value : "";
|
||||
check();
|
||||
}
|
||||
|
||||
private void check() {
|
||||
if (!connected || !receivedHostInfo || attempted) return;
|
||||
attempted = true;
|
||||
// iOS treats missing/invalid peer versions as legacy, without a request.
|
||||
if (version.isEmpty()) { notify.run(); return; }
|
||||
long currentGeneration = generation;
|
||||
String currentVersion = version;
|
||||
request = fetcher.fetch(data -> {
|
||||
if (generation != currentGeneration || !connected) return;
|
||||
request = null;
|
||||
if (data != null && NativeSession.nHasAppUpdate(currentVersion, data)) notify.run();
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
++generation;
|
||||
connected = receivedHostInfo = attempted = false;
|
||||
version = "";
|
||||
if (request != null) request.cancel();
|
||||
request = null;
|
||||
}
|
||||
|
||||
private 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;
|
||||
private final Runnable timeout = () -> finish(null);
|
||||
private volatile boolean cancelled;
|
||||
private HttpsURLConnection connection;
|
||||
|
||||
private ReleaseRequest(Consumer<byte[]> completion) { this.completion = completion; }
|
||||
static Cancellation start(Consumer<byte[]> completion) {
|
||||
ReleaseRequest request = new ReleaseRequest(completion);
|
||||
request.main.postDelayed(request.timeout, 15000);
|
||||
Thread worker = new Thread(request, "CrossDesk version check");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override public void run() {
|
||||
byte[] result = null;
|
||||
HttpsURLConnection opened = null;
|
||||
try {
|
||||
// Fetch public metadata only: no peer identity, version or credentials.
|
||||
opened = (HttpsURLConnection)new URL(RELEASE_URL).openConnection();
|
||||
synchronized (this) {
|
||||
if (cancelled) return;
|
||||
connection = opened;
|
||||
}
|
||||
opened.setConnectTimeout(5000);
|
||||
opened.setReadTimeout(10000);
|
||||
opened.setUseCaches(false);
|
||||
opened.setInstanceFollowRedirects(false);
|
||||
if (opened.getResponseCode() == 200 && opened.getContentLengthLong() <= MAX_BYTES) {
|
||||
try (InputStream input = opened.getInputStream();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int count;
|
||||
while (!cancelled && (count = input.read(buffer)) != -1) {
|
||||
if (output.size() + count > MAX_BYTES) return;
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
if (!cancelled) result = output.toByteArray();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// A failed check is silent, as on iOS; it never interrupts control.
|
||||
} finally {
|
||||
if (opened != null) opened.disconnect();
|
||||
byte[] response = result;
|
||||
main.post(() -> finish(response));
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(byte[] response) {
|
||||
if (cancelled) return;
|
||||
cancel();
|
||||
completion.accept(response);
|
||||
}
|
||||
|
||||
@Override public void cancel() {
|
||||
HttpsURLConnection active;
|
||||
synchronized (this) { cancelled = true; active = connection; connection = null; }
|
||||
main.removeCallbacks(timeout);
|
||||
if (active != null) active.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Same request/acknowledgement and rollback rules as the iOS controller. */
|
||||
final class RemoteVideoSettings {
|
||||
static final String[] PREFERENCES={"帧率优先","画质优先","平衡"};
|
||||
static final String[] DETAILS={
|
||||
"优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。",
|
||||
"始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。",
|
||||
"兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。"};
|
||||
static final class Values {
|
||||
final int quality,frameRate,preference;
|
||||
Values(int quality,int frameRate,int preference){this.quality=quality;this.frameRate=frameRate;this.preference=preference;}
|
||||
boolean valid(){return quality>=0&&quality<=2&&(frameRate==30||frameRate==60)&&preference>=0&&preference<=2;}
|
||||
JSONObject status(long requestId,boolean accepted){
|
||||
try{return new JSONObject().put("type",12).put("video_settings",new JSONObject().put("quality",quality).put("frame_rate",frameRate).put("preference",preference).put("request_id",requestId).put("accepted",accepted));}
|
||||
catch(org.json.JSONException impossible){throw new IllegalStateException(impossible);}
|
||||
}
|
||||
}
|
||||
boolean supported,pending,failed;
|
||||
Values selection,applied;
|
||||
long requestId,appliedRequestId;
|
||||
RemoteVideoSettings(int preference){this(preference,1920);}
|
||||
RemoteVideoSettings(int preference,int displayLongEdge){
|
||||
// This Android pipeline uses software decoding. A 4K/60 startup can
|
||||
// outrun mobile CPUs before the first settings acknowledgement arrives.
|
||||
// Small screens need no more than 720p; larger screens start at 1080p.
|
||||
// All quality/frame-rate choices remain available for manual adjustment.
|
||||
selection=applied=new Values(displayLongEdge>0&&displayLongEdge<=1280?0:1,30,preference>=0&&preference<=2?preference:1);
|
||||
}
|
||||
void setSupported(boolean value){supported=value;if(!value){selection=applied;pending=false;failed=false;}}
|
||||
long begin(Values value){
|
||||
if(!supported||!value.valid())return -1;
|
||||
requestId=(requestId+1)&0xffffffffL;selection=value;pending=true;failed=false;return requestId;
|
||||
}
|
||||
void receive(Values value,long responseId,boolean accepted){
|
||||
if(!supported||!value.valid()||responseId<0||responseId>0xffffffffL||(int)(requestId-responseId)<0)return;
|
||||
if(accepted&&(int)(responseId-appliedRequestId)>=0){applied=value;appliedRequestId=responseId;}
|
||||
if(responseId!=requestId)return;
|
||||
pending=false;failed=!accepted;selection=applied;
|
||||
}
|
||||
void receive(JSONObject message){
|
||||
try{
|
||||
if(message.getInt("type")!=12)return;
|
||||
JSONObject settings=message.getJSONObject("video_settings");
|
||||
for(String key:new String[]{"quality","frame_rate","preference","request_id"}){
|
||||
Object value=settings.get(key);if(!(value instanceof Integer)&&!(value instanceof Long))return;
|
||||
}
|
||||
long quality=settings.getLong("quality"),rate=settings.getLong("frame_rate"),preference=settings.getLong("preference");
|
||||
if(quality<0||quality>2||(rate!=30&&rate!=60)||preference<0||preference>2)return;
|
||||
Object accepted=settings.opt("accepted");if(accepted!=null&&!(accepted instanceof Boolean))return;
|
||||
receive(new Values((int)quality,(int)rate,(int)preference),settings.getLong("request_id"),Boolean.TRUE.equals(accepted));
|
||||
}catch(org.json.JSONException ignored){ }
|
||||
}
|
||||
void expire(long expiredId){if(pending&&expiredId==requestId){selection=applied;pending=false;failed=true;}}
|
||||
String feedback(){return !supported?"连接建立后可调整,需被控端支持。":failed?"调整失败,请重试。":"";}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.PixelCopy;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
@android.annotation.SuppressLint("ViewConstructor") // Constructed only with a live session, never from XML.
|
||||
final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callback {
|
||||
private final SurfaceView video;
|
||||
private final Cursor cursor;
|
||||
private final NativeSession session;
|
||||
private final GestureDetector gestures;
|
||||
private int frameWidth = 16, frameHeight = 9;
|
||||
private float x = .5f, y = .5f, scrollY;
|
||||
private boolean dragging, twoFinger, relative;
|
||||
private float lastX,lastY;
|
||||
void setRelative(boolean value) { relative=value; }
|
||||
RemoteVideoView(Context context, NativeSession session) {
|
||||
super(context); this.session = session;
|
||||
setBackgroundColor(Color.BLACK);
|
||||
video = new SurfaceView(context); video.getHolder().addCallback(this);
|
||||
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
|
||||
cursor = new Cursor(context);
|
||||
addView(cursor, new LayoutParams(1, 1, android.view.Gravity.CENTER));
|
||||
gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
|
||||
@Override public boolean onDown(MotionEvent e) { point(e); return true; }
|
||||
@Override public boolean onSingleTapUp(MotionEvent e) {
|
||||
if (!twoFinger) { point(e); cursor.performClick(); } return true;
|
||||
}
|
||||
@Override public void onLongPress(MotionEvent e) { if (!twoFinger && !dragging) { point(e); click(3, 4); } }
|
||||
@Override public boolean onScroll(MotionEvent first, MotionEvent current, float dx, float dy) {
|
||||
if (twoFinger) return true;
|
||||
if (!relative && !dragging) { point(first); session.pointer(x, y, 1, 0); dragging = true; }
|
||||
point(current); return true;
|
||||
}
|
||||
});
|
||||
cursor.setOnGenericMotionListener((v, event) -> {
|
||||
point(event);
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) {
|
||||
float delta = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
|
||||
if (delta != 0) session.pointer(x, y, 7, delta > 0 ? 1 : -1);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setContentDescription("远程桌面:轻触点击,拖动移动,长按右键,双指上下滑动滚动");
|
||||
}
|
||||
void videoSize(int width, int height) { frameWidth = width; frameHeight = height; fit(); }
|
||||
static Rect previewCrop(int width,int height){
|
||||
int w=width,h=height;
|
||||
if((long)width*RemotePreviewStore.HEIGHT>(long)height*RemotePreviewStore.WIDTH)w=Math.max(1,height*RemotePreviewStore.WIDTH/RemotePreviewStore.HEIGHT);
|
||||
else h=Math.max(1,width*RemotePreviewStore.HEIGHT/RemotePreviewStore.WIDTH);
|
||||
return new Rect((width-w)/2,(height-h)/2,(width+w)/2,(height+h)/2);
|
||||
}
|
||||
void capturePreview(java.util.function.Consumer<Bitmap> completion){
|
||||
// Copy only the decoded-video Surface, excluding cursor, controls, keyboard and black margins.
|
||||
if(!isAttachedToWindow()||!video.getHolder().getSurface().isValid()){completion.accept(null);return;}
|
||||
Bitmap image=Bitmap.createBitmap(RemotePreviewStore.WIDTH,RemotePreviewStore.HEIGHT,Bitmap.Config.ARGB_8888);
|
||||
try{
|
||||
PixelCopy.request(video.getHolder().getSurface(),previewCrop(frameWidth,frameHeight),image,result->{
|
||||
if(result==PixelCopy.SUCCESS)completion.accept(image);else{image.recycle();completion.accept(null);}
|
||||
},new Handler(Looper.getMainLooper()));
|
||||
}catch(IllegalArgumentException error){image.recycle();completion.accept(null);}
|
||||
}
|
||||
private void fit() {
|
||||
if (getWidth() == 0 || getHeight() == 0) return;
|
||||
double scale = Math.min((double)getWidth()/frameWidth, (double)getHeight()/frameHeight);
|
||||
int w = Math.max(1,(int)(frameWidth*scale)), h = Math.max(1,(int)(frameHeight*scale));
|
||||
video.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER));
|
||||
cursor.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER));
|
||||
}
|
||||
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
|
||||
private void point(MotionEvent e) {
|
||||
boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE;
|
||||
float px=e.getX(),py=e.getY();
|
||||
if(touchpad){
|
||||
if(e.getActionMasked()==MotionEvent.ACTION_MOVE){
|
||||
x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,cursor.getWidth())));
|
||||
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,cursor.getHeight())));
|
||||
}
|
||||
}else{
|
||||
x=Math.max(0,Math.min(1,px/Math.max(1,cursor.getWidth())));
|
||||
y=Math.max(0,Math.min(1,py/Math.max(1,cursor.getHeight())));
|
||||
}
|
||||
lastX=px;lastY=py;
|
||||
session.pointer(x,y,0,0); cursor.invalidate();
|
||||
}
|
||||
private void click(int down, int up) { session.pointer(x,y,down,0); session.pointer(x,y,up,0); }
|
||||
private boolean touch(MotionEvent e) {
|
||||
int action = e.getActionMasked();
|
||||
if (action == MotionEvent.ACTION_DOWN) twoFinger = false;
|
||||
if (action == MotionEvent.ACTION_POINTER_DOWN) {
|
||||
if (dragging) { session.pointer(x,y,2,0); dragging = false; }
|
||||
twoFinger = true; scrollY = e.getY();
|
||||
MotionEvent cancel = MotionEvent.obtain(e); cancel.setAction(MotionEvent.ACTION_CANCEL);
|
||||
gestures.onTouchEvent(cancel); cancel.recycle();
|
||||
}
|
||||
if (twoFinger && action == MotionEvent.ACTION_MOVE) {
|
||||
float delta = e.getY() - scrollY;
|
||||
if (Math.abs(delta) > 16 * getResources().getDisplayMetrics().density) {
|
||||
session.pointer(x,y,7,delta > 0 ? 1 : -1); scrollY = e.getY();
|
||||
}
|
||||
} else if (!twoFinger) {
|
||||
if ((e.getButtonState() & MotionEvent.BUTTON_SECONDARY) != 0 && action == MotionEvent.ACTION_DOWN) {
|
||||
point(e); click(3,4); return true;
|
||||
}
|
||||
gestures.onTouchEvent(e);
|
||||
}
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
|
||||
if (dragging) session.pointer(x,y,2,0);
|
||||
dragging = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@Override public boolean performClick() { super.performClick(); return true; }
|
||||
@Override public void surfaceCreated(SurfaceHolder holder) { session.surface(holder.getSurface()); }
|
||||
@Override public void surfaceChanged(SurfaceHolder holder,int format,int width,int height) { }
|
||||
@Override public void surfaceDestroyed(SurfaceHolder holder) { session.surface(null); }
|
||||
private final class Cursor extends View {
|
||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
Cursor(Context context) { super(context); setClickable(true); setContentDescription("远程桌面触控区域"); }
|
||||
// GestureDetector calls performClick only after resolving tap vs drag/long press.
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility")
|
||||
@Override public boolean onTouchEvent(MotionEvent event) { return touch(event); }
|
||||
@Override public boolean performClick() { super.performClick(); click(1, 2); return true; }
|
||||
@Override protected void onDraw(Canvas canvas) {
|
||||
paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(3);
|
||||
paint.setColor(Color.WHITE); canvas.drawCircle(x*getWidth(),y*getHeight(),8,paint);
|
||||
paint.setColor(Color.rgb(36,117,232)); paint.setStrokeWidth(1);
|
||||
canvas.drawCircle(x*getWidth(),y*getHeight(),10,paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
|
||||
/** Server-scoped credentials encrypted with a non-exportable Android Keystore key. */
|
||||
final class SecretStore {
|
||||
private static final String ALIAS = "crossdesk.signaling.v1";
|
||||
private final SharedPreferences preferences;
|
||||
SecretStore(Context context) { preferences = context.getSharedPreferences("identities", Context.MODE_PRIVATE); }
|
||||
private SecretKey key() throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
|
||||
store.load(null);
|
||||
if (!store.containsAlias(ALIAS)) {
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
|
||||
generator.init(new KeyGenParameterSpec.Builder(ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build());
|
||||
generator.generateKey();
|
||||
}
|
||||
return (SecretKey) store.getKey(ALIAS, null);
|
||||
}
|
||||
String get(String scope) throws Exception {
|
||||
String stored = preferences.getString(scope, "");
|
||||
if (stored.isEmpty()) return "";
|
||||
String[] parts = stored.split(":", 2);
|
||||
if (parts.length != 2) throw new IllegalStateException("Invalid credential record");
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)));
|
||||
cipher.updateAAD(scope.getBytes(StandardCharsets.UTF_8));
|
||||
return new String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), StandardCharsets.UTF_8);
|
||||
}
|
||||
void remove(String scope) { preferences.edit().remove(scope).apply(); }
|
||||
void put(String scope, String credential) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key());
|
||||
cipher.updateAAD(scope.getBytes(StandardCharsets.UTF_8));
|
||||
String value = Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP) + ":" +
|
||||
Base64.encodeToString(cipher.doFinal(credential.getBytes(StandardCharsets.UTF_8)), Base64.NO_WRAP);
|
||||
if (!preferences.edit().putString(scope, value).commit()) throw new IllegalStateException("Credential save failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.animation.PathInterpolator;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/** A single sliding selection highlight, matching the iOS segmented settings pickers. */
|
||||
@android.annotation.SuppressLint("ViewConstructor") // Programmatic control with labels and a change callback.
|
||||
final class SegmentedControl extends LinearLayout {
|
||||
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF highlight=new RectF(), destination=new RectF();
|
||||
private final float radius;
|
||||
private final IntConsumer changed;
|
||||
private int selected;
|
||||
private ValueAnimator animator;
|
||||
|
||||
SegmentedControl(MobileUi ui,String[] titles,int selection,boolean enabled,IntConsumer changed){
|
||||
super(ui.context);this.changed=changed;selected=Math.max(0,Math.min(titles.length-1,selection));radius=ui.dp(6);
|
||||
setGravity(Gravity.CENTER_VERTICAL);setPadding(ui.dp(3),ui.dp(3),ui.dp(3),ui.dp(3));setBackground(ui.background(0xFFE9E9EF,8));paint.setColor(Color.WHITE);
|
||||
for(int i=0;i<titles.length;i++){
|
||||
final int index=i;TextView choice=ui.action(titles[i],MobileUi.INK,()->select(index));
|
||||
choice.setTextSize(13);choice.setMinHeight(ui.dp(30));choice.setSelected(i==selected);choice.setEnabled(enabled);choice.setAlpha(enabled?1:.45f);
|
||||
addView(choice,new LayoutParams(0,ui.dp(30),1));
|
||||
}
|
||||
}
|
||||
|
||||
private RectF selectedBounds(){View choice=getChildAt(selected);return new RectF(choice.getLeft(),choice.getTop(),choice.getRight(),choice.getBottom());}
|
||||
private void select(int index){
|
||||
if(index==selected)return;
|
||||
setSelection(index);changed.accept(index);
|
||||
}
|
||||
void setSelection(int index){
|
||||
if(index==selected)return;
|
||||
selected=index;for(int i=0;i<getChildCount();i++)getChildAt(i).setSelected(i==selected);
|
||||
cancelAnimation();destination.set(selectedBounds());
|
||||
if(!isAttachedToWindow()||!isLaidOut()||highlight.isEmpty()||!ValueAnimator.areAnimatorsEnabled()){
|
||||
highlight.set(destination);invalidate();
|
||||
}else{
|
||||
// Restart from the displayed position, including when the user reverses mid-animation.
|
||||
RectF start=new RectF(highlight),end=new RectF(destination);ValueAnimator next=ValueAnimator.ofFloat(0,1);animator=next;
|
||||
next.setDuration(200);next.setInterpolator(new PathInterpolator(.25f,.1f,.25f,1));
|
||||
next.addUpdateListener(value->{
|
||||
if(animator!=value)return;float fraction=(float)value.getAnimatedValue();
|
||||
highlight.set(start.left+(end.left-start.left)*fraction,start.top+(end.top-start.top)*fraction,start.right+(end.right-start.right)*fraction,start.bottom+(end.bottom-start.bottom)*fraction);invalidate();
|
||||
});
|
||||
next.addListener(new AnimatorListenerAdapter(){@Override public void onAnimationEnd(Animator value){if(animator==value){animator=null;highlight.set(destination);invalidate();}}});next.start();
|
||||
}
|
||||
}
|
||||
void setOptionsEnabled(boolean enabled){
|
||||
for(int i=0;i<getChildCount();i++){getChildAt(i).setEnabled(enabled);getChildAt(i).setAlpha(enabled?1:.45f);}
|
||||
if(!enabled){cancelAnimation();highlight.set(destination);invalidate();}
|
||||
}
|
||||
private void cancelAnimation(){ValueAnimator old=animator;animator=null;if(old!=null)old.cancel();}
|
||||
@Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){
|
||||
super.onLayout(changed,left,top,right,bottom);RectF bounds=selectedBounds();
|
||||
// Text below the picker can reflow without interrupting an unchanged highlight path.
|
||||
if(!destination.equals(bounds)){cancelAnimation();destination.set(bounds);highlight.set(bounds);}
|
||||
}
|
||||
@Override protected void dispatchDraw(Canvas canvas){canvas.drawRoundRect(highlight,radius,radius,paint);super.dispatchDraw(canvas);}
|
||||
@Override protected void onDetachedFromWindow(){cancelAnimation();highlight.set(destination);super.onDetachedFromWindow();}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Safe-area overlay: a draggable 54 dp orb, a three-column menu and a floating keyboard. */
|
||||
@android.annotation.SuppressLint("ViewConstructor")
|
||||
final class SessionControls extends FrameLayout {
|
||||
interface Actions {
|
||||
void disconnect(); void display(); void clipboard(); void key(int code);
|
||||
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
|
||||
void videoSettings(int field,int value);
|
||||
}
|
||||
private final MobileUi ui;
|
||||
private final Actions actions;
|
||||
private final View orb;
|
||||
private final LinearLayout waiting;
|
||||
private View shield;
|
||||
private LinearLayout menu,keyboard;
|
||||
private TextView networkLabel,videoFeedback;
|
||||
private String menuPage="controls";
|
||||
private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0);
|
||||
private RemoteVideoSettings settings=new RemoteVideoSettings(1);
|
||||
private final java.util.List<SettingRow> settingRows=new java.util.ArrayList<>();
|
||||
private TextView[][] trafficCells;
|
||||
private LinearLayout[] trafficRows;
|
||||
private TextView[] networkDetails;
|
||||
private View encryptionLock,encryptionOpen;
|
||||
private boolean muted,relative=true,shift,computerKeyboard,orbPositioned;
|
||||
private final java.util.Set<Integer> modifiers=new java.util.LinkedHashSet<>();
|
||||
SessionControls(Context context,MobileUi ui,Actions actions){
|
||||
super(context);this.ui=ui;this.actions=actions;
|
||||
// This container must remain transparent to remote touch outside its controls.
|
||||
waiting=ui.column();waiting.setMinimumWidth(ui.dp(200));waiting.setGravity(Gravity.CENTER);waiting.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));waiting.setBackground(ui.background(0xB0202024,14));
|
||||
ProgressBar progress=new ProgressBar(context);progress.setIndeterminateTintList(android.content.res.ColorStateList.valueOf(Color.WHITE));waiting.addView(progress,ui.size(28,28));ui.gap(waiting,12);TextView message=ui.text("等待远端画面…",14,false);message.setTextColor(Color.WHITE);message.setGravity(Gravity.CENTER);waiting.addView(message);addView(waiting,new LayoutParams(-2,-2,Gravity.CENTER));
|
||||
orb=ui.iconButton("display","展开远程控制菜单",()->{if(menu==null)openMenu("controls");else closeMenu();});
|
||||
ImageView brand=new ImageView(context);brand.setImageResource(R.drawable.mobile_brand);brand.setScaleType(ImageView.ScaleType.CENTER_CROP);brand.setBackground(ui.background(Color.WHITE,23));brand.setClipToOutline(true);brand.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
((FrameLayout)orb).removeAllViews();((FrameLayout)orb).addView(brand,new LayoutParams(ui.dp(46),ui.dp(46),Gravity.CENTER));
|
||||
orb.setBackground(ui.background(0xFFF5F5F7,27));orb.setElevation(ui.dp(7));addView(orb,new LayoutParams(ui.dp(54),ui.dp(54)));
|
||||
drag(orb,orb,()->{if(menu==null)openMenu("controls");else closeMenu();});
|
||||
}
|
||||
void setRelative(boolean value){relative=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
|
||||
void setMuted(boolean value){muted=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
|
||||
void videoSize(int width,int height){waiting.setVisibility(GONE);}
|
||||
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
|
||||
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
||||
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
|
||||
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
|
||||
private void openMenu(String page){
|
||||
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
|
||||
menu=ui.column();menu.setPadding(ui.dp(12),ui.dp(12),ui.dp(12),ui.dp(12));ui.card(menu,18);menu.setElevation(ui.dp(12));
|
||||
LinearLayout header=ui.row();
|
||||
if(!page.equals("controls"))header.addView(ui.iconButton("back","返回控制栏",()->openMenu("controls")),ui.size(28,32));
|
||||
LinearLayout titles=ui.column();TextView title=ui.text(page.equals("controls")?"● 已连接":page.equals("video")?"画面设置":"网络状态",14,true);titles.addView(title);
|
||||
if(page.equals("controls")){ui.gap(titles,3);networkLabel=ui.text(network.summary(),10,false);networkLabel.setFontFeatureSettings("tnum");networkLabel.setTextColor(SECONDARY);titles.addView(networkLabel);}header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭控制栏",this::closeMenu),ui.size(30,32));menu.addView(header);ui.gap(menu,9);
|
||||
if(page.equals("video")){videoFeedback=ui.text("",12,false);videoFeedback.setTextColor(SECONDARY);videoFeedback.setPadding(0,0,0,ui.dp(9));menu.addView(videoFeedback);}
|
||||
ScrollView scroll=new ScrollView(getContext());menu.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout body=ui.column();scroll.addView(body);
|
||||
if(page.equals("network")){
|
||||
// Reserve the scrollbar's width plus a gap before right-aligned statistics.
|
||||
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
|
||||
}
|
||||
if(page.equals("controls")){
|
||||
String[] labels={"键盘","显示器","画面设置",muted?"静音":"声音",relative?"相对鼠标":"绝对鼠标","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
|
||||
String[] icons={"keyboard","display","sliders","audio","mouse","folder","chart","lock","clipboard"};
|
||||
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,()->{},()->openMenu("network"),()->{},actions::clipboard};
|
||||
for(int row=0;row<3;row++){LinearLayout line=ui.row();for(int col=0;col<3;col++){int index=row*3+col;LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
|
||||
if(index==5||index==7){control.setEnabled(false);control.setAlpha(.38f);control.setContentDescription(labels[index]+",暂不支持");}
|
||||
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
|
||||
}body.addView(line);if(row<2)ui.gap(body,7);}
|
||||
}else if(page.equals("network")){
|
||||
networkPage(body);
|
||||
}else{
|
||||
settingRows.add(new SettingRow(body,0,"画面质量",new String[]{"低","中","高"},new int[]{0,1,2}));ui.gap(body,12);
|
||||
settingRows.add(new SettingRow(body,1,"画面采集帧率",new String[]{"30 fps","60 fps"},new int[]{30,60}));ui.gap(body,12);
|
||||
settingRows.add(new SettingRow(body,2,"画面偏好",RemoteVideoSettings.PREFERENCES,new int[]{0,1,2}));renderVideoSettings();
|
||||
}
|
||||
ui.gap(menu,9);TextView disconnect=ui.action("断开连接",Color.WHITE,()->{closeMenu();closeKeyboard();actions.disconnect();});disconnect.setMinHeight(ui.dp(34));disconnect.setTextSize(14);disconnect.setBackground(ui.background(RED,8));menu.addView(disconnect,ui.size(-1,34));
|
||||
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
|
||||
}
|
||||
private final class SettingRow {
|
||||
final int field;final int[] values;final SegmentedControl group;
|
||||
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){
|
||||
this.field=field;this.values=values;
|
||||
TextView label=ui.text(title,12,false);label.setTextColor(SECONDARY);body.addView(label);ui.gap(body,5);
|
||||
group=new SegmentedControl(ui,labels,selectionIndex(),settings.supported,index->actions.videoSettings(field,values[index]));
|
||||
group.setPadding(ui.dp(2),ui.dp(2),ui.dp(2),ui.dp(2));body.addView(group);
|
||||
for(int i=0;i<labels.length;i++){
|
||||
TextView button=(TextView)group.getChildAt(i);button.setTextSize(12);button.setMinHeight(ui.dp(28));button.setSingleLine();button.setContentDescription(title+","+labels[i]);
|
||||
button.setLayoutParams(new LinearLayout.LayoutParams(0,ui.dp(28),1));
|
||||
}
|
||||
}
|
||||
int selectionIndex(){int selected=field==0?settings.selection.quality:field==1?settings.selection.frameRate:settings.selection.preference;
|
||||
for(int i=0;i<values.length;i++)if(values[i]==selected)return i;return 0;
|
||||
}
|
||||
void render(){group.setSelection(selectionIndex());group.setOptionsEnabled(settings.supported);}
|
||||
}
|
||||
private void renderVideoSettings(){
|
||||
if(videoFeedback!=null){String feedback=settings.feedback();videoFeedback.setText(feedback);videoFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);}
|
||||
for(SettingRow row:settingRows)row.render();
|
||||
}
|
||||
private void networkPage(LinearLayout body){
|
||||
trafficCells=new TextView[4][3];trafficRows=new LinearLayout[4];String[] names={"视频","音频","数据","合计"};
|
||||
LinearLayout heading=trafficRow(body,"",new TextView[3],false);((TextView)heading.getChildAt(1)).setText("接收");((TextView)heading.getChildAt(2)).setText("发送");((TextView)heading.getChildAt(3)).setText("丢包率");
|
||||
for(int i=0;i<heading.getChildCount();i++)((TextView)heading.getChildAt(i)).setTextColor(SECONDARY);
|
||||
for(int i=0;i<4;i++)trafficRows[i]=trafficRow(body,names[i],trafficCells[i],i==3);
|
||||
ui.gap(body,10);ui.divider(body);ui.gap(body,10);
|
||||
String[] titles={"帧率","分辨率","画面延时","连接延时(RTT)","连接方式","媒体加密"};networkDetails=new TextView[titles.length];
|
||||
for(int i=0;i<titles.length;i++){LinearLayout row=ui.row();TextView title=ui.text(titles[i],12,false);title.setTextColor(SECONDARY);row.addView(title,new LinearLayout.LayoutParams(0,-2,1));
|
||||
if(i==5){encryptionLock=ui.icon("lock",GREEN);encryptionOpen=ui.icon("unlock",SECONDARY);for(View icon:new View[]{encryptionLock,encryptionOpen}){LinearLayout.LayoutParams p=ui.size(14,14);p.rightMargin=ui.dp(4);row.addView(icon,p);}}
|
||||
TextView value=ui.text("—",12,false);value.setFontFeatureSettings("tnum");row.addView(value);networkDetails[i]=value;body.addView(row);if(i<titles.length-1)ui.gap(body,7);}
|
||||
renderNetwork();
|
||||
}
|
||||
private LinearLayout trafficRow(LinearLayout body,String title,TextView[] cells,boolean total){
|
||||
LinearLayout row=ui.row();row.setPadding(ui.dp(4),ui.dp(5),ui.dp(4),ui.dp(5));body.addView(row);if(total)row.setBackground(ui.background(0x122E7ADB,5));
|
||||
TextView label=ui.text(title,11,total);row.addView(label,ui.size(28,-2));
|
||||
for(int i=0;i<3;i++){TextView cell=ui.text("—",11,total);cell.setGravity(Gravity.END);cell.setSingleLine();cell.setFontFeatureSettings("tnum");cell.setAutoSizeTextTypeUniformWithConfiguration(8,11,1,android.util.TypedValue.COMPLEX_UNIT_SP);
|
||||
LinearLayout.LayoutParams p=i==2?ui.size(46,16):new LinearLayout.LayoutParams(0,ui.dp(16),1);p.leftMargin=ui.dp(4);row.addView(cell,p);cells[i]=cell;}
|
||||
return row;
|
||||
}
|
||||
private void renderNetwork(){
|
||||
if(networkLabel!=null)networkLabel.setText(network.summary());
|
||||
if(trafficCells==null)return;
|
||||
String[] names={"视频","音频","数据","合计"};
|
||||
for(int i=0;i<4;i++){
|
||||
RemoteNetworkStatistics.Traffic traffic=network.report==null?null:network.report.traffic[i];
|
||||
String in=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.inbound),out=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.outbound),loss=RemoteNetworkStatistics.loss(traffic==null?Double.NaN:traffic.loss);
|
||||
trafficCells[i][0].setText(in);trafficCells[i][1].setText(out);trafficCells[i][2].setText(loss);trafficRows[i].setContentDescription(names[i]+",接收 "+in+",发送 "+out+",丢包率 "+loss);
|
||||
}
|
||||
String[] values={network.fps+" FPS",network.resolution(),RemoteNetworkStatistics.latency(network.latency),RemoteNetworkStatistics.latency(network.rtt),network.mode(),network.report==null?"—":network.report.srtp?"SRTP 已启用":"未启用"};
|
||||
for(int i=0;i<values.length;i++)networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
|
||||
encryptionLock.setVisibility(network.report!=null&&network.report.srtp?VISIBLE:GONE);encryptionOpen.setVisibility(network.report!=null&&!network.report.srtp?VISIBLE:GONE);
|
||||
}
|
||||
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
|
||||
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
|
||||
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
|
||||
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;}}
|
||||
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
|
||||
private void showKeyboard(){
|
||||
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
|
||||
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
|
||||
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
|
||||
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keyboard.addView(line);ui.gap(keyboard,3);}
|
||||
int width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));addView(keyboard,new LayoutParams(width,-2));keyboard.post(()->{if(keyboard!=null){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);constrainPanels();}});
|
||||
}
|
||||
private void keyboardKey(String value){
|
||||
switch(value){
|
||||
case "⇧":shift=!shift;showKeyboard();return;
|
||||
case "电脑":computerKeyboard=true;showKeyboard();return;
|
||||
case "ABC":computerKeyboard=false;showKeyboard();return;
|
||||
case "剪贴板":actions.clipboard();return;
|
||||
case "复制":actions.shortcut(0x43);return;case "粘贴":actions.shortcut(0x56);return;case "全选":actions.shortcut(0x41);return;
|
||||
case "空格":sendKey(32);return;case "⌫":sendKey(8);return;case "Enter":sendKey(13);return;case "Esc":sendKey(27);return;case "Tab":sendKey(9);return;
|
||||
case "↑":sendKey(38);return;case "↓":sendKey(40);return;case "←":sendKey(37);return;case "→":sendKey(39);return;
|
||||
// Latch locally; the next key sends a complete chord without leaving remote modifiers down.
|
||||
case "Ctrl":toggleModifier(17);return;case "Alt":toggleModifier(18);return;case "Win":toggleModifier(91);return;
|
||||
default:if(value.matches("F[1-6]")){sendKey(111+Integer.parseInt(value.substring(1)));return;}if(!modifiers.isEmpty())sendKey(KeyMap.ascii(value.charAt(0))&255);else actions.text(shift?value.toUpperCase(java.util.Locale.ROOT):value);
|
||||
}
|
||||
}
|
||||
private void toggleModifier(int code){if(!modifiers.add(code))modifiers.remove(code);showKeyboard();}
|
||||
private void sendKey(int code){if(modifiers.isEmpty())actions.key(code);else{actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());modifiers.clear();showKeyboard();}}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // Tap dispatches performClick; dragging is an additional action.
|
||||
private void drag(View handle,View target,Runnable tap){
|
||||
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
|
||||
public boolean onTouch(View v,MotionEvent event){
|
||||
switch(event.getActionMasked()){
|
||||
case MotionEvent.ACTION_DOWN:startX=event.getRawX();startY=event.getRawY();originX=target.getX();originY=target.getY();moved=false;return true;
|
||||
case MotionEvent.ACTION_MOVE:float dx=event.getRawX()-startX,dy=event.getRawY()-startY;if(Math.hypot(dx,dy)>ui.dp(4))moved=true;if(moved){if(target==orb)closeMenu();target.setX(bound(originX+dx,ui.dp(8),getWidth()-target.getWidth()-ui.dp(8)));target.setY(bound(originY+dy,ui.dp(8),getHeight()-target.getHeight()-ui.dp(8)));}return true;
|
||||
case MotionEvent.ACTION_UP:if(!moved)v.performClick();return true;
|
||||
case MotionEvent.ACTION_CANCEL:return true;
|
||||
default:return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
void constrainPanels(){
|
||||
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
||||
if(menu!=null)requestLayout();
|
||||
if(keyboard!=null){ViewGroup.LayoutParams p=keyboard.getLayoutParams();p.width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));keyboard.setLayoutParams(p);keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-p.width-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}
|
||||
}
|
||||
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||
super.onMeasure(widthSpec,heightSpec);
|
||||
if(menu!=null){
|
||||
// Use this traversal's viewport, including rotation/inset changes, before drawing.
|
||||
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
|
||||
if(p.width!=width||p.height!=height){p.width=width;p.height=height;super.onMeasure(widthSpec,heightSpec);}
|
||||
}
|
||||
}
|
||||
@Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){
|
||||
super.onLayout(changed,left,top,right,bottom);
|
||||
if(!orbPositioned){orb.setX(Math.max(ui.dp(12),getWidth()-ui.dp(66)));orb.setY(ui.dp(17));orbPositioned=true;}
|
||||
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
||||
// New/rebuilt menus have real dimensions now; position them in the same frame.
|
||||
positionMenu();
|
||||
}
|
||||
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
|
||||
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,6 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white" />
|
||||
<foreground>
|
||||
<inset android:drawable="@drawable/mobile_brand" android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#2E7ADB</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
<item name="android:windowBackground">#F2F2F7</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Reference in New Issue
Block a user