mirror of
https://github.com/bangfireball/Phonecast_VR.git
synced 2026-10-23 15:00:24 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fdb452c2a |
No files matched your search
+12
-1
@@ -1,6 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(phonecast-vr VERSION 0.8.0 LANGUAGES CXX)
|
||||
project(phonecast-vr VERSION 0.9.0 LANGUAGES CXX)
|
||||
execute_process(COMMAND git rev-parse --short HEAD WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
OUTPUT_VARIABLE PHONECAST_BUILD_ID OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
|
||||
execute_process(COMMAND git status --porcelain WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
OUTPUT_VARIABLE PHONECAST_BUILD_CHANGES ERROR_QUIET)
|
||||
if(NOT PHONECAST_BUILD_ID)
|
||||
set(PHONECAST_BUILD_ID "source")
|
||||
elseif(PHONECAST_BUILD_CHANGES)
|
||||
string(APPEND PHONECAST_BUILD_ID "-dirty")
|
||||
endif()
|
||||
add_compile_definitions(PHONECAST_VERSION="${PROJECT_VERSION}" PHONECAST_BUILD_ID="${PHONECAST_BUILD_ID}")
|
||||
|
||||
include(CTest)
|
||||
|
||||
@@ -21,6 +31,7 @@ add_subdirectory(vr)
|
||||
|
||||
if(PHONECAST_BUILD_RECEIVER)
|
||||
add_subdirectory(platform/network)
|
||||
add_subdirectory(platform/support)
|
||||
add_subdirectory(platform/openvr)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
add_subdirectory(platform/steam-frame-arm64)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Mirror your phone into VR, check notifications, watch videos with optional phone audio, and interact using your controllers—all while a standalone game runs underneath.
|
||||
|
||||
**v0.8 · First public release preparation · Early-access software**
|
||||
**v0.9.0 · Connection and support update · Early-access software**
|
||||
|
||||
[Downloads & release notes](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/releases) · [Report a bug](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/issues) · [Build from source](docs/development.md)
|
||||
|
||||
@@ -29,10 +29,13 @@ Use your own captures and hide pairing codes, notification text, and personal de
|
||||
- **Control Android:** tap, hold, swipe, drag-scroll, and Android Back with the dashboard laser when optional remote control is enabled.
|
||||
- **Hear phone audio:** optional supported playback capture, headset volume/mute controls, and local-phone mute.
|
||||
- **See notifications:** optional cards, app allow/block lists, and notification-to-app navigation; sensitive content is redacted by default.
|
||||
- **Keep setup convenient:** saved receiver details, Android Quick connect widget, in-headset settings, and reconnect during an active capture session.
|
||||
- **Keep setup convenient:** Find headset on LAN, saved receiver details, Android Quick connect widget, in-headset settings, and reconnect during an active capture session.
|
||||
- **Get clear connection feedback:** receiver-response status, stable retry warnings, and actionable guidance instead of treating local capture as a successful connection.
|
||||
- **Get support information:** About/version details and privacy-safe diagnostic exports to Downloads on phone and headset.
|
||||
- **Dim the physical phone:** default-on dimming while casting, subject to Android approval, with temporary restore controls.
|
||||
- **Prevent idle phone locking while casting:** temporary screen-timeout protection with Modify system settings access, restored afterward; intentional manual locking remains available.
|
||||
|
||||
The primary release target is **the native Steam Frame ARM64 receiver + Android app**. A Windows SteamVR receiver also works, but is a secondary development/fallback path—not the focus of v0.8. A Windows-hosted overlay cannot follow you into headset-native standalone games.
|
||||
The primary release target is **the native Steam Frame ARM64 receiver + Android app**. A Windows SteamVR receiver also works, but is a secondary development/fallback path—not the focus of v0.9.0. A Windows-hosted overlay cannot follow you into headset-native standalone games.
|
||||
|
||||
## Before you install
|
||||
|
||||
@@ -42,24 +45,24 @@ You need:
|
||||
|
||||
- **Steam Frame** with native SteamVR running.
|
||||
- **Android 10 or newer**, with screen-capture and H.264 encoder support. Individual apps may block capture.
|
||||
- Both devices on the same trusted local network, with phone-to-headset TCP access on port `49321`.
|
||||
- Both devices on the same trusted local network, with phone-to-headset TCP access on port `49321`. The optional LAN finder also needs UDP `49322`; manual IP entry remains available if discovery is blocked.
|
||||
|
||||
PhoneCast has been physically used on Steam Frame with standalone Cubism, phone interaction, notifications, rotation, and sustained phone video/audio playback. It is still early-access software: broader game compatibility, both launch orders, sleep/wake, and failure recovery are not fully validated.
|
||||
|
||||
## Quick start
|
||||
|
||||
Download the **Steam Frame ARM64 receiver archive** and **Android APK** from [GitHub Releases](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/releases). Use the matching v0.8 assets, not GitHub's automatically generated source-code archives. If v0.8 is not listed yet, publication is still pending.
|
||||
Download the **Steam Frame ARM64 receiver archive** and **Android APK** from [GitHub Releases](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/releases). Use the matching **v0.9.0** assets, not GitHub's automatically generated source-code archives. If v0.9.0 is not listed yet, publication is still pending. The release also includes `SHA256SUMS-v0.9.0` for verifying downloads.
|
||||
|
||||
### 1. Install on Steam Frame
|
||||
|
||||
1. Download `phonecast-vr-v0.8-steam-frame-arm64.tar.gz` to the headset's **Downloads** folder. Quit PhoneCast first if updating.
|
||||
1. Download `phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz` to the headset's **Downloads** folder. Quit PhoneCast first if updating.
|
||||
2. Open a terminal in desktop mode and paste these commands to extract and install it. Run as your normal user—**not with `sudo`**:
|
||||
|
||||
```bash
|
||||
set -eu
|
||||
cd "$HOME/Downloads"
|
||||
tar -xzf phonecast-vr-v0.8-steam-frame-arm64.tar.gz
|
||||
cd phonecast-vr-v0.8-steam-frame-arm64
|
||||
tar -xzf phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz
|
||||
cd phonecast-vr-v0.9.0-steam-frame-arm64
|
||||
./steam-frame-installer/install-phonecast.sh
|
||||
```
|
||||
|
||||
@@ -72,12 +75,14 @@ Installation currently needs a terminal; ordinary launches afterward do not. The
|
||||
### 2. Install and connect Android
|
||||
|
||||
1. Install the downloaded APK. Android may ask you to allow installation from the browser/file manager you used.
|
||||
2. Open PhoneCast and enter the **headset's local IP address** and the **six-digit pairing code shown in its PhoneCast dashboard**. Check the headset's network settings for its address; automatic discovery is not implemented.
|
||||
2. Open PhoneCast, choose **Find headset on LAN**, and explicitly select your headset. Enter the **six-digit pairing code shown in its PhoneCast dashboard**. If discovery finds nothing, enter the **headset's current local IP address** from its network settings. Discovery does not bypass pairing or screen-sharing consent.
|
||||
3. Start with the **Standard** streaming profile.
|
||||
4. Press **Start casting** and approve Android's screen-sharing prompt.
|
||||
5. In the headset's PhoneCast dashboard, choose **Show**.
|
||||
|
||||
PhoneCast remembers connection details. Android may require fresh screen-sharing consent for each new capture session. If connecting fails, check the address, pairing code, and whether guest Wi-Fi/client isolation prevents devices from talking to each other.
|
||||
PhoneCast remembers connection details. Android may require fresh screen-sharing consent for each new capture session. If connecting fails, check the address, pairing code, VPN and whether guest Wi-Fi/client isolation prevents devices from talking to each other. Amber means the receiver is not yet confirmed; green requires a receiver reply, not merely a running encoder. A green status does not prove decoded picture delivery. Retry warnings remain visible between attempts; tap the connection details text for the full message.
|
||||
|
||||
If the headset IP changes during a failed cast, stop casting, search/select again, and start a new cast. Silent active-session IP retargeting is not implemented.
|
||||
|
||||
### 3. Use it in VR
|
||||
|
||||
@@ -98,6 +103,7 @@ Casting works without enabling remote control, notifications, or phone audio.
|
||||
| Notifications | Notification forwarding and Android notification access | Disabled by default; revealing sensitive title/body text requires a separate opt-in. |
|
||||
| Phone audio | **Phone playback audio** and Android audio permission; start a new cast | Playback only, not microphone capture. Protected/capture-blocked audio is unsupported. Optional local-phone mute is separate. |
|
||||
| Phone dimming | **Dim phone while casting** and Android **Modify system settings** access | Preference is on by default, but without permission casting continues undimmed. Restore via the app/foreground notification; Android brightness controls are the emergency fallback. |
|
||||
| Idle-lock prevention | Android **Modify system settings** access; start a new cast after granting it | Temporarily extends the screen timeout independently of dimming and restores the original on reachable stop paths. Manual locking still ends projection. OEM/device-admin restrictions may apply. |
|
||||
|
||||
See [remote control](docs/remote-control.md), [notifications](docs/notifications.md), [phone audio](docs/phone-audio.md), and [phone dimming](docs/android-screen-off.md) for details.
|
||||
|
||||
@@ -112,7 +118,7 @@ Android 13 and newer may block Accessibility access for a sideloaded APK with a
|
||||
|
||||
Menu names and availability vary by phone and Android version. This approval does not enable remote control by itself. Casting still works without Accessibility. Only allow restricted settings for an app you trust; see [Android's restricted-settings guidance](https://support.google.com/android/answer/12623953).
|
||||
|
||||
## Known limitations in v0.8
|
||||
## Known limitations in v0.9.0
|
||||
|
||||
- **Native GPU/decoder reliability is still follow-up work.** Video freezes and Adreno/Vulkan device-loss exits have occurred in some sessions, including game-active launches. Decoder recovery is implemented but its fault cases still need physical validation; bounded Vulkan recovery is not implemented. GPU resets can also affect the running game. Save game progress before testing.
|
||||
- **Headset sleep/wake, SteamVR restart, crash recovery, and broader launch-order/game compatibility are not fully validated.** Relaunching PhoneCast or starting a new cast may be necessary.
|
||||
@@ -120,7 +126,8 @@ Menu names and availability vary by phone and Android version. This approval doe
|
||||
- Controller-docked panels can jitter during movement. Defaults may need per-hand calibration.
|
||||
- Keyboard/text entry and wrist gestures are backlogged. Hotkeys are not guaranteed conflict-free.
|
||||
- Audio capture depends on the source app's policy; source UI mute is not always reflected in Android playback capture.
|
||||
- Android Force Stop cannot immediately restore brightness or phone volume. Best-effort later recovery depends on retained permissions; manual recovery may be needed.
|
||||
- Android Force Stop cannot immediately restore brightness, screen timeout or phone volume. Best-effort later recovery depends on retained permissions; manual recovery may be needed.
|
||||
- LAN discovery is a bounded IPv4/default-port finder, not authenticated identity. Broadcast-blocked, guest/client-isolated, cross-subnet or IPv6-only networks may need manual entry. Broader discovery/network and storage-failure cases remain to be exercised.
|
||||
- SteamOS/SteamVR updates may change compatibility. Broader Android-device testing is welcome.
|
||||
|
||||
The detailed [roadmap and physical evidence](design.md) distinguish owner-approved behavior from automated tests and deferred cases. No measured photon-level latency or universal compatibility claim is made.
|
||||
@@ -137,7 +144,20 @@ The detailed [roadmap and physical evidence](design.md) distinguish owner-approv
|
||||
|
||||
Receiver settings and pairing remain in `~/.config/phonecast-vr` unless you remove them separately.
|
||||
|
||||
Receiver logs normally live in `~/.local/state/phonecast-vr/receiver.log`, with diagnostics in the adjacent `performance.csv`. When [reporting an issue](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/issues), include app/release version, Android device/OS, SteamOS/SteamVR version, game, launch order, and reproduction steps. Review logs before sharing; never include pairing codes, credentials, or private phone content.
|
||||
### Export logs without ADB or SSH
|
||||
|
||||
**On the headset:**
|
||||
|
||||
1. Open the SteamVR dashboard → **PhoneCast → Settings → About / Export logs**.
|
||||
2. Select **Export logs once**. The row changes to **Confirm export**.
|
||||
3. **Select the same row a second time to confirm.** These are two deliberate clicks/selections, not the thumbstick double-click hotkey.
|
||||
4. Wait for **Saved to Downloads** and note the displayed filename, `phonecast-headset-<timestamp>.txt`. Find it in the headset's Downloads folder using the desktop file manager.
|
||||
|
||||
**On the phone:** open **Settings → About → Export diagnostic logs to Downloads**, review the privacy notice, and confirm **Export**. The result shows the saved `phonecast-android-<timestamp>.txt` location.
|
||||
|
||||
Exports are bounded support summaries, not unrestricted system/raw-log dumps. Review files before sharing privately; never post pairing codes, credentials or private phone content. About shows installed version/build information. When [reporting an issue](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/issues), include both app versions, Android device/OS, SteamOS/SteamVR version, game, launch order, and reproduction steps.
|
||||
|
||||
For deeper diagnosis, native raw logs normally live in `~/.local/state/phonecast-vr/receiver.log`, with diagnostics in the adjacent `performance.csv`. Review/redact raw files before sharing. See [v0.9 support details and validation boundaries](docs/v0.9-support.md).
|
||||
|
||||
## Windows receiver and development
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PhoneCast Android Sender
|
||||
|
||||
This Android application captures the user-approved display with `MediaProjection`, feeds it into a surface-input H.264/AVC `MediaCodec` encoder, and streams encoded access units to the native Steam Frame receiver over the local network. The Windows receiver is a secondary supported path. The v0.8 app uses `versionCode` 3.
|
||||
This Android application captures the user-approved display with `MediaProjection`, feeds it into a surface-input H.264/AVC `MediaCodec` encoder, and streams encoded access units to the native Steam Frame receiver over the local network. The Windows receiver is a secondary supported path. The v0.9.0 release uses `versionCode` 6 with the same release signing key. It retains the physically validated screen-timeout fix from code 4; the original v0.8 release used code 3.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -23,7 +23,7 @@ APK:
|
||||
app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## Signed v0.8 release
|
||||
## Signed release
|
||||
|
||||
Release builds use a private `signing.properties` file outside the repository:
|
||||
|
||||
@@ -61,7 +61,7 @@ Release assembly fails when signing configuration is missing; debug builds/tests
|
||||
apksigner.bat verify --verbose --print-certs app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
Publish only the verified APK, never the signing material. For GitHub, the APK can be copied/renamed to `phonecast-vr-v0.8-android.apk`.
|
||||
Publish only the verified APK, never the signing material. The v0.9.0 release APK is copied/renamed to `phonecast-vr-v0.9.0-android.apk`; it must not silently replace v0.8 assets. Public publication remains separate from building the artifacts.
|
||||
|
||||
**Debug-to-release migration:** a release-signed APK cannot update a debug-signed installation. Stop casting first so brightness and phone volume can restore, note your settings, uninstall the debug app, then install the release APK and grant optional permissions again. Uninstalling clears saved connection/settings data. Subsequent public updates must use this same release key and a higher `versionCode`; losing the key or password prevents ordinary in-place updates for GitHub APK users.
|
||||
|
||||
@@ -89,6 +89,14 @@ Useful diagnostics:
|
||||
adb logcat -s PhoneCastCapture
|
||||
```
|
||||
|
||||
## v0.9.0 connection and support update
|
||||
|
||||
- Amber connection warnings are distinct from local capture. Green requires a valid recent receiver ping reply, not simply a running encoder or TCP socket. This does not confirm decoded/visible headset video. Retry failures stay visible between attempts instead of flashing Connecting/Disconnected; fixed-size status/detail/counter fields prevent page-height jitter. Tap the details field for its full text.
|
||||
- **Find headset on LAN** searches for running v0.9 receivers for four seconds and requires explicit selection. Same non-guest IPv4 LAN and UDP 49322 reachability are required; manual IP entry/pairing remain available. Stop casting before changing endpoints. Remembered names are hints, not authenticated identities; silent active-session IP retargeting is not implemented.
|
||||
- **Settings → About** shows installed version/code and build type. **Export diagnostic logs to Downloads** produces a bounded private-content-free app support summary via MediaStore, without ADB or broad storage permission. Review before sharing privately.
|
||||
- Receiver About/export and discovery require the updated receiver. Ordinary TCP remains compatible with older receivers.
|
||||
- The owner accepted the revised normal-use release baseline. Expanded discovery/storage/lifecycle cases remain separately unvalidated; see `docs/v0.9-support.md` for the matrix and limitations.
|
||||
|
||||
## Current behavior
|
||||
|
||||
- Provides persisted Battery Saver (960/20 FPS), Standard (1280/30 FPS), and Quality (1920/30 FPS) streaming profiles; values are long-edge caps.
|
||||
|
||||
@@ -25,8 +25,9 @@ android {
|
||||
applicationId "com.phonecastvr.sender"
|
||||
minSdk 29
|
||||
targetSdk 36
|
||||
versionCode 3
|
||||
versionName "0.8"
|
||||
versionCode 6
|
||||
versionName "0.9.0"
|
||||
buildConfigField "String", "SUPPORT_BUILD", '"v0.9.0-code6"'
|
||||
|
||||
testInstrumentationRunner "android.test.InstrumentationTestRunner"
|
||||
}
|
||||
@@ -52,6 +53,8 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures { buildConfig true }
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
final class AndroidScreenTimeout {
|
||||
private static final String ACTIVE = "timeout_recovery_active";
|
||||
private static final String SAVED = "timeout_recovery_value";
|
||||
|
||||
static ScreenTimeoutController create(Context context) {
|
||||
Context app = context.getApplicationContext();
|
||||
SharedPreferences prefs = app.getSharedPreferences(
|
||||
ScreenCaptureService.PREFERENCES, Context.MODE_PRIVATE);
|
||||
return new ScreenTimeoutController(new ScreenTimeoutController.Access() {
|
||||
public boolean canWrite() { return Settings.System.canWrite(app); }
|
||||
public int read() {
|
||||
try {
|
||||
return Settings.System.getInt(app.getContentResolver(), Settings.System.SCREEN_OFF_TIMEOUT);
|
||||
} catch (Settings.SettingNotFoundException | RuntimeException error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
public boolean write(int value) {
|
||||
try {
|
||||
return Settings.System.putInt(app.getContentResolver(), Settings.System.SCREEN_OFF_TIMEOUT, value);
|
||||
} catch (RuntimeException error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}, new ScreenTimeoutController.Recovery() {
|
||||
public boolean active() { return prefs.getBoolean(ACTIVE, false); }
|
||||
public int saved() { return prefs.getInt(SAVED, 30000); }
|
||||
public boolean save(int value) {
|
||||
return prefs.edit().putInt(SAVED, value).putBoolean(ACTIVE, true).commit();
|
||||
}
|
||||
public boolean clear() {
|
||||
return prefs.edit().remove(ACTIVE).remove(SAVED).commit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void recoverAfterUncleanShutdown(Context context) {
|
||||
if (!create(context).restore())
|
||||
Log.w("PhoneCastCapture", "Screen timeout restoration pending: Modify system settings access may be required");
|
||||
}
|
||||
private AndroidScreenTimeout() {}
|
||||
}
|
||||
+2
-1
@@ -4,11 +4,12 @@ import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
/** Best-effort recovery of a brightness override left active across a reboot. */
|
||||
/** Best-effort recovery of display-setting overrides left active across a reboot. */
|
||||
public final class BrightnessRecoveryReceiver extends BroadcastReceiver {
|
||||
@Override public void onReceive(Context context, Intent intent) {
|
||||
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
|
||||
AndroidScreenBrightness.recoverAfterUncleanShutdown(context);
|
||||
AndroidScreenTimeout.recoverAfterUncleanShutdown(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
/** Text is fixed and safe to export: never contains addresses or credentials. */
|
||||
final class ConnectionFeedback {
|
||||
static final String CONNECTING = "Connecting to receiver…";
|
||||
static final String WAITING = "Receiver not confirmed · check headset pairing code";
|
||||
static final String CONNECTED = "Connected · receiver responding";
|
||||
static final String CLOSED = "Receiver disconnected · check pairing code and relaunch PhoneCast on the headset";
|
||||
static String failure(Exception error) {
|
||||
if (error instanceof UnknownHostException) return "Address not found · check receiver IP or Find headset";
|
||||
if (error instanceof SocketTimeoutException) return "Receiver not responding · check same non-guest LAN, VPN and headset IP";
|
||||
if (error instanceof ConnectException) return "Cannot reach receiver · launch PhoneCast on headset and check IP/network";
|
||||
if ("Unsupported PhoneCast protocol version".equals(error.getMessage()))
|
||||
return "Receiver protocol incompatible · update both apps from the same release";
|
||||
return CLOSED;
|
||||
}
|
||||
private ConnectionFeedback() {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
/** Short stable heading; actionable details are shown in a separately sized field. */
|
||||
final class ConnectionPresentation {
|
||||
static String title(boolean responsive, String message) {
|
||||
if (responsive) return "Connected to receiver";
|
||||
if (ConnectionFeedback.CONNECTING.equals(message)) return "Connecting to receiver";
|
||||
if (ConnectionFeedback.WAITING.equals(message)) return "Waiting for receiver reply";
|
||||
return "Not connected · retrying";
|
||||
}
|
||||
private ConnectionPresentation() {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore;
|
||||
import java.io.File;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Only app-generated safe events are recorded; no system logcat or private payloads. */
|
||||
final class DiagnosticLog {
|
||||
private static final int LIMIT = 65536;
|
||||
private static long lastSample;
|
||||
static synchronized void record(Context context, String safeEvent) {
|
||||
try {
|
||||
File file = new File(context.getFilesDir(), "support.log");
|
||||
String previous = file.isFile() ? new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8) : "";
|
||||
String text = previous + System.currentTimeMillis() + " " + safeEvent + "\n";
|
||||
if (text.length() > LIMIT) {
|
||||
text = text.substring(text.length() - LIMIT);
|
||||
int newline = text.indexOf('\n');
|
||||
if (newline >= 0) text = text.substring(newline + 1);
|
||||
}
|
||||
Files.write(file.toPath(), text.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception ignored) { /* Logging must not interrupt casting. */ }
|
||||
}
|
||||
static synchronized void sample(Context context, boolean active, boolean responsive,
|
||||
long frames, long dropped, long rtt) {
|
||||
long now = android.os.SystemClock.elapsedRealtime();
|
||||
if (active && now - lastSample < 5000) return;
|
||||
lastSample = now;
|
||||
record(context, "capture=" + active + " receiver=" + responsive + " encoded=" + frames +
|
||||
" local_drops=" + dropped + " rtt_us=" + rtt);
|
||||
}
|
||||
static String version(Context context) {
|
||||
try {
|
||||
android.content.pm.PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
|
||||
return info.versionName + " (code " + info.getLongVersionCode() + ")";
|
||||
} catch (Exception ignored) { return "unknown"; }
|
||||
}
|
||||
static synchronized String export(Context context) throws Exception {
|
||||
String name = "phonecast-android-" + new SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(new Date()) + ".txt";
|
||||
File file = new File(context.getFilesDir(), "support.log");
|
||||
String report = "PhoneCast " + version(context) + "\nAndroid API " + Build.VERSION.SDK_INT +
|
||||
"\nBuild " + BuildConfig.SUPPORT_BUILD + " " + BuildConfig.BUILD_TYPE + "\nSafe app diagnostics; no screen/audio samples, IPs or pairing codes.\n" +
|
||||
(file.isFile() ? new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8) : "No events recorded.\n");
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
|
||||
values.put(MediaStore.MediaColumns.MIME_TYPE, "text/plain");
|
||||
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
Uri uri = context.getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
|
||||
if (uri == null) throw new java.io.IOException("Unable to create download");
|
||||
try {
|
||||
try (OutputStream output = context.getContentResolver().openOutputStream(uri)) {
|
||||
if (output == null) throw new java.io.IOException("Unable to open download");
|
||||
output.write(report.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
values.clear(); values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||
if (context.getContentResolver().update(uri, values, null, null) != 1)
|
||||
throw new java.io.IOException("Unable to finish download");
|
||||
return "Downloads/" + name;
|
||||
} catch (Exception error) {
|
||||
context.getContentResolver().delete(uri, null, null);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private DiagnosticLog() {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
final class DiscoveryProtocol {
|
||||
static final int PORT = 49322;
|
||||
static String query(String nonce) { return "PCVR_DISCOVER 1 " + nonce; }
|
||||
static String[] parse(String text, String nonce) {
|
||||
if (text.length() > 160) return null;
|
||||
String[] fields = text.split(" ", 6);
|
||||
if (fields.length != 6 || !fields[0].equals("PCVR_RECEIVER") ||
|
||||
!fields[1].equals("1") || !fields[2].equals(nonce)) return null;
|
||||
try {
|
||||
int port = Integer.parseInt(fields[3]);
|
||||
if (port != StreamProtocol.DEFAULT_PORT) return null; // Current Android TCP port is fixed.
|
||||
} catch (NumberFormatException error) { return null; }
|
||||
if (!fields[4].matches("[0-9.]{1,16}") || !fields[5].matches("[A-Za-z0-9_. -]{1,48}")) return null;
|
||||
return new String[]{fields[4], fields[5]};
|
||||
}
|
||||
private DiscoveryProtocol() {}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InterfaceAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Explicit, bounded search. Replies are untrusted hints, never authentication. */
|
||||
final class LanDiscovery {
|
||||
interface Listener { void finished(ArrayList<String[]> receivers, boolean failed); }
|
||||
private volatile DatagramSocket socket;
|
||||
private volatile boolean cancelled;
|
||||
void cancel() { cancelled = true; DatagramSocket current = socket; if (current != null) current.close(); }
|
||||
void start(Context context, Listener listener) {
|
||||
new Thread(() -> {
|
||||
ArrayList<String[]> found = new ArrayList<>();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
WifiManager.MulticastLock lock = null;
|
||||
boolean failed = false;
|
||||
try {
|
||||
WifiManager wifi = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
|
||||
if (wifi != null) { lock = wifi.createMulticastLock("phonecast-discovery"); lock.setReferenceCounted(false); lock.acquire(); }
|
||||
Set<InetAddress> destinations = new LinkedHashSet<>();
|
||||
destinations.add(InetAddress.getByName("255.255.255.255"));
|
||||
java.util.Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
while (interfaces != null && interfaces.hasMoreElements()) {
|
||||
NetworkInterface item = interfaces.nextElement();
|
||||
if (!item.isUp() || item.isLoopback()) continue;
|
||||
for (InterfaceAddress address : item.getInterfaceAddresses())
|
||||
if (address.getBroadcast() != null && destinations.size() < 16) destinations.add(address.getBroadcast());
|
||||
}
|
||||
try (DatagramSocket udp = new DatagramSocket()) {
|
||||
socket = udp;
|
||||
if (cancelled) return;
|
||||
udp.setBroadcast(true); udp.setSoTimeout(250);
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "");
|
||||
byte[] query = DiscoveryProtocol.query(nonce).getBytes(StandardCharsets.US_ASCII);
|
||||
long end = android.os.SystemClock.elapsedRealtime() + 4000;
|
||||
long nextSend = 0;
|
||||
int packets = 0;
|
||||
while (!cancelled && android.os.SystemClock.elapsedRealtime() < end && packets < 128) {
|
||||
long now = android.os.SystemClock.elapsedRealtime();
|
||||
if (now >= nextSend) {
|
||||
for (InetAddress destination : destinations)
|
||||
udp.send(new DatagramPacket(query, query.length, destination, DiscoveryProtocol.PORT));
|
||||
nextSend = now + 1000;
|
||||
}
|
||||
DatagramPacket reply = new DatagramPacket(new byte[161], 161);
|
||||
try { udp.receive(reply); } catch (SocketTimeoutException timeout) { continue; }
|
||||
++packets;
|
||||
if (!(reply.getAddress() instanceof Inet4Address) || reply.getAddress().isLoopbackAddress()) continue;
|
||||
String[] parsed = DiscoveryProtocol.parse(new String(reply.getData(), 0, reply.getLength(), StandardCharsets.US_ASCII), nonce);
|
||||
String ip = reply.getAddress().getHostAddress();
|
||||
if (parsed != null && seen.add(ip) && found.size() < 32) found.add(new String[]{ip, parsed[1], parsed[0]});
|
||||
}
|
||||
}
|
||||
} catch (Exception error) { failed = true; }
|
||||
finally { socket = null; if (lock != null && lock.isHeld()) lock.release(); }
|
||||
final boolean resultFailed = failed;
|
||||
if (!cancelled) new Handler(Looper.getMainLooper()).post(() -> { if (!cancelled) listener.finished(found, resultFailed); });
|
||||
}, "phonecast-discovery").start();
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ public final class MainActivity extends Activity {
|
||||
|
||||
private TextView screenTitleView;
|
||||
private TextView statusView;
|
||||
private TextView connectionDetailsView;
|
||||
private TextView metricsView;
|
||||
private TextView audioStatusView;
|
||||
private CheckBox playbackAudioView;
|
||||
@@ -76,6 +77,8 @@ public final class MainActivity extends Activity {
|
||||
private boolean captureAfterPermission;
|
||||
private boolean receiverRegistered;
|
||||
private boolean running;
|
||||
private Button findHeadsetButton;
|
||||
private LanDiscovery discovery;
|
||||
|
||||
private final BroadcastReceiver statusReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context context, Intent intent) {
|
||||
@@ -140,6 +143,8 @@ public final class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
@Override protected void onStop() {
|
||||
if (discovery != null) { discovery.cancel(); discovery = null; }
|
||||
if (findHeadsetButton != null) { findHeadsetButton.setText("Find headset on LAN"); findHeadsetButton.setEnabled(!running); }
|
||||
saveNotificationPreferences();
|
||||
if (receiverRegistered) {
|
||||
unregisterReceiver(statusReceiver);
|
||||
@@ -206,8 +211,19 @@ public final class MainActivity extends Activity {
|
||||
|
||||
LinearLayout statusCard = card();
|
||||
statusView = text("Ready to cast", 20, COLOR_PRIMARY, Gravity.START);
|
||||
statusView.setLines(2);
|
||||
statusView.setEllipsize(android.text.TextUtils.TruncateAt.END);
|
||||
statusCard.addView(statusView, matchWrap());
|
||||
connectionDetailsView = text("No active receiver connection", 14, COLOR_SECONDARY, Gravity.START);
|
||||
connectionDetailsView.setLines(3);
|
||||
connectionDetailsView.setEllipsize(android.text.TextUtils.TruncateAt.END);
|
||||
connectionDetailsView.setOnClickListener(view -> new AlertDialog.Builder(this)
|
||||
.setTitle("Connection details").setMessage(connectionDetailsView.getText())
|
||||
.setPositiveButton("OK", null).show());
|
||||
statusCard.addView(connectionDetailsView, spaced(0, dp(4), 0, 0));
|
||||
metricsView = text("No active capture", 14, COLOR_SECONDARY, Gravity.START);
|
||||
metricsView.setLines(3);
|
||||
metricsView.setEllipsize(android.text.TextUtils.TruncateAt.END);
|
||||
LinearLayout.LayoutParams metricsParams = matchWrap();
|
||||
metricsParams.setMargins(0, dp(8), 0, 0);
|
||||
statusCard.addView(metricsView, metricsParams);
|
||||
@@ -234,6 +250,10 @@ public final class MainActivity extends Activity {
|
||||
preferences.getString("pair_code", ""),
|
||||
InputType.TYPE_CLASS_NUMBER);
|
||||
connectionCard.addView(pairCodeView, fieldParams());
|
||||
findHeadsetButton = secondaryButton();
|
||||
findHeadsetButton.setText("Find headset on LAN");
|
||||
findHeadsetButton.setOnClickListener(view -> findHeadsets());
|
||||
connectionCard.addView(findHeadsetButton, buttonParams());
|
||||
mainContent.addView(connectionCard, cardParams());
|
||||
|
||||
primaryButton = new Button(this);
|
||||
@@ -436,12 +456,72 @@ public final class MainActivity extends Activity {
|
||||
});
|
||||
settingsContent.addView(notificationCard, cardParams());
|
||||
|
||||
LinearLayout about = card();
|
||||
about.addView(sectionTitle("About"), matchWrap());
|
||||
about.addView(text("PhoneCast " + DiagnosticLog.version(this) + "\nBuild " + BuildConfig.SUPPORT_BUILD + " " + BuildConfig.BUILD_TYPE,
|
||||
14, COLOR_SECONDARY, Gravity.START), matchWrap());
|
||||
Button export = secondaryButton();
|
||||
export.setText("Export diagnostic logs to Downloads");
|
||||
export.setOnClickListener(view -> new AlertDialog.Builder(this)
|
||||
.setTitle("Export diagnostics?")
|
||||
.setMessage("Exports bounded PhoneCast connection events and counters, not system logcat, screen/audio data or pairing codes. Review the file before sharing privately with support.")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Export", (dialog, which) -> {
|
||||
export.setEnabled(false);
|
||||
new Thread(() -> {
|
||||
String result;
|
||||
try { result = "Saved " + DiagnosticLog.export(getApplicationContext()); }
|
||||
catch (Exception error) { result = "Export failed. Check available storage and try again."; }
|
||||
final String message = result;
|
||||
runOnUiThread(() -> {
|
||||
export.setEnabled(true);
|
||||
if (!isFinishing() && !isDestroyed()) new AlertDialog.Builder(this)
|
||||
.setTitle("Diagnostic export").setMessage(message).setPositiveButton("OK", null).show();
|
||||
});
|
||||
}, "phonecast-export").start();
|
||||
}).show());
|
||||
about.addView(export, buttonParams());
|
||||
settingsContent.addView(about, cardParams());
|
||||
|
||||
Button done = secondaryButton();
|
||||
done.setText("Done");
|
||||
done.setOnClickListener(view -> showSettings(false));
|
||||
settingsContent.addView(done, buttonParams());
|
||||
}
|
||||
|
||||
private void findHeadsets() {
|
||||
if (running || discovery != null) return;
|
||||
findHeadsetButton.setEnabled(false);
|
||||
findHeadsetButton.setText("Searching LAN…");
|
||||
discovery = new LanDiscovery();
|
||||
discovery.start(this, (receivers, failed) -> {
|
||||
discovery = null;
|
||||
findHeadsetButton.setEnabled(!running);
|
||||
findHeadsetButton.setText("Find headset on LAN");
|
||||
if (running || isFinishing() || isDestroyed()) return;
|
||||
DiagnosticLog.record(this, "discovery results=" + receivers.size() + " failed=" + failed);
|
||||
if (receivers.isEmpty()) {
|
||||
new AlertDialog.Builder(this).setTitle("No receiver found")
|
||||
.setMessage("Launch PhoneCast on the headset and use the same non-guest LAN. VPN or Wi-Fi client isolation can block discovery. Older receivers may not support discovery; manual IP entry still works.")
|
||||
.setPositiveButton("OK", null).show();
|
||||
return;
|
||||
}
|
||||
String[] labels = new String[receivers.size()];
|
||||
for (int i = 0; i < labels.length; ++i) {
|
||||
String[] receiver = receivers.get(i);
|
||||
boolean remembered = receiver[1].equals(senderPreferences().getString("receiver_name", ""));
|
||||
labels[i] = (remembered ? "Previously selected name · " : "") + receiver[1] + " · " + receiver[0] + " · v" + receiver[2];
|
||||
}
|
||||
new AlertDialog.Builder(this).setTitle("Select your headset · pairing still required")
|
||||
.setItems(labels, (dialog, index) -> {
|
||||
receiverHostView.setText(receivers.get(index)[0]);
|
||||
senderPreferences().edit().putString("receiver_host", receivers.get(index)[0])
|
||||
.putString("receiver_name", receivers.get(index)[1]).apply();
|
||||
connectionHintView.setText("Headset selected. Verify its dashboard pairing code before casting.");
|
||||
}).setNegativeButton("Cancel", null).show();
|
||||
});
|
||||
}
|
||||
|
||||
private void showSettings(boolean show) {
|
||||
saveNotificationPreferences();
|
||||
mainContent.setVisibility(show ? View.GONE : View.VISIBLE);
|
||||
@@ -458,12 +538,7 @@ public final class MainActivity extends Activity {
|
||||
|
||||
private void refreshServiceState() {
|
||||
boolean active = senderPreferences().getBoolean("capture_running", false);
|
||||
if (active != running) {
|
||||
updateState(active, active ? "Casting is active" : "Ready to cast",
|
||||
0, 0, 0, 0, 0, -1);
|
||||
} else if (!active) {
|
||||
updateState(false, "Ready to cast", 0, 0, 0, 0, 0, -1);
|
||||
}
|
||||
updateState(active, active ? "Casting is active" : "Ready to cast", 0, 0, 0, 0, 0, -1);
|
||||
}
|
||||
|
||||
private void handleQuickConnectIntent(Intent intent) {
|
||||
@@ -501,9 +576,7 @@ public final class MainActivity extends Activity {
|
||||
.putString("pair_code", pairCode)
|
||||
.putString("stream_profile", profile.name())
|
||||
.apply();
|
||||
if (preferences.getBoolean(AndroidScreenBrightness.PREFERENCE_ENABLED,
|
||||
AndroidScreenBrightness.DEFAULT_ENABLED) &&
|
||||
!Settings.System.canWrite(this) &&
|
||||
if (!Settings.System.canWrite(this) &&
|
||||
!preferences.getBoolean(AndroidScreenBrightness.PREFERENCE_PERMISSION_PROMPTED,
|
||||
false)) {
|
||||
showBrightnessAccessExplanation();
|
||||
@@ -526,14 +599,14 @@ public final class MainActivity extends Activity {
|
||||
|
||||
private void showBrightnessAccessExplanation() {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Allow phone dimming?")
|
||||
.setMessage("Dim phone while casting is enabled by default. Android requires Modify system settings access so PhoneCast can lower and restore device brightness. You can cast without it or turn dimming off at any time.")
|
||||
.setPositiveButton("Allow dimming", (dialog, which) -> {
|
||||
.setTitle("Allow casting display settings?")
|
||||
.setMessage("Modify system settings access lets PhoneCast temporarily extend the screen timeout while casting in other apps and restore it afterward. It also enables optional phone dimming. Without access, the phone may automatically lock and end capture. You can cast without access; manual locking remains available.")
|
||||
.setPositiveButton("Allow", (dialog, which) -> {
|
||||
senderPreferences().edit().putBoolean(
|
||||
AndroidScreenBrightness.PREFERENCE_PERMISSION_PROMPTED, true).apply();
|
||||
openBrightnessSettings(true);
|
||||
})
|
||||
.setNegativeButton("Cast without dimming", (dialog, which) -> {
|
||||
.setNegativeButton("Cast without access", (dialog, which) -> {
|
||||
senderPreferences().edit().putBoolean(
|
||||
AndroidScreenBrightness.PREFERENCE_PERMISSION_PROMPTED, true).apply();
|
||||
continueCaptureRequest();
|
||||
@@ -608,6 +681,8 @@ public final class MainActivity extends Activity {
|
||||
.putExtra(ScreenCaptureService.EXTRA_PAIR_CODE, pairCode)
|
||||
.putExtra(ScreenCaptureService.EXTRA_STREAM_PROFILE,
|
||||
selectedStreamProfile().name());
|
||||
senderPreferences().edit().putBoolean("receiver_responsive", false)
|
||||
.putString("connection_message", ConnectionFeedback.CONNECTING).apply();
|
||||
startForegroundService(service);
|
||||
updateState(true, "Starting secure screen permission session…",
|
||||
0, 0, 0, 0, 0, -1);
|
||||
@@ -622,14 +697,22 @@ public final class MainActivity extends Activity {
|
||||
int width, int height, long droppedFrames, long rttMicros) {
|
||||
running = isRunning;
|
||||
senderPreferences().edit().putBoolean("capture_running", isRunning).apply();
|
||||
statusView.setText(message == null ? (isRunning ? "Casting" : "Ready to cast") : message);
|
||||
statusView.setTextColor(isRunning ? COLOR_SUCCESS : COLOR_PRIMARY);
|
||||
boolean responsive = isRunning && senderPreferences().getBoolean("receiver_responsive", false);
|
||||
String connectionMessage = senderPreferences().getString("connection_message", ConnectionFeedback.CONNECTING);
|
||||
statusView.setText(isRunning ? ConnectionPresentation.title(responsive, connectionMessage)
|
||||
: (message == null ? "Ready to cast" : message));
|
||||
String details = isRunning ? (responsive
|
||||
? "Receiver ping replies are arriving; decoded/visible video is not confirmed by this status."
|
||||
: connectionMessage) : (message == null ? "No active receiver connection" : message);
|
||||
connectionDetailsView.setText(details);
|
||||
connectionDetailsView.setContentDescription(details + ". Tap for full connection details.");
|
||||
statusView.setTextColor(responsive ? COLOR_SUCCESS : (isRunning ? Color.rgb(255, 191, 90) : COLOR_PRIMARY));
|
||||
if (isRunning && width > 0) {
|
||||
metricsView.setText(String.format(Locale.US,
|
||||
"%d × %d · %,d frames\n%.1f MB · %,d dropped · %s",
|
||||
"Capturing %d × %d · %,d encoded frames\n%.1f MB encoded · %,d local drops · %s",
|
||||
width, height, frames, bytes / 1_000_000.0, droppedFrames,
|
||||
rttMicros >= 0 ? String.format(Locale.US, "%.1f ms RTT", rttMicros / 1000.0)
|
||||
: "RTT pending"));
|
||||
: "Waiting for receiver reply"));
|
||||
} else {
|
||||
metricsView.setText(isRunning ? "Preparing video and receiver connection…"
|
||||
: "No active capture");
|
||||
@@ -639,10 +722,13 @@ public final class MainActivity extends Activity {
|
||||
isRunning ? Color.rgb(242, 103, 112) : COLOR_PRIMARY));
|
||||
receiverHostView.setEnabled(!isRunning);
|
||||
pairCodeView.setEnabled(!isRunning);
|
||||
if (findHeadsetButton != null) findHeadsetButton.setEnabled(!isRunning && discovery == null);
|
||||
if (streamProfileView != null) streamProfileView.setEnabled(!isRunning);
|
||||
if (restoreBrightnessButton != null) restoreBrightnessButton.setEnabled(
|
||||
isRunning && dimPhoneView != null && dimPhoneView.isChecked());
|
||||
connectionHintView.setText(isRunning ? "Connection details are locked while casting."
|
||||
connectionHintView.setText(isRunning
|
||||
? (responsive ? "Receiver responds; this does not confirm decoded picture delivery."
|
||||
: "Not connected yet. Retrying automatically. Check headset IP/code and same non-guest LAN. Stop casting to edit details or search again.")
|
||||
: "Saved details make the next cast a two-step start.");
|
||||
}
|
||||
|
||||
@@ -661,7 +747,7 @@ public final class MainActivity extends Activity {
|
||||
boolean granted = Settings.System.canWrite(this);
|
||||
brightnessAccessButton.setText(granted
|
||||
? R.string.brightness_access_enabled : R.string.grant_brightness_access);
|
||||
brightnessAccessButton.setEnabled(enabled && !granted);
|
||||
brightnessAccessButton.setEnabled(!granted);
|
||||
dimDelayView.setEnabled(enabled);
|
||||
restoreBrightnessButton.setEnabled(enabled && running);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
@@ -25,7 +23,6 @@ final class NetworkStreamer {
|
||||
void onAudioCapability(boolean supported);
|
||||
}
|
||||
|
||||
private static final String TAG = "PhoneCastNetwork";
|
||||
private static final int CONNECT_TIMEOUT_MILLIS = 3000;
|
||||
private static final int MAX_BACKOFF_MILLIS = 5000;
|
||||
|
||||
@@ -52,6 +49,22 @@ final class NetworkStreamer {
|
||||
private volatile Packet latestRemoteControlStatus;
|
||||
private volatile Socket socket;
|
||||
private Thread thread;
|
||||
private volatile boolean receiverResponsive;
|
||||
private volatile String connectionMessage = ConnectionFeedback.CONNECTING;
|
||||
private volatile String readerFailure;
|
||||
private volatile String retryMessage;
|
||||
private volatile boolean confirmedThisAttempt;
|
||||
private final AtomicLong lastReplyNanos = new AtomicLong();
|
||||
|
||||
boolean receiverResponsive() { return receiverResponsive; }
|
||||
String connectionMessage() { return connectionMessage; }
|
||||
private void connection(boolean responsive, String message) {
|
||||
if (receiverResponsive == responsive && connectionMessage.equals(message)) return;
|
||||
receiverResponsive = responsive;
|
||||
if (!responsive) roundTripMicros.set(-1);
|
||||
connectionMessage = message;
|
||||
listener.onConnectionChanged(responsive, message);
|
||||
}
|
||||
|
||||
NetworkStreamer(String host, int port, String pairCode, Listener listener) {
|
||||
this.host = host;
|
||||
@@ -179,6 +192,12 @@ final class NetworkStreamer {
|
||||
while (running.get()) {
|
||||
try (Socket activeSocket = new Socket()) {
|
||||
socket = activeSocket;
|
||||
roundTripMicros.set(-1);
|
||||
readerFailure = null;
|
||||
confirmedThisAttempt = false;
|
||||
lastReplyNanos.set(System.nanoTime());
|
||||
connection(false, retryMessage == null ? ConnectionFeedback.CONNECTING : retryMessage);
|
||||
activeSocket.setSoTimeout(10000);
|
||||
activeSocket.setTcpNoDelay(true);
|
||||
activeSocket.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MILLIS);
|
||||
DataOutputStream output = new DataOutputStream(
|
||||
@@ -193,7 +212,8 @@ final class NetworkStreamer {
|
||||
Thread reader = new Thread(() -> readReceiverMessages(input,activeSocket),
|
||||
"phonecast-network-replies");
|
||||
reader.setDaemon(true);
|
||||
reader.start();
|
||||
if (retryMessage == null) connection(false, ConnectionFeedback.WAITING);
|
||||
reader.start(); // Read deadline can also interrupt a blocked initial write.
|
||||
StreamProtocol.write(output, StreamProtocol.TYPE_HELLO, 0, 0,
|
||||
System.nanoTime() / 1000L, 0, 0,
|
||||
pairCode.getBytes(StandardCharsets.US_ASCII));
|
||||
@@ -208,17 +228,20 @@ final class NetworkStreamer {
|
||||
cachedKeyFrame.write(output);
|
||||
}
|
||||
output.flush();
|
||||
listener.onConnectionChanged(true, "Connected to " + host + ':' + port);
|
||||
droppedFrames.addAndGet(frames.size());
|
||||
frames.clear();
|
||||
notifications.clear();
|
||||
waitingForKeyFrame.set(true);
|
||||
listener.onKeyFrameNeeded();
|
||||
backoffMillis = 250;
|
||||
long nextPingNanos = 0;
|
||||
Packet sentAudioConfig = null, sentAudioStatus = null;
|
||||
|
||||
while (running.get()) {
|
||||
if (System.nanoTime() - lastReplyNanos.get() > 10000000000L) {
|
||||
java.net.SocketTimeoutException timeout = new java.net.SocketTimeoutException("Receiver reply timeout");
|
||||
readerFailure = ConnectionFeedback.failure(timeout);
|
||||
throw timeout;
|
||||
}
|
||||
Packet currentRemoteControlStatus = latestRemoteControlStatus;
|
||||
if (currentRemoteControlStatus != null &&
|
||||
currentRemoteControlStatus != sentRemoteControlStatus) {
|
||||
@@ -269,8 +292,14 @@ final class NetworkStreamer {
|
||||
output.flush();
|
||||
} catch (IOException error) {
|
||||
if (running.get()) {
|
||||
listener.onConnectionChanged(false, "Waiting for receiver at " + host + ':' + port);
|
||||
Log.w(TAG, "Receiver connection failed: " + error.getMessage());
|
||||
// Closing a timed-out writer also wakes the reader with SocketException;
|
||||
// do not let that secondary close hide the actionable timeout reason.
|
||||
String reason = error instanceof java.net.SocketTimeoutException
|
||||
? ConnectionFeedback.failure(error)
|
||||
: (readerFailure != null ? readerFailure : ConnectionFeedback.failure(error));
|
||||
retryMessage = reason;
|
||||
connection(false, reason);
|
||||
if (confirmedThisAttempt) backoffMillis = 250;
|
||||
sleep(backoffMillis);
|
||||
backoffMillis = Math.min(MAX_BACKOFF_MILLIS, backoffMillis * 2);
|
||||
}
|
||||
@@ -285,7 +314,7 @@ final class NetworkStreamer {
|
||||
listener.onAudioCapability(false);
|
||||
}
|
||||
}
|
||||
listener.onConnectionChanged(false, "Streaming stopped");
|
||||
connection(false, "Not connected · casting stopped");
|
||||
}
|
||||
|
||||
private void readReceiverMessages(DataInputStream input, Socket owner) {
|
||||
@@ -296,6 +325,13 @@ final class NetworkStreamer {
|
||||
if (header.payloadSize > 0) input.readFully(payload);
|
||||
if (socket != owner) return;
|
||||
if (header.type == StreamProtocol.TYPE_PONG) {
|
||||
long nowMicros = System.nanoTime() / 1000L;
|
||||
if (header.timestampMicros <= 0 || header.timestampMicros > nowMicros ||
|
||||
nowMicros - header.timestampMicros > 10000000L) continue;
|
||||
lastReplyNanos.set(System.nanoTime());
|
||||
retryMessage = null;
|
||||
confirmedThisAttempt = true;
|
||||
if (!receiverResponsive) connection(true, ConnectionFeedback.CONNECTED);
|
||||
if (audioRequested && !audioSupported && AudioProtocol.hasCapability(payload)) {
|
||||
audioSupported = true;
|
||||
listener.onAudioCapability(true);
|
||||
@@ -314,9 +350,14 @@ final class NetworkStreamer {
|
||||
listener.onNotificationOpen(header.sequence);
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// Wake a blocked writer and stop capture promptly on reader disconnect.
|
||||
} catch (IOException error) {
|
||||
// No reply/EOF must immediately remove the green connected state.
|
||||
if (socket == owner) {
|
||||
if (readerFailure == null) readerFailure = ConnectionFeedback.failure(error);
|
||||
if (running.get()) {
|
||||
retryMessage = readerFailure;
|
||||
connection(false, readerFailure);
|
||||
}
|
||||
audioSupported = false;
|
||||
listener.onAudioCapability(false);
|
||||
try { owner.close(); } catch (IOException closing) { /* Already closed. */ }
|
||||
|
||||
@@ -5,9 +5,14 @@ import android.app.Application;
|
||||
public final class PhoneCastApplication extends Application {
|
||||
@Override public void onCreate() {
|
||||
super.onCreate();
|
||||
getSharedPreferences(ScreenCaptureService.PREFERENCES, MODE_PRIVATE).edit()
|
||||
.putBoolean("capture_running", false).putBoolean("receiver_responsive", false)
|
||||
.putString("connection_message", ConnectionFeedback.CONNECTING).apply();
|
||||
DiagnosticLog.record(this, "process start " + BuildConfig.SUPPORT_BUILD);
|
||||
// If the prior process died after muting global media volume, repair it
|
||||
// before any new activity or capture service starts.
|
||||
AndroidPhoneAudioMute.recoverAfterUncleanShutdown(this);
|
||||
AndroidScreenBrightness.recoverAfterUncleanShutdown(this);
|
||||
AndroidScreenTimeout.recoverAfterUncleanShutdown(this);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ public final class ScreenCaptureService extends Service {
|
||||
private PlaybackAudioCapture audioCapture;
|
||||
private PhoneAudioMuteController phoneAudioMute;
|
||||
private ScreenBrightnessController brightnessController;
|
||||
private ScreenTimeoutController timeoutController;
|
||||
private PowerManager.WakeLock screenWakeLock;
|
||||
private long audioGeneration;
|
||||
private String audioStatus = "Audio off";
|
||||
@@ -213,6 +214,7 @@ public final class ScreenCaptureService extends Service {
|
||||
createNotificationChannel();
|
||||
phoneAudioMute = AndroidPhoneAudioMute.create(this);
|
||||
brightnessController = AndroidScreenBrightness.create(this);
|
||||
timeoutController = AndroidScreenTimeout.create(this);
|
||||
phoneAudioMute.setRequested(getSharedPreferences(PREFERENCES,MODE_PRIVATE)
|
||||
.getBoolean(AndroidPhoneAudioMute.PREFERENCE_ENABLED,false));
|
||||
codecThread = new HandlerThread("phonecast-avc-output");
|
||||
@@ -297,6 +299,7 @@ public final class ScreenCaptureService extends Service {
|
||||
pairCode, new NetworkStreamer.Listener() {
|
||||
@Override public void onConnectionChanged(boolean connected, String message) {
|
||||
Log.i(TAG, message);
|
||||
DiagnosticLog.record(ScreenCaptureService.this, message);
|
||||
if (connected) RemoteControlAccessibilityService.resetSession();
|
||||
mainHandler.post(() -> {
|
||||
if (stopping) return;
|
||||
@@ -343,10 +346,14 @@ public final class ScreenCaptureService extends Service {
|
||||
if (virtualDisplay == null) throw new IllegalStateException("Virtual display creation failed");
|
||||
|
||||
acquireScreenWakeLock();
|
||||
boolean timeoutProtected = timeoutController.begin();
|
||||
if (!timeoutProtected)
|
||||
Log.w(TAG, "Automatic screen timeout protection unavailable; grant Modify system settings access or check device policy");
|
||||
scheduleDim();
|
||||
setRunningPreference(true);
|
||||
String message = "Casting with " + streamProfile.label + " at " +
|
||||
streamProfile.frameRate + " FPS";
|
||||
streamProfile.frameRate + " FPS" + (timeoutProtected ? "" :
|
||||
" · automatic lock protection unavailable (check Modify system settings)");
|
||||
updateNotification(message);
|
||||
broadcastStatus(true, message, size);
|
||||
Log.i(TAG, message + " (" + size.width + "x" + size.height + ")");
|
||||
@@ -625,6 +632,8 @@ public final class ScreenCaptureService extends Service {
|
||||
mainHandler.removeCallbacks(dimScreen);
|
||||
if (brightnessController != null && !brightnessController.restoreAndRelease())
|
||||
Log.w(TAG, "Could not restore brightness while stopping capture");
|
||||
if (timeoutController != null && !timeoutController.restore())
|
||||
Log.w(TAG, "Could not restore screen timeout; recovery retained for next app start");
|
||||
releaseScreenWakeLock();
|
||||
stopAudio();
|
||||
audioEnabledForSession = false;
|
||||
@@ -666,6 +675,14 @@ public final class ScreenCaptureService extends Service {
|
||||
}
|
||||
|
||||
private void broadcastStatus(boolean running, String message, CaptureConfig.Size size) {
|
||||
boolean responsive = running && networkStreamer != null && networkStreamer.receiverResponsive();
|
||||
String connection = running && networkStreamer != null ? networkStreamer.connectionMessage() :
|
||||
(running ? ConnectionFeedback.CONNECTING : "Not connected · casting stopped");
|
||||
getSharedPreferences(PREFERENCES, MODE_PRIVATE).edit()
|
||||
.putBoolean("receiver_responsive", responsive).putString("connection_message", connection).apply();
|
||||
DiagnosticLog.sample(this, running, responsive, encodedFrames.get(),
|
||||
networkStreamer == null ? 0 : networkStreamer.droppedFrames(),
|
||||
networkStreamer == null ? -1 : networkStreamer.roundTripMicros());
|
||||
Intent status = new Intent(ACTION_STATUS)
|
||||
.setPackage(getPackageName())
|
||||
.putExtra(EXTRA_RUNNING, running)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
/** Casting-only timeout ownership; never changes keyguard or wakes a locked device. */
|
||||
final class ScreenTimeoutController {
|
||||
static final int CASTING_TIMEOUT_MS = Integer.MAX_VALUE;
|
||||
|
||||
interface Access {
|
||||
boolean canWrite();
|
||||
int read();
|
||||
boolean write(int value);
|
||||
}
|
||||
interface Recovery {
|
||||
boolean active();
|
||||
int saved();
|
||||
boolean save(int value);
|
||||
boolean clear();
|
||||
}
|
||||
private final Access access;
|
||||
private final Recovery recovery;
|
||||
|
||||
ScreenTimeoutController(Access access, Recovery recovery) {
|
||||
this.access = access;
|
||||
this.recovery = recovery;
|
||||
}
|
||||
|
||||
boolean begin() {
|
||||
// Resolve abandoned ownership before taking a new baseline.
|
||||
if (recovery.active() && !restore()) return false;
|
||||
if (!access.canWrite()) return false;
|
||||
int original = access.read();
|
||||
if (original < 0 || !recovery.save(original)) return false;
|
||||
if (!access.write(CASTING_TIMEOUT_MS)) return false;
|
||||
// Do not claim success when an OEM rejects/clamps the setting.
|
||||
if (access.read() != CASTING_TIMEOUT_MS) {
|
||||
restore();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean restore() {
|
||||
if (!recovery.active()) return true;
|
||||
if (!access.canWrite()) return false;
|
||||
int current = access.read();
|
||||
if (current < 0) return false;
|
||||
// Preserve deliberate user changes (or an OEM's replacement value).
|
||||
if (current == CASTING_TIMEOUT_MS &&
|
||||
(!access.write(recovery.saved()) || access.read() != recovery.saved()))
|
||||
return false;
|
||||
return recovery.clear();
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
<string name="stop_casting">Stop casting</string>
|
||||
<string name="restore_brightness">Restore brightness</string>
|
||||
<string name="dim_phone_title">Dim phone while casting</string>
|
||||
<string name="dim_phone_summary">Enabled by default. PhoneCast temporarily uses minimum nonzero brightness while keeping the captured stream unchanged. Android requires separate Modify system settings approval. If the optional PhoneCast Accessibility service is enabled, a physical touch temporarily restores brightness; otherwise use the restore action here or in the casting notification. Turning this off restores the previous brightness policy.</string>
|
||||
<string name="dim_phone_summary">Enabled by default. PhoneCast temporarily uses minimum nonzero brightness while keeping the captured stream unchanged. With Modify system settings approval, casting also extends the screen timeout to about 24.8 days, even if dimming is off, and restores the original timeout afterward. Manual locking still ends capture. Force Stop may delay restoration until the next app start or reboot. If the optional PhoneCast Accessibility service is enabled, a physical touch temporarily restores brightness; otherwise use the restore action here or in the casting notification. Turning this off restores the previous brightness policy.</string>
|
||||
<string name="enable_dim_phone">Dim phone while casting</string>
|
||||
<string name="grant_brightness_access">Allow brightness changes</string>
|
||||
<string name="grant_brightness_access">Allow casting display settings</string>
|
||||
<string name="brightness_access_enabled">Modify system settings enabled</string>
|
||||
<string name="restore_brightness_now">Restore brightness temporarily</string>
|
||||
<string name="sprint_two_explanation">Enter the receiver address and pairing code, then approve screen capture to stream H.264 over your local network.</string>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.phonecastvr.sender;
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
import java.net.*;
|
||||
public class ConnectionFeedbackTest {
|
||||
@Test public void errorsAreActionableAndDoNotExportExceptionText() {
|
||||
assertTrue(ConnectionFeedback.failure(new ConnectException("secret 123456")).contains("launch PhoneCast"));
|
||||
assertTrue(ConnectionFeedback.failure(new SocketTimeoutException("private address")).contains("VPN"));
|
||||
assertTrue(ConnectionFeedback.failure(new UnknownHostException("private address")).contains("Find headset"));
|
||||
assertFalse(ConnectionFeedback.failure(new java.io.IOException("123456 private data")).contains("123456"));
|
||||
assertFalse(ConnectionFeedback.WAITING.equals(ConnectionFeedback.CONNECTED));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.phonecastvr.sender;
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
public class ConnectionPresentationTest {
|
||||
@Test public void failureReasonsShareAStableShortHeading() {
|
||||
assertEquals("Not connected · retrying", ConnectionPresentation.title(false, ConnectionFeedback.CLOSED));
|
||||
assertEquals("Not connected · retrying", ConnectionPresentation.title(false,
|
||||
ConnectionFeedback.failure(new java.net.ConnectException())));
|
||||
assertEquals("Connecting to receiver", ConnectionPresentation.title(false, ConnectionFeedback.CONNECTING));
|
||||
assertEquals("Waiting for receiver reply", ConnectionPresentation.title(false, ConnectionFeedback.WAITING));
|
||||
assertEquals("Connected to receiver", ConnectionPresentation.title(true, ConnectionFeedback.CLOSED));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.phonecastvr.sender;
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
public class DiscoveryProtocolTest {
|
||||
private final String nonce = "0123456789abcdef0123456789abcdef";
|
||||
private String reply(String port, String name) { return "PCVR_RECEIVER 1 " + nonce + " " + port + " 0.9.0 " + name; }
|
||||
@Test public void receiverHintsAreBoundedAndCorrelated() {
|
||||
assertEquals("PCVR_DISCOVER 1 " + nonce, DiscoveryProtocol.query(nonce));
|
||||
assertArrayEquals(new String[]{"0.9.0", "Living room Frame"}, DiscoveryProtocol.parse(reply("49321", "Living room Frame"), nonce));
|
||||
assertNull(DiscoveryProtocol.parse(reply("49321", "Frame"), "wrong"));
|
||||
assertNull(DiscoveryProtocol.parse(reply("0", "Frame"), nonce));
|
||||
assertNull(DiscoveryProtocol.parse(reply("49323", "Frame"), nonce));
|
||||
assertNull(DiscoveryProtocol.parse(reply("49321", "Frame\nforged"), nonce));
|
||||
assertNull(DiscoveryProtocol.parse(reply("49321", new String(new char[49]).replace('\0','x')), nonce));
|
||||
assertNull(DiscoveryProtocol.parse("PCVR_RECEIVER 2 " + nonce + " 49321 0.9.0 Frame", nonce));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.phonecastvr.sender;
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
public class NetworkFeedbackTest {
|
||||
private static void await(BooleanSupplier condition) throws Exception {
|
||||
long end = System.nanoTime() + 3000000000L;
|
||||
while (!condition.getAsBoolean() && System.nanoTime() < end) Thread.sleep(10);
|
||||
assertTrue(condition.getAsBoolean());
|
||||
}
|
||||
private static NetworkStreamer sender(int port) { return sender(port, message -> {}); }
|
||||
private static NetworkStreamer sender(int port, java.util.function.Consumer<String> observe) {
|
||||
return new NetworkStreamer("127.0.0.1", port, "123456", new NetworkStreamer.Listener() {
|
||||
public void onConnectionChanged(boolean ready, String message) { observe.accept(message); }
|
||||
public void onKeyFrameNeeded() {}
|
||||
public void onRemoteInput(RemoteInputEvent event) {}
|
||||
public void onNotificationOpen(long token) {}
|
||||
public void onAudioCapability(boolean supported) {}
|
||||
});
|
||||
}
|
||||
private static long ping(Socket socket) throws Exception {
|
||||
DataInputStream input = new DataInputStream(socket.getInputStream());
|
||||
while (true) {
|
||||
StreamProtocol.Header header = StreamProtocol.readHeader(input);
|
||||
input.readFully(new byte[header.payloadSize]);
|
||||
if (header.type == StreamProtocol.TYPE_PING) return header.timestampMicros;
|
||||
}
|
||||
}
|
||||
@Test public void tcpIsNotConfirmationAndDisconnectReconnectResetReadiness() throws Exception {
|
||||
try (ServerSocket receiver = new ServerSocket(0)) {
|
||||
receiver.setSoTimeout(4000);
|
||||
NetworkStreamer sender = sender(receiver.getLocalPort());
|
||||
sender.start();
|
||||
try {
|
||||
try (Socket first = receiver.accept()) {
|
||||
first.setSoTimeout(3000);
|
||||
long timestamp = ping(first);
|
||||
await(() -> sender.connectionMessage().equals(ConnectionFeedback.WAITING));
|
||||
assertFalse(sender.receiverResponsive());
|
||||
DataOutputStream output = new DataOutputStream(first.getOutputStream());
|
||||
StreamProtocol.write(output, StreamProtocol.TYPE_PONG,0,0,0,0,0,null); output.flush();
|
||||
Thread.sleep(50); assertFalse(sender.receiverResponsive());
|
||||
StreamProtocol.write(output, StreamProtocol.TYPE_PONG,0,0,timestamp,0,0,null); output.flush();
|
||||
await(sender::receiverResponsive);
|
||||
assertTrue(sender.roundTripMicros() >= 0);
|
||||
}
|
||||
await(() -> !sender.receiverResponsive());
|
||||
try (Socket second = receiver.accept()) {
|
||||
second.setSoTimeout(3000);
|
||||
long timestamp = ping(second);
|
||||
assertFalse(sender.receiverResponsive());
|
||||
assertEquals(-1, sender.roundTripMicros());
|
||||
DataOutputStream output = new DataOutputStream(second.getOutputStream());
|
||||
StreamProtocol.write(output, StreamProtocol.TYPE_PONG,0,0,timestamp,0,0,null); output.flush();
|
||||
await(sender::receiverResponsive);
|
||||
}
|
||||
} finally { sender.stop(); }
|
||||
}
|
||||
}
|
||||
@Test public void unavailableReceiverIsWarningNotGreen() throws Exception {
|
||||
int port;
|
||||
try (ServerSocket unused = new ServerSocket(0)) { port = unused.getLocalPort(); }
|
||||
NetworkStreamer sender = sender(port); sender.start();
|
||||
try {
|
||||
await(() -> sender.connectionMessage().contains("Cannot reach"));
|
||||
assertFalse(sender.receiverResponsive());
|
||||
assertEquals(-1, sender.roundTripMicros());
|
||||
} finally { sender.stop(); }
|
||||
}
|
||||
@Test public void rejectedHandshakeRetriesKeepTheFailureVisible() throws Exception {
|
||||
try (ServerSocket receiver = new ServerSocket(0)) {
|
||||
receiver.setSoTimeout(5000);
|
||||
java.util.List<String> events = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
NetworkStreamer sender = sender(receiver.getLocalPort(), events::add);
|
||||
sender.start();
|
||||
try {
|
||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||
try (Socket rejected = receiver.accept()) {
|
||||
rejected.setSoTimeout(3000);
|
||||
DataInputStream input = new DataInputStream(rejected.getInputStream());
|
||||
StreamProtocol.Header hello = StreamProtocol.readHeader(input);
|
||||
assertEquals(StreamProtocol.TYPE_HELLO, hello.type);
|
||||
input.readFully(new byte[hello.payloadSize]);
|
||||
// Existing receiver rejects pairing by closing without a PONG.
|
||||
}
|
||||
await(() -> sender.connectionMessage().equals(ConnectionFeedback.CLOSED));
|
||||
}
|
||||
int firstFailure = events.indexOf(ConnectionFeedback.CLOSED);
|
||||
assertTrue(firstFailure >= 0);
|
||||
for (String event : events.subList(firstFailure, events.size())) {
|
||||
assertFalse(event.equals(ConnectionFeedback.CONNECTING));
|
||||
assertFalse(event.equals(ConnectionFeedback.WAITING));
|
||||
}
|
||||
assertFalse(sender.receiverResponsive());
|
||||
} finally { sender.stop(); }
|
||||
}
|
||||
}
|
||||
@Test public void unconfirmedConnectionTimesOutAndRetries() throws Exception {
|
||||
try (ServerSocket receiver = new ServerSocket(0)) {
|
||||
java.util.concurrent.CountDownLatch timeout = new java.util.concurrent.CountDownLatch(1);
|
||||
NetworkStreamer sender = sender(receiver.getLocalPort(), message -> {
|
||||
if (message.contains("not responding")) timeout.countDown();
|
||||
});
|
||||
sender.start();
|
||||
try (Socket connection = receiver.accept()) {
|
||||
connection.setSoTimeout(3000); ping(connection);
|
||||
assertTrue(sender.connectionMessage(), timeout.await(13, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertFalse(sender.receiverResponsive());
|
||||
} finally { sender.stop(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.phonecastvr.sender;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class ScreenTimeoutControllerTest {
|
||||
private static final class Fixture implements ScreenTimeoutController.Access, ScreenTimeoutController.Recovery {
|
||||
int value = 15000, original;
|
||||
boolean allowed = true, active, saveOk = true, writeOk = true, clearOk = true, clamp;
|
||||
int writes;
|
||||
public boolean canWrite() { return allowed; }
|
||||
public int read() { return value; }
|
||||
public boolean write(int next) { writes++; if (!writeOk) return false; value = clamp ? 600000 : next; return true; }
|
||||
public boolean active() { return active; }
|
||||
public int saved() { return original; }
|
||||
public boolean save(int next) { if (!saveOk) return false; original = next; active = true; return true; }
|
||||
public boolean clear() { if (!clearOk) return false; active = false; return true; }
|
||||
ScreenTimeoutController controller() { return new ScreenTimeoutController(this, this); }
|
||||
}
|
||||
@Test public void extendsAndRestores() {
|
||||
Fixture f = new Fixture(); ScreenTimeoutController c = f.controller();
|
||||
assertTrue(c.begin()); assertEquals(Integer.MAX_VALUE, f.value);
|
||||
assertEquals(15000, f.original); assertTrue(c.restore());
|
||||
assertEquals(15000, f.value); assertFalse(f.active); assertTrue(c.restore());
|
||||
}
|
||||
@Test public void abandonedSessionRestoresBeforeNewBaseline() {
|
||||
Fixture f = new Fixture(); assertTrue(f.controller().begin());
|
||||
assertTrue(f.controller().begin()); assertEquals(15000, f.original);
|
||||
assertTrue(f.controller().restore()); assertEquals(15000, f.value);
|
||||
}
|
||||
@Test public void deniedPermissionAndFailedPersistenceNeverWrite() {
|
||||
Fixture f = new Fixture(); f.allowed = false; assertFalse(f.controller().begin());
|
||||
f.allowed = true; f.saveOk = false; assertFalse(f.controller().begin()); assertEquals(0, f.writes);
|
||||
}
|
||||
@Test public void preservesUserChange() {
|
||||
Fixture f = new Fixture(); assertTrue(f.controller().begin()); f.value = 60000;
|
||||
assertTrue(f.controller().restore()); assertEquals(60000, f.value);
|
||||
}
|
||||
@Test public void revokedAccessRetainsRecovery() {
|
||||
Fixture f = new Fixture(); assertTrue(f.controller().begin()); f.allowed = false;
|
||||
assertFalse(f.controller().restore()); assertTrue(f.active);
|
||||
f.allowed = true; assertTrue(f.controller().restore()); assertEquals(15000, f.value);
|
||||
}
|
||||
@Test public void failedRestoreRetainsRecovery() {
|
||||
Fixture f = new Fixture(); assertTrue(f.controller().begin()); f.writeOk = false;
|
||||
assertFalse(f.controller().restore()); assertTrue(f.active);
|
||||
f.writeOk = true; assertTrue(f.controller().restore());
|
||||
}
|
||||
@Test public void failedInitialWriteAndClampingDoNotClaimProtection() {
|
||||
Fixture f = new Fixture(); f.writeOk = false; assertFalse(f.controller().begin());
|
||||
f.writeOk = true; assertTrue(f.controller().restore());
|
||||
f.clamp = true; assertFalse(f.controller().begin()); assertFalse(f.active);
|
||||
}
|
||||
@Test public void failedRecoveryClearAndReadRetainOwnership() {
|
||||
Fixture f = new Fixture(); assertTrue(f.controller().begin()); f.value = -1;
|
||||
assertFalse(f.controller().restore()); assertTrue(f.active);
|
||||
f.value = Integer.MAX_VALUE; f.clearOk = false;
|
||||
assertFalse(f.controller().restore()); assertTrue(f.active);
|
||||
f.clearOk = true; assertTrue(f.controller().restore());
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ endif()
|
||||
add_executable(phonecast-vr-stream-receiver src/vr_main.cpp)
|
||||
target_compile_features(phonecast-vr-stream-receiver PRIVATE cxx_std_17)
|
||||
target_link_libraries(phonecast-vr-stream-receiver PRIVATE
|
||||
phonecast-core phonecast-vr phonecast-network phonecast-openvr-backend
|
||||
phonecast-core phonecast-vr phonecast-network phonecast-support phonecast-openvr-backend
|
||||
)
|
||||
if(WIN32)
|
||||
target_link_libraries(phonecast-vr-stream-receiver PRIVATE phonecast-windows-streaming)
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
#include "phonecast/core/audio/AudioPlayback.h"
|
||||
#include "phonecast/platform/openvr/OpenVrOverlayRenderer.h"
|
||||
#include "phonecast/platform/network/TcpVideoServer.h"
|
||||
#include "phonecast/platform/network/LanDiscovery.h"
|
||||
#include "phonecast/platform/support/SupportDiagnostics.h"
|
||||
#include <future>
|
||||
#ifdef _WIN32
|
||||
#include "phonecast/platform/windows/MfH264Decoder.h"
|
||||
#include "phonecast/platform/windows/WasapiAudioOutput.h"
|
||||
@@ -389,6 +392,9 @@ int main(int argc, char** argv) {
|
||||
renderer.Stop();
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
phonecast::platform::network::LanDiscovery discovery;
|
||||
if (!discovery.Start(port)) std::cerr << "[discovery] Unavailable; manual IP still works.\n";
|
||||
std::future<std::pair<std::string, std::string>> diagnosticExport;
|
||||
PrintUsage();
|
||||
std::cout << "\nPhoneCast VR receiver listening on port " << port
|
||||
<< ". The pairing credential is shown in the PhoneCast dashboard.\n";
|
||||
@@ -462,6 +468,11 @@ int main(int argc, char** argv) {
|
||||
while (running) {
|
||||
const auto loopStarted = std::chrono::steady_clock::now();
|
||||
if (!renderer.PumpEvents()) break;
|
||||
if (diagnosticExport.valid() && diagnosticExport.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
|
||||
const auto exported = diagnosticExport.get();
|
||||
settingsMenu.SetExportStatus(exported.first, exported.second);
|
||||
if (settingsMenu.IsOpen()) renderer.ShowSettingsMenu(settingsMenu.View(), error);
|
||||
}
|
||||
#ifndef _WIN32
|
||||
if (standaloneRuntime.TakeDashboardFocusRequest() &&
|
||||
!renderer.FocusDashboard(error))
|
||||
@@ -589,6 +600,18 @@ int main(int argc, char** argv) {
|
||||
phonecast::vr::SettingsMenuInput settingsInput{};
|
||||
if (settingsMenu.IsOpen() && renderer.TakeSettingsMenuInput(settingsInput)) {
|
||||
const auto result = settingsMenu.Handle(settingsInput);
|
||||
if (result == phonecast::vr::SettingsMenuResult::ExportRequested && !diagnosticExport.valid()) {
|
||||
try {
|
||||
std::cout.flush();
|
||||
diagnosticExport = std::async(std::launch::async, [] {
|
||||
std::string filename, exportError;
|
||||
const bool saved = phonecast::platform::support::ExportDiagnostics(filename, exportError);
|
||||
return std::make_pair(saved ? std::string("SAVED TO DOWNLOADS") : exportError, filename);
|
||||
});
|
||||
} catch (const std::exception&) {
|
||||
settingsMenu.SetExportStatus("EXPORT WORKER UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
if (result == phonecast::vr::SettingsMenuResult::Updated ||
|
||||
result == phonecast::vr::SettingsMenuResult::Applied) {
|
||||
controls.ReplaceSettings(settingsMenu.Draft());
|
||||
|
||||
@@ -2293,9 +2293,59 @@ Detailed existing research, closure notes, and the validation matrix are in
|
||||
|
||||
# Feature Backlog
|
||||
|
||||
## Android connection feedback and actionable status
|
||||
|
||||
**Status:** `[x] v0.9.0 normal-use release baseline accepted by the owner — extended failure/lifecycle cases remain separately documented`
|
||||
|
||||
- Separate capture/encoder activity from receiver connectivity in the phone UI; local capture must not imply successful streaming.
|
||||
- Show connecting, connected/receiver-responsive, reconnecting, and disconnected states with appropriate non-green warning styling when the receiver is unavailable.
|
||||
- Preserve connection status across encoder-stat updates, orientation changes, and activity resume rather than overwriting it with generic capturing text.
|
||||
- Distinguish socket connection from confirmed receiver response/authentication where the protocol permits; do not treat a successful TCP connect alone as pairing acceptance or decoded-video confirmation.
|
||||
- Surface safe, actionable failure details and retry guidance (receiver running, current IP/code, same trusted LAN, VPN/client isolation), without exposing credentials.
|
||||
- Keep frame counters but explain local drops and pending RTT without claiming the receiver rejected frames.
|
||||
- Test initial unreachable receiver, pairing rejection, no receiver response, successful connection, interruption/reconnect, and physical phone/headset UI behavior.
|
||||
|
||||
Implementation separates encoder activity from receiver replies, preserves warnings across statistics, resets RTT, and supplies safe retry guidance. Initial owner testing confirmed useful receiver-close and wrong-IP/code feedback, but found retry-text/page-height flicker. Code 6 retains the failure during retries and uses fixed-size status/detail/counter fields. The owner subsequently accepted the revised update for immediate release; this does not claim every original failure/lifecycle case was individually witnessed. See `docs/v0.9-support.md` for behavior, testing, and remaining physical checks.
|
||||
|
||||
## Settings About and diagnostic-log export
|
||||
|
||||
**Status:** `[x] v0.9.0 release baseline accepted by the owner — extended storage/privacy edge cases retain separate validation status`
|
||||
|
||||
Add an **About** section to PhoneCast Settings on Android and the headset:
|
||||
|
||||
- Show the installed application version; include Android version code and receiver build identifier where available so support can distinguish patched builds.
|
||||
- Provide an explicit **Export logs to Downloads** action, with a clear success/failure result and the saved filename/location.
|
||||
- Android exports PhoneCast-owned diagnostics using supported Android storage APIs; ordinary users must not need ADB, root, or broad storage access. Do not assume an app can retrieve unrestricted system logcat.
|
||||
- The headset exports its receiver diagnostics to the user's Downloads folder without requiring SSH or terminal commands.
|
||||
- Use timestamped filenames, bounded exports, and a privacy warning before users share files. Exclude pairing codes, credentials, notification content, input/text contents, and screen/audio samples; redact potentially sensitive metadata where necessary.
|
||||
- Keep logging/export platform-specific behind the existing boundaries, with reusable About/status behavior where practical.
|
||||
- Validate displayed versions, export permissions and failures, file discoverability, redaction, and physical use on Android and Steam Frame.
|
||||
|
||||
About surfaces and explicit bounded safe Downloads exports are implemented on Android and the native receiver. Exports intentionally omit arbitrary raw log text/private payloads. The owner confirmed native export; code-6 feedback changes the abbreviated `pc-vr` filename to `phonecast-headset-<timestamp>.txt`. Expanded About/storage validation remains pending. A proposed migration of Settings into the dashboard has been assessed but is not implemented; see `docs/v0.9-support.md`.
|
||||
|
||||
## Casting screen-timeout fix — 2026-10-07
|
||||
|
||||
- `[x]` Implemented temporary `SCREEN_OFF_TIMEOUT = Integer.MAX_VALUE` (about 24.85 days) during an authorized casting session, independently of brightness dimming, using explicitly granted Modify system settings access.
|
||||
- `[x]` Persist the original timeout before writing; restore on normal teardown/projection termination and attempt abandoned-session recovery at process start and boot. Preserve subsequent user timeout changes; retain recovery on permission/write failure. Force Stop cannot perform immediate cleanup.
|
||||
- `[x]` Android JVM tests, debug APK build, and lint pass.
|
||||
- `[x]` Owner physically confirmed idle casting no longer locks after the configured 15 seconds on Samsung S22+ / Android 16 with release-signed `versionCode` 4. Power logs show timeout changing from 15,000 to 2,147,483,647 ms during casting and restoring to 15,000 ms at teardown, followed by normal timeout sleep. Samsung Settings displayed its largest selectable option (10 minutes), not the actual extended value.
|
||||
- `[ ]` Separate dimming-disabled, manual-lock, and interrupted-session recovery checks remain unvalidated. OEM/device-admin policy can still restrict effective timeout on other devices.
|
||||
- `[~]` Release readiness: signed code-4 APK, debug/release tests and lint, matching signing-certificate verification, and both local HTTP routes/checksums pass. Public GitHub publication is not yet claimed. The owner subsequently selected a v0.9.0 feature candidate (code 5), which includes this fix; code-4 artifacts remain separate rather than silently replacing public release assets.
|
||||
|
||||
Read-only device logs show Samsung marking `PhoneCast:casting-screen` as `abuse wakelock package : com.phonecastvr.sender`, followed by sleep due to the 15-second timeout and projection termination. The screen wake lock alone is therefore insufficient on this device. No phone settings were changed during diagnosis.
|
||||
|
||||
## Optional developer/ADB mode — future investigation
|
||||
|
||||
**Status:** `[ ] Research backlog — owner-requested optional advanced path, not implemented`
|
||||
|
||||
Investigate an explicitly opt-in developer/ADB mode for users willing to enable Developer options, debugging, and device authorization. Review scrcpy's privileged `userActivity()` keep-active mechanism, charging-only stay-awake behavior, setup/security implications, disconnect recovery, and compatibility on Samsung/Android versions. Keep ordinary casting independent of debugging, root, and a PC. Preserve intentional manual locking and document permissions honestly.
|
||||
|
||||
This new owner request supersedes the earlier blanket rejection of future developer/ADB research, but does not reopen Sprint 15 or authorize implementing ADB, Shizuku, or panel-off functionality now. Local-only display blanking remains a separate feasibility/privacy question, not an assumed benefit. Any advanced integration requires a separate design and physical validation.
|
||||
|
||||
|
||||
## Auto-find headset — automatic LAN discovery
|
||||
|
||||
**Status:** `[ ] Not started — backlogged; not part of v0.8`
|
||||
**Status:** `[~] v0.9.0 bounded LAN finder included in the owner-approved release baseline — extended discovery cases and active-session identity-based rediscovery remain follow-up work`
|
||||
|
||||
The Android app should search the local network for a running PhoneCast headset receiver so the user does not need to find or type the headset's IP address.
|
||||
|
||||
@@ -2310,7 +2360,7 @@ Planned behavior and acceptance criteria:
|
||||
- Evaluate a standard LAN discovery mechanism such as mDNS/DNS-SD during implementation; preserve portable receiver/platform boundaries and document Android network permissions.
|
||||
- Test first connection, multiple receivers, changed IP, network interruption, unavailable receiver, and discovery-blocked Wi-Fi on physical Android/Steam Frame hardware.
|
||||
|
||||
This makes the previously deferred Sprint 12 automatic-discovery goal an explicit product backlog feature. No discovery implementation or physical validation is claimed by this entry.
|
||||
The v0.9.0 **Find headset on LAN** implementation uses a four-second IPv4 UDP query/reply search with explicit selection, version/name display, remembered address/name, no continuous scanning, and manual-IP fallback. mDNS/DNS-SD was evaluated; the initial custom finder avoids a new receiver daemon/dependency. Discovery is not authentication. Silent changed-IP retargeting during an active reconnect is not implemented; users stop casting, search/select again, and start a consented cast. This remaining goal requires a trustworthy identity design rather than assuming names are unique. See `docs/v0.9-support.md` for protocol bounds, network limitations, tests and physical matrix. The owner accepted the overall release baseline; a separate individually exercised discovery/multiple-receiver/blocked-network matrix is not claimed.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -370,6 +370,35 @@ best-effort path cannot run.
|
||||
- [Android 15 behavior changes](https://developer.android.com/about/versions/15/behavior-changes-all) — force-stop/stopped-state behavior.
|
||||
- [Android media projection](https://developer.android.com/media/grow/media-projection) — lock-screen termination and invalid projection lifecycle.
|
||||
|
||||
## Samsung background timeout follow-up — 2026-10-07
|
||||
|
||||
The owner's SM-S906U (S22+) running Android 16 locks when casting from other apps. Read-only power logs show Samsung classifying the casting screen-dim wake lock as `abuse wakelock`, disabling its contribution and sleeping due to timeout. Foreground-only keep-screen-on is not sufficient for phone mirroring.
|
||||
|
||||
The sender now temporarily writes `Settings.System.SCREEN_OFF_TIMEOUT` to `Integer.MAX_VALUE` (2,147,483,647 ms, approximately 24.85 days) while projection is active, with the existing explicit Modify system settings capability. This is independent of the dimming preference. The original timeout is synchronously saved before writing and restored on teardown; app-start/boot recovery handles abandoned ownership best-effort. User timeout changes are preserved. Permission revocation, write failure, and Force Stop can delay restoration; Android Display settings remain the emergency recovery path. Without access, casting remains available with an automatic-lock warning. Grant access before starting a new cast.
|
||||
|
||||
Manual locking remains untouched and ends projection normally. OEM clamping and device-admin maximum-lock policy remain possible; readback verifies the setting, not the effective awake policy. JVM tests, APK assembly, and lint pass. No device setting was changed or APK installed during the initial implementation; the subsequent authorized installation and physical validation are recorded below.
|
||||
|
||||
The timeout-fix APK was release-signed and published to the local download server on 2026-10-07 as v0.8 `versionCode` 4. Debug/release JVM tests, both APK builds, and debug/release lint pass. `apksigner` verified the same certificate as the original code-3 release, permitting an in-place release update. Both live APK routes match the release SHA-256 `963be8172accc7539ff768f870932b44493ae98716fceb6d2edaf1a796580227`; served `SHA256SUMS` was updated without changing the receiver archive. This is local publication only, not a GitHub release update or physical phone validation. Stop casting before installing, retain Modify system settings approval, and start a new cast afterward.
|
||||
|
||||
### Physical confirmation — 2026-10-07
|
||||
|
||||
The initial reported 15-second lock occurred with the original release (`versionCode` 3) still installed; it was not a test of the fix. With owner authorization, the signed code-4 APK was installed using `adb install -r`, and package inspection confirmed code 4. The owner then reported that idle casting worked without the prior 15-second lock.
|
||||
|
||||
Read-only `PowerManagerService` logs corroborate both application and restoration:
|
||||
|
||||
- 21:32:06: configured timeout was 15,000 ms.
|
||||
- 21:32:19: system setting and effective screen-off timeout changed to 2,147,483,647 ms (about 24.85 days).
|
||||
- 21:33:35: timeout returned to 15,000 ms as the cast ended; capture-stop logs followed at 21:33:36.
|
||||
- 21:33:52: normal sleep occurred due to the restored 15-second timeout.
|
||||
|
||||
Samsung Settings showed **10 minutes**, its maximum selectable preset, while logs reported the actual much larger timeout. The displayed preset is not the authoritative value for this temporary override. Do not manually select another timeout during casting unless intentionally replacing the override; user changes are preserved.
|
||||
|
||||
This confirms the owner's Samsung S22+ / Android 16 idle-casting and normal restoration case, not every device or failure path. Dimming-disabled testing, manual-lock testing of this build, permission-loss, and interrupted-session recovery remain separate checks. Raw logs are ignored under `out/diagnostics/phone-timeout-followup/`.
|
||||
|
||||
The initial distribution recommendation was a separate **v0.8.1** patch with the same signing key, release notes and fresh checksums rather than silently replacing published v0.8 artifacts. The owner subsequently chose **v0.9.0** for the connection/About/export/discovery work; that code-5 test candidate includes this timeout fix and is tracked in `docs/v0.9-support.md`. The timeout fix alone does not require a receiver reinstall; v0.9 discovery/About features do require the new receiver. No GitHub release was created or modified by this validation.
|
||||
|
||||
The owner additionally requested future optional developer/ADB research in `design.md`, superseding the prior prohibition on researching that option. No privileged integration is implemented. scrcpy's [device controls](https://github.com/Genymobile/scrcpy/blob/master/doc/device.md) distinguish privileged periodic user activity, charging-only stay-awake, temporary timeout changes, and panel-power control. These are not equivalent to a normal app wake lock.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Samsung Phone Link does not appear to solve this by merely dimming the phone. It offers a special black/hidden local-display state while remote mirroring continues. Public documentation confirms the behavior but does not disclose its implementation. PhoneCast will therefore use default-on, user-disableable minimum-brightness operation as the supported Sprint 15 baseline, subject to explicit Android special-access approval and best-effort recovery. Panel-off operation remains limited to future privileged or advanced experiments.
|
||||
@@ -213,6 +213,14 @@ approved native playback/game-mixing results, and explicitly deferred physical
|
||||
cases. Android's optional local-phone mute is platform-side lifecycle policy around
|
||||
the portable capture stream; it stores/restores media volume without entering Core.
|
||||
|
||||
## v0.9.0 support candidate
|
||||
|
||||
`platform/network/LanDiscovery` owns the WinSock/POSIX UDP responder; Android owns a bounded foreground LAN search and explicit endpoint selection. Neither discovery nor sockets enter Core. Discovery is an untrusted hint layer over unchanged TCP pairing, not an identity mechanism.
|
||||
|
||||
Portable `SettingsMenuController` adds an About view and explicit export request/result without filesystem dependencies. The receiver composition root runs `platform/support` export I/O asynchronously and returns status/filename to the view. The platform exporter selects safe diagnostic lines, honors Downloads/XDG paths and bounds file reads/output. Android separately records fixed safe app events and exports through MediaStore Downloads. No unrestricted system logcat, private phone payload or authentication material is exported.
|
||||
|
||||
Android connection feedback is driven by valid recent PONG replies independently of capture counters, with bounded no-reply timeout and safe actionable error text. A responding receiver does not prove decoder/display success. See [`v0.9-support.md`](v0.9-support.md) for limitations, test evidence and physical validation still required.
|
||||
|
||||
## Deferred work
|
||||
|
||||
Encrypted pairing, automatic discovery, extended ergonomic tuning of controller-placement defaults, physical standalone native-overlay validation, native decoder-to-GPU surface sharing, and process-attributed GPU utilization remain deferred. The custom TCP transport is subject to head-of-line blocking and must be measured on real Wi-Fi before it is treated as a long-term choice.
|
||||
Encrypted pairing, identity-based active-session rediscovery, extended ergonomic tuning of controller-placement defaults, physical standalone native-overlay validation, native decoder-to-GPU surface sharing, and process-attributed GPU utilization remain deferred. The custom TCP transport is subject to head-of-line blocking and must be measured on real Wi-Fi before it is treated as a long-term choice.
|
||||
@@ -289,6 +289,8 @@ adb install -r app\build\outputs\apk\debug\app-debug.apk
|
||||
adb logcat -s PhoneCastCapture
|
||||
```
|
||||
|
||||
With **Modify system settings** approval, casting now temporarily extends the device screen timeout to about 24.85 days even when dimming is disabled, restoring the original on teardown and attempting recovery at process start/boot. Manual locking still ends projection; Force Stop or revoked access can delay restoration. Samsung background wake-lock suppression motivated this fix. The owner confirmed idle casting with a 15-second baseline on Samsung S22+ / Android 16 using signed version code 4; power logs verify the extended timeout and normal 15-second restoration after teardown. Samsung Settings may show its maximum selectable preset (10 minutes) instead of the actual override. Dimming-disabled and interrupted-session/failure checks remain separate. Start a new cast after granting access. See `docs/android-screen-off.md`.
|
||||
|
||||
**Dim phone while casting** is enabled by default. Open PhoneCast Settings → Dim phone while casting to change its 5/15/30/60-second delay or opt out. Android's separate **Modify system settings** screen must be approved before device-wide brightness can change; declining leaves casting available and undimmed. The sender and foreground notification expose **Restore brightness**. PhoneCast stores restoration state before dimming and attempts cleanup on stop, projection revocation, process restart, and boot, but Force Stop cannot run immediate cleanup and revoked access prevents writes. Use Android Quick Settings as the emergency recovery path. Treat dimming, adaptive/manual restoration, MediaProjection brightness isolation, and touch wake as physically unvalidated until the Sprint 15 matrix in `docs/android-screen-off.md` is run.
|
||||
|
||||
For optional VR remote control, first read the disclosure in the sender, check **Allow remote control while casting**, choose **Enable PhoneCast in Accessibility settings**, and explicitly enable **PhoneCast remote control**. Screen casting works without this service. The service injects only receiver-requested gestures and Back; it does not retrieve window content. While dimming is active, the enabled service also observes Android's coordinate-free touch-start event to attempt temporary brightness restoration. PhoneCast suppresses events around its own injected gestures, but physical-versus-injected behavior remains a target-device test. Disable either the in-app toggle or Accessibility service to stop remote control; disabling the service also removes automatic physical-touch wake, while explicit restore controls remain available.
|
||||
@@ -335,6 +337,14 @@ recovery and projection-revoke restoration remain physically deferred, as do
|
||||
protected-source behavior and external skew distributions. Sprint 12 decoder fault
|
||||
and Vulkan reliability follow-ups remain separate.
|
||||
|
||||
## v0.9.0 connection/support release baseline
|
||||
|
||||
See [`v0.9-support.md`](v0.9-support.md) for the implementation and physical matrix. Android now shows receiver-response state separately from encoding, has Settings → About/build details and safe Downloads log export, and provides a four-second **Find headset on LAN** search with explicit selection. The receiver adds About/export, Git build identification, and a non-fatal UDP 49322 discovery responder. Manual TCP/pairing remains supported; automatic identity-based active-stream IP retargeting is not implemented.
|
||||
|
||||
Build/test the signed Android candidate with `testDebugUnitTest assembleDebug lintDebug testReleaseUnitTest assembleRelease lintRelease`; signing stays outside the repository. CTest adds `phonecast-support-tests` (real loopback UDP, safe export, bounds, failure, About confirmation). ARM64 tests can run under QEMU, but physical UI/network/storage validation is still required. On Windows, discovery may need a separate private-network UDP 49322 firewall allowance; never forward ports publicly.
|
||||
|
||||
Candidate filenames use `phonecast-vr-v0.9.0-*`; local candidate links are separate from the v0.8 aliases/bootstrap. The owner accepted the revised normal-use baseline for release; extended network/storage/lifecycle cases remain separately documented. Public GitHub publication is not implied by packaging. The headset Export logs row requires two deliberate selections: first to show Confirm export, second to write the file.
|
||||
|
||||
## CMake options
|
||||
|
||||
- `PHONECAST_BUILD_RECEIVER` — build the generated OpenVR receiver and, on Windows, the desktop and OpenVR stream receivers (default `ON`).
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ A `REMOTE_CONTROL_STATUS` payload is exactly two bytes: payload version `1`, fol
|
||||
11. Android reports both remote-control gates independently at connection and after changes so the receiver can warn rather than failing silently.
|
||||
12. Selecting a card reveals the phone and returns its opaque action token; Android invokes the original notification launch action only while that token remains valid and forwarding remains enabled.
|
||||
|
||||
Manual address entry satisfies Sprint 3's “discovers or connects” criterion. Automatic discovery is deferred until the basic stream has physical-device validation.
|
||||
Manual address entry satisfies Sprint 3's “discovers or connects” criterion. The v0.9.0 test candidate adds a bounded, explicit-selection IPv4 UDP finder on port 49322; the TCP framing/pairing gate remains unchanged. Discovery responses are untrusted hints and never contain pairing credentials. See [`v0.9-support.md`](v0.9-support.md) for the query/reply contract, bounds, mDNS evaluation, manual fallback and pending physical checks. Identity-based changed-IP retargeting during an active reconnect remains deferred.
|
||||
|
||||
## Decoder decision
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# PhoneCast VR v0.9.0
|
||||
|
||||
Connection feedback, easier receiver discovery, and diagnostics for early-access support.
|
||||
|
||||
## What's new
|
||||
|
||||
- **Clear phone connection status:** green requires a receiver reply, not merely local screen capture. Unreachable receivers, wrong-address/code failures and reconnects provide actionable guidance.
|
||||
- **Calmer retries:** failed connection messages stay visible between attempts, with stable status-card sizing instead of flashing Connecting/Disconnected.
|
||||
- **Find headset on LAN:** a bounded IPv4 search displays running receiver names, addresses and versions. You explicitly select a receiver; pairing and Android screen-sharing consent remain required. Manual IP entry is retained.
|
||||
- **About on phone and headset:** installed version/build information for support. Android is version 0.9.0, version code 6, signed with the same release key for in-place updates from the release app.
|
||||
- **Export diagnostics to Downloads:** safe, bounded support summaries on both devices without ADB/SSH. Headset filenames use `phonecast-headset-<timestamp>.txt`.
|
||||
- **Idle screen-timeout fix:** with Modify system settings access, casting temporarily extends the timeout independently of dimming and restores the original afterward. This resolves the physically reproduced 15-second idle-lock case on the owner's Samsung S22+ / Android 16. Intentional manual locking still ends projection.
|
||||
- **Hotkey guidance:** the dashboard now explicitly tells users to close it before using stick shortcuts.
|
||||
|
||||
## Install or update
|
||||
|
||||
Install both v0.9.0 assets to use the new headset About/export and discovery features. Stop phone casting and quit the receiver before updating. Rerun the included receiver installer; saved settings/pairing are retained. Install the APK over the existing release-signed app. Debug-signed installations require the previously documented uninstall/migration procedure.
|
||||
|
||||
See the [README](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/blob/v0.9.0/README.md) for installation commands, permissions and troubleshooting.
|
||||
|
||||
**Headset log export needs two deliberate selections:** Settings → About / Export logs → select Export logs once to show Confirm export → select the same row again. Wait for Saved to Downloads and the filename. This is not the thumbstick double-click hotkey.
|
||||
|
||||
## Assets and SHA-256
|
||||
|
||||
| Asset | SHA-256 |
|
||||
| --- | --- |
|
||||
| `phonecast-vr-v0.9.0-android.apk` | `c16adb399f0c16c830873d65fa063a61161d342d4eee1cad4754bf7b1312382d` |
|
||||
| `phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz` | `18b4a3643575920af86a0373877a2c60bfe73210fc1446a3fcc99a057e7ecc99` |
|
||||
|
||||
Also upload `SHA256SUMS-v0.9.0`. Do not use the old v0.8 checksum file. Assets are the APK and ARM64 archive, not GitHub's automatically generated source archives.
|
||||
|
||||
## Validation and remaining limitations
|
||||
|
||||
The project owner accepted the revised normal-use UI/support baseline for release. Android debug/release JVM tests (49 each), APK builds and lint pass; all 11 Windows CTest tests pass; ARM64 compilation and QEMU portable/audio/support/runtime tests pass. Signature, archive permissions and live-download checksums are verified.
|
||||
|
||||
This remains early-access software, not an exhaustive hardware/network compatibility approval:
|
||||
|
||||
- **Trusted LAN only:** transport is not encrypted. Do not expose the receiver publicly.
|
||||
- Discovery needs same-LAN IPv4 broadcast reachability and UDP 49322. Guest/client isolation, VPN, cross-subnet or IPv6-only networks may require manual IP entry. Stop/search/select/start if an IP changes; active-session identity-based retargeting is not implemented. Multiple-receiver and every blocked-network/storage-failure case were not individually physically witnessed before release acceptance.
|
||||
- A receiver reply does not prove decoded/visible video. Pairing rejection uses connection closure rather than a distinct wire error, so guidance does not claim a confirmed wrong-code diagnosis.
|
||||
- Existing Qualcomm decoder freezes and Adreno/Vulkan device-loss failures remain separate reliability work. Recovery, sleep/wake, SteamVR restart, wider game compatibility and both launch orders are not exhaustively validated.
|
||||
- Force Stop cannot immediately restore brightness, timeout or phone volume; later recovery depends on permissions. Manual locking continues to follow Android privacy rules.
|
||||
- Settings still uses the floating panel. Dashboard-integrated Settings is assessed but not implemented. Keyboard/text entry, wrist gestures and per-hotkey toggles remain backlogged.
|
||||
|
||||
Detailed evidence and boundaries: [v0.9 support work](https://github.com/bangfireball/Steam_Frame_Phone_Overlay/blob/v0.9.0/docs/v0.9-support.md).
|
||||
@@ -34,6 +34,8 @@ The Start Location dummy preview currently reuses the otherwise dormant gesture-
|
||||
|
||||
## Thumbstick shortcuts
|
||||
|
||||
The v0.9 dashboard help explicitly says **CLOSE DASHBOARD FOR HOTKEYS** so users know to dismiss the dashboard before using stick shortcuts. This is a text-only clarification, not a change to bindings or focus policy. The owner accepted the revised v0.9 presentation baseline for release; extended per-case input/lifecycle coverage remains separately documented.
|
||||
|
||||
The shipped action manifest now has only the narrow shortcut action set. `/actions/phonecast_shortcuts` is submitted only while the dashboard is closed. A binding-layer long press on **either** thumbstick button toggles visibility without resetting placement. A binding-layer double click opens the phone and docks it to the controller whose stick was clicked. Repeating the double click while the visible phone is already docked to that same controller converts its current tracked pose and physical size into a stationary world anchor. While the dashboard is visible, no action set is submitted. The failed `/actions/phonecast_panel` axis set and bindings have been removed. The old trigger/grip/calibration bindings are not shipped.
|
||||
|
||||
The user physically confirmed that the normal-priority shortcuts do not deliver while the SteamVR dashboard owns focus, even when PhoneCast submitted their action set alongside the former panel set. Both dashboard-active action experiments have been removed. The dashboard laser remains the interaction route after summoning the phone. The dashboard-closed shortcut path acts directly on active changed events rather than requiring a separately observed released sample first. The shortcut set uses normal action-set priority `0`, following the source-reviewed Framecorder approach, and are user-remappable/unbindable through SteamVR. Binding activity is logged without input contents or credentials.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# v0.9.0 connection and support work
|
||||
|
||||
Status: project owner accepted the revised normal-use UI/support baseline for immediate v0.9.0 release. Extended network/storage/lifecycle validation remains explicitly deferred below. GitHub publication is pending; the agent did not install this update on a device.
|
||||
|
||||
## Implemented scope
|
||||
|
||||
- Android v0.9.0, version code 6, signed with the existing release key. The approved screen-timeout fix remains included.
|
||||
- Capture activity and receiver status are separate. Local encoding cannot turn the status green. A valid, recent PONG is required for **Connected · receiver responding**; this does not prove decoded/visible picture delivery.
|
||||
- Connecting, awaiting confirmation, unreachable/address errors, reply timeout, disconnected/retry states have fixed actionable text and amber styling. Encoder counters no longer overwrite receiver status. RTT resets on each attempt/disconnect. A 10-second reply deadline closes stalled connections, including blocked initial writes through the reader deadline.
|
||||
- Pairing rejection is currently connection closure, not a distinct wire error. The phone suggests checking pairing/relaunching but does not falsely assert a confirmed wrong-code diagnosis. Protocol-version errors receive update guidance.
|
||||
- Phone Settings → About shows version, version code, build label and debug/release type. Headset Settings → About / Export logs shows receiver version and Git build identifier (including dirty/source fallback).
|
||||
- Both About surfaces explicitly export bounded diagnostics to Downloads and report success/failure. Headset export requires a second deliberate activation, displays the saved filename, and runs file I/O on a worker rather than the render loop.
|
||||
- Phone **Find headset on LAN** performs a bounded automatic network search rather than requiring the user to know an IP. Results show receiver name, IP and version; selection is explicit even for one result. The chosen address/name is remembered, but a name is not authenticated identity. Capture consent and pairing remain required.
|
||||
|
||||
## Discovery decision and wire contract
|
||||
|
||||
Android [NSD/DNS-SD](https://developer.android.com/develop/connectivity/wifi/use-nsd) was evaluated. It is a good standard option, but using it here would require a receiver-side mDNS implementation/library or additional Avahi service/runtime integration. This first bounded same-LAN implementation instead uses IPv4 UDP, without a new headset daemon or dependency. This is a PhoneCast-specific finder, not mDNS interoperability or cryptographic identity.
|
||||
|
||||
- Receiver listens on UDP **49322** only while the VR stream receiver runs; failure is non-fatal and manual TCP still works. TCP streaming defaults to **49321**.
|
||||
- Query: ASCII `PCVR_DISCOVER 1 <32 lowercase hexadecimal nonce>`.
|
||||
- Unicast reply: ASCII `PCVR_RECEIVER 1 <same nonce> <TCP port> <version> <name>`.
|
||||
- Reply version is digits/dots up to 16 characters; hostname-derived name is sanitized ASCII, at most 48 characters. No pairing code, user stream metadata or payload is advertised.
|
||||
- Receiver accepts private/link-local IPv4 sources (and loopback for tests), validates exact query size/content, and caps replies globally at 20/second. Responses are under 160 bytes.
|
||||
- Android broadcasts to discovered interface broadcast addresses and limited broadcast, sends at most once/second for four seconds, limits interfaces/results/received packets, validates nonce and fields, and cancels on activity stop. A temporary Wi-Fi multicast lock is released on every exit.
|
||||
- Android's existing TCP endpoint is fixed at 49321, so the finder deliberately ignores custom-port responders. Manual address UI likewise retains its existing default-port behavior.
|
||||
- Android adds normal ACCESS_WIFI_STATE and CHANGE_WIFI_MULTICAST_STATE permissions, not location, root or broad storage permission. INTERNET remains required. Future Android local-network permission changes need separate compatibility validation.
|
||||
- Windows private-network firewall rules must also allow UDP 49322 for discovery. Never recommend router port forwarding or disabling security.
|
||||
|
||||
Guest/client isolation, VPNs, broadcast-blocked networks, cross-subnet and IPv6-only networks can prevent discovery. Manual IP entry remains available; discovery failure is not proof that TCP is unreachable. Existing v0.8 receivers do not implement this finder.
|
||||
|
||||
Changed-IP recovery: stop casting, run Find headset again, explicitly select the receiver, and start a new consented cast. Silent identity-based rediscovery/retargeting during an active reconnect is **not implemented**; it remains follow-up work with the trusted-device identity model. There is no continuous background scan or automatic capture start.
|
||||
|
||||
## Diagnostic export privacy and limits
|
||||
|
||||
Android records its own fixed safe connection events plus periodic capture/receiver/frame-drop/RTT counters. The persistent ring is capped at 64 KiB and exported through MediaStore Downloads, using pending-file cleanup on failure. It does not read unrestricted system logcat, touch/notification contents, addresses, pairing credentials, or screen/audio samples. Android [shared-storage APIs](https://developer.android.com/training/data-storage/shared/media) keep this independent of broad storage permission on API 29+.
|
||||
|
||||
Native export reads at most the trailing 256 KiB of the standard receiver log (`$XDG_STATE_HOME/phonecast-vr/receiver.log`, default `~/.local/state/phonecast-vr/receiver.log`). Only explicitly allowed numeric diagnostic counters and fixed connection/pairing-result messages are retained; arbitrary text, notification/input/audio payloads, addresses and credentials are excluded. Filtered events are capped at 64 KiB plus a short header. Downloads honors XDG user-dirs configuration, with `~/Downloads` fallback; exported Linux files are owner-only. Custom launch/log locations may yield a clearly marked log-unavailable report. The Windows fallback uses its standard user paths; this work primarily targets the native headset launcher.
|
||||
|
||||
Export is a **safe support summary**, not a full raw log dump. Review files before sharing privately. Native About reports the timestamped filename and saved Downloads location; phone export reports its exact saved path. Failed exports must not disrupt streaming.
|
||||
|
||||
## Test and release checklist
|
||||
|
||||
- [x] JVM tests for fixed safe errors and bounded/correlated discovery parsing.
|
||||
- [x] Real loopback TCP tests for TCP-without-PONG, invalid PONG, responsive PONG, disconnect/reconnect RTT reset, unreachable receiver and no-reply timeout.
|
||||
- [x] Native tests for discovery query validation, real UDP query/reply/start-stop, About navigation/confirmation, safe export filtering, bounds, localized Downloads and failed export.
|
||||
- [x] Local server candidate route/isolation tests; v0.8 aliases remain separate.
|
||||
- [x] Final Android debug/release JVM tests (49 per variant), both APK assemblies and both lint tasks pass after the timeout-reason race fix.
|
||||
- [x] Windows build and all 11 CTest tests pass.
|
||||
- [x] Final Linux ARM64 build and QEMU portable/audio/support/runtime tests pass after final UI/export changes.
|
||||
- [x] APK signature/version, ARM64 package contents/modes, checksum and live-download verification pass.
|
||||
- [x] Owner accepted the revised normal-use UI/support baseline for immediate release.
|
||||
- [ ] Extended individual network/storage/lifecycle cases in the matrix below remain separately unvalidated.
|
||||
|
||||
The initial timeout test exposed a secondary reader-close error hiding the writer's actual reply timeout; reason preservation was corrected before final packaging. No new headset audio backend or render-path change is included. Existing decoder/Vulkan reliability follow-ups are not claimed fixed.
|
||||
|
||||
## Release artifacts and local publication
|
||||
|
||||
Artifacts are ignored build output under `out/packages`:
|
||||
|
||||
- `phonecast-vr-v0.9.0-android.apk`, SHA-256 `c16adb399f0c16c830873d65fa063a61161d342d4eee1cad4754bf7b1312382d`.
|
||||
- `phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz`, SHA-256 `18b4a3643575920af86a0373877a2c60bfe73210fc1446a3fcc99a057e7ecc99`.
|
||||
- `SHA256SUMS-v0.9.0` contains both entries. The APK verifies with the same certificate as v0.8 and reports version 0.9.0/code 6. Receiver About uses build `5195329-dirty` for this uncommitted candidate.
|
||||
|
||||
The archive was created in Linux with stripped AArch64 binaries, 755 directories/executables/install scripts, 644 data/templates, and installer syntax checks. Only the local Node download server was restarted to expose a clearly labelled v0.9.0 test-candidate section. Both live downloads and served checksums match local artifacts. The old v0.8 aliases/bootstrap remain unchanged; the stable APK still matches the earlier code-4 timeout-fix artifact.
|
||||
|
||||
No phone/headset app, SteamVR, game or audio service was restarted or installed by this task. No GitHub release, commit or push was made. The owner subsequently accepted the revised release baseline; public GitHub publication remains a separate step. These artifacts were built from the working tree before the v0.9 source commit. The full implementation/documentation must be committed before tagging so the release's source includes these features. The tested receiver currently identifies its build as `5195329-dirty`; rebuilding after a clean commit would change build metadata and artifact checksums and requires fresh verification.
|
||||
|
||||
## Release documentation closure
|
||||
|
||||
The README now targets v0.9.0 filenames/install steps and explicitly explains the headset's two-selection export confirmation (not a thumbstick double-click). Copy-ready GitHub release notes are in `docs/releases/v0.9.0.md`. The local server is labelled release-ready/GitHub-publication-pending, and its revised export instructions and both live artifact hashes were checked. Three server/bootstrap tests, all 11 Windows CTest tests, README local-link checks and artifact checksums pass for the documentation closure; no native/Android source or binary changed in this closure. The full Android/ARM64 validation above remains the evidence for these exact artifacts.
|
||||
|
||||
The owner will create the GitHub release. Upload both versioned artifacts and `SHA256SUMS-v0.9.0`, and commit/tag all implementation/documentation changes first. GitHub release publication and tagging remain the owner's separate steps. Source commit/push is handled separately on the owner's request; it does not rebuild or change these tested artifacts.
|
||||
|
||||
## Owner feedback and revision 6
|
||||
|
||||
The owner reported that the initial candidate works, receiver shutdown produces useful connection feedback, wrong pairing/IP produce relevant messages, and native log export works. Wrong-pairing retries rapidly alternated Connecting/Disconnected and changed page height; wrong-IP retries produced a slower variant. This is evidence for those exercised cases, not separate approval of discovery/multiple receivers, all About controls or storage failure paths.
|
||||
|
||||
Revision 6 addresses the feedback:
|
||||
|
||||
- Retain the actionable failure across retry attempts until a valid PONG arrives, rather than flashing through Connecting/Awaiting states on each retry. Duplicate connection notifications are suppressed. Exponential backoff resets after a receiver-confirmed attempt, not merely TCP establishment, avoiding rapid wrong-code retry loops.
|
||||
- Use a stable short heading and fixed-line details/metrics areas so retry text and counters do not grow/shrink the status card. Tap the details text for the full message if it is ellipsized at large font sizes.
|
||||
- Native export filename is now `phonecast-headset-<timestamp>.txt`. The original `pc-vr` was only an abbreviation for PhoneCast VR, not another application or format.
|
||||
- Dashboard shortcut instructions now explicitly say **CLOSE DASHBOARD FOR HOTKEYS**. Shortcut behavior/input priority is unchanged.
|
||||
- Loopback regression tests cover repeated rejected handshakes without renewed Connecting/Awaiting text; presentation tests cover stable headings. Export tests check the clearer filename prefix.
|
||||
|
||||
Android debug/release tests, APKs and lint pass; Windows all 11 CTest tests and native ARM64/QEMU portable/audio/support/runtime tests pass. Both revised live downloads and checksums were verified without restarting the Node server. No device app/runtime was restarted. The owner subsequently said the revised update looks good and requested immediate release documentation. This closes the normal-use presentation baseline, not every independently witnessed discovery, storage-failure or lifecycle test.
|
||||
|
||||
### Dashboard Settings assessment — proposal, not implemented
|
||||
|
||||
Moving Settings into the existing PhoneCast dashboard is moderate work rather than a settings-model rewrite. Keep the portable controller, transactional Apply/Cancel and live previews; add dashboard page navigation (Home → Settings → category), a dashboard-sized layout and shared settings pointer/slider handling. The existing dashboard handles only quick-action mouse-downs, whereas the floating panel owns settings row/drag events, so this is not just replacing a texture. Navigation, dirty-draft cancellation, dashboard closing/recreation and preview cleanup need regression coverage. Phone/notification/start-location previews can remain independent scene surfaces even if the settings menu stops floating.
|
||||
|
||||
A preliminary estimate is 1–2 focused development days plus physical headset layout/input tuning; it is not a committed delivery date. The current candidate still uses the floating Settings surface. Do not implement this proposal without the owner's follow-up approval.
|
||||
|
||||
## Extended physical validation matrix (remaining cases deferred by release acceptance)
|
||||
|
||||
1. Install both test endpoints; check About version/build information and retain saved settings/pairing. Stop casting and quit the receiver before updating.
|
||||
2. Receiver absent/wrong IP: amber error, no misleading green capture text, local drops explained, retry visible.
|
||||
3. Wrong pairing code and incompatible receiver: useful guidance, no claim that capture means pairing success.
|
||||
4. Correct receiver: green only after response, regular video/audio/control behavior unchanged; loss/reconnect removes/restores green and resets RTT.
|
||||
5. Find one and multiple receivers; require selection, check remembered-name hint, changed-IP search, activity cancellation and repeated search. Test blocked/guest network and manual fallback.
|
||||
6. Phone/headset exports are readable in Downloads, show correct versions, have bounded size, contain useful connection diagnostics, and exclude pairing/private content. Exercise storage failure separately.
|
||||
7. Validate portrait/landscape, consent cancellation, activity resume, stop/restart, and standalone-game input coexistence. Retain screen-timeout restoration checks.
|
||||
|
||||
Do not mark these physical cases complete from compilation, loopback tests or simulated receivers.
|
||||
@@ -29,6 +29,18 @@ node local-apk-server/server.js
|
||||
|
||||
Open `http://YOUR_PC_LAN_IP:8080` from the phone/headset. Do not use `localhost` on another device. HTTP is unencrypted and does not authenticate artifacts; checksums from the same server detect corruption, not a malicious server. Stop the server with Ctrl+C after downloading.
|
||||
|
||||
## v0.9.0 versioned release assets
|
||||
|
||||
When both artifacts exist, the page exposes a separately labelled **release-ready build — GitHub publication pending** section:
|
||||
|
||||
- `/phonecast-vr-v0.9.0-android.apk`
|
||||
- `/phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz`
|
||||
- `/SHA256SUMS-v0.9.0`
|
||||
|
||||
These read matching files from `out/packages` and never replace the v0.8 aliases or bootstrap. Install both endpoints to exercise discovery; stop casting/quit the receiver before updating. The candidate uses the same APK signing key. Manual commands extract the `phonecast-vr-v0.9.0-steam-frame-arm64` directory and run its included installer. Do **not** use the old bootstrap to install the candidate.
|
||||
|
||||
About/build details, safe Downloads exports and the IPv4 UDP finder are documented in `docs/v0.9-support.md`. The owner accepted the normal-use release baseline, not every extended network/storage/lifecycle case. Local serving does not publish a GitHub release. The headset Export logs row needs two deliberate selections: first to show Confirm export, second to write. Server tests cover versioned candidate routes, missing files, no-store headers, and separation from the stable aliases.
|
||||
|
||||
## Steam Frame install
|
||||
|
||||
The page provides copyable manual and curl-based install commands. For an archive already downloaded to Steam Frame:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createDownloadServer } = require('./server');
|
||||
test('v0.9 candidates stay separate from v0.8 stable aliases', async t => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'phonecast-candidate-'));
|
||||
const old = path.join(dir, 'old'), apk = path.join(dir, 'apk'), frame = path.join(dir, 'frame'), sums = path.join(dir, 'sums');
|
||||
fs.writeFileSync(old, 'old'); fs.writeFileSync(apk, 'candidate apk'); fs.writeFileSync(frame, 'candidate receiver'); fs.writeFileSync(sums, 'candidate sums');
|
||||
const server = createDownloadServer({ apkPath: old, steamFramePath: old, checksumsPath: old,
|
||||
candidateApkPath: apk, candidateFramePath: frame, candidateChecksumsPath: sums });
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(async () => { await new Promise(resolve => server.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
assert.equal(await (await fetch(origin + '/phonecast-sender.apk')).text(), 'old');
|
||||
assert.equal(await (await fetch(origin + '/phonecast-steam-frame-arm64.tar.gz')).text(), 'old');
|
||||
for (const [name, body] of [['phonecast-vr-v0.9.0-android.apk','candidate apk'], ['phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz','candidate receiver'], ['SHA256SUMS-v0.9.0','candidate sums']]) {
|
||||
const response = await fetch(origin + '/' + name);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(response.headers.get('content-disposition'), `attachment; filename="${name}"`);
|
||||
assert.equal(await response.text(), body);
|
||||
}
|
||||
const page = await (await fetch(origin)).text();
|
||||
assert.match(page, /GitHub publication pending/);
|
||||
assert.match(page, /same row a second time to write/);
|
||||
assert.match(page, /sh phonecast-vr-v0\.9\.0-steam-frame-arm64\/steam-frame-installer\/install-phonecast\.sh/);
|
||||
fs.unlinkSync(apk);
|
||||
assert.equal((await fetch(origin + '/phonecast-vr-v0.9.0-android.apk')).status, 404);
|
||||
assert.doesNotMatch(await (await fetch(origin)).text(), /Signed v0.9.0 Android APK/);
|
||||
});
|
||||
@@ -5,6 +5,6 @@
|
||||
"description": "Disposable local APK download server",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node --test server.test.js"
|
||||
"test": "node --test server.test.js candidate.test.js"
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ function createDownloadServer(files = {}) {
|
||||
const apkPath = files.apkPath || defaultApkPath;
|
||||
const steamFramePath = files.steamFramePath || defaultSteamFramePath;
|
||||
const checksumsPath = files.checksumsPath || defaultChecksumsPath;
|
||||
const candidateApkPath = files.candidateApkPath || path.resolve(__dirname, '../out/packages/phonecast-vr-v0.9.0-android.apk');
|
||||
const candidateFramePath = files.candidateFramePath || path.resolve(__dirname, '../out/packages/phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz');
|
||||
const candidateChecksumsPath = files.candidateChecksumsPath || path.resolve(__dirname, '../out/packages/SHA256SUMS-v0.9.0');
|
||||
return http.createServer((request, response) => {
|
||||
const url = new URL(request.url, `http://${request.headers.host || "localhost"}`);
|
||||
|
||||
@@ -47,7 +50,8 @@ return http.createServer((request, response) => {
|
||||
if (url.pathname === "/") {
|
||||
const apkAvailable = fs.existsSync(apkPath);
|
||||
const steamFrameAvailable = fs.existsSync(steamFramePath);
|
||||
response.writeHead(apkAvailable || steamFrameAvailable ? 200 : 503, {
|
||||
const candidateAvailable = fs.existsSync(candidateApkPath) && fs.existsSync(candidateFramePath);
|
||||
response.writeHead(apkAvailable || steamFrameAvailable || candidateAvailable ? 200 : 503, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
@@ -56,9 +60,18 @@ return http.createServer((request, response) => {
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>PhoneCast Downloads</title>
|
||||
<style>pre {background:#eee;padding:1rem;overflow:auto} button {cursor:pointer}</style></head>
|
||||
<body style="font-family:sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem">
|
||||
<h1>PhoneCast v0.8 Downloads</h1>
|
||||
<h1>PhoneCast Downloads</h1>
|
||||
<p>Early-access release: native Steam Frame receiver and release-signed Android app.</p>
|
||||
${fs.existsSync(checksumsPath) ? '<p><a href="/SHA256SUMS">Download SHA256SUMS</a> to verify both downloads. Checksums from this HTTP server detect corruption, not a malicious server.</p>' : ''}
|
||||
${fs.existsSync(candidateApkPath) && fs.existsSync(candidateFramePath) ? `<h2>v0.9.0 release-ready build — GitHub publication pending</h2>
|
||||
<p>Owner-approved normal-use baseline: connection feedback, About/version details, safe Downloads log exports, and Find headset on LAN. Install both endpoints for discovery. Extended network/storage/lifecycle cases remain separately unvalidated. Existing v0.8 downloads and bootstrap below are unchanged.</p>
|
||||
<p><a href="/phonecast-vr-v0.9.0-android.apk">Signed v0.9.0 Android APK</a> · <a href="/phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz">v0.9.0 Steam Frame bundle</a> · <a href="/SHA256SUMS-v0.9.0">Candidate checksums</a></p>
|
||||
<p>Stop casting before the APK update. Quit PhoneCast before the receiver update; saved settings and pairing are retained. The signing key is unchanged.</p>
|
||||
<pre>set -eu
|
||||
cd "$HOME/Downloads"
|
||||
tar -xzf phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz
|
||||
sh phonecast-vr-v0.9.0-steam-frame-arm64/steam-frame-installer/install-phonecast.sh</pre>
|
||||
<p>Receiver: Settings → About / Export logs. Select Export logs once to show Confirm export, then select the same row a second time to write. Phone: Settings → About. LAN search requires UDP 49322 on the same non-guest network; manual IP/pairing still works.</p>` : ''}
|
||||
<h2>Android phone</h2>
|
||||
${apkAvailable
|
||||
? `<p><a href="/${apkFileName}">Download the signed Android v0.8 APK</a></p><p>If you previously installed the debug app, stop casting, note your settings, and uninstall it before installing this release: the signing keys differ. Uninstalling clears saved settings. Optional phone playback audio is off by default. Dim phone while casting is enabled by default but requires Android's separate Modify system settings approval; casting works undimmed without it.</p>`
|
||||
@@ -113,6 +126,17 @@ for (const button of document.querySelectorAll('[data-copy]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = {
|
||||
'/phonecast-vr-v0.9.0-android.apk': [candidateApkPath, 'application/vnd.android.package-archive'],
|
||||
'/phonecast-vr-v0.9.0-steam-frame-arm64.tar.gz': [candidateFramePath, 'application/gzip'],
|
||||
'/SHA256SUMS-v0.9.0': [candidateChecksumsPath, 'text/plain; charset=utf-8'],
|
||||
};
|
||||
if (Object.hasOwn(candidates, url.pathname)) {
|
||||
const [file, type] = candidates[url.pathname];
|
||||
serveDownload(response, file, type, url.pathname.slice(1), 'Candidate artifact not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/install-phonecast.sh") {
|
||||
serveDownload(response, path.join(__dirname, "install-phonecast.sh"),
|
||||
"text/plain; charset=utf-8", "install-phonecast.sh", "Installer script not found");
|
||||
|
||||
@@ -21,7 +21,9 @@ test('download page commands, origin substitution, routes and failure handling',
|
||||
fs.writeFileSync(apk, 'test apk');
|
||||
const checksums = path.join(dir, 'SHA256SUMS');
|
||||
fs.writeFileSync(checksums, 'test checksums\n');
|
||||
const server = createDownloadServer({ apkPath: apk, steamFramePath: bundle, checksumsPath: checksums });
|
||||
const server = createDownloadServer({ apkPath: apk, steamFramePath: bundle, checksumsPath: checksums,
|
||||
candidateApkPath: path.join(dir, 'absent.apk'), candidateFramePath: path.join(dir, 'absent.tar.gz'),
|
||||
candidateChecksumsPath: path.join(dir, 'absent-sums') });
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(async () => {
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
add_library(phonecast-network
|
||||
src/TcpVideoServer.cpp
|
||||
src/LanDiscovery.cpp
|
||||
)
|
||||
target_include_directories(phonecast-network PUBLIC include)
|
||||
target_compile_features(phonecast-network PUBLIC cxx_std_17)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
namespace phonecast::platform::network {
|
||||
// Bounded public LAN hints only; never carries pairing or stream data.
|
||||
std::string DiscoveryReply(const std::string& query, std::uint16_t port,
|
||||
const std::string& name, const std::string& version);
|
||||
class LanDiscovery final {
|
||||
public:
|
||||
LanDiscovery();
|
||||
~LanDiscovery();
|
||||
bool Start(std::uint16_t streamPort);
|
||||
void Stop();
|
||||
private:
|
||||
struct Implementation;
|
||||
std::unique_ptr<Implementation> impl_;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "phonecast/platform/network/LanDiscovery.h"
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
using SOCKET = int;
|
||||
constexpr SOCKET INVALID_SOCKET = -1;
|
||||
inline int closesocket(SOCKET value) { return close(value); }
|
||||
#endif
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <algorithm>
|
||||
|
||||
namespace phonecast::platform::network {
|
||||
std::string DiscoveryReply(const std::string& query, std::uint16_t port,
|
||||
const std::string& name, const std::string& version) {
|
||||
const std::string prefix = "PCVR_DISCOVER 1 ";
|
||||
if (query.size() != prefix.size() + 32 || query.compare(0, prefix.size(), prefix) != 0 || port == 0) return {};
|
||||
const auto nonce = query.substr(prefix.size());
|
||||
if (nonce.find_first_not_of("0123456789abcdef") != std::string::npos) return {};
|
||||
std::string safeName = name.substr(0, 48);
|
||||
for (auto& c : safeName) if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_' || c == ' ')) c = '-';
|
||||
if (safeName.empty()) safeName = "PhoneCast headset";
|
||||
if (version.empty() || version.size() > 16 || version.find_first_not_of("0123456789.") != std::string::npos) return {};
|
||||
return "PCVR_RECEIVER 1 " + nonce + " " + std::to_string(port) + " " + version + " " + safeName;
|
||||
}
|
||||
struct LanDiscovery::Implementation {
|
||||
SOCKET socket{INVALID_SOCKET};
|
||||
std::atomic<bool> running{false};
|
||||
std::thread worker;
|
||||
#ifdef _WIN32
|
||||
bool winsock{false};
|
||||
#endif
|
||||
};
|
||||
LanDiscovery::LanDiscovery() : impl_(std::make_unique<Implementation>()) {}
|
||||
LanDiscovery::~LanDiscovery() { Stop(); }
|
||||
bool LanDiscovery::Start(std::uint16_t streamPort) {
|
||||
Stop();
|
||||
#ifdef _WIN32
|
||||
WSADATA data{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return false;
|
||||
impl_->winsock = true;
|
||||
#endif
|
||||
impl_->socket = ::socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (impl_->socket == INVALID_SOCKET) { Stop(); return false; }
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET; address.sin_port = htons(49322); address.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
if (bind(impl_->socket, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0) { Stop(); return false; }
|
||||
std::array<char, 256> host{};
|
||||
std::string name = "PhoneCast headset";
|
||||
if (gethostname(host.data(), static_cast<int>(host.size() - 1)) == 0) name = host.data();
|
||||
impl_->running = true;
|
||||
impl_->worker = std::thread([this, streamPort, name] {
|
||||
auto lastReply = std::chrono::steady_clock::time_point{};
|
||||
while (impl_->running) {
|
||||
fd_set set; FD_ZERO(&set); FD_SET(impl_->socket, &set);
|
||||
timeval wait{0, 200000};
|
||||
if (select(static_cast<int>(impl_->socket + 1), &set, nullptr, nullptr, &wait) <= 0) continue;
|
||||
std::array<char, 128> bytes{}; sockaddr_in sender{};
|
||||
#ifdef _WIN32
|
||||
int size = sizeof(sender);
|
||||
#else
|
||||
socklen_t size = sizeof(sender);
|
||||
#endif
|
||||
const int count = recvfrom(impl_->socket, bytes.data(), static_cast<int>(bytes.size()), 0,
|
||||
reinterpret_cast<sockaddr*>(&sender), &size);
|
||||
if (count <= 0) continue;
|
||||
const auto ip = ntohl(sender.sin_addr.s_addr);
|
||||
const bool local = (ip >> 24) == 10 || (ip >> 24) == 127 ||
|
||||
(ip >> 16) == 0xc0a8 || (ip >> 20) == 0xac1 || (ip >> 16) == 0xa9fe;
|
||||
if (!local) continue; // IPv4 private/link-local LAN only; manual TCP remains available.
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now - lastReply < std::chrono::milliseconds(50)) continue; // global response cap, no amplification flood
|
||||
const auto reply = DiscoveryReply(std::string(bytes.data(), static_cast<std::size_t>(count)), streamPort, name, PHONECAST_VERSION);
|
||||
if (reply.empty()) continue;
|
||||
sendto(impl_->socket, reply.data(), static_cast<int>(reply.size()), 0,
|
||||
reinterpret_cast<sockaddr*>(&sender), size);
|
||||
lastReply = now;
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
void LanDiscovery::Stop() {
|
||||
impl_->running = false;
|
||||
if (impl_->worker.joinable()) impl_->worker.join();
|
||||
if (impl_->socket != INVALID_SOCKET) { closesocket(impl_->socket); impl_->socket = INVALID_SOCKET; }
|
||||
#ifdef _WIN32
|
||||
if (impl_->winsock) { WSACleanup(); impl_->winsock = false; }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -380,6 +380,9 @@ std::vector<std::uint8_t> MakeSettingsTexture(const phonecast::vr::SettingsMenuV
|
||||
view.values[index].size() > 12 ? 1 : 2, secondary);
|
||||
DrawSettingsLabel(image, ">", 474, centerY, 2, secondary);
|
||||
}
|
||||
FillSettingsRoundedRect(image, 16, 666, 480, 46, 12,
|
||||
view.selectedIndex == 10 ? selected : row);
|
||||
DrawSettingsLabel(image, "ABOUT / EXPORT LOGS", 256, 689, 2, text);
|
||||
constexpr std::array<int, 3> actionLeft{16, 174, 332};
|
||||
constexpr std::array<int, 3> actionWidth{142, 142, 164};
|
||||
for (std::size_t action = 0; action < 3; ++action) {
|
||||
@@ -405,7 +408,7 @@ std::vector<std::uint8_t> MakeSettingsTexture(const phonecast::vr::SettingsMenuV
|
||||
index == view.selectedIndex ? selected : row);
|
||||
DrawSettingsLabel(image, view.labels[index], 152, centerY - 12,
|
||||
view.labels[index].size() > 15 ? 1 : 2, text);
|
||||
const bool adjustable = index < view.values.size() &&
|
||||
const bool adjustable = view.title != "ABOUT PHONECAST" && index < view.values.size() &&
|
||||
!view.values[index].empty() && view.values[index] != ">";
|
||||
if (index < view.values.size())
|
||||
DrawSettingsLabel(image, view.values[index], 372, centerY - 12,
|
||||
@@ -729,11 +732,13 @@ std::vector<std::uint8_t> MakeDashboardTexture(
|
||||
}
|
||||
|
||||
DrawImageLabel(image, kDashboardTextureWidth, kDashboardTextureHeight,
|
||||
"HOLD EITHER STICK", 204, 366, 2, secondary);
|
||||
"CLOSE DASHBOARD FOR HOTKEYS", 204, 350, 2, primary);
|
||||
DrawImageLabel(image, kDashboardTextureWidth, kDashboardTextureHeight,
|
||||
"TOGGLE PHONE", 204, 402, 2, secondary);
|
||||
"HOLD EITHER STICK", 204, 382, 2, secondary);
|
||||
DrawImageLabel(image, kDashboardTextureWidth, kDashboardTextureHeight,
|
||||
"DOUBLE CLICK DOCK", 204, 438, 2, secondary);
|
||||
"TOGGLE PHONE", 204, 414, 2, secondary);
|
||||
DrawImageLabel(image, kDashboardTextureWidth, kDashboardTextureHeight,
|
||||
"DOUBLE CLICK DOCK", 204, 446, 2, secondary);
|
||||
const auto& settings = kDashboardTargets[6];
|
||||
const auto& quit = kDashboardTargets[7];
|
||||
FillImageRoundedRect(image, kDashboardTextureWidth, kDashboardTextureHeight,
|
||||
@@ -1641,6 +1646,7 @@ public:
|
||||
if (y >= center - 32 && y < center + 32 && x >= 16 && x < 496)
|
||||
return row;
|
||||
}
|
||||
if (y >= 666 && y < 712 && x >= 16 && x < 496) return 10;
|
||||
constexpr std::array<int, 3> actionLeft{16, 174, 332};
|
||||
constexpr std::array<int, 3> actionWidth{142, 142, 164};
|
||||
for (int action = 0; action < 3; ++action) {
|
||||
@@ -1672,6 +1678,7 @@ public:
|
||||
settingsLaserTargetRow = row;
|
||||
const auto index = static_cast<std::size_t>(row);
|
||||
const bool adjustable = settingsMenuView.title != "PHONECAST SETTINGS" &&
|
||||
settingsMenuView.title != "ABOUT PHONECAST" &&
|
||||
index < settingsMenuView.values.size() &&
|
||||
!settingsMenuView.values[index].empty() &&
|
||||
settingsMenuView.values[index] != ">";
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
add_library(phonecast-support src/SupportDiagnostics.cpp)
|
||||
target_include_directories(phonecast-support PUBLIC include)
|
||||
target_compile_features(phonecast-support PUBLIC cxx_std_17)
|
||||
phonecast_enable_warnings(phonecast-support)
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
namespace phonecast::platform::support {
|
||||
// Whitelist, not a best-effort credential regex: arbitrary log text is excluded.
|
||||
std::string SafeDiagnosticLine(const std::string& line);
|
||||
bool ExportDiagnostics(std::string& filename, std::string& error);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "phonecast/platform/support/SupportDiagnostics.h"
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
|
||||
namespace phonecast::platform::support {
|
||||
namespace {
|
||||
std::string Environment(const char* name) { const char* value = std::getenv(name); return value ? value : ""; }
|
||||
}
|
||||
std::string SafeDiagnosticLine(const std::string& raw) {
|
||||
std::string line = raw;
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.size() > 8192) return {};
|
||||
if (line.rfind("[diagnostics] ", 0) == 0) {
|
||||
std::istringstream tokens(line.substr(14));
|
||||
std::string token;
|
||||
while (tokens >> token) {
|
||||
const auto equals = token.find('=');
|
||||
if (equals == std::string::npos || equals == 0 || equals + 1 == token.size()) return {};
|
||||
const auto key = token.substr(0, equals), value = token.substr(equals + 1);
|
||||
if (key.find_first_not_of("abcdefghijklmnopqrstuvwxyz-") != std::string::npos) return {};
|
||||
// Only the existing numeric counters plus the fixed connected flag.
|
||||
if (key == "connected") { if (value != "yes" && value != "no") return {}; }
|
||||
else {
|
||||
bool known = false;
|
||||
for (const auto allowed : {"rx-fps", "decode-fps", "render-fps", "bitrate-mbps", "decode-ms", "render-ms",
|
||||
"queue-ms", "queue-max-ms", "queue-depth", "keyframes", "dropped", "resyncs",
|
||||
"decoder-recovery-triggers", "decoder-recovery-successes", "decoder-recovery-failed-attempts",
|
||||
"audio-submitted", "audio-dropped", "audio-failures", "audio-sequence-gaps", "audio-timestamp-gaps",
|
||||
"audio-reanchors", "audio-flushes", "audio-opens", "audio-stale-drops", "audio-overflow-drops",
|
||||
"audio-rejected-drops", "audio-max-open-ms", "audio-queued", "audio-output-latency-ms", "audio-bitrate-mbps",
|
||||
"video-sync-queued", "video-sync-deadline-releases", "video-sync-overflow-releases", "video-sync-coalesced",
|
||||
"video-sync-last-hold-ms", "video-sync-skew-valid", "video-sync-estimated-skew-ms",
|
||||
"dispatch-max-messages", "dispatch-max-ms", "presentation-max-gap-ms", "loop-max-ms",
|
||||
"process-cpu-percent", "working-set-mb", "vr-total-gpu-ms", "vr-compositor-gpu-ms", "vr-compositor-cpu-ms",
|
||||
"vr-dropped", "vr-mispresented"}) if (key == allowed) known = true;
|
||||
if (!known || value.find_first_not_of("0123456789.eE+-") != std::string::npos) return {};
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
const std::array<const char*, 7> network{
|
||||
"Rejected a client with an invalid pairing code", "Phone connected and authenticated",
|
||||
"TCP client connected; waiting for pairing handshake", "Phone disconnected; waiting for reconnection",
|
||||
"Could not create listening socket", "Rejected malformed remote-control gate status", "Phone connected; remote control ready"};
|
||||
for (const auto message : network) if (line == std::string("[network] ") + message) return line;
|
||||
for (const auto prefix : {"[network] Waiting for phone on TCP port ", "[network] Could not listen on TCP port "}) {
|
||||
const std::string start(prefix);
|
||||
if (line.rfind(start, 0) == 0 && line.substr(start.size()).find_first_not_of("0123456789") == std::string::npos) return line;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
bool ExportDiagnostics(std::string& filename, std::string& error) {
|
||||
try {
|
||||
#ifdef _WIN32
|
||||
const auto home = Environment("USERPROFILE");
|
||||
const auto state = std::filesystem::path(Environment("LOCALAPPDATA")) / "PhoneCastVR";
|
||||
#else
|
||||
const auto home = Environment("HOME");
|
||||
const auto configuredState = Environment("XDG_STATE_HOME");
|
||||
const auto state = (configuredState.empty() ? std::filesystem::path(home) / ".local/state" : std::filesystem::path(configuredState)) / "phonecast-vr";
|
||||
#endif
|
||||
if (home.empty()) { error = "HOME UNAVAILABLE"; return false; }
|
||||
auto downloads = std::filesystem::path(home) / "Downloads";
|
||||
#ifndef _WIN32
|
||||
const auto configuredConfig = Environment("XDG_CONFIG_HOME");
|
||||
const auto config = configuredConfig.empty() ? std::filesystem::path(home) / ".config" : std::filesystem::path(configuredConfig);
|
||||
std::ifstream directories(config / "user-dirs.dirs");
|
||||
std::string line;
|
||||
while (std::getline(directories, line)) {
|
||||
const std::string prefix = "XDG_DOWNLOAD_DIR=\"";
|
||||
if (line.rfind(prefix, 0) != 0 || line.back() != '"') continue;
|
||||
auto value = line.substr(prefix.size(), line.size() - prefix.size() - 1);
|
||||
if (value.rfind("$HOME/", 0) == 0) value = home + value.substr(5);
|
||||
if (value.find('$') == std::string::npos && std::filesystem::path(value).is_absolute()) downloads = value;
|
||||
}
|
||||
#endif
|
||||
std::filesystem::create_directories(downloads);
|
||||
std::string report = std::string("PhoneCast ") + PHONECAST_VERSION + "\nBuild " + PHONECAST_BUILD_ID +
|
||||
"\nBounded safe diagnostics; arbitrary messages/private payloads omitted.\n";
|
||||
std::ifstream log(state / "receiver.log", std::ios::binary);
|
||||
if (log) {
|
||||
log.seekg(0, std::ios::end);
|
||||
const auto length = log.tellg();
|
||||
const auto start = length > std::streamoff(262144) ? length - std::streamoff(262144) : std::streampos(0);
|
||||
log.seekg(start);
|
||||
std::string data(262144, '\0');
|
||||
log.read(data.data(), static_cast<std::streamsize>(data.size()));
|
||||
data.resize(static_cast<std::size_t>(log.gcount()));
|
||||
std::istringstream lines(data); std::string line;
|
||||
if (start > std::streampos(0)) std::getline(lines, line);
|
||||
std::string safe;
|
||||
while (std::getline(lines, line)) {
|
||||
const auto filtered = SafeDiagnosticLine(line);
|
||||
if (filtered.empty()) continue;
|
||||
safe += filtered + '\n';
|
||||
while (safe.size() > 65536) safe.erase(0, safe.find('\n') + 1);
|
||||
}
|
||||
report += safe.empty() ? "No eligible log events.\n" : safe;
|
||||
} else report += "Receiver log unavailable at the standard state location.\n";
|
||||
const auto stamp = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
const auto outputPath = downloads / ("phonecast-headset-" + std::to_string(stamp) + ".txt");
|
||||
std::ofstream output(outputPath, std::ios::binary);
|
||||
if (!output) { error = "DOWNLOADS NOT WRITABLE"; return false; }
|
||||
output << report; output.close();
|
||||
if (!output) { std::filesystem::remove(outputPath); error = "EXPORT WRITE FAILED"; return false; }
|
||||
#ifndef _WIN32
|
||||
std::filesystem::permissions(outputPath, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write);
|
||||
#endif
|
||||
filename = outputPath.filename().string(); error.clear(); return true;
|
||||
} catch (const std::exception&) { error = "EXPORT FAILED: CHECK STORAGE"; return false; }
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
add_executable(phonecast-support-tests support_tests.cpp)
|
||||
target_link_libraries(phonecast-support-tests PRIVATE phonecast-network phonecast-support phonecast-vr)
|
||||
phonecast_enable_warnings(phonecast-support-tests)
|
||||
if(WIN32)
|
||||
target_link_libraries(phonecast-support-tests PRIVATE ws2_32)
|
||||
endif()
|
||||
if(MINGW)
|
||||
target_link_options(phonecast-support-tests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
if(NOT CMAKE_CROSSCOMPILING)
|
||||
add_test(NAME phonecast-support-tests COMMAND phonecast-support-tests)
|
||||
endif()
|
||||
|
||||
add_executable(phonecast-tests phonecast_tests.cpp)
|
||||
target_compile_features(phonecast-tests PRIVATE cxx_std_17)
|
||||
target_link_libraries(phonecast-tests PRIVATE phonecast-receiver-lib)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#include "phonecast/platform/network/LanDiscovery.h"
|
||||
#include "phonecast/platform/support/SupportDiagnostics.h"
|
||||
#include "phonecast/vr/overlay/SettingsMenuController.h"
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <chrono>
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
void CheckAt(bool value, int line) { if (!value) throw std::runtime_error("support test failed at line " + std::to_string(line)); }
|
||||
#define Check(value) CheckAt((value), __LINE__)
|
||||
void Env(const char* key, const std::string& value) {
|
||||
#ifdef _WIN32
|
||||
_putenv_s(key, value.c_str());
|
||||
#else
|
||||
setenv(key, value.c_str(), 1);
|
||||
#endif
|
||||
}
|
||||
int main() {
|
||||
try {
|
||||
using namespace phonecast::platform;
|
||||
const std::string nonce = "0123456789abcdef0123456789abcdef";
|
||||
const auto query = "PCVR_DISCOVER 1 " + nonce;
|
||||
Check(network::DiscoveryReply(query,49321,"Frame","0.9.0") == "PCVR_RECEIVER 1 " + nonce + " 49321 0.9.0 Frame");
|
||||
Check(network::DiscoveryReply(query + "x",49321,"Frame","0.9.0").empty());
|
||||
Check(network::DiscoveryReply("PCVR_DISCOVER 1 " + std::string(32,'z'),49321,"Frame","0.9.0").empty());
|
||||
Check(network::DiscoveryReply(query,0,"Frame","0.9.0").empty());
|
||||
Check(network::DiscoveryReply(query,49321,"Frame\nsecret","0.9.0").find('\n') == std::string::npos);
|
||||
Check(support::SafeDiagnosticLine("[network] Rejected a client with an invalid pairing code").size() > 0);
|
||||
Check(support::SafeDiagnosticLine("[network] code=123456").empty());
|
||||
Check(support::SafeDiagnosticLine("[notification] private text").empty());
|
||||
Check(support::SafeDiagnosticLine("[diagnostics] connected=yes rx-fps=30 dropped=0").size() > 0);
|
||||
Check(support::SafeDiagnosticLine("[diagnostics] connected=yes pair-code=123456").empty());
|
||||
Check(support::SafeDiagnosticLine("[diagnostics] connected=yes working-set-mb=105.2 audio-opens=1 vr-dropped=0").size() > 0);
|
||||
Check(support::SafeDiagnosticLine("[diagnostics] audio-secret=123456").empty());
|
||||
Check(support::SafeDiagnosticLine("[diagnostics] rx-fps=private").empty());
|
||||
phonecast::vr::SettingsMenuController menu;
|
||||
using phonecast::vr::SettingsMenuCommand;
|
||||
menu.Open({});
|
||||
for (int i=0;i<10;++i) menu.Handle(SettingsMenuCommand::NextItem);
|
||||
menu.Handle(SettingsMenuCommand::Activate);
|
||||
Check(menu.View().title == "ABOUT PHONECAST");
|
||||
Check(menu.View().values[0] == PHONECAST_VERSION);
|
||||
menu.Handle(SettingsMenuCommand::NextItem); menu.Handle(SettingsMenuCommand::NextItem);
|
||||
Check(menu.Handle(SettingsMenuCommand::Activate) == phonecast::vr::SettingsMenuResult::None);
|
||||
Check(menu.View().values[2] == "CONFIRM EXPORT");
|
||||
Check(menu.Handle(SettingsMenuCommand::Activate) == phonecast::vr::SettingsMenuResult::ExportRequested);
|
||||
Check(menu.Handle(SettingsMenuCommand::Activate) == phonecast::vr::SettingsMenuResult::None);
|
||||
menu.SetExportStatus("SAVED TO DOWNLOADS");
|
||||
menu.Handle(SettingsMenuCommand::Activate);
|
||||
menu.Handle(SettingsMenuCommand::NextItem);
|
||||
menu.Handle(SettingsMenuCommand::PreviousItem);
|
||||
Check(menu.Handle(SettingsMenuCommand::Activate) == phonecast::vr::SettingsMenuResult::None);
|
||||
|
||||
const auto root = std::filesystem::temp_directory_path() / ("phonecast-support-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(root / "state/phonecast-vr");
|
||||
std::filesystem::create_directories(root / "config");
|
||||
#ifdef _WIN32
|
||||
Env("USERPROFILE", root.string()); Env("LOCALAPPDATA", (root / "state").string());
|
||||
std::filesystem::create_directories(root / "state/PhoneCastVR");
|
||||
auto logPath = root / "state/PhoneCastVR/receiver.log";
|
||||
#else
|
||||
Env("HOME", root.string()); Env("XDG_STATE_HOME", (root / "state").string()); Env("XDG_CONFIG_HOME", (root / "config").string());
|
||||
auto logPath = root / "state/phonecast-vr/receiver.log";
|
||||
#endif
|
||||
{ std::ofstream log(logPath); log << "private 123456\n[diagnostics] connected=yes rx-fps=30 dropped=0\n[network] Rejected a client with an invalid pairing code\n"; }
|
||||
std::string filename,error;
|
||||
Check(support::ExportDiagnostics(filename,error));
|
||||
Check(filename.rfind("phonecast-headset-", 0) == 0);
|
||||
std::ifstream exported(root / "Downloads" / filename);
|
||||
std::string report((std::istreambuf_iterator<char>(exported)), {});
|
||||
Check(report.find("123456") == std::string::npos);
|
||||
Check(report.find("rx-fps=30") != std::string::npos);
|
||||
Check(report.find("invalid pairing code") != std::string::npos);
|
||||
exported.close();
|
||||
{ std::ofstream log(logPath); for (int i=0;i<12000;++i) log << "[diagnostics] connected=yes rx-fps=30 working-set-mb=105 dropped=0\n"; }
|
||||
Check(support::ExportDiagnostics(filename,error));
|
||||
Check(std::filesystem::file_size(root / "Downloads" / filename) < 70000);
|
||||
#ifndef _WIN32
|
||||
{ std::ofstream dirs(root / "config/user-dirs.dirs"); dirs << "XDG_DOWNLOAD_DIR=\"$HOME/LocalizedDownloads\"\n"; }
|
||||
Check(support::ExportDiagnostics(filename,error));
|
||||
Check(std::filesystem::exists(root / "LocalizedDownloads" / filename));
|
||||
std::filesystem::remove(root / "config/user-dirs.dirs");
|
||||
#endif
|
||||
std::filesystem::remove_all(root / "Downloads");
|
||||
{ std::ofstream blocked(root / "Downloads"); blocked << "not a directory"; }
|
||||
Check(!support::ExportDiagnostics(filename,error));
|
||||
Check(!error.empty());
|
||||
std::filesystem::remove_all(root);
|
||||
|
||||
network::LanDiscovery responder; Check(responder.Start(49321));
|
||||
auto socket = ::socket(AF_INET,SOCK_DGRAM,0);
|
||||
#ifdef _WIN32
|
||||
DWORD timeout = 2000; setsockopt(socket,SOL_SOCKET,SO_RCVTIMEO,reinterpret_cast<const char*>(&timeout),sizeof(timeout));
|
||||
#else
|
||||
timeval timeout{2,0}; setsockopt(socket,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));
|
||||
#endif
|
||||
sockaddr_in address{}; address.sin_family=AF_INET; address.sin_port=htons(49322); address.sin_addr.s_addr=htonl(INADDR_LOOPBACK);
|
||||
sendto(socket,query.data(),static_cast<int>(query.size()),0,reinterpret_cast<sockaddr*>(&address),sizeof(address));
|
||||
char reply[256]{};
|
||||
const auto received=recv(socket,reply,sizeof(reply),0);
|
||||
Check(received>0);
|
||||
Check(std::string(reply,static_cast<std::size_t>(received)).rfind("PCVR_RECEIVER 1 " + nonce,0)==0);
|
||||
#ifdef _WIN32
|
||||
closesocket(socket);
|
||||
#else
|
||||
close(socket);
|
||||
#endif
|
||||
responder.Stop(); Check(responder.Start(49321)); responder.Stop();
|
||||
std::cout << "Support/discovery tests passed\n";
|
||||
return EXIT_SUCCESS;
|
||||
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return EXIT_FAILURE; }
|
||||
}
|
||||
@@ -9,7 +9,8 @@ enum class SettingsMenuResult {
|
||||
None,
|
||||
Updated,
|
||||
Applied,
|
||||
Cancelled
|
||||
Cancelled,
|
||||
ExportRequested
|
||||
};
|
||||
|
||||
class SettingsMenuController {
|
||||
@@ -21,6 +22,9 @@ public:
|
||||
[[nodiscard]] SettingsMenuView View() const;
|
||||
void MergeRendererUpdate(const OverlaySettings& settings) noexcept;
|
||||
void SetAudioStatus(std::string status) { audioStatus_ = std::move(status); }
|
||||
void SetExportStatus(std::string status, std::string filename = {}) {
|
||||
exportStatus_ = std::move(status); exportFilename_ = std::move(filename);
|
||||
}
|
||||
SettingsMenuResult Handle(SettingsMenuCommand command);
|
||||
SettingsMenuResult Handle(const SettingsMenuInput& input);
|
||||
void Close() noexcept { open_ = false; }
|
||||
@@ -35,7 +39,8 @@ private:
|
||||
Glance,
|
||||
Notifications,
|
||||
Audio,
|
||||
StartLocation
|
||||
StartLocation,
|
||||
About
|
||||
};
|
||||
|
||||
[[nodiscard]] std::size_t ItemCount() const noexcept;
|
||||
@@ -48,6 +53,9 @@ private:
|
||||
Page page_{Page::Root};
|
||||
std::size_t selected_{0};
|
||||
std::string audioStatus_{"OFF"};
|
||||
std::string exportStatus_{"REVIEW BEFORE SHARING"};
|
||||
std::string exportFilename_{"NONE"};
|
||||
bool exportConfirmation_{false};
|
||||
OverlaySettings original_{};
|
||||
OverlaySettings draft_{};
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ void SettingsMenuController::Open(const OverlaySettings& settings) {
|
||||
page_ = Page::Root;
|
||||
selected_ = 0;
|
||||
resetConfirmation_ = false;
|
||||
exportConfirmation_ = false;
|
||||
open_ = true;
|
||||
}
|
||||
|
||||
@@ -77,7 +78,8 @@ void SettingsMenuController::MergeRendererUpdate(const OverlaySettings& settings
|
||||
|
||||
std::size_t SettingsMenuController::ItemCount() const noexcept {
|
||||
switch (page_) {
|
||||
case Page::Root: return 10;
|
||||
case Page::Root: return 11;
|
||||
case Page::About: return 7;
|
||||
case Page::Appearance: return 4;
|
||||
case Page::Placement: return 6;
|
||||
case Page::StartLocation: return 4;
|
||||
@@ -98,13 +100,13 @@ SettingsMenuView SettingsMenuController::View() const {
|
||||
view.title = "PHONECAST SETTINGS";
|
||||
view.labels = {"APPEARANCE", "PLACEMENT", "LEFT DOCK", "RIGHT DOCK",
|
||||
"GLANCE AND CONTROLS", "NOTIFICATIONS", "PHONE AUDIO",
|
||||
"RESET ALL", "CANCEL", "APPLY"};
|
||||
"RESET ALL", "CANCEL", "APPLY", "ABOUT"};
|
||||
view.values = {"DARK", PlacementName(draft_.placementMode),
|
||||
Decimal(draft_.leftController.distanceMeters) + " M",
|
||||
Decimal(draft_.rightController.distanceMeters) + " M",
|
||||
"ON", "PRIORITY",
|
||||
draft_.audioMuted ? "MUTED" : "HEADSET",
|
||||
resetConfirmation_ ? "CONFIRM" : "", "", ""};
|
||||
resetConfirmation_ ? "CONFIRM" : "", "", "", ">"};
|
||||
view.normalizedValues.assign(view.labels.size(), -1.0F);
|
||||
break;
|
||||
case Page::Appearance:
|
||||
@@ -180,6 +182,14 @@ SettingsMenuView SettingsMenuController::View() const {
|
||||
Normalize(draft_.startOffsetXMeters, -1.00F, 1.00F),
|
||||
Normalize(draft_.startOffsetYMeters, -0.75F, 0.75F), -1.0F};
|
||||
break;
|
||||
case Page::About:
|
||||
view.title = "ABOUT PHONECAST";
|
||||
view.labels = {"VERSION", "BUILD", "EXPORT LOGS", "EXPORT STATUS", "FILE", "PRIVACY", "BACK"};
|
||||
view.values = {PHONECAST_VERSION, PHONECAST_BUILD_ID,
|
||||
exportConfirmation_ ? "CONFIRM EXPORT" : "TO DOWNLOADS", exportStatus_,
|
||||
exportFilename_, "REVIEW BEFORE SHARING", ""};
|
||||
view.normalizedValues.assign(7, -1.0F);
|
||||
break;
|
||||
case Page::Audio:
|
||||
view.title = "PHONE AUDIO";
|
||||
view.labels = {"MUTE", "VOLUME", "OUTPUT", "STATUS", "BACK"};
|
||||
@@ -201,6 +211,7 @@ SettingsMenuResult SettingsMenuController::Adjust(int direction) {
|
||||
return SettingsMenuResult::Updated;
|
||||
}
|
||||
switch (page_) {
|
||||
case Page::About:
|
||||
case Page::Audio:
|
||||
case Page::Root:
|
||||
return SettingsMenuResult::None;
|
||||
@@ -294,6 +305,7 @@ SettingsMenuResult SettingsMenuController::SetNormalized(float value) {
|
||||
return SettingsMenuResult::Updated;
|
||||
}
|
||||
switch (page_) {
|
||||
case Page::About:
|
||||
case Page::Audio:
|
||||
case Page::Root:
|
||||
return SettingsMenuResult::None;
|
||||
@@ -344,8 +356,16 @@ SettingsMenuResult SettingsMenuController::SetNormalized(float value) {
|
||||
}
|
||||
|
||||
SettingsMenuResult SettingsMenuController::Activate() {
|
||||
if (page_ == Page::About && selected_ == 2) {
|
||||
if (exportStatus_ == "EXPORTING") return SettingsMenuResult::None;
|
||||
if (!exportConfirmation_) { exportConfirmation_ = true; return SettingsMenuResult::None; }
|
||||
exportConfirmation_ = false;
|
||||
exportStatus_ = "EXPORTING";
|
||||
return SettingsMenuResult::ExportRequested;
|
||||
}
|
||||
if (page_ == Page::Audio && (selected_ == 0 || selected_ == 2)) return Adjust(1);
|
||||
if (page_ == Page::Root) {
|
||||
if (selected_ == 10) { page_ = Page::About; selected_ = 0; exportConfirmation_ = false; return SettingsMenuResult::None; }
|
||||
if (selected_ <= 5) {
|
||||
page_ = static_cast<Page>(static_cast<int>(Page::Appearance) + static_cast<int>(selected_));
|
||||
selected_ = 0;
|
||||
@@ -408,10 +428,12 @@ SettingsMenuResult SettingsMenuController::Handle(const SettingsMenuInput& input
|
||||
if (!open_) return SettingsMenuResult::None;
|
||||
switch (input.command) {
|
||||
case SettingsMenuCommand::PreviousItem:
|
||||
exportConfirmation_ = false;
|
||||
selected_ = selected_ == 0 ? ItemCount() - 1 : selected_ - 1;
|
||||
resetConfirmation_ = false;
|
||||
return SettingsMenuResult::None;
|
||||
case SettingsMenuCommand::NextItem:
|
||||
exportConfirmation_ = false;
|
||||
selected_ = (selected_ + 1) % ItemCount();
|
||||
resetConfirmation_ = false;
|
||||
return SettingsMenuResult::None;
|
||||
@@ -424,6 +446,7 @@ SettingsMenuResult SettingsMenuController::Handle(const SettingsMenuInput& input
|
||||
case SettingsMenuCommand::Activate:
|
||||
return Activate();
|
||||
case SettingsMenuCommand::Back:
|
||||
exportConfirmation_ = false;
|
||||
resetConfirmation_ = false;
|
||||
if (page_ == Page::Root) {
|
||||
open_ = false;
|
||||
|
||||
Reference in new issue
Block a user