mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-17 17:33:21 +08:00
[feat] enable Android MediaCodec decoding and simplify tests
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDLTCCAhWgAwIBAgIUAl+BaiMSPm1zRwBr0qnyzpufOaIwDQYJKoZIhvcNAQEL
|
||||
BQAwJjEkMCIGA1UEAwwbQ3Jvc3NEZXNrIGxvY2FsIFRMUyB0ZXN0IENBMB4XDTI2
|
||||
MTAwMTA4MjYyMVoXDTM2MDkyODA4MjYyMVowJjEkMCIGA1UEAwwbQ3Jvc3NEZXNr
|
||||
IGxvY2FsIFRMUyB0ZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
|
||||
AQEAmEAURCIgoeNOGiXcII2v/0SEN8UJ3ItdQrU4A5jOQe2fD12mjOJsQAUQrq/j
|
||||
Bpz3cglAafJlOusgHqYyhFjHn58yZ1I+Z1jPa2CKeyWFYnmFUoL2yyNFmr3YMdkh
|
||||
3caTehZ0+dit6bDYwTvhUwI9CFXLacAZ60tzvnPn+IHmszNNH89mgfr/aJcuXZb/
|
||||
kSiJ/gUiiwp/PJznO9b0kjoJK6eZNoNNcqUyiVsBb0nL2+950GCbwvAA0px3PF/R
|
||||
/qtNstkMPFB6WuMvw/DsxKFDZ8/XbfjJBXi0Rpr+DEW+BOadlTH6Q+CcKxC/YG6q
|
||||
x2oxDGGr1Glz4SYE/OowCS7SaQIDAQABo1MwUTAdBgNVHQ4EFgQU9lut5DDz8k4A
|
||||
MUDIioygb+l+z64wHwYDVR0jBBgwFoAU9lut5DDz8k4AMUDIioygb+l+z64wDwYD
|
||||
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAJauLQRg5mnORh3fjenif
|
||||
nGmHMOlvuBtOFdHGcUYyzkfPuRBp2rCd2mL2rdnJfcyZJAaWQhP9ZZtThbioczTj
|
||||
Kf8UUTv2WYLKIWcZ1jyEEmukBhYxRpUfn4FqQG1yCqmF112coSCCY+7aGjl/Tcnx
|
||||
E5SKS/FhARnXwObBaiG4sZoOQ4fbC70I94xfY8uSu6h9uVnnZS05tVbUD2gATn57
|
||||
1snZUoJYTHi15QPZM3H/UViSYq2Inkw9yOcV3FRs6f8klePGVmUUDvT+I48jGjSz
|
||||
cg2zgNErBYvLEJ8FxHX5oxJZzVZAuVxugYu1Ru5kEl1XO1GRHRf4/8JIl1nyat0a
|
||||
hQ==
|
||||
-----END CERTIFICATE-----
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
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);}
|
||||
}
|
||||
private 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 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);
|
||||
});}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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.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);}
|
||||
private 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 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"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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 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 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.net.InetAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.security.KeyStore;
|
||||
import java.util.concurrent.*;
|
||||
import javax.net.ssl.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/** Exercises the actual MiniRTC TLS path inside an Android app process (AT_SECURE=1).
|
||||
* The bundled PKCS12 keys are public test fixtures, only served on loopback. No
|
||||
* websocket upgrade/login or external server is involved in these tests. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class TlsTrustTest {
|
||||
@Test public void explicitlyTrustedCertificateCompletesHandshake() throws Exception {
|
||||
assertTrue("The app must load the supplied trust bundle despite AT_SECURE", handshake("valid",true));
|
||||
}
|
||||
@Test public void untrustedCertificateIsRejected() throws Exception {
|
||||
assertFalse("A test CA absent from the system store must not be trusted",handshake("valid",false));
|
||||
}
|
||||
@Test public void certificateForAnotherHostIsRejected() throws Exception {
|
||||
assertFalse("Trusting the CA must not bypass hostname verification",handshake("wrong-host",true));
|
||||
}
|
||||
private boolean handshake(String certificate,boolean trustFixture) throws Exception {
|
||||
var instrumentation=InstrumentationRegistry.getInstrumentation();Context context=instrumentation.getTargetContext();
|
||||
KeyStore keyStore=KeyStore.getInstance("PKCS12");char[] password="local-test-only".toCharArray();
|
||||
try(InputStream input=instrumentation.getContext().getAssets().open("tls/"+certificate+".p12")){keyStore.load(input,password);}
|
||||
KeyManagerFactory keys=KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());keys.init(keyStore,password);
|
||||
SSLContext tls=SSLContext.getInstance("TLS");tls.init(keys.getKeyManagers(),null,null);
|
||||
ExecutorService serverWorker=Executors.newSingleThreadExecutor();
|
||||
NativeSession session=new NativeSession(context,"127.0.0.1",1,"unused","unused",new NativeSession.Listener(){
|
||||
public void status(String value){} public void connected(){fail("Test server never opens a desktop session");}
|
||||
public void ended(String value){} public void data(JSONObject value){}public void videoSize(int w,int h){}
|
||||
public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){}
|
||||
});
|
||||
File roots=new File(context.getCacheDir(),"tls-test-roots.pem");
|
||||
Field rtcField=NativeSession.class.getDeclaredField("RTC");rtcField.setAccessible(true);ExecutorService rtc=(ExecutorService)rtcField.get(null);
|
||||
try(SSLServerSocket server=(SSLServerSocket)tls.getServerSocketFactory().createServerSocket(0,1,InetAddress.getByName("127.0.0.1"))){
|
||||
server.setSoTimeout(10000);
|
||||
if(trustFixture){try(InputStream input=instrumentation.getContext().getAssets().open("tls/root.pem")){Files.copy(input,roots.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);}}
|
||||
else{Method export=NativeSession.class.getDeclaredMethod("exportTrust");export.setAccessible(true);File systemRoots=(File)export.invoke(session);Files.copy(systemRoots.toPath(),roots.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);}
|
||||
Future<Boolean> request=serverWorker.submit(()->{
|
||||
try(SSLSocket socket=(SSLSocket)server.accept()){
|
||||
socket.setSoTimeout(10000);socket.startHandshake();
|
||||
String first=new BufferedReader(new InputStreamReader(socket.getInputStream(),java.nio.charset.StandardCharsets.US_ASCII)).readLine();
|
||||
return first!=null&&first.startsWith("GET ");
|
||||
}catch(SSLException expected){return false;}
|
||||
});
|
||||
Method create=NativeSession.class.getDeclaredMethod("nCreate",NativeSession.class,String.class,int.class,String.class,String.class,String.class);create.setAccessible(true);
|
||||
Field handle=NativeSession.class.getDeclaredField("handle");handle.setAccessible(true);
|
||||
rtc.submit(()->{try{long value=(long)create.invoke(null,session,"127.0.0.1",server.getLocalPort(),"",context.getCacheDir().getAbsolutePath(),roots.getAbsolutePath());handle.setLong(session,value);assertNotEquals(0L,value);}catch(ReflectiveOperationException error){throw new RuntimeException(error);}}).get(10,TimeUnit.SECONDS);
|
||||
return request.get(15,TimeUnit.SECONDS);
|
||||
}finally{session.close();rtc.submit(()->{}).get(10,TimeUnit.SECONDS);serverWorker.shutdownNow();roots.delete();}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "controller_protocol.h"
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
int main() {
|
||||
using namespace crossdesk;
|
||||
for (int quality = -1; quality <= 3; ++quality)
|
||||
for (int rate : {0, 24, 30, 60, 120})
|
||||
for (int preference = -1; preference <= 3; ++preference) {
|
||||
const auto message = android_controller::VideoSettingsMessage(quality, rate, preference, UINT32_MAX);
|
||||
const bool valid = quality >= 0 && quality <= 2 && (rate == 30 || rate == 60) && preference >= 0 && preference <= 2;
|
||||
assert(message.empty() != valid);
|
||||
if (!valid) continue;
|
||||
// Decode the actual Android outgoing message with the desktop's wire parser.
|
||||
RemoteAction desktop{};
|
||||
assert(desktop.from_json(message) && desktop.type == video_settings);
|
||||
assert(desktop.vs.quality == quality && desktop.vs.frame_rate == rate && desktop.vs.preference == preference);
|
||||
assert(desktop.vs.request_id == UINT32_MAX && !desktop.vs.accepted);
|
||||
desktop.type = video_settings_status; desktop.vs.accepted = true;
|
||||
RemoteAction reply{}; assert(reply.from_json(desktop.to_json()));
|
||||
assert(reply.type == video_settings_status && reply.vs.accepted && reply.vs.request_id == UINT32_MAX);
|
||||
}
|
||||
MiniRtcNetTrafficStats stats{};
|
||||
stats.video_inbound_stats.bitrate = 8'000'000; stats.video_inbound_stats.loss_rate = .02f;
|
||||
stats.audio_inbound_stats.bitrate = 64'000; stats.audio_outbound_stats.bitrate = 128'000;
|
||||
stats.data_inbound_stats.bitrate = 2000; stats.data_outbound_stats.bitrate = 3000;
|
||||
stats.total_inbound_stats.bitrate = UINT32_MAX; stats.total_outbound_stats.bitrate = 131'000;
|
||||
stats.total_inbound_stats.loss_rate = 1.2f; stats.rtt_ms = 40; stats.srtp_active = true;
|
||||
auto json = android_controller::NetworkReport(stats, Relay);
|
||||
assert(json.at("mode") == 1 && json.at("srtp") == true && json.at("rtt") == 40);
|
||||
assert(json.at("video").at("inbound") == 8'000'000 && json.at("audio").at("outbound") == 128'000);
|
||||
assert(json.at("data").at("inbound") == 2000 && json.at("data").at("outbound") == 3000);
|
||||
assert(json.at("total").at("inbound").get<uint32_t>() == UINT32_MAX);
|
||||
assert(std::abs(json.at("total").at("loss").get<double>() - 1.2) < .001);
|
||||
stats.rtt_ms = -1; stats.srtp_active = false;
|
||||
json = android_controller::NetworkReport(stats, UnknownMode);
|
||||
assert(json.at("mode") == 2 && json.at("srtp") == false && json.at("rtt") == -1);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "latest_frame_worker.h"
|
||||
#include "video_timing.h"
|
||||
#include "video_picture.h"
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
int main() {
|
||||
using namespace crossdesk::android_controller;
|
||||
using namespace std::chrono_literals;
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 1'050'000) == 50);
|
||||
// Reproduce a valid frame delayed in the local decode/Surface queue.
|
||||
// The old submission-time five-second cutoff hid both of these samples.
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 7'000'000) == 6000);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 31'000'000) == 30000);
|
||||
assert(VideoLatencyMilliseconds(0, 1'020'000, 7'000'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'030'000, 1'020'000, 7'000'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 6'000'001, 7'000'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 1'010'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, -1) < 0);
|
||||
|
||||
struct Owner { int references = 1; uint8_t pixels[12]{}; } owner;
|
||||
MiniRtcNativeVideoFrame native{};
|
||||
native.struct_size = sizeof(native); native.type = MiniRtcNativeVideoFrameCpuNv12;
|
||||
native.width = native.height = 2; native.owner = &owner;
|
||||
native.retain = [](void* p) { ++static_cast<Owner*>(p)->references; };
|
||||
native.release = [](void* p) { --static_cast<Owner*>(p)->references; };
|
||||
native.payload.cpu_nv12 = {owner.pixels, owner.pixels + 8, 4, 4};
|
||||
MiniRtcVideoFrame source{}; source.width = source.height = 2;
|
||||
source.native_frame = &native; source.captured_timestamp = 100; source.received_timestamp = 120;
|
||||
{
|
||||
auto retained = VideoPicture::RetainOrCopy(source, 3);
|
||||
assert(retained && owner.references == 2 && retained->pixels.empty());
|
||||
assert(retained->Y() == owner.pixels && retained->UV() == owner.pixels + 8);
|
||||
assert(retained->YStride() == 4 && retained->UVStride() == 4);
|
||||
assert(retained->captured_us == 100 && retained->received_us == 120 && retained->generation == 3);
|
||||
auto moved = std::move(*retained); retained.reset(); assert(owner.references == 2);
|
||||
}
|
||||
assert(owner.references == 1);
|
||||
uint8_t borrowed[6]{1, 2, 3, 4, 5, 6};
|
||||
source.native_frame = nullptr; source.data = reinterpret_cast<const char*>(borrowed); source.size = 6;
|
||||
auto copied = VideoPicture::RetainOrCopy(source, 4); borrowed[0] = 0;
|
||||
assert(copied && copied->Y()[0] == 1 && copied->UV()[0] == 5 && copied->YStride() == 2);
|
||||
source.size = 5; assert(!VideoPicture::RetainOrCopy(source, 4));
|
||||
source.width = 3; assert(!VideoPicture::RetainOrCopy(source, 4));
|
||||
|
||||
std::promise<void> started, release, finished;
|
||||
auto released = release.get_future();
|
||||
std::vector<int> rendered;
|
||||
LatestFrameWorker<std::unique_ptr<int>> worker([&](std::unique_ptr<int> value) {
|
||||
rendered.push_back(*value);
|
||||
if (*value == 1) { started.set_value(); released.wait(); }
|
||||
if (*value == 4) finished.set_value();
|
||||
});
|
||||
assert(worker.Submit(std::make_unique<int>(1)));
|
||||
assert(started.get_future().wait_for(2s) == std::future_status::ready);
|
||||
// A blocked Surface must not block frame submission; obsolete frames 2/3
|
||||
// must not be displayed after it recovers, even with move-only ownership.
|
||||
assert(worker.Submit(std::make_unique<int>(2)));
|
||||
assert(worker.Submit(std::make_unique<int>(3)));
|
||||
assert(worker.Submit(std::make_unique<int>(4)));
|
||||
release.set_value();
|
||||
assert(finished.get_future().wait_for(2s) == std::future_status::ready);
|
||||
worker.Stop();
|
||||
assert((rendered == std::vector<int>{1, 4}));
|
||||
assert(!worker.Submit(std::make_unique<int>(5)));
|
||||
worker.Stop();
|
||||
|
||||
std::promise<void> first_started, first_release;
|
||||
auto first_released = first_release.get_future();
|
||||
int count = 0;
|
||||
LatestFrameWorker<int> cleared([&](int value) {
|
||||
++count; assert(value == 1); first_started.set_value(); first_released.wait();
|
||||
});
|
||||
cleared.Submit(1);
|
||||
assert(first_started.get_future().wait_for(2s) == std::future_status::ready);
|
||||
cleared.Submit(2);
|
||||
cleared.Clear(); // Surface replacement/display switch discards pending pixels.
|
||||
first_release.set_value();
|
||||
cleared.Stop();
|
||||
assert(count == 1);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Android's native packets against the shared desktop wire parser."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ANDROID = Path(__file__).resolve().parents[1]
|
||||
ROOT = ANDROID.parents[1]
|
||||
headers = list((ANDROID / '.native/packages/n/nlohmann_json').glob('*/**/include/nlohmann/json.hpp'))
|
||||
if not headers:
|
||||
raise SystemExit('Build the Android app first to populate its pinned native dependencies.')
|
||||
with tempfile.TemporaryDirectory(prefix='crossdesk-android-protocol-') as temporary:
|
||||
binary = Path(temporary) / 'controller-protocol-test'
|
||||
subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17',
|
||||
'-I' + str(headers[0].parents[1]),
|
||||
'-I' + str(ANDROID / 'native'),
|
||||
'-I' + str(ROOT / 'deps/submodules/minirtc/src/api'),
|
||||
'-I' + str(ROOT / 'libs/wire/include'),
|
||||
str(ANDROID / 'test/native/controller_protocol_test.cpp'),
|
||||
str(ROOT / 'libs/wire/src/remote_action.cpp'), '-o', str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
video_test = Path(temporary) / 'video-pipeline-test'
|
||||
subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17', '-pthread',
|
||||
'-I' + str(ANDROID / 'native'),
|
||||
'-I' + str(ROOT / 'deps/submodules/minirtc/src/api'),
|
||||
str(ANDROID / 'test/native/video_pipeline_test.cpp'),
|
||||
'-o', str(video_test)], check=True)
|
||||
subprocess.run([str(video_test)], check=True, timeout=10)
|
||||
print('PASS: Android video settings / desktop wire round-trip and native network fields')
|
||||
print('PASS: long video latency, invalid clocks, latest-frame rendering and shutdown')
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def verify(path):
|
||||
with zipfile.ZipFile(path) as archive, open(path, 'rb') as raw:
|
||||
libraries = [i for i in archive.infolist() if i.filename.endswith('.so')]
|
||||
if [i.filename for i in libraries] != ['lib/arm64-v8a/libcrossdesk_android.so']:
|
||||
raise ValueError('APK must contain exactly the arm64 native controller library')
|
||||
library = libraries[0]
|
||||
data = archive.read(library)
|
||||
if data[:6] != b'\x7fELF\x02\x01' or struct.unpack_from('<H', data, 18)[0] != 183:
|
||||
raise ValueError('Native library is not little-endian ELF64 AArch64')
|
||||
offset = struct.unpack_from('<Q', data, 32)[0]
|
||||
size, count = struct.unpack_from('<HH', data, 54)
|
||||
segments = [struct.unpack_from('<IIQQQQQQ', data, offset + i * size) for i in range(count)]
|
||||
loads = [s for s in segments if s[0] == 1]
|
||||
if not loads or any(s[7] < 16384 or (s[2] - s[3]) % 16384 for s in loads):
|
||||
raise ValueError('ELF PT_LOAD segments must be aligned to at least 16 KB')
|
||||
if library.compress_type != zipfile.ZIP_STORED:
|
||||
raise ValueError('Native library must be stored uncompressed')
|
||||
raw.seek(library.header_offset + 26)
|
||||
name, extra = struct.unpack('<HH', raw.read(4))
|
||||
if (library.header_offset + 30 + name + extra) % 16384:
|
||||
raise ValueError('APK native library entry is not 16 KB aligned')
|
||||
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
|
||||
raise ValueError('Missing third-party notices')
|
||||
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
|
||||
source = json.loads(archive.read('assets/SourceMetadata.json'))
|
||||
components = catalog['components']
|
||||
ids = [item['id'] for item in components]
|
||||
if catalog['schemaVersion'] != 1 or len(ids) != len(set(ids)) or not {
|
||||
'crossdesk', 'minirtc', 'openfec', 'openh264', 'libiconv', 'android-ndk', 'svt-av1'}.issubset(ids):
|
||||
raise ValueError('Incomplete About catalog')
|
||||
for component in components:
|
||||
if not component['documents']:
|
||||
raise ValueError('Missing component documents')
|
||||
for document in component['documents']:
|
||||
text = archive.read('assets/'+document['asset'])
|
||||
if not text or hashlib.sha256(text).hexdigest() != document['sha256']:
|
||||
raise ValueError('Incomplete license document: '+document['asset'])
|
||||
for document in component.get('buildDocuments', []):
|
||||
if not archive.read('assets/'+document['asset']):
|
||||
raise ValueError('Missing build document')
|
||||
if source['schemaVersion'] != 1 or len(source['revision']) != 40:
|
||||
raise ValueError('Missing source revision')
|
||||
for document in source['buildDocuments']:
|
||||
if not archive.read('assets/'+document['asset']):
|
||||
raise ValueError('Missing source build instructions')
|
||||
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, notices, About catalog and documents')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
verify(sys.argv[1])
|
||||
Reference in New Issue
Block a user