mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 11:17:16 -04:00
Compare commits
4
Commits
4e75d53a71
...
0a1dd16647
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a1dd16647 | ||
|
|
05b7732149 | ||
|
|
207b15b64a | ||
|
|
539a9e7ec4 |
No files matched your search
+55
-13
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user