mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 18:03:08 +08:00
[feat] enable Android MediaCodec decoding and simplify tests
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
-----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.
@@ -1,119 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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();}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ final class RemoteVideoSettings {
|
||||
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.
|
||||
// Start within common mobile decoder limits. Hardware availability and
|
||||
// maximum resolution vary, and AV1 may still need software fallback.
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user