Compare commits

...
4 Commits
51 changed files with 1039 additions and 109 deletions

No files matched your search

+55 -13
View File
@@ -482,7 +482,24 @@ jobs:
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
id: unit-tests
run: |
set -o pipefail
mkdir -p app/build
./gradlew testReleaseUnitTest --console=plain --info --stacktrace -Drobolectric.logging=stderr -PndkVersion="$NDK_VERSION" 2>&1 | tee app/build/unit-tests.log
- name: Upload unit test diagnostics
id: unit-test-diagnostics
if: always() && steps.unit-tests.outcome != 'skipped'
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: |
app/build/unit-tests.log
app/build/test-results/testReleaseUnitTest/
app/build/reports/tests/testReleaseUnitTest/
if-no-files-found: ignore
retention-days: 14
- name: Sign with the testkey (v1 + v2 + v3)
run: |
@@ -512,6 +529,15 @@ jobs:
grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \
|| { echo "::error::apk is not signed with the AOSP testkey"; exit 1; }
- uses: actions/upload-artifact@v4
id: apk-upload
with:
name: droiddeck-apk
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error
retention-days: 30
compression-level: 0
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
@@ -520,6 +546,9 @@ jobs:
PR: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
BUILD_OUTCOME: ${{ steps.build.outcome }}
UNIT_TEST_OUTCOME: ${{ steps.unit-tests.outcome }}
TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }}
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
run: |
set -uo pipefail
@@ -530,8 +559,8 @@ jobs:
echo
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
echo
case "$BUILD_OUTCOME" in
success)
case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
success:success:success)
echo "### Build passed"
echo
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
@@ -544,7 +573,28 @@ jobs:
echo
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
;;
failure)
success:success:*)
echo "### APK publication incomplete"
echo
echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)."
;;
success:failure:*)
echo "### ERROR: Unit tests failed"
echo
echo "The APK compiled, but unit tests failed. No installable artifact was published."
echo
if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."
else
echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)."
fi
;;
success:*)
echo "### Build incomplete"
echo
echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)."
;;
failure:*)
echo "### ERROR: Build failed"
echo
echo "The compiler's errors ([full log]($RUN_URL)):"
@@ -562,7 +612,7 @@ jobs:
echo '```'
echo "</details>"
;;
skipped|cancelled)
skipped:*|cancelled:*)
echo "### Not built"
echo
echo "The build did not run (see above, or [the run]($RUN_URL))."
@@ -578,14 +628,6 @@ jobs:
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
fi
- uses: actions/upload-artifact@v4
with:
name: droiddeck-apk
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error
retention-days: 30
compression-level: 0
sign:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+4
View File
@@ -157,6 +157,10 @@ android {
testOptions {
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests.all {
def logTarget = providers.systemProperty('robolectric.logging').orNull
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
}
}
compileOptions {
+34 -8
View File
@@ -6,6 +6,7 @@
#include <sys/stat.h>
#include <unistd.h>
#include <stdint.h>
#include <stdatomic.h>
#include <fcntl.h>
#include <time.h>
#include <stdarg.h>
@@ -271,7 +272,9 @@ volatile int g_ubwc = 1;
volatile int g_no_render_node;
volatile int g_output_refresh_mhz;
static atomic_int g_output_refresh_mhz = 60000;
static atomic_int g_output_refresh_pending;
static int g_output_announced_refresh_mhz = 60000;
volatile int g_output_w, g_output_h;
struct pending_release {
@@ -794,7 +797,7 @@ static void feedback_discard_all(struct wl_list *list) {
}
static uint32_t nominal_refresh_ns(void) {
int mhz = g_output_refresh_mhz;
int mhz = atomic_load(&g_output_refresh_mhz);
if (mhz <= 0) mhz = 60000;
return (uint32_t)(1000000000000LL / (int64_t)mhz);
}
@@ -1507,10 +1510,10 @@ static const struct wl_output_interface output_impl = { .release = output_releas
static struct wl_list g_outputs;
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
static void output_send_mode(struct wl_resource *r) {
static void output_send_mode(struct wl_resource *r, int mhz) {
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
mhz);
}
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
@@ -1520,7 +1523,7 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t
wl_list_insert(&g_outputs, wl_resource_get_link(r));
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
output_send_mode(r);
output_send_mode(r, atomic_load(&g_output_refresh_mhz));
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
wl_output_send_name(r, "DroidDeck-1");
wl_output_send_description(r, "DroidDeck display");
@@ -2876,13 +2879,19 @@ static void key_event(uint32_t evdev, int pressed) {
wl_display_flush_clients(g_display);
}
static void output_resized(void) {
struct surface *s;
static void output_notify_mode(void) {
int mhz = atomic_load(&g_output_refresh_mhz);
struct wl_resource *r;
wl_resource_for_each(r, &g_outputs) {
output_send_mode(r);
output_send_mode(r, mhz);
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
}
g_output_announced_refresh_mhz = mhz;
}
static void output_resized(void) {
struct surface *s;
output_notify_mode();
wl_list_for_each(s, &g_surfaces, link)
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
@@ -2903,9 +2912,15 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break;
case 7: deliver_touch(&m); break;
case 8: output_resized(); break;
case 9: break;
default: deliver_pointer(&m); break;
}
}
if (atomic_exchange(&g_output_refresh_pending, 0) &&
atomic_load(&g_output_refresh_mhz) != g_output_announced_refresh_mhz) {
output_notify_mode();
wl_display_flush_clients(g_display);
}
if (vsync) on_vsync(vsync_ns);
return 0;
}
@@ -2938,6 +2953,16 @@ void droiddeck_wayland_output_resized(void) {
(void)n;
}
void droiddeck_wayland_set_output_refresh_rate(int mhz) {
if (mhz <= 0) mhz = 60000;
if (atomic_exchange(&g_output_refresh_mhz, mhz) == mhz || g_input_pipe[1] < 0) return;
atomic_store(&g_output_refresh_pending, 1);
struct input_msg m = { 9, 0, 0, 0, 0 };
ssize_t n;
do { n = write(g_input_pipe[1], &m, sizeof(m)); } while (n < 0 && errno == EINTR);
(void)n;
}
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 5, (int)(frame_time_ns >> 32), (int)(uint32_t)frame_time_ns, 0, 0 };
@@ -3172,6 +3197,7 @@ int droiddeck_wayland_run(void) {
if ((g_stats_timer = wl_event_loop_add_timer(wl_display_get_event_loop(display), on_stats_timer, NULL)))
wl_event_source_timer_update(g_stats_timer, 10000);
g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz);
wl_display_run(display);
wl_display_destroy(display);
@@ -26,13 +26,13 @@ extern void droiddeck_wayland_send_key(int evdev, int state);
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
extern void droiddeck_wayland_output_resized(void);
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
extern volatile int g_fps_limit;
extern volatile int g_hide_shell;
extern volatile int g_zero_copy;
extern volatile unsigned g_zero_copy_last;
extern volatile int g_ubwc;
extern volatile int g_no_render_node;
extern volatile int g_output_refresh_mhz;
extern volatile int g_output_w, g_output_h;
#define TAG "DroidDeckWayland"
@@ -327,7 +327,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLayerFrameRate(JN
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputRefreshRate(JNIEnv *env, jclass clazz, jfloat hz) {
g_output_refresh_mhz = hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0;
droiddeck_wayland_set_output_refresh_rate(hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0);
}
extern void vkp_reset_first_frame(void);
@@ -315,9 +315,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun followPanelRefresh(displayId: Int) {
if (!CompositorHost.isStarted) return
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
if (display.displayId != displayId) return
val hz = display.refreshRate
val output = glassesScreen?.display
?: (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
if (output.displayId != displayId) return
val hz = output.mode.refreshRate
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
}
private var isHomeApp by mutableStateOf(false)
@@ -2515,13 +2516,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun onGlassesChanged() {
val g = glasses
val s = glassesSettings
val key = listOf(g.present, g.usable, g.displayId, g.sbs, g.canControl, g.width, g.height, g.hz, s)
val key = listOf(g.present, g.usable, g.displayId, g.sbs, g.switching3d, g.canControl, g.width, g.height, g.hz, s)
if (key == glassesKey) return
glassesKey = key
updateGlassesScreen()
applyCardboard(cardboard)
if (glassesPresent && !g.present) onGlassesUnplugged()
glassesPresent = g.present
if (!g.switching3d) {
if (glassesPresent && !g.present) onGlassesUnplugged()
glassesPresent = g.present
}
}
private fun onGlassesUnplugged() {
@@ -34,6 +34,7 @@ data class GlassesState(
val height: Int = 0,
val hz: Float = 0f,
val sbs: Boolean = false,
val switching3d: Boolean = false,
val brightness: Int = -1,
val volume: Int = -1,
val brightnessMax: Int = 8,
@@ -109,15 +110,15 @@ object Glasses {
private lateinit var usb: UsbManager
private lateinit var displays: DisplayManager
@Volatile private var link: VitureLink? = null
@Volatile private var linkGeneration = 0L
private var linkDevice: UsbDevice? = null
private var model: VitureProtocol.Model? = null
private var asked = false
private var lastModeSentAt = 0L
private var sbsAskedAt = 0L
private val modeState = GlassesModeState(SBS_CONFIRM_MS)
private val confirmMode = Runnable { refresh() }
private var glassesMode = 0
private var autoModeSent = false
private var vr3d = false
private var vr3dRevert = false
@Volatile private var sink: PoseSink? = null
@Volatile private var probe: ((ByteArray) -> Unit)? = null
@@ -132,7 +133,7 @@ object Glasses {
usb = app.getSystemService(Context.USB_SERVICE) as UsbManager
displays = app.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
_settings.value = load()
_state.value = _state.value.copy(sbsSupport = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_SBS_SUPPORT, GlassesState.SBS_UNKNOWN))
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_SBS_SUPPORT).apply()
handler = Handler(HandlerThread("droiddeck-glasses").apply { start() }.looper)
handler.post {
val filter = IntentFilter(ACTION_PERMISSION).apply {
@@ -176,25 +177,20 @@ object Glasses {
}
fun set3d(on: Boolean) = post {
vr3d = false
vr3dRevert = false
modeState.manual()
val s = _state.value
if (if (on) s.can3d else s.canControl) sendMode(on)
if (!s.canControl || (on && s.noSignal)) return@post
if (on) rememberSbs(GlassesState.SBS_UNKNOWN)
sendMode(on)
}
fun set3dForVr(on: Boolean) = post {
val s = _state.value
if (on) {
vr3dRevert = false
if (!s.can3d || s.sbs) return@post
vr3d = true
sendMode(true)
} else if (vr3d) {
vr3d = false
when {
!s.canControl -> vr3dRevert = true
s.sbs -> sendMode(false)
}
if (!s.can3d || s.sbs || modeState.pendingStereo) return@post
if (sendMode(true)) modeState.startVr()
} else if (modeState.stopVr() && s.canControl) {
sendMode(false)
}
}
@@ -226,7 +222,11 @@ object Glasses {
fun setScreenDistanceDm(dm: Int) = post { update { it.copy(screenDistanceDm = dm.coerceIn(5, 200)) } }
fun setVrFovDeg(deg: Int) = post { update { it.copy(vrFovDeg = deg.coerceIn(GlassesSettings.VR_FOV_MIN, GlassesSettings.VR_FOV_MAX)) } }
fun outputHz(): Int = _settings.value.let { if (_state.value.sbs) it.sbsHz else it.refreshHz }
fun outputHz(): Int {
val s = _state.value
val g = _settings.value
return if (s.sbs) if (s.sbs90) g.sbsHz else 60 else g.refreshHz
}
fun glassesDisplay(): Display? = _state.value.displayId.takeIf { it != Display.INVALID_DISPLAY }?.let { displays.getDisplay(it) }
@@ -274,7 +274,8 @@ object Glasses {
.put("present", s.present).put("model", s.model).put("controllable", s.controllable)
.put("permission", s.permission.name).put("connected", s.connected)
.put("display", if (s.hasDisplay) JSONObject().put("id", s.displayId).put("width", s.width).put("height", s.height).put("hz", s.hz.toDouble()) else JSONObject.NULL)
.put("sbs", s.sbs).put("brightness", s.brightness).put("volume", s.volume)
.put("sbs", s.sbs).put("deviceMode", glassesMode).put("switching3d", s.switching3d)
.put("brightness", s.brightness).put("volume", s.volume)
.put("brightnessMax", s.brightnessMax).put("volumeMax", s.volumeMax).put("sunblock", s.sunblock)
.put("tracking", s.tracking).put("simulating", simulating.get() != null).put("imuPackets", imuPackets)
.put("pose", q?.let { JSONArray().put(it[0].toDouble()).put(it[1].toDouble()).put(it[2].toDouble()).put(it[3].toDouble()) } ?: JSONObject.NULL)
@@ -321,7 +322,6 @@ object Glasses {
refresh(ask = true)
}
UsbManager.ACTION_USB_DEVICE_DETACHED -> if (intent.device()?.vendorId == VitureProtocol.VENDOR_ID) {
closeLink()
refresh()
}
}
@@ -337,42 +337,46 @@ object Glasses {
}
private fun refresh(ask: Boolean = false) {
val device = usb.deviceList.values.firstOrNull { it.vendorId == VitureProtocol.VENDOR_ID }
val m = device?.let { VitureProtocol.model(it.productId, runCatching { it.productName }.getOrNull()) }
val devices = usb.deviceList.values.filter { it.vendorId == VitureProtocol.VENDOR_ID }
val known = devices.mapNotNull { device ->
VitureProtocol.model(device.productId, runCatching { device.productName }.getOrNull())?.let { device to it }
}
val selected = known.firstOrNull { it.second.controllable } ?: known.firstOrNull()
val device = selected?.first
val m = selected?.second
val controllable = m?.controllable == true
val permitted = controllable && device != null && usb.hasPermission(device)
val identity = device?.let {
GlassesModeState.Device(it.productId, if (permitted) runCatching { it.serialNumber }.getOrNull() else null)
}
if (modeState.updateDevice(identity, SystemClock.uptimeMillis())) {
rememberSbs(GlassesState.SBS_UNKNOWN)
}
if (device == null || (linkDevice != null && linkDevice?.deviceName != device.deviceName)) closeLink()
model = m
val display = findDisplay(device != null)
val display = findDisplay(devices.isNotEmpty())
val mode = display?.mode
val width = mode?.physicalWidth ?: 0
val height = mode?.physicalHeight ?: 0
val sbsByDisplay = width >= 3000
val noSignal = display != null && width in 1 until 1280
if (sbsAskedAt != 0L) {
when {
SystemClock.uptimeMillis() - sbsAskedAt > SBS_CONFIRM_MS -> sbsAskedAt = 0L
display == null -> Unit
sbsByDisplay -> {
sbsAskedAt = 0L
rememberSbs(GlassesState.SBS_WORKS)
}
noSignal -> {
sbsAskedAt = 0L
Log.w(TAG, "3D came back as ${width}x$height: this phone can't send the side-by-side signal")
rememberSbs(GlassesState.SBS_FAILS)
handler.postDelayed({ sendMode(false) }, 500)
}
when (modeState.observe(sbsByDisplay, SystemClock.uptimeMillis())) {
GlassesModeState.Confirmation.FAILED -> {
Log.w(TAG, "3D signal was not confirmed at ${width}x$height; restoring 2D")
rememberSbs(GlassesState.SBS_FAILS)
}
else -> Unit
}
val permitted = device != null && usb.hasPermission(device)
if (device != null && !permitted && (ask || !asked) && _state.value.permission != Permission.ASKING) askPermission(device)
if (device != null && permitted && link == null && m?.controllable == true) openLink(device)
if (sbsByDisplay) rememberSbs(GlassesState.SBS_WORKS)
if (controllable && device != null && !permitted && (ask || !asked) && _state.value.permission != Permission.ASKING) askPermission(device)
if (device != null && permitted && link == null) openLink(device)
publish {
it.copy(
present = device != null || display != null,
present = devices.isNotEmpty() || display != null,
model = m?.name ?: display?.let(::displayName) ?: "VITURE",
controllable = m?.controllable == true,
controllable = controllable,
permission = when {
device == null -> Permission.NONE
!controllable -> Permission.NONE
permitted -> Permission.GRANTED
it.permission == Permission.ASKING || it.permission == Permission.DENIED -> it.permission
else -> Permission.NONE
@@ -380,7 +384,8 @@ object Glasses {
connected = link != null,
displayId = display?.displayId ?: Display.INVALID_DISPLAY,
width = width, height = height, hz = mode?.refreshRate ?: 0f,
sbs = if (display != null) sbsByDisplay else it.sbs && link != null,
sbs = sbsByDisplay,
switching3d = modeState.pendingStereo,
brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8,
sunblock = _settings.value.sunblock ?: it.sunblock,
diagonalFovDeg = m?.diagonalFovDeg ?: 46f,
@@ -389,7 +394,8 @@ object Glasses {
sbs90 = m?.sbs90 == true,
)
}
if (link != null && display != null && !noSignal) applyMode(force = false)
if (link != null) applyMode(force = false)
scheduleModeCheck()
}
private fun findDisplay(usbPresent: Boolean): Display? {
@@ -419,7 +425,9 @@ object Glasses {
}
private fun openLink(device: UsbDevice) {
val l = VitureLink.open(usb, device, linkListener) ?: run {
val generation = ++linkGeneration
val l = VitureLink.open(usb, device, linkListener(generation)) ?: run {
linkGeneration++
Log.w(TAG, "could not open ${device.deviceName}")
return
}
@@ -442,6 +450,7 @@ object Glasses {
private fun closeLink() {
val l = link ?: return
linkGeneration++
link = null
linkDevice = null
glassesMode = 0
@@ -452,22 +461,18 @@ object Glasses {
private fun rememberSbs(support: Int) {
if (_state.value.sbsSupport == support) return
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_SBS_SUPPORT, support).apply()
publish { it.copy(sbsSupport = support) }
}
private fun applyMode(force: Boolean) {
val s = _state.value
val m = model ?: return
if (!s.canControl || !s.usable) return
if (vr3dRevert) {
vr3dRevert = false
if (s.sbs) {
sendMode(false)
return
}
if (!s.canControl) return
if (modeState.restorePending) {
sendMode(false)
return
}
if (s.sbs) return
if (!s.usable || s.sbs || modeState.pendingStereo) return
val hz = _settings.value.refreshHz
if (glassesMode == m.mode(hz, false)) return
if (!force) {
@@ -478,14 +483,24 @@ object Glasses {
sendMode(false)
}
private fun sendMode(sbs: Boolean) {
val m = model ?: return
if (sbs && _state.value.sbsSupport == GlassesState.SBS_FAILS) return
private fun sendMode(sbs: Boolean): Boolean {
val m = model ?: return false
val g = _settings.value
val mode = m.mode(if (sbs) g.sbsHz else g.refreshHz, sbs)
if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) {
lastModeSentAt = SystemClock.uptimeMillis()
sbsAskedAt = if (sbs) lastModeSentAt else 0L
modeState.sent(sbs, lastModeSentAt)
publish { it.copy(switching3d = modeState.pendingStereo) }
scheduleModeCheck()
return true
}
return false
}
private fun scheduleModeCheck() {
handler.removeCallbacks(confirmMode)
modeState.nextCheckAt?.let { at ->
handler.postDelayed(confirmMode, (at - SystemClock.uptimeMillis()).coerceAtLeast(0))
}
}
@@ -503,8 +518,9 @@ object Glasses {
publish { it.copy(tracking = want) }
}
private val linkListener = object : VitureLink.Listener {
override fun onFrame(frame: VitureProtocol.Frame) {
private fun linkListener(generation: Long) = object : VitureLink.Listener {
override fun onFrame(frame: VitureProtocol.Frame) = post {
if (generation != linkGeneration) return@post
val line = "%s 0x%04X %s".format(
when (frame.magic) { VitureProtocol.MAGIC_REPORT -> "FD"; VitureProtocol.MAGIC_COMMAND -> "FE"; else -> "FC" },
frame.msgId, frame.payload.joinToString("") { "%02X".format(it) },
@@ -514,10 +530,11 @@ object Glasses {
while (frames.size > 64) frames.removeFirst()
}
Log.i(TAG, "frame $line")
post { onReport(frame) }
onReport(frame)
}
override fun onImu(payload: ByteArray, timeNs: Long) {
if (generation != linkGeneration) return
imuPackets++
probe?.invoke(payload)
if (simulating.get() != null) return
@@ -525,6 +542,7 @@ object Glasses {
}
override fun onLost() = post {
if (generation != linkGeneration) return@post
closeLink()
refresh()
}
@@ -543,7 +561,6 @@ object Glasses {
VitureProtocol.EVENT_DISPLAY -> {
Log.i(TAG, "glasses switched to mode 0x%02X".format(event))
glassesMode = event
publish { it.copy(sbs = VitureProtocol.modeIsSbs(event)) }
}
VitureProtocol.EVENT_DISPLAY_STEP -> model?.modeForStep(event)?.let { mode ->
Log.i(TAG, "glasses report mode 0x%02X".format(mode))
@@ -555,11 +572,12 @@ object Glasses {
VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_VOLUME -> answer?.let { v -> publish { it.copy(volume = v) } }
VitureProtocol.MSG_DISPLAY -> if (event != 0) {
Log.w(TAG, "glasses refused display mode 0x%02X".format(p.getOrNull(1)?.toInt()?.and(0xFF) ?: 0))
sbsAskedAt = 0L
modeState.cancelPending()
publish { it.copy(switching3d = false) }
scheduleModeCheck()
} else answer?.let { glassesMode = it }
VitureProtocol.MSG_GET_DISPLAY -> answer?.let { v ->
glassesMode = v
if (VitureProtocol.modeIsSbs(v) != _state.value.sbs && !_state.value.hasDisplay) publish { it.copy(sbs = VitureProtocol.modeIsSbs(v)) }
}
}
}
@@ -0,0 +1,84 @@
package com.droiddeck.launcher.glasses
internal class GlassesModeState(private val timeoutMs: Long) {
enum class Confirmation { NONE, WAITING, CONFIRMED, FAILED }
data class Device(val productId: Int, val serial: String?)
private var deadline: Long? = null
private var device: Device? = null
private var attached = false
private var reconnectDeadline: Long? = null
private var vrOwned = false
var restorePending = false
private set
val pendingStereo: Boolean get() = deadline != null
val nextCheckAt: Long? get() = listOfNotNull(deadline, reconnectDeadline).minOrNull()
fun updateDevice(current: Device?, now: Long): Boolean {
if (reconnectDeadline?.let { now >= it } == true) abandon()
if (current == null) {
if (attached) reconnectDeadline = now + timeoutMs
attached = false
return false
}
val previous = device
val changed = previous == null || previous.productId != current.productId ||
(previous.serial != null && current.serial != null && previous.serial != current.serial)
if (changed) abandon()
device = if (!changed && current.serial == null) previous else current
attached = true
reconnectDeadline = null
return changed
}
private fun abandon() {
deadline = null
reconnectDeadline = null
vrOwned = false
restorePending = false
}
fun manual() {
vrOwned = false
restorePending = false
}
fun startVr() {
vrOwned = true
restorePending = false
}
fun stopVr(): Boolean {
if (vrOwned) {
vrOwned = false
restorePending = true
}
return restorePending
}
fun sent(stereo: Boolean, now: Long) {
deadline = if (stereo) now + timeoutMs else null
if (!stereo) restorePending = false
}
fun cancelPending() {
deadline = null
}
fun observe(stereo: Boolean, now: Long): Confirmation {
val until = deadline ?: return Confirmation.NONE
if (stereo) {
deadline = null
return Confirmation.CONFIRMED
}
if (now < until) return Confirmation.WAITING
if (!attached) {
abandon()
return Confirmation.NONE
}
deadline = null
vrOwned = false
restorePending = true
return Confirmation.FAILED
}
}
@@ -17,10 +17,7 @@ class GlassesScreen(context: Context, display: Display, refreshHz: Int, private
val holder: SurfaceHolder get() = surfaceView.holder
private var wantHz = refreshHz
val hz: Float get() {
val id = window?.attributes?.preferredDisplayModeId ?: 0
return display.supportedModes.firstOrNull { id != 0 && it.modeId == id }?.refreshRate ?: display.refreshRate
}
val hz: Float get() = display.mode.refreshRate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -36,7 +36,7 @@ object VitureProtocol {
class Model(val name: String, val family: Family) {
val controllable: Boolean get() = family != Family.BEAST
val tall: Boolean get() = family == Family.LUMA || family == Family.ULTRA
val sbs90: Boolean get() = tall
val sbs90: Boolean get() = family == Family.ULTRA
val brightnessMax: Int get() = 8
val volumeMax: Int get() = 8
val diagonalFovDeg: Float get() = when (family) {
@@ -47,7 +47,7 @@ object VitureProtocol {
}
fun mode(hz: Int, sbs: Boolean): Int = when {
tall && sbs -> if (hz >= 90) 0x45 else 0x42
tall && sbs -> if (hz >= 90 && sbs90) 0x45 else 0x42
tall -> when { hz >= 120 -> 0x44; hz >= 90 -> 0x43; else -> 0x41 }
sbs -> if (hz >= 90 && sbs90) 0x35 else 0x32
else -> when { hz >= 120 -> 0x34; hz >= 90 -> 0x33; else -> 0x31 }
@@ -127,13 +127,13 @@ object VitureProtocol {
class Frame(val magic: Int, val msgId: Int, val payload: ByteArray, val crcOk: Boolean)
fun parse(buf: ByteArray, size: Int): Frame? {
if (size < HEADER || (buf[0].toInt() and 0xFF) != 0xFF) return null
if (size < HEADER || size > buf.size || (buf[0].toInt() and 0xFF) != 0xFF) return null
val magic = buf[1].toInt() and 0xFF
if (magic != MAGIC_COMMAND && magic != MAGIC_REPORT && magic != MAGIC_IMU) return null
val len = u16(buf, 4)
if (len < 0x0C) return null
val dataLen = (len - 0x0C).coerceAtMost(size - HEADER)
val crcOk = len + 6 <= size && crc16(buf, 4, len + 2) == u16(buf, 2)
if (len < 0x0C || len + 6 > size) return null
val dataLen = len - 0x0C
val crcOk = crc16(buf, 4, len + 2) == u16(buf, 2)
return Frame(magic, u16(buf, 14), buf.copyOfRange(HEADER, HEADER + dataLen), crcOk)
}
@@ -108,7 +108,8 @@ internal fun GlassesGroups(
ChoiceRow(host, "glasses-hz", stringResource(R.string.glasses_refresh), null,
listOf(60, 90, 120).map { it to "$it Hz" }, s.refreshHz, chipModifier = track("glasses-hz")) { Glasses.setRefreshHz(it) }
if (g.sbsSupport == GlassesState.SBS_FAILS && !g.sbs) {
SettingsRow(stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_unsupported)) {}
ActionRow(stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_failed),
stringResource(R.string.stores_dl_retry), { Glasses.set3d(true) })
} else {
if (inSession || g.sbs) ToggleRow(host, "glasses-3d", stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_hint),
g.sbs, chipModifier = track("glasses-3d")) { Glasses.set3d(it) }
@@ -29,6 +29,7 @@ object CompositorHost {
fpsLimit: Int,
): Boolean {
pace(refreshHz, fpsLimit)
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
if (!SessionState.running) WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
if (started) {
attached = surface
@@ -36,7 +37,6 @@ object CompositorHost {
resumeVsync()
return false
}
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
WaylandCompositor.nativeStartWithSurface(
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
)
+1
View File
@@ -2314,6 +2314,7 @@
<string name="glasses_3d">3D (جنبًا إلى جنب)</string>
<string name="glasses_3d_hint">تحصل كل عين على نصفها الخاص من صورة بدقة 3840×1080. إذا انقطعت الصورة، فاضغط مطولًا على زر 2D/3D في النظارة.</string>
<string name="glasses_3d_unsupported">لا يمكن لهذا الهاتف إرسال إشارة 3840×1080 التي يحتاجها وضع 3D. تعرض ألعاب الواقع الافتراضي منظور عين واحدة، مع تتبّع الرأس.</string>
<string name="glasses_3d_failed">لم يتم تأكيد إشارة 3D. حاول مجددًا بعد عودة صورة 2D.</string>
<string name="glasses_no_signal">لا توجد صورة: النظارة في وضع لا يستطيع هذا الهاتف إرساله. اضغط مطولًا على زر 2D/3D فيها، أو افصلها ثم أعد توصيلها.</string>
<string name="glasses_3d_refresh">معدل تحديث 3D</string>
<string name="glasses_auto3d">3D لألعاب الواقع الافتراضي</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (един до друг)</string>
<string name="glasses_3d_hint">Всяко око получава своята половина от картина 3840×1080. Ако картината изчезне, задръжте бутона 2D/3D на очилата.</string>
<string name="glasses_3d_unsupported">Този телефон не може да изпрати сигнала 3840×1080, нужен за 3D. VR игрите показват изгледа за едно око, с проследяване на главата.</string>
<string name="glasses_3d_failed">3D сигналът не беше потвърден. Опитайте отново, когато 2D картината се върне.</string>
<string name="glasses_no_signal">Няма картина: очилата са в режим, който този телефон не може да изпрати. Задръжте бутона им 2D/3D или ги изключете и включете отново.</string>
<string name="glasses_3d_refresh">Честота на опресняване в 3D</string>
<string name="glasses_auto3d">3D за VR игри</string>
+1
View File
@@ -2264,6 +2264,7 @@
<string name="glasses_3d">3D (vedle sebe)</string>
<string name="glasses_3d_hint">Každé oko dostane vlastní polovinu obrazu 3840×1080. Pokud obraz vypadne, podržte na brýlích tlačítko 2D/3D.</string>
<string name="glasses_3d_unsupported">Tento telefon neumí vyslat signál 3840×1080, který 3D vyžaduje. Hry VR zobrazí pohled jednoho oka se sledováním hlavy.</string>
<string name="glasses_3d_failed">Signál 3D se nepodařilo potvrdit. Zkuste to znovu, až se vrátí obraz 2D.</string>
<string name="glasses_no_signal">Žádný obraz: brýle jsou v režimu, který tento telefon neumí vyslat. Podržte jejich tlačítko 2D/3D, nebo je odpojte a znovu připojte.</string>
<string name="glasses_3d_refresh">Obnovovací frekvence ve 3D</string>
<string name="glasses_auto3d">3D pro hry VR</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (side-by-side)</string>
<string name="glasses_3d_hint">Hvert øje får sin egen halvdel af et billede på 3840×1080. Forsvinder billedet, så hold brillens 2D/3D-knap inde.</string>
<string name="glasses_3d_unsupported">Denne telefon kan ikke sende det 3840×1080-signal, som 3D kræver. VR-spil viser ét øjes billede med hovedsporing.</string>
<string name="glasses_3d_failed">3D-signalet blev ikke bekræftet. Prøv igen, når 2D-billedet er tilbage.</string>
<string name="glasses_no_signal">Intet billede: brillen er i en tilstand, som telefonen ikke kan sende. Hold dens 2D/3D-knap inde, eller tag stikket ud og sæt det i igen.</string>
<string name="glasses_3d_refresh">3D-opdateringshastighed</string>
<string name="glasses_auto3d">3D til VR-spil</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (Side-by-Side)</string>
<string name="glasses_3d_hint">Jedes Auge bekommt seine eigene Hälfte eines 3840×1080-Bildes. Fällt das Bild aus, halte die 2D/3D-Taste der Brille gedrückt.</string>
<string name="glasses_3d_unsupported">Dieses Smartphone kann das 3840×1080-Signal für 3D nicht ausgeben. VR-Spiele zeigen die Sicht eines Auges, mit Kopf-Tracking.</string>
<string name="glasses_3d_failed">Das 3D-Signal wurde nicht bestätigt. Versuche es erneut, sobald das 2D-Bild wieder da ist.</string>
<string name="glasses_no_signal">Kein Bild: Die Brille ist in einem Modus, den dieses Smartphone nicht ausgeben kann. Halte ihre 2D/3D-Taste gedrückt oder steck sie aus und wieder ein.</string>
<string name="glasses_3d_refresh">3D-Bildwiederholrate</string>
<string name="glasses_auto3d">3D für VR-Spiele</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (δίπλα-δίπλα)</string>
<string name="glasses_3d_hint">Κάθε μάτι παίρνει το δικό του μισό μιας εικόνας 3840×1080. Αν η εικόνα χαθεί, κρατήστε πατημένο το κουμπί 2D/3D των γυαλιών.</string>
<string name="glasses_3d_unsupported">Αυτό το τηλέφωνο δεν μπορεί να στείλει το σήμα 3840×1080 που χρειάζεται το 3D. Τα παιχνίδια VR δείχνουν την εικόνα του ενός ματιού, με παρακολούθηση κεφαλιού.</string>
<string name="glasses_3d_failed">Το σήμα 3D δεν επιβεβαιώθηκε. Δοκιμάστε ξανά μόλις επανέλθει η εικόνα 2D.</string>
<string name="glasses_no_signal">Δεν υπάρχει εικόνα: τα γυαλιά είναι σε λειτουργία που αυτό το τηλέφωνο δεν μπορεί να στείλει. Κρατήστε πατημένο το κουμπί 2D/3D τους ή αποσυνδέστε τα και συνδέστε τα ξανά.</string>
<string name="glasses_3d_refresh">Ρυθμός ανανέωσης 3D</string>
<string name="glasses_auto3d">3D για παιχνίδια VR</string>
+1
View File
@@ -2247,6 +2247,7 @@
<string name="glasses_3d">3D (lado a lado)</string>
<string name="glasses_3d_hint">Cada ojo recibe su propia mitad de una imagen de 3840×1080. Si la imagen se pierde, mantén pulsado el botón 2D/3D de las gafas.</string>
<string name="glasses_3d_unsupported">Este teléfono no puede enviar la señal de 3840×1080 que necesita el 3D. Los juegos de RV muestran la vista de un ojo, con seguimiento de cabeza.</string>
<string name="glasses_3d_failed">No se ha confirmado la señal 3D. Inténtalo de nuevo cuando vuelva la imagen 2D.</string>
<string name="glasses_no_signal">Sin imagen: las gafas están en un modo que este teléfono no puede enviar. Mantén pulsado su botón 2D/3D o desconéctalas y vuelve a conectarlas.</string>
<string name="glasses_3d_refresh">Frecuencia en 3D</string>
<string name="glasses_auto3d">3D para juegos de RV</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (rinnakkain)</string>
<string name="glasses_3d_hint">Kumpikin silmä saa oman puoliskonsa 3840×1080-kuvasta. Jos kuva katoaa, pidä lasien 2D/3D-painiketta pohjassa.</string>
<string name="glasses_3d_unsupported">Tämä puhelin ei pysty lähettämään 3D:n vaatimaa 3840×1080-signaalia. VR-pelit näyttävät yhden silmän näkymän pään seurannalla.</string>
<string name="glasses_3d_failed">3D-signaalia ei voitu vahvistaa. Yritä uudelleen, kun 2D-kuva palautuu.</string>
<string name="glasses_no_signal">Ei kuvaa: lasit ovat tilassa, jota tämä puhelin ei pysty lähettämään. Pidä niiden 2D/3D-painiketta pohjassa tai irrota ja kytke ne uudelleen.</string>
<string name="glasses_3d_refresh">3D-virkistystaajuus</string>
<string name="glasses_auto3d">3D VR-peleille</string>
+1
View File
@@ -2247,6 +2247,7 @@
<string name="glasses_3d">3D (côte à côte)</string>
<string name="glasses_3d_hint">Chaque œil reçoit sa propre moitié d\'une image de 3840×1080. Si l\'image disparaît, maintenez le bouton 2D/3D des lunettes.</string>
<string name="glasses_3d_unsupported">Ce téléphone ne peut pas envoyer le signal 3840×1080 nécessaire à la 3D. Les jeux VR affichent la vue d\'un seul œil, avec le suivi de la tête.</string>
<string name="glasses_3d_failed">Le signal 3D n\'a pas été confirmé. Réessayez une fois l\'image 2D rétablie.</string>
<string name="glasses_no_signal">Pas d\'image : les lunettes sont dans un mode que ce téléphone ne peut pas envoyer. Maintenez leur bouton 2D/3D, ou débranchez-les puis rebranchez-les.</string>
<string name="glasses_3d_refresh">Fréquence en 3D</string>
<string name="glasses_auto3d">3D pour les jeux VR</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (साइड-बाय-साइड)</string>
<string name="glasses_3d_hint">हर आंख को 3840×1080 तस्वीर का अपना आधा हिस्सा मिलता है। अगर तस्वीर चली जाए, तो चश्मे का 2D/3D बटन दबाकर रखें।</string>
<string name="glasses_3d_unsupported">यह फ़ोन 3D के लिए ज़रूरी 3840×1080 सिग्नल नहीं भेज सकता। VR गेम हेड ट्रैकिंग के साथ एक आंख का व्यू दिखाते हैं।</string>
<string name="glasses_3d_failed">3D सिग्नल की पुष्टि नहीं हुई। 2D तस्वीर वापस आने पर फिर कोशिश करें।</string>
<string name="glasses_no_signal">कोई तस्वीर नहीं: चश्मा ऐसे मोड में है जिसे यह फ़ोन नहीं भेज सकता। उसका 2D/3D बटन दबाकर रखें, या केबल निकालकर फिर से लगाएं।</string>
<string name="glasses_3d_refresh">3D रिफ्रेश रेट</string>
<string name="glasses_auto3d">VR गेम के लिए 3D</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (egymás melletti)</string>
<string name="glasses_3d_hint">Mindkét szem egy 3840×1080-as kép saját felét kapja. Ha a kép eltűnik, tartsa lenyomva a szemüveg 2D/3D gombját.</string>
<string name="glasses_3d_unsupported">Ez a telefon nem tudja küldeni a 3D-hez szükséges 3840×1080-as jelet. A VR-játékok egy szem nézetét mutatják, fejkövetéssel.</string>
<string name="glasses_3d_failed">Nem sikerült megerősíteni a 3D-jelet. Próbáld újra, ha visszatér a 2D-kép.</string>
<string name="glasses_no_signal">Nincs kép: a szemüveg olyan módban van, amelyet ez a telefon nem tud küldeni. Tartsa lenyomva a 2D/3D gombját, vagy húzza ki, és dugja vissza.</string>
<string name="glasses_3d_refresh">3D frissítési frekvencia</string>
<string name="glasses_auto3d">3D a VR-játékokhoz</string>
+1
View File
@@ -2189,6 +2189,7 @@
<string name="glasses_3d">3D (berdampingan)</string>
<string name="glasses_3d_hint">Setiap mata mendapat separuhnya sendiri dari gambar 3840×1080. Jika gambar hilang, tahan tombol 2D/3D kacamata.</string>
<string name="glasses_3d_unsupported">Ponsel ini tidak bisa mengirim sinyal 3840×1080 yang dibutuhkan 3D. Game VR menampilkan pandangan satu mata, dengan pelacakan kepala.</string>
<string name="glasses_3d_failed">Sinyal 3D belum terkonfirmasi. Coba lagi setelah gambar 2D kembali.</string>
<string name="glasses_no_signal">Tidak ada gambar: kacamata dalam mode yang tidak bisa dikirim ponsel ini. Tahan tombol 2D/3D-nya, atau cabut lalu colokkan lagi.</string>
<string name="glasses_3d_refresh">Refresh rate 3D</string>
<string name="glasses_auto3d">3D untuk game VR</string>
+1
View File
@@ -2239,6 +2239,7 @@
<string name="glasses_3d">3D (affiancato)</string>
<string name="glasses_3d_hint">Ogni occhio riceve la propria metà di un\'immagine 3840×1080. Se l\'immagine scompare, tieni premuto il pulsante 2D/3D degli occhiali.</string>
<string name="glasses_3d_unsupported">Questo telefono non può inviare il segnale 3840×1080 richiesto dal 3D. I giochi VR mostrano la vista di un solo occhio, con il tracciamento della testa.</string>
<string name="glasses_3d_failed">Il segnale 3D non è stato confermato. Riprova quando torna l\'immagine 2D.</string>
<string name="glasses_no_signal">Nessuna immagine: gli occhiali sono in una modalità che questo telefono non può inviare. Tieni premuto il loro pulsante 2D/3D, oppure scollegali e ricollegali.</string>
<string name="glasses_3d_refresh">Frequenza di aggiornamento 3D</string>
<string name="glasses_auto3d">3D per i giochi VR</string>
+1
View File
@@ -2197,6 +2197,7 @@
<string name="glasses_3d">3D(サイドバイサイド)</string>
<string name="glasses_3d_hint">左右の目に 3840×1080 の画像の半分ずつを表示します。映像が消えたら、グラスの 2D/3D ボタンを長押ししてください。</string>
<string name="glasses_3d_unsupported">このスマートフォンは 3D に必要な 3840×1080 の信号を出力できません。VR ゲームはヘッドトラッキング付きで片目の映像を表示します。</string>
<string name="glasses_3d_failed">3D 信号を確認できませんでした。2D 映像が戻ってから再試行してください。</string>
<string name="glasses_no_signal">映像なし: グラスがこのスマートフォンで出力できないモードになっています。2D/3D ボタンを長押しするか、抜き差ししてください。</string>
<string name="glasses_3d_refresh">3D のリフレッシュレート</string>
<string name="glasses_auto3d">VR ゲームで 3D</string>
+1
View File
@@ -2197,6 +2197,7 @@
<string name="glasses_3d">3D (사이드 바이 사이드)</string>
<string name="glasses_3d_hint">각 눈이 3840×1080 화면의 절반씩을 봅니다. 화면이 끊기면 안경의 2D/3D 버튼을 길게 누르세요.</string>
<string name="glasses_3d_unsupported">이 휴대폰은 3D에 필요한 3840×1080 신호를 보낼 수 없습니다. VR 게임은 헤드 트래킹과 함께 한쪽 눈의 화면을 보여 줍니다.</string>
<string name="glasses_3d_failed">3D 신호를 확인하지 못했습니다. 2D 화면이 돌아오면 다시 시도하세요.</string>
<string name="glasses_no_signal">화면 없음: 안경이 이 휴대폰에서 보낼 수 없는 모드에 있습니다. 2D/3D 버튼을 길게 누르거나 안경을 뺐다가 다시 연결하세요.</string>
<string name="glasses_3d_refresh">3D 주사율</string>
<string name="glasses_auto3d">VR 게임에서 3D</string>
+1
View File
@@ -2189,6 +2189,7 @@
<string name="glasses_3d">3D (sebelah-menyebelah)</string>
<string name="glasses_3d_hint">Setiap mata mendapat separuh gambar 3840×1080 sendiri. Jika gambar hilang, tahan butang 2D/3D cermin mata.</string>
<string name="glasses_3d_unsupported">Telefon ini tidak dapat menghantar isyarat 3840×1080 yang diperlukan oleh 3D. Permainan VR memaparkan pandangan sebelah mata, dengan penjejakan kepala.</string>
<string name="glasses_3d_failed">Isyarat 3D tidak dapat disahkan. Cuba lagi selepas gambar 2D kembali.</string>
<string name="glasses_no_signal">Tiada gambar: cermin mata dalam mod yang tidak dapat dihantar oleh telefon ini. Tahan butang 2D/3D-nya, atau cabut dan pasang semula.</string>
<string name="glasses_3d_refresh">Kadar penyegaran 3D</string>
<string name="glasses_auto3d">3D untuk permainan VR</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (side ved side)</string>
<string name="glasses_3d_hint">Hvert øye får sin egen halvdel av et bilde på 3840×1080. Hvis bildet faller ut, holder du inne 2D/3D-knappen på brillene.</string>
<string name="glasses_3d_unsupported">Denne telefonen kan ikke sende 3840×1080-signalet som 3D krever. VR-spill viser bildet for ett øye, med hodesporing.</string>
<string name="glasses_3d_failed">3D-signalet ble ikke bekreftet. Prøv igjen når 2D-bildet er tilbake.</string>
<string name="glasses_no_signal">Ikke noe bilde: brillene er i en modus denne telefonen ikke kan sende. Hold inne 2D/3D-knappen deres, eller koble dem fra og til igjen.</string>
<string name="glasses_3d_refresh">3D-oppdateringsfrekvens</string>
<string name="glasses_auto3d">3D for VR-spill</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (side-by-side)</string>
<string name="glasses_3d_hint">Elk oog krijgt een eigen helft van een beeld van 3840×1080. Valt het beeld weg, houd dan de 2D/3D-knop van de bril ingedrukt.</string>
<string name="glasses_3d_unsupported">Deze telefoon kan het 3840×1080-signaal voor 3D niet versturen. VR-games tonen het beeld van één oog, met hoofdtracking.</string>
<string name="glasses_3d_failed">Het 3D-signaal is niet bevestigd. Probeer het opnieuw zodra het 2D-beeld terug is.</string>
<string name="glasses_no_signal">Geen beeld: de bril staat in een modus die deze telefoon niet kan versturen. Houd de 2D/3D-knop ingedrukt, of koppel de bril los en sluit hem opnieuw aan.</string>
<string name="glasses_3d_refresh">3D-verversingssnelheid</string>
<string name="glasses_auto3d">3D voor VR-games</string>
+1
View File
@@ -2264,6 +2264,7 @@
<string name="glasses_3d">3D (obok siebie)</string>
<string name="glasses_3d_hint">Każde oko dostaje własną połowę obrazu 3840×1080. Jeśli obraz zniknie, przytrzymaj przycisk 2D/3D na okularach.</string>
<string name="glasses_3d_unsupported">Ten telefon nie może wysłać sygnału 3840×1080 potrzebnego do 3D. Gry VR pokazują widok jednego oka ze śledzeniem głowy.</string>
<string name="glasses_3d_failed">Nie udało się potwierdzić sygnału 3D. Spróbuj ponownie, gdy wróci obraz 2D.</string>
<string name="glasses_no_signal">Brak obrazu: okulary są w trybie, którego ten telefon nie obsługuje. Przytrzymaj ich przycisk 2D/3D albo odłącz je i podłącz ponownie.</string>
<string name="glasses_3d_refresh">Częstotliwość odświeżania w 3D</string>
<string name="glasses_auto3d">3D w grach VR</string>
@@ -2239,6 +2239,7 @@
<string name="glasses_3d">3D (lado a lado)</string>
<string name="glasses_3d_hint">Cada olho recebe a sua metade de uma imagem de 3840×1080. Se a imagem desaparecer, mantenha premido o botão 2D/3D dos óculos.</string>
<string name="glasses_3d_unsupported">Este telemóvel não consegue enviar o sinal de 3840×1080 de que o 3D precisa. Os jogos de RV mostram a vista de um só olho, com seguimento da cabeça.</string>
<string name="glasses_3d_failed">O sinal 3D não foi confirmado. Tenta novamente quando a imagem 2D voltar.</string>
<string name="glasses_no_signal">Sem imagem: os óculos estão num modo que este telemóvel não consegue enviar. Mantenha premido o botão 2D/3D ou desligue-os e volte a ligá-los.</string>
<string name="glasses_3d_refresh">Taxa de atualização em 3D</string>
<string name="glasses_auto3d">3D para jogos de RV</string>
+1
View File
@@ -2239,6 +2239,7 @@
<string name="glasses_3d">3D (lado a lado)</string>
<string name="glasses_3d_hint">Cada olho recebe a própria metade de uma imagem de 3840×1080. Se a imagem sumir, mantenha pressionado o botão 2D/3D dos óculos.</string>
<string name="glasses_3d_unsupported">Este celular não consegue enviar o sinal de 3840×1080 de que o 3D precisa. Jogos de RV mostram a visão de um olho só, com rastreamento da cabeça.</string>
<string name="glasses_3d_failed">O sinal 3D não foi confirmado. Tente novamente quando a imagem 2D voltar.</string>
<string name="glasses_no_signal">Sem imagem: os óculos estão em um modo que este celular não consegue enviar. Mantenha pressionado o botão 2D/3D deles ou desconecte e reconecte os óculos.</string>
<string name="glasses_3d_refresh">Taxa de atualização em 3D</string>
<string name="glasses_auto3d">3D para jogos de RV</string>
+1
View File
@@ -2239,6 +2239,7 @@
<string name="glasses_3d">3D (alăturat)</string>
<string name="glasses_3d_hint">Fiecare ochi primește propria jumătate dintr-o imagine de 3840×1080. Dacă imaginea dispare, ține apăsat butonul 2D/3D al ochelarilor.</string>
<string name="glasses_3d_unsupported">Acest telefon nu poate trimite semnalul de 3840×1080 de care are nevoie 3D-ul. Jocurile VR arată imaginea unui singur ochi, cu urmărirea capului.</string>
<string name="glasses_3d_failed">Semnalul 3D nu a fost confirmat. Încearcă din nou când revine imaginea 2D.</string>
<string name="glasses_no_signal">Nicio imagine: ochelarii sunt într-un mod pe care acest telefon nu îl poate trimite. Ține apăsat butonul lor 2D/3D sau deconectează-i și reconectează-i.</string>
<string name="glasses_3d_refresh">Rată de reîmprospătare 3D</string>
<string name="glasses_auto3d">3D pentru jocuri VR</string>
+1
View File
@@ -2272,6 +2272,7 @@
<string name="glasses_3d">3D (стереопара)</string>
<string name="glasses_3d_hint">Каждый глаз получает свою половину изображения 3840×1080. Если изображение пропадёт, удерживайте кнопку 2D/3D на очках.</string>
<string name="glasses_3d_unsupported">Этот телефон не может передать сигнал 3840×1080, нужный для 3D. VR-игры показывают вид одного глаза с отслеживанием головы.</string>
<string name="glasses_3d_failed">Сигнал 3D не подтверждён. Повторите попытку, когда вернётся изображение 2D.</string>
<string name="glasses_no_signal">Нет изображения: очки в режиме, который этот телефон не может передать. Удерживайте их кнопку 2D/3D или отключите и снова подключите очки.</string>
<string name="glasses_3d_refresh">Частота в 3D</string>
<string name="glasses_auto3d">3D для VR-игр</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (sida vid sida)</string>
<string name="glasses_3d_hint">Varje öga får sin egen halva av en bild på 3840×1080. Om bilden försvinner håller du in glasögonens 2D/3D-knapp.</string>
<string name="glasses_3d_unsupported">Den här telefonen kan inte skicka den 3840×1080-signal som 3D kräver. VR-spel visar ett ögas vy, med huvudspårning.</string>
<string name="glasses_3d_failed">3D-signalen kunde inte bekräftas. Försök igen när 2D-bilden är tillbaka.</string>
<string name="glasses_no_signal">Ingen bild: glasögonen är i ett läge som den här telefonen inte kan skicka. Håll in deras 2D/3D-knapp, eller koppla ur dem och anslut dem igen.</string>
<string name="glasses_3d_refresh">3D-uppdateringsfrekvens</string>
<string name="glasses_auto3d">3D för VR-spel</string>
+1
View File
@@ -2189,6 +2189,7 @@
<string name="glasses_3d">3D (แบบเคียงข้าง)</string>
<string name="glasses_3d_hint">ตาแต่ละข้างจะเห็นภาพคนละครึ่งของภาพขนาด 3840×1080 หากภาพหายไป ให้กดปุ่ม 2D/3D ของแว่นตาค้างไว้</string>
<string name="glasses_3d_unsupported">โทรศัพท์นี้ส่งสัญญาณ 3840×1080 ที่ 3D ต้องใช้ไม่ได้ เกม VR จะแสดงภาพของตาข้างเดียวพร้อมการติดตามศีรษะ</string>
<string name="glasses_3d_failed">ยังยืนยันสัญญาณ 3D ไม่ได้ ลองอีกครั้งเมื่อภาพ 2D กลับมา</string>
<string name="glasses_no_signal">ไม่มีภาพ: แว่นตาอยู่ในโหมดที่โทรศัพท์นี้ส่งสัญญาณให้ไม่ได้ กดปุ่ม 2D/3D ของแว่นตาค้างไว้ หรือถอดสายแล้วเสียบใหม่</string>
<string name="glasses_3d_refresh">อัตรารีเฟรช 3D</string>
<string name="glasses_auto3d">3D สำหรับเกม VR</string>
+1
View File
@@ -2214,6 +2214,7 @@
<string name="glasses_3d">3D (yan yana)</string>
<string name="glasses_3d_hint">Her göz 3840×1080 görüntünün kendi yarısını alır. Görüntü kesilirse gözlüğün 2D/3D düğmesini basılı tutun.</string>
<string name="glasses_3d_unsupported">Bu telefon 3D\'nin gerektirdiği 3840×1080 sinyalini gönderemez. VR oyunları, kafa takibiyle tek gözün görüntüsünü gösterir.</string>
<string name="glasses_3d_failed">3D sinyali doğrulanamadı. 2D görüntü geri geldiğinde tekrar deneyin.</string>
<string name="glasses_no_signal">Görüntü yok: gözlük bu telefonun gönderemediği bir modda. Gözlüğün 2D/3D düğmesini basılı tutun ya da kablosunu çıkarıp yeniden takın.</string>
<string name="glasses_3d_refresh">3D yenileme hızı</string>
<string name="glasses_auto3d">VR oyunlarında 3D</string>
+1
View File
@@ -2264,6 +2264,7 @@
<string name="glasses_3d">3D (стереопара)</string>
<string name="glasses_3d_hint">Кожне око отримує свою половину зображення 3840×1080. Якщо зображення зникне, утримуйте кнопку 2D/3D на окулярах.</string>
<string name="glasses_3d_unsupported">Цей телефон не може передати сигнал 3840×1080, потрібний для 3D. VR-ігри показують вигляд для одного ока з відстеженням голови.</string>
<string name="glasses_3d_failed">Сигнал 3D не підтверджено. Спробуйте знову, коли повернеться зображення 2D.</string>
<string name="glasses_no_signal">Немає зображення: окуляри в режимі, який цей телефон не може передати. Утримуйте їхню кнопку 2D/3D або від’єднайте й знову під’єднайте їх.</string>
<string name="glasses_3d_refresh">Частота оновлення в 3D</string>
<string name="glasses_auto3d">3D для VR-ігор</string>
+1
View File
@@ -2189,6 +2189,7 @@
<string name="glasses_3d">3D (cạnh nhau)</string>
<string name="glasses_3d_hint">Mỗi mắt nhận một nửa riêng của hình 3840×1080. Nếu mất hình, hãy giữ nút 2D/3D của kính.</string>
<string name="glasses_3d_unsupported">Điện thoại này không thể xuất tín hiệu 3840×1080 mà 3D cần. Game VR hiển thị góc nhìn của một mắt, có theo dõi đầu.</string>
<string name="glasses_3d_failed">Chưa xác nhận được tín hiệu 3D. Hãy thử lại khi hình ảnh 2D trở lại.</string>
<string name="glasses_no_signal">Không có hình: kính đang ở chế độ mà điện thoại này không thể xuất. Hãy giữ nút 2D/3D của kính, hoặc rút ra rồi cắm lại.</string>
<string name="glasses_3d_refresh">Tốc độ làm mới 3D</string>
<string name="glasses_auto3d">3D cho game VR</string>
@@ -2197,6 +2197,7 @@
<string name="glasses_3d">3D(左右分屏)</string>
<string name="glasses_3d_hint">每只眼睛各看 3840×1080 画面的一半。如果画面消失,请长按眼镜上的 2D/3D 按钮。</string>
<string name="glasses_3d_unsupported">这部手机无法输出 3D 所需的 3840×1080 信号。VR 游戏会显示单眼画面,并带有头部追踪。</string>
<string name="glasses_3d_failed">未能确认 3D 信号。请在 2D 画面恢复后重试。</string>
<string name="glasses_no_signal">无画面:眼镜处于这部手机无法输出的模式。请长按眼镜的 2D/3D 按钮,或拔下后重新连接。</string>
<string name="glasses_3d_refresh">3D 刷新率</string>
<string name="glasses_auto3d">VR 游戏使用 3D</string>
@@ -2197,6 +2197,7 @@
<string name="glasses_3d">3D(左右並排)</string>
<string name="glasses_3d_hint">每隻眼睛各看 3840×1080 畫面的一半。如果畫面消失,請長按眼鏡上的 2D/3D 按鈕。</string>
<string name="glasses_3d_unsupported">這部手機無法輸出 3D 所需的 3840×1080 訊號。VR 遊戲會顯示單眼畫面,並配合頭部追蹤。</string>
<string name="glasses_3d_failed">未能確認 3D 訊號。請在 2D 畫面恢復後重試。</string>
<string name="glasses_no_signal">沒有畫面:眼鏡處於這部手機無法輸出的模式。請長按眼鏡的 2D/3D 按鈕,或拔除後重新連接。</string>
<string name="glasses_3d_refresh">3D 更新率</string>
<string name="glasses_auto3d">VR 遊戲使用 3D</string>
@@ -2197,6 +2197,7 @@
<string name="glasses_3d">3D(左右並排)</string>
<string name="glasses_3d_hint">每隻眼睛各看 3840×1080 畫面的一半。如果畫面消失,請長按眼鏡上的 2D/3D 按鈕。</string>
<string name="glasses_3d_unsupported">這支手機無法輸出 3D 所需的 3840×1080 訊號。VR 遊戲會顯示單眼畫面,並搭配頭部追蹤。</string>
<string name="glasses_3d_failed">未能確認 3D 訊號。請在 2D 畫面恢復後再試。</string>
<string name="glasses_no_signal">沒有畫面:眼鏡處於這支手機無法輸出的模式。請長按眼鏡的 2D/3D 按鈕,或拔除後重新連接。</string>
<string name="glasses_3d_refresh">3D 更新率</string>
<string name="glasses_auto3d">VR 遊戲使用 3D</string>
+1
View File
@@ -2280,6 +2280,7 @@
<string name="glasses_3d">3D (side-by-side)</string>
<string name="glasses_3d_hint">Each eye gets its own half of a 3840×1080 picture. If the picture drops out, hold the glasses\' 2D/3D button.</string>
<string name="glasses_3d_unsupported">This phone can\'t send the 3840×1080 signal 3D needs. VR games show one eye\'s view, with head tracking.</string>
<string name="glasses_3d_failed">The 3D signal was not confirmed. Try again once the 2D picture returns.</string>
<string name="glasses_no_signal">No picture: the glasses are in a mode this phone can\'t send. Hold their 2D/3D button, or unplug and replug them.</string>
<string name="glasses_3d_refresh">3D refresh rate</string>
<string name="glasses_auto3d">3D for VR games</string>
@@ -0,0 +1,86 @@
package com.droiddeck.launcher.glasses
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
@LooperMode(LooperMode.Mode.PAUSED)
class GlassesCallbacksTest {
private fun field(name: String) = Glasses::class.java.getDeclaredField(name).apply { isAccessible = true }
private fun generation(value: Long) = field("linkGeneration").setLong(null, value)
private fun listener(generation: Long): VitureLink.Listener =
Glasses::class.java.getDeclaredMethod("linkListener", Long::class.javaPrimitiveType).apply { isAccessible = true }
.invoke(Glasses, generation) as VitureLink.Listener
@Before fun prepare() {
field("handler").set(null, Handler(Looper.getMainLooper()))
generation(1)
@Suppress("UNCHECKED_CAST")
val state = field("_state").get(null) as MutableStateFlow<GlassesState>
state.value = GlassesState(connected = true, brightness = 2, displayId = 2, width = 640, height = 480, noSignal = true)
field("imuPackets").setLong(null, 0)
field("probe").set(null, null)
}
private fun brightness(value: Int) = VitureProtocol.Frame(
VitureProtocol.MAGIC_REPORT, VitureProtocol.EVENT_BRIGHTNESS, byteArrayOf(value.toByte()), true,
)
@Test fun queuedDisconnectCannotCloseAReplacementLink() {
val old = listener(1)
old.onLost()
old.onLost()
generation(2)
shadowOf(Looper.getMainLooper()).idle()
assertTrue(Glasses.state.value.connected)
}
@Test fun queuedReportCannotOverwriteReplacementState() {
listener(1).onFrame(brightness(7))
generation(2)
shadowOf(Looper.getMainLooper()).idle()
assertEquals(2, Glasses.state.value.brightness)
listener(2).onFrame(brightness(4))
listener(1).onFrame(brightness(8))
shadowOf(Looper.getMainLooper()).idle()
assertEquals(4, Glasses.state.value.brightness)
}
@Test fun oldImuPacketsCannotReachTheNewProbe() {
var samples = 0
val probe: (ByteArray) -> Unit = { samples++ }
field("probe").set(null, probe)
generation(2)
listener(1).onImu(ByteArray(12), 0)
assertEquals(0, samples)
assertEquals(0L, field("imuPackets").getLong(null))
listener(2).onImu(ByteArray(12), 0)
assertEquals(1, samples)
assertEquals(1L, field("imuPackets").getLong(null))
}
@Test fun deviceModeReportsCannotConfirmThePhysicalStereoSignal() {
listener(1).onFrame(VitureProtocol.Frame(
VitureProtocol.MAGIC_REPORT, VitureProtocol.EVENT_DISPLAY, byteArrayOf(0x32), true,
))
listener(1).onFrame(VitureProtocol.Frame(
VitureProtocol.MAGIC_REPORT, VitureProtocol.MSG_GET_DISPLAY, byteArrayOf(0, 0x32), true,
))
shadowOf(Looper.getMainLooper()).idle()
assertFalse(Glasses.state.value.sbs)
assertTrue(Glasses.state.value.noSignal)
assertEquals(640, Glasses.state.value.width)
}
}
@@ -0,0 +1,162 @@
package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.glasses.GlassesModeState.Confirmation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GlassesModeStateTest {
private val device = GlassesModeState.Device(0x101d, "pro")
private val state = GlassesModeState(20_000).apply { updateDevice(device, 0) }
@Test fun transientFallbackDoesNotRejectAWorkingStereoTransition() {
state.sent(true, 100)
assertEquals(Confirmation.WAITING, state.observe(false, 2_000))
assertEquals(Confirmation.WAITING, state.observe(false, 19_000))
assertFalse(state.restorePending)
assertEquals(Confirmation.CONFIRMED, state.observe(true, 19_500))
assertFalse(state.pendingStereo)
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
}
@Test fun missingSignalAtDeadlineRequestsOneFallback() {
state.sent(true, 100)
assertEquals(Confirmation.WAITING, state.observe(false, 20_099))
assertEquals(Confirmation.FAILED, state.observe(false, 20_100))
assertTrue(state.restorePending)
assertEquals(Confirmation.NONE, state.observe(false, 40_000))
state.sent(false, 40_001)
assertFalse(state.restorePending)
assertFalse(state.stopVr())
}
@Test fun stoppingVrBeforeStereoAppearsStillRestores2d() {
state.sent(true, 100)
state.startVr()
assertTrue(state.stopVr())
assertTrue(state.restorePending)
state.sent(false, 200)
assertFalse(state.pendingStereo)
assertFalse(state.restorePending)
assertFalse(state.stopVr())
assertEquals(Confirmation.NONE, state.observe(true, 400))
}
@Test fun failedRevertRemainsPendingUntilAWriteSucceeds() {
state.sent(true, 100)
state.startVr()
state.observe(true, 200)
state.stopVr()
state.cancelPending()
assertTrue(state.restorePending)
assertTrue(state.stopVr())
state.sent(false, 300)
assertFalse(state.restorePending)
}
@Test fun manualModeTakesOwnershipFromVr() {
state.sent(true, 100)
state.startVr()
state.manual()
state.sent(true, 200)
assertFalse(state.stopVr())
assertFalse(state.restorePending)
assertEquals(Confirmation.CONFIRMED, state.observe(true, 300))
}
@Test fun manualRetryGetsItsOwnConfirmationWindow() {
state.sent(true, 100)
assertEquals(Confirmation.FAILED, state.observe(false, 20_100))
state.sent(false, 20_101)
state.manual()
state.sent(true, 30_000)
assertEquals(Confirmation.WAITING, state.observe(false, 49_999))
assertEquals(Confirmation.CONFIRMED, state.observe(true, 50_000))
assertFalse(state.restorePending)
}
@Test fun cancelledOrDisconnectedRequestCannotFailLater() {
state.sent(true, 100)
state.cancelPending()
assertFalse(state.pendingStereo)
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
assertFalse(state.restorePending)
}
@Test fun manual2dCancelsThePendingStereoDeadline() {
state.sent(true, 100)
state.manual()
state.sent(false, 200)
assertFalse(state.pendingStereo)
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
}
@Test fun modeReenumerationPreservesDeadlineAndVrOwnership() {
state.sent(true, 100)
state.startVr()
assertFalse(state.updateDevice(null, 1_000))
assertEquals(Confirmation.WAITING, state.observe(false, 1_000))
assertFalse(state.updateDevice(device, 2_000))
assertEquals(20_100L, state.nextCheckAt)
assertEquals(Confirmation.CONFIRMED, state.observe(true, 3_000))
assertTrue(state.stopVr())
}
@Test fun absentAtDeadlineCancelsWithoutFailureOrLateRestore() {
state.sent(true, 100)
state.startVr()
state.updateDevice(null, 1_000)
state.stopVr()
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
assertFalse(state.restorePending)
assertNull(state.nextCheckAt)
assertFalse(state.updateDevice(device, 40_000))
assertFalse(state.stopVr())
}
@Test fun differentModelOrKnownSerialCannotInheritARequest() {
listOf(GlassesModeState.Device(0x1131, "pro"), device.copy(serial = "other")).forEach { other ->
state.updateDevice(device, 0)
state.sent(true, 100)
state.startVr()
state.updateDevice(null, 1_000)
assertTrue(state.updateDevice(other, 2_000))
assertFalse(state.pendingStereo)
assertFalse(state.stopVr())
}
}
@Test fun permissionPendingReconnectKeepsTheKnownIdentity() {
state.sent(true, 100)
state.startVr()
state.updateDevice(null, 1_000)
assertFalse(state.updateDevice(device.copy(serial = null), 2_000))
assertTrue(state.pendingStereo)
assertTrue(state.updateDevice(device.copy(serial = "other"), 3_000))
assertFalse(state.pendingStereo)
assertFalse(state.stopVr())
}
@Test fun sameDeviceReconnectDoesNotResetFailureStatus() {
state.sent(true, 100)
assertEquals(Confirmation.FAILED, state.observe(false, 20_100))
state.sent(false, 20_101)
assertFalse(state.updateDevice(null, 21_000))
assertFalse(state.updateDevice(device, 22_000))
}
@Test fun confirmedVrCannotRestoreAfterAProlongedUnplug() {
state.sent(true, 100)
state.startVr()
state.observe(true, 200)
state.updateDevice(null, 1_000)
assertTrue(state.stopVr())
assertEquals(21_000L, state.nextCheckAt)
state.updateDevice(device, 22_000)
assertFalse(state.restorePending)
assertFalse(state.stopVr())
assertNull(state.nextCheckAt)
}
}
@@ -0,0 +1,129 @@
package com.droiddeck.launcher.glasses
import android.app.PendingIntent
import android.content.Context
import android.hardware.display.DisplayManager
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Handler
import android.os.Looper
import android.view.Display
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.annotation.LooperMode
import org.robolectric.shadow.api.Shadow
import org.robolectric.shadows.ShadowDisplayManager
import org.robolectric.shadows.ShadowUsbManager
import org.robolectric.util.ReflectionHelpers
import org.robolectric.util.ReflectionHelpers.ClassParameter.from
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE, shadows = [GlassesUsbTest.RecordingUsbManager::class])
@LooperMode(LooperMode.Mode.PAUSED)
class GlassesUsbTest {
private lateinit var manager: RecordingUsbManager
private lateinit var state: MutableStateFlow<GlassesState>
private fun field(name: String) = Glasses::class.java.getDeclaredField(name).apply { isAccessible = true }
@Before fun prepare() {
val context = RuntimeEnvironment.getApplication()
val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
manager = Shadow.extract(usb)
field("app").set(null, context)
field("usb").set(null, usb)
field("displays").set(null, context.getSystemService(Context.DISPLAY_SERVICE))
field("handler").set(null, Handler(Looper.getMainLooper()))
field("asked").setBoolean(null, false)
@Suppress("UNCHECKED_CAST")
state = field("_state").get(null) as MutableStateFlow<GlassesState>
state.value = GlassesState()
}
@Test fun controllableGlassesTakePriorityOverDockAndUnsupportedGlasses() {
val dock = device(0x3011)
val beast = device(0x1201)
val pro = device(0x101d)
manager.devices.putAll(listOf(dock, beast, pro).associateBy { it.deviceName })
refresh()
assertEquals(listOf(pro), manager.requests)
assertTrue(state.value.controllable)
assertEquals(Glasses.Permission.ASKING, state.value.permission)
}
@Test fun unsupportedDevicesNeverRequestOrAdvertiseUsbPermission() {
for (pid in listOf(0x3011, 0xffff, 0x1201)) {
val device = device(pid)
manager.devices.clear()
manager.devices[device.deviceName] = device
state.value = GlassesState()
refresh(ask = true)
assertTrue(manager.requests.isEmpty())
assertEquals(Glasses.Permission.NONE, state.value.permission)
manager.granted.add(device.deviceName)
state.value = GlassesState(permission = Glasses.Permission.ASKING)
refresh(ask = true)
assertTrue(manager.requests.isEmpty())
assertEquals(Glasses.Permission.NONE, state.value.permission)
assertTrue(state.value.present)
assertFalse(state.value.controllable)
assertFalse(state.value.connected)
}
}
@Test fun dockPresenceStillIdentifiesAnUnnamedPresentationDisplay() {
val hdmi = ReflectionHelpers.getStaticField<Int>(Display::class.java, "TYPE_HDMI")
val displayId = ShadowDisplayManager.addDisplay("w1920dp-h1080dp", hdmi)
val displays = RuntimeEnvironment.getApplication().getSystemService(DisplayManager::class.java)
val display = displays.getDisplay(displayId)
shadowOf(display).setFlags(Display.FLAG_PRESENTATION)
assertEquals(displayId, displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).single().displayId)
assertFalse(display.name.contains("VITURE", ignoreCase = true))
val dock = device(0x3011)
manager.devices[dock.deviceName] = dock
refresh()
assertEquals(displayId, state.value.displayId)
assertTrue(manager.requests.isEmpty())
assertEquals(Glasses.Permission.NONE, state.value.permission)
}
private fun refresh(ask: Boolean = false) {
Glasses::class.java.getDeclaredMethod("refresh", Boolean::class.javaPrimitiveType)
.apply { isAccessible = true }.invoke(Glasses, ask)
}
private fun device(pid: Int): UsbDevice = ReflectionHelpers.callConstructor(
UsbDevice::class.java,
from(String::class.java, "/dev/bus/usb/$pid"),
from(Int::class.javaPrimitiveType, VitureProtocol.VENDOR_ID),
from(Int::class.javaPrimitiveType, pid),
from(Int::class.javaPrimitiveType, 0),
from(Int::class.javaPrimitiveType, 0),
from(Int::class.javaPrimitiveType, 0),
from(String::class.java, "VITURE"),
from(String::class.java, "VITURE device"),
from(String::class.java, "1.0"),
from(String::class.java, "test-$pid"),
)
@Implements(UsbManager::class)
class RecordingUsbManager : ShadowUsbManager() {
val devices = linkedMapOf<String, UsbDevice>()
val granted = mutableSetOf<String>()
val requests = mutableListOf<UsbDevice>()
@Implementation override fun getDeviceList(): HashMap<String, UsbDevice> = LinkedHashMap(devices)
@Implementation override fun hasPermission(device: UsbDevice): Boolean = device.deviceName in granted
@Implementation fun requestPermission(device: UsbDevice, intent: PendingIntent) { requests += device }
}
}
@@ -41,7 +41,40 @@ class VitureProtocolTest {
assertArrayEquals(byteArrayOf(0x34), f.payload)
assertTrue(f.crcOk)
p[18] = 0x31
assertFalse(VitureProtocol.parse(p, p.size)!!.crcOk)
val badCrc = VitureProtocol.parse(p, p.size)!!
assertFalse(badCrc.crcOk)
assertArrayEquals(byteArrayOf(0x31), badCrc.payload)
}
@Test fun parseAcceptsPaddingOutsideDeclaredFrame() {
val payload = byteArrayOf(0, 0x32)
val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, payload)
val end = 18 + payload.size
p.fill(0x5A, end)
for (size in listOf(end, p.size)) {
val frame = VitureProtocol.parse(p, size)!!
assertArrayEquals(payload, frame.payload)
assertTrue(frame.crcOk)
}
}
@Test fun parseRejectsTruncatedDeclaredFrame() {
val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, byteArrayOf(0, 0x32))
val received = 19
assertNull(VitureProtocol.parse(p, received))
assertNull(VitureProtocol.parse(p.copyOf(received), received))
p[4] = 0xFF.toByte()
p[5] = 0xFF.toByte()
assertNull(VitureProtocol.parse(p, p.size))
}
@Test fun parseRejectsInvalidReceiveSize() {
val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, byteArrayOf(0, 0x32))
assertNull(VitureProtocol.parse(p, p.size + 1))
assertNull(VitureProtocol.parse(p, 17))
assertNull(VitureProtocol.parse(ByteArray(0), 18))
assertNull(VitureProtocol.parse(ByteArray(0), 0))
assertNull(VitureProtocol.parse(p, -1))
}
@Test fun parseReadsRepliesAndRejectsJunk() {
@@ -70,11 +103,16 @@ class VitureProtocolTest {
val luma = VitureProtocol.model(0x1131, null)!!
assertEquals("VITURE Luma", luma.name)
assertTrue(luma.sbs90)
assertFalse(luma.sbs90)
assertEquals(0x44, luma.mode(120, false))
assertEquals(0x45, luma.mode(90, true))
assertEquals(0x42, luma.mode(90, true))
assertEquals(0x42, luma.mode(60, true))
val ultra = VitureProtocol.model(0x1101, null)!!
assertTrue(ultra.sbs90)
assertEquals(0x45, ultra.mode(90, true))
assertEquals(0x42, ultra.mode(60, true))
assertFalse(VitureProtocol.model(0x1201, null)!!.controllable)
assertNull(VitureProtocol.model(0x9999, "VITURE"))
assertEquals("VITURE One", VitureProtocol.model(0x1011, "Something else")!!.name)
+4
View File
@@ -129,6 +129,10 @@ tools/droiddeckctl glasses mode 0x34
tools/droiddeckctl glasses raw 0x0007
```
Glasses status reports `deviceMode` from the MCU separately from `sbs`, which
requires a physical stereo display mode. `switching3d` marks the bounded
confirmation window. A successful mode-command reply alone does not confirm 3D.
A sleeping device suspends the session and hides the app's windows from `ui dump`; `wake` first.
`input` needs a session that has drawn its first frame. Pad input goes through the session screen's
+6
View File
@@ -143,6 +143,12 @@ With VITURE glasses connected, the Steam page also offers **Play SteamVR**, whic
SteamVR (installing it first if needed), and stops SteamVR when the glasses are unplugged. **Play
Steam** never starts it: while SteamVR runs, Steam keeps its own window in front of flat games.
Stereo also requires a physical side-by-side display mode. On the tested Astra 2
firmware the Pro XR command succeeds but DisplayPort falls back to 640×480;
direct-connection stereo remains unresolved. See the
[Astra/VITURE investigation](development/astra-viture-stereo.md) for measured
results, app recovery behavior, and the unverified powered-adapter design.
### Not done yet
- **Controllers**: the driver adds two Vive-profile hands driven by the session's game pad. It reads
+145
View File
@@ -0,0 +1,145 @@
# Astra 2 and VITURE stereo
## Tested connection
2026-10-14: REDMAGIC Astra 2, model `NP06J`, Android 16,
`RedMagicOS11.5.16_NP06J_GB`, Snapdragon `SM8850P`; VITURE Pro XR
(`35ca:101d`), firmware `07.0.03.013_20250422`. The tablet reports a locked
bootloader and green verified boot. Tests used the ordinary ADB shell, without root.
2D negotiates **1920×1080 at 120 Hz**. The glasses accept stereo command
`0x0008 [32]` with reply `00 32`, but Android then exposes **only 640×480 at
60 Hz**. An accepted USB command is not proof of a working stereo signal.
In this state the glasses reject a 2D command (`01 31` or `01 34`) and their
mode query returns `01 00`. A physical cable reconnect restored 1920×1080/120
with this update; Android then requested USB permission again and control resumed.
On the fresh attempt, `0x030D [03]` arrived 3.1 seconds after the accepted stereo
command. The vendor SDK maps that report to 2D/120 (`0x34`); the subsequent mode
query was rejected. The report does not encode a fallback reason or establish a
fixed timeout. The earlier command acknowledgement cannot prove stereo retention.
## What was checked
| Route | Result on this firmware |
| --- | --- |
| Pro full SBS command `0x32` | Accepted by MCU; physical output falls to 640×480 |
| Android preferred mode 3840×1080/60 | Preference saved; physical supported modes remain 640×480 only; preference cleared after test |
| USB source/host → source/device | Request issued; actual port stayed source/host |
| USB data disable/enable | Data status changed and was restored; physical display remained 640×480 |
| `IUsbManager.resetUsbPort` through ADB | HAL callback reported success (`0`); physical display mode did not change |
| External-display power off/reset | Did not recover the signal; glasses still rejected 2D |
| Physical cable reconnect | Restored 1920×1080/120 and USB controls after the permission prompt |
| OEM mirror/3D controls, source review | Change virtual-display routing, not physical stereo timings |
| OEM resolution selection, source review | Chooses cached resolution candidates and prefers stale 16:9 mode |
| Pro startup mode / half SBS, SDK review | No supported Pro startup-mode setting or half-SBS input |
Public driver decompilations from the related REDMAGIC 11 Pro (`NX809J`, SM8850)
[retain an active mode override](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/dp_display_get_modes.c#L37-L44)
across a glasses hotplug. Their [USB-PD disconnect path](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/dp_display_usbpd_disconnect_cb.c#L127-L147)
clears that override; their simulated hotplug path does not. This is consistent
with the Astra's measured behavior, but does not prove its exact kernel
implementation. That driver also [validates saved user modes against EDID](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/nubia_edid_modes.c#L226-L250),
so active overrides and saved preferences must not be treated as the same
mechanism. The independent [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md)
documents a related failure on its separately identified REDMAGIC firmware.
The [VITURE SDK matrix](https://www.viture.com/en-SG/developer/glasses-sdk/glasses)
specifies full SBS at 3840×1080/60 for Pro. Persistent startup mode is restricted
to Luma/Luma Pro. Among the Luma families supported here, 90 Hz stereo is an
Ultra capability. The connected Pro firmware matches both the N6PL entry in the
[public updater catalog](https://static.viture.com/dfu-util/js/vers.js) and the newest
N6P entry in the [public release-note service](https://cloud.viture.dev/api/v1/system/releasenote)
checked on 2026-10-14. This does not rule out unpublished, device-specific updates.
## Powered dock results
A VITURE Pro Mobile Dock (`35ca:3011`) was connected between the Astra and Pro XR.
Its official read-only P9 version query returned `2.01.03.0.03` with a valid
checksum. The Astra negotiated 1920×1080/120, but enumerated only the dock's
USB interfaces, not the glasses' `35ca:101d` control/IMU interfaces. A one-second
DroidDeck IMU sample returned zero packets.
DroidDeck now keeps the dock's display available without requesting unsupported
USB control access. The updated build was installed on this connection: no USB
permission prompt appeared, video remained at 1080p/120 and tracking stayed off.
[VITURE's Pro Dock specifications](https://www.viture.com/product/viture-pro-mobile-dock)
confirm 3840×1080/60 video support and exclude phone features requiring
communication with the glasses. The vendor SDK also treats the dock as a separate
accessory, not an IMU device. This dock may test the video path, but it does not
provide DroidDeck's current head-tracking path. Stereo negotiation and mode
retention during an upstream-only reconnect still require a physical test.
## No-root hardware design to test
A powered pass-through connection may let the tablet see the stereo EDID on a
fresh USB-PD connection while the glasses retain their stereo mode:
1. Power the adapter and glasses independently of the tablet connection.
2. Enable stereo on the glasses.
3. Disconnect only the tablet's upstream USB-C connection promptly after the
stereo acknowledgement, keeping the glasses powered. Compare with a delayed
disconnect to test whether the observed mode report affects retention.
4. Reconnect the tablet and check its physical supported modes before starting a game.
This requires an adapter that preserves glasses power and mode during the
upstream disconnect, passes 3840×1080/60 video, and forwards USB HID for controls
and head tracking. [VITURE's Adapter Ultra documentation](https://www.viture.com/academy/adapters/usb-c-xr-charging-adapter-ultra)
explicitly excludes wired glasses data on Android, so it does not satisfy
DroidDeck's current USB control/tracking path. Its power and video specifications
also do not promise mode retention across upstream disconnect. This design
remains **unverified**, not a purchasing recommendation. First verifying stereo
retention through the adapter on a compatible host would provide a useful control.
An intermediary presenting the stereo EDID before first host
connection is another possible design; it must pass the actual stereo timing
and preserve HID access, not merely advertise a wider desktop.
VITURE advertises full Android functionality for its separate
[USB-C to Glasses and Charging Adapter](https://www.viture.com/en-MY/product/usb-c-to-glasses-and-charging-adapter).
That makes it a candidate for the USB-data experiment, not a confirmed fix.
First check USB enumeration, brightness controls and live IMU data through the
adapter; then test power/mode retention and the stereo timing.
Success requires Android to expose and select 3840×1080/60, different left/right
eye images to reach the lenses, live head tracking, and repeatable return to 2D.
A wide screenshot or `wm size` override alone does not meet those conditions.
## App safeguards
DroidDeck waits up to 20 seconds for physical stereo confirmation. A temporary
low-resolution mode does not permanently blacklist the device. Failed attempts
offer manual retry once the 2D signal returns; automatic retries do not loop.
USB mode reports are tracked separately from the physical display. When a session
enabled 3D, stopping it requests a return to 2D, including during negotiation;
this firmware can still require a cable reconnect. Output timing follows
the active glasses display rather than the tablet's requested refresh rate.
Keep hardware experiments separate from game loads. End the session, force-stop
DroidDeck, and reopen its home screen after each game test. Read current HAL
temperatures rather than cached thermal-service samples.
## Validation of this update
- Full debug APK build, including the Rust store engine; native-library, session
asset and runtime-input packaging checks passed.
- 846 Android unit tests across 141 suites passed, including mode deadlines,
reconnect ownership, stale USB callbacks, dock selection and permission handling,
truncated USB frames and model-specific protocol cases.
- Four host tests passed, including native refresh notification, event coalescing,
full-pipe handling and Cardboard output checks.
- On the Astra: brightness changed and was restored; IMU delivered 400 packets
in two seconds; Steam reached its first frame in 4.9 seconds and exited cleanly.
Wayland reported the actual 120 Hz tablet mode. Four complete steady-state
ten-second windows recorded 116.8–119.5 screen frames/s in Steam's UI.
- After reconnect: a fresh stereo attempt remained pending during the 640×480
fallback, failed at the 20-second deadline and issued one 2D request. The MCU
refused that request; another 28 seconds of observation showed no retry loop.
- DroidDeck was force-stopped and relaunched after testing. Debugging tools and
temporary logging/display overrides were restored. Current HAL temperatures
after the session were 25°C battery and 28.3°C skin.
- The dock checks ran without a game load; the idle battery temperature was 24°C.
These checks do not establish stereo-game performance or a sustained thermal
baseline. Direct-cable stereo and the powered-adapter design remain unverified
as working solutions on this Astra firmware.
+153
View File
@@ -0,0 +1,153 @@
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / 'app/src/main/cpp/waylandcomp/src/compositor.c'
def extract_function(source, name):
match = re.search(r'^(?:static )?(?:void|int|uint32_t) ' + re.escape(name) + r'\([^;]*?\) \{',
source, re.MULTILINE)
if match is None:
raise AssertionError(f'Missing compositor function: {name}')
cursor, depth = match.end(), 1
while depth:
depth += (source[cursor] == '{') - (source[cursor] == '}')
cursor += 1
return source[match.start():cursor]
PREAMBLE = r'''
#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/ioctl.h>
struct wl_resource { int version, modes, done, hz, w, h; struct wl_resource *next; };
struct surface { int xdg_toplevel, toplevel_committed; struct surface *next; };
struct input_msg { int type, p1, p2, p3, p4; };
static struct wl_resource second = { .version = 1 };
static struct wl_resource first = { .version = 4, .next = &second };
static struct surface surface = { .xdg_toplevel = 1, .toplevel_committed = 1 };
static int g_input_pipe[2] = {-1, -1};
static atomic_int g_output_refresh_mhz = 60000;
static atomic_int g_output_refresh_pending;
static int g_output_announced_refresh_mhz = 60000;
static int g_output_w = 1920, g_output_h = 1080;
static int flushes, resizes, pointers, ticks;
static void *g_display;
#define WL_OUTPUT_MODE_CURRENT 1
#define WL_OUTPUT_MODE_PREFERRED 2
#define wl_resource_for_each(r, list) for ((r) = &first; (r); (r) = (r)->next)
#define wl_list_for_each(s, list, link) for ((s) = &surface; (s); (s) = (s)->next)
static int wl_resource_get_version(struct wl_resource *r) { return r->version; }
static void wl_output_send_mode(struct wl_resource *r, int flags, int w, int h, int hz) {
assert(flags == 3); r->modes++; r->hz = hz; r->w = w; r->h = h;
}
static void wl_output_send_done(struct wl_resource *r) { r->done++; }
static void wl_display_flush_clients(void *display) { (void)display; flushes++; }
static void send_toplevel_configure(struct surface *s) { (void)s; resizes++; }
#define droiddeck_log(...) ((void)0)
#define deliver_key(m) ((void)0)
#define pointer_event(...) ((void)0)
#define pointer_button(...) ((void)0)
#define scroll_event(...) ((void)0)
#define pointer_delta(...) ((void)0)
#define deliver_touch(m) ((void)0)
#define deliver_pointer(m) (pointers++)
static void on_vsync(int64_t ns) { assert(ns > 0); ticks++; }
'''
CHECKS = r'''
static int queued(void) { int n; assert(ioctl(g_input_pipe[0], FIONREAD, &n) == 0); return n; }
static void drain(void) { on_input_readable(g_input_pipe[0], 0, NULL); }
static void expect(int modes, int hz, int resize_count) {
assert(first.modes == modes && second.modes == modes);
assert(first.hz == hz && second.hz == hz);
assert(first.w == 1920 && second.w == 1920 && first.h == 1080 && second.h == 1080);
assert(first.done == modes && second.done == 0);
assert(resizes == resize_count);
assert(pointers == 0);
}
int main(void) {
droiddeck_wayland_set_output_refresh_rate(120000);
assert(atomic_load(&g_output_refresh_mhz) == 120000);
assert(!atomic_load(&g_output_refresh_pending));
assert(first.modes == 0 && second.modes == 0);
assert(pipe2(g_input_pipe, O_CLOEXEC | O_NONBLOCK) == 0);
g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz);
droiddeck_wayland_set_output_refresh_rate(120000);
assert(queued() == 0);
droiddeck_wayland_set_output_refresh_rate(60000);
assert(first.modes == 0 && queued() == sizeof(struct input_msg));
drain(); expect(1, 60000, 0); assert(flushes == 1);
droiddeck_wayland_set_output_refresh_rate(60000);
assert(queued() == 0); drain(); expect(1, 60000, 0);
droiddeck_wayland_set_output_refresh_rate(90000);
droiddeck_wayland_set_output_refresh_rate(120000);
drain(); expect(2, 120000, 0);
droiddeck_wayland_set_output_refresh_rate(90000);
droiddeck_wayland_set_output_refresh_rate(120000);
drain(); expect(2, 120000, 0);
droiddeck_wayland_set_output_refresh_rate(60000);
droiddeck_wayland_output_resized();
drain(); expect(3, 60000, 1); assert(flushes == 3);
struct input_msg wake = {9, 0, 0, 0, 0};
int n = 0;
while (write(g_input_pipe[1], &wake, sizeof(wake)) == sizeof(wake)) n++;
assert(n > 0 && errno == EAGAIN);
droiddeck_wayland_set_output_refresh_rate(120000);
drain(); expect(4, 120000, 1);
droiddeck_wayland_set_output_refresh_rate(0);
drain(); expect(5, 60000, 1);
droiddeck_wayland_set_output_refresh_rate(-1);
assert(queued() == 0);
struct input_msg vsync = {5, 0, 16000000, 0, 0};
assert(write(g_input_pipe[1], &vsync, sizeof(vsync)) == sizeof(vsync));
drain(); expect(5, 60000, 1); assert(ticks == 1);
assert(nominal_refresh_ns() == 16666666);
assert(flushes == 5);
close(g_input_pipe[0]); close(g_input_pipe[1]);
puts("PASS");
}
'''
@unittest.skipUnless(sys.platform.startswith('linux'), 'needs Linux nonblocking pipes')
class OutputRefreshTest(unittest.TestCase):
def test_refresh_notifications_preserve_client_pacing_without_resizing(self):
compiler = shutil.which(os.environ.get('CC', 'cc'))
if compiler is None:
self.skipTest('A host C compiler is required')
source = SOURCE.read_text()
functions = ('nominal_refresh_ns', 'output_send_mode', 'output_notify_mode',
'output_resized', 'on_input_readable', 'droiddeck_wayland_output_resized',
'droiddeck_wayland_set_output_refresh_rate')
harness = PREAMBLE + '\n'.join(extract_function(source, name) for name in functions) + CHECKS
with tempfile.TemporaryDirectory(prefix='droiddeck-output-refresh-') as folder:
path = Path(folder)
(path / 'refresh.c').write_text(harness)
binary = path / 'refresh-test'
build = subprocess.run([compiler, '-std=c11', '-Wall', '-Wextra', '-Werror',
'-Wno-unused-parameter', '-O2', str(path / 'refresh.c'),
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(0, build.returncode, build.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual('PASS', result.stdout.strip())
if __name__ == '__main__':
unittest.main()