adds initial support for companion app
This commit is contained in:
parent
91c8cc67ce
commit
e9e9ef68da
309
docs/companion-app-deep-link-API.md
Normal file
309
docs/companion-app-deep-link-API.md
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
# CrossPoint Companion Deep Link API
|
||||||
|
|
||||||
|
This document describes the deep link functionality that allows the CrossPoint Companion Android app to be launched from QR codes displayed on CrossPoint e-reader devices.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The CrossPoint firmware can generate QR codes containing deep link URLs. When scanned with a mobile device, these URLs launch the companion app directly to a specific tab and optionally auto-connect to the device.
|
||||||
|
|
||||||
|
## URL Scheme
|
||||||
|
|
||||||
|
```
|
||||||
|
crosspoint://<path>?<query_parameters>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `crosspoint://` | Custom URL scheme registered by the app |
|
||||||
|
| `<path>` | Target tab in the app (see [Path Mapping](#path-mapping)) |
|
||||||
|
| `<query_parameters>` | Optional device connection parameters |
|
||||||
|
|
||||||
|
## Path Mapping
|
||||||
|
|
||||||
|
The URL path determines which tab the app navigates to:
|
||||||
|
|
||||||
|
| Path | App Tab | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `files` | Device | File browser for device storage |
|
||||||
|
| `library` | Library | Local book library |
|
||||||
|
| `lists` | Lists | Reading lists management |
|
||||||
|
| `settings` | Settings | App settings |
|
||||||
|
|
||||||
|
**Note:** Unknown paths default to the Library tab.
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
Query parameters provide device connection information for automatic connection:
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `host` | string | *(required for auto-connect)* | IP address or hostname of the device |
|
||||||
|
| `port` | integer | `80` | HTTP API port |
|
||||||
|
| `wsPort` | integer | `81` | WebSocket port for file uploads |
|
||||||
|
|
||||||
|
## URL Examples
|
||||||
|
|
||||||
|
### Basic Navigation (No Auto-Connect)
|
||||||
|
|
||||||
|
Navigate to a specific tab without connecting to a device:
|
||||||
|
|
||||||
|
```
|
||||||
|
crosspoint://files
|
||||||
|
crosspoint://library
|
||||||
|
crosspoint://lists
|
||||||
|
crosspoint://settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Connect to Device
|
||||||
|
|
||||||
|
Navigate to Device tab and auto-connect:
|
||||||
|
|
||||||
|
```
|
||||||
|
crosspoint://files?host=192.168.1.100
|
||||||
|
crosspoint://files?host=192.168.1.100&port=80&wsPort=81
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Ports
|
||||||
|
|
||||||
|
Connect to a device with non-default ports:
|
||||||
|
|
||||||
|
```
|
||||||
|
crosspoint://files?host=192.168.1.100&port=8080&wsPort=8081
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hostname Instead of IP
|
||||||
|
|
||||||
|
```
|
||||||
|
crosspoint://files?host=crosspoint.local&port=80&wsPort=81
|
||||||
|
```
|
||||||
|
|
||||||
|
## Firmware Implementation
|
||||||
|
|
||||||
|
### QR Code Generation
|
||||||
|
|
||||||
|
The CrossPoint firmware should generate QR codes containing the deep link URL. Example format:
|
||||||
|
|
||||||
|
```
|
||||||
|
crosspoint://files?host=<device_ip>&port=<http_port>&wsPort=<ws_port>
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `<device_ip>` is the device's current IP address (e.g., from WiFi connection)
|
||||||
|
- `<http_port>` is the HTTP API port (default: 80)
|
||||||
|
- `<ws_port>` is the WebSocket port (default: 81)
|
||||||
|
|
||||||
|
### Example Firmware Code (C++)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
String generateDeepLinkUrl(const char* path = "files") {
|
||||||
|
String url = "crosspoint://";
|
||||||
|
url += path;
|
||||||
|
url += "?host=";
|
||||||
|
url += WiFi.localIP().toString();
|
||||||
|
url += "&port=";
|
||||||
|
url += String(HTTP_PORT); // e.g., 80
|
||||||
|
url += "&wsPort=";
|
||||||
|
url += String(WS_PORT); // e.g., 81
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code with:
|
||||||
|
// String url = generateDeepLinkUrl("files");
|
||||||
|
// displayQRCode(url);
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Behavior
|
||||||
|
|
||||||
|
### Launch Scenarios
|
||||||
|
|
||||||
|
#### 1. App Not Running
|
||||||
|
|
||||||
|
When the app is launched via deep link:
|
||||||
|
1. App starts and parses the deep link URL
|
||||||
|
2. Navigates to the target tab
|
||||||
|
3. If device connection info is present and target is "files":
|
||||||
|
- Checks for existing device with matching IP
|
||||||
|
- If found: uses existing device (preserving user's custom name)
|
||||||
|
- If not found: creates temporary connection
|
||||||
|
- Attempts to connect automatically
|
||||||
|
|
||||||
|
#### 2. App Already Running
|
||||||
|
|
||||||
|
When a deep link is received while the app is open:
|
||||||
|
1. `onNewIntent` receives the new URL
|
||||||
|
2. Navigates to the target tab
|
||||||
|
3. Handles device connection (same as above)
|
||||||
|
|
||||||
|
### Device Matching Logic
|
||||||
|
|
||||||
|
When connecting via deep link:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Look up device by IP address in database
|
||||||
|
2. If device exists:
|
||||||
|
a. Check if ports match
|
||||||
|
b. If ports differ, update the stored device with new ports
|
||||||
|
c. Connect using the existing device (preserves custom name)
|
||||||
|
3. If device doesn't exist:
|
||||||
|
a. Create temporary Device object (not saved to database)
|
||||||
|
b. Connect using temporary device
|
||||||
|
c. Display as "CrossPoint (<ip>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Malformed URL | App opens to Library tab (default) |
|
||||||
|
| Unknown path | App opens to Library tab with warning logged |
|
||||||
|
| Invalid host format | Navigation succeeds, no auto-connect |
|
||||||
|
| Invalid port values | Default ports used (80, 81) |
|
||||||
|
| Connection failure | Error message displayed, user can retry |
|
||||||
|
| Device unreachable | Error message with device IP shown |
|
||||||
|
|
||||||
|
## Android Implementation Details
|
||||||
|
|
||||||
|
### Intent Filter (AndroidManifest.xml)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="crosspoint" />
|
||||||
|
</intent-filter>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `DeepLinkParser` | Parses URI into `DeepLinkData` |
|
||||||
|
| `DeepLinkData` | Data class holding parsed deep link info |
|
||||||
|
| `DeviceConnectionInfo` | Data class for host/port/wsPort |
|
||||||
|
| `MainActivity` | Handles incoming intents |
|
||||||
|
| `CrossPointApp` | Routes navigation based on deep link |
|
||||||
|
| `DeviceBrowserViewModel` | Handles `connectFromDeepLink()` |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
QR Code Scan
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Android Intent (ACTION_VIEW)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MainActivity.onCreate() / onNewIntent()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DeepLinkParser.parse(uri)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DeepLinkData { targetTab, deviceConnection? }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CrossPointApp (LaunchedEffect)
|
||||||
|
│
|
||||||
|
├─► Navigate to targetTab
|
||||||
|
│
|
||||||
|
└─► If targetTab == Device && deviceConnection != null
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DeviceBrowserScreen
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DeviceBrowserViewModel.connectFromDeepLink()
|
||||||
|
│
|
||||||
|
├─► Check existing device by IP
|
||||||
|
├─► Update ports if needed
|
||||||
|
└─► Connect and load files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
### Host Validation
|
||||||
|
|
||||||
|
Valid hosts:
|
||||||
|
- IPv4 addresses: `192.168.1.100`, `10.0.0.1`
|
||||||
|
- Hostnames: `crosspoint.local`, `my-device`
|
||||||
|
|
||||||
|
Invalid hosts (rejected):
|
||||||
|
- Empty strings
|
||||||
|
- Malformed IPs: `192.168.1.256`, `192.168.1`
|
||||||
|
- IPs with invalid octets
|
||||||
|
|
||||||
|
### Port Validation
|
||||||
|
|
||||||
|
- Valid range: 1-65535
|
||||||
|
- Out-of-range values default to 80 (HTTP) or 81 (WebSocket)
|
||||||
|
- Non-numeric values default to standard ports
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing with ADB
|
||||||
|
|
||||||
|
Test deep links without a QR code using ADB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic navigation
|
||||||
|
adb shell am start -a android.intent.action.VIEW -d "crosspoint://files"
|
||||||
|
adb shell am start -a android.intent.action.VIEW -d "crosspoint://library"
|
||||||
|
|
||||||
|
# With device connection
|
||||||
|
adb shell am start -a android.intent.action.VIEW -d "crosspoint://files?host=192.168.1.100"
|
||||||
|
adb shell am start -a android.intent.action.VIEW -d "crosspoint://files?host=192.168.1.100&port=80&wsPort=81"
|
||||||
|
|
||||||
|
# Test while app is running (onNewIntent)
|
||||||
|
adb shell am start -a android.intent.action.VIEW -d "crosspoint://settings"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Cases
|
||||||
|
|
||||||
|
1. **Valid deep link with connection info**
|
||||||
|
- URL: `crosspoint://files?host=192.168.1.100&port=80&wsPort=81`
|
||||||
|
- Expected: Opens Device tab, auto-connects to device
|
||||||
|
|
||||||
|
2. **Valid deep link without connection info**
|
||||||
|
- URL: `crosspoint://files`
|
||||||
|
- Expected: Opens Device tab, shows device selection
|
||||||
|
|
||||||
|
3. **Unknown path**
|
||||||
|
- URL: `crosspoint://unknown`
|
||||||
|
- Expected: Opens Library tab (default)
|
||||||
|
|
||||||
|
4. **Missing host parameter**
|
||||||
|
- URL: `crosspoint://files?port=80`
|
||||||
|
- Expected: Opens Device tab, no auto-connect
|
||||||
|
|
||||||
|
5. **Invalid host format**
|
||||||
|
- URL: `crosspoint://files?host=invalid..host`
|
||||||
|
- Expected: Opens Device tab, no auto-connect
|
||||||
|
|
||||||
|
6. **Device already exists in database**
|
||||||
|
- Precondition: Device with IP 192.168.1.100 saved as "My Reader"
|
||||||
|
- URL: `crosspoint://files?host=192.168.1.100`
|
||||||
|
- Expected: Connects using "My Reader" name
|
||||||
|
|
||||||
|
7. **Existing device with different ports**
|
||||||
|
- Precondition: Device saved with port=80, wsPort=81
|
||||||
|
- URL: `crosspoint://files?host=192.168.1.100&port=8080&wsPort=8081`
|
||||||
|
- Expected: Updates device ports, then connects
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Local Network Only**: Deep links should only contain local network addresses. The app does not validate this, but firmware should only generate URLs with local IPs.
|
||||||
|
|
||||||
|
2. **No Authentication**: The deep link does not include authentication. Device security relies on network-level access control.
|
||||||
|
|
||||||
|
3. **Temporary Devices**: Devices created from deep links (when no matching device exists) are not persisted, preventing automatic accumulation of device entries.
|
||||||
|
|
||||||
|
4. **No Sensitive Data**: Deep link URLs should not contain sensitive information as QR codes can be photographed.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Version | Changes |
|
||||||
|
|---------|---------|
|
||||||
|
| 1.0.0 | Initial deep link support with `crosspoint://` scheme |
|
||||||
@ -46,6 +46,8 @@ void CrossPointWebServerActivity::onEnter() {
|
|||||||
connectedIP.clear();
|
connectedIP.clear();
|
||||||
connectedSSID.clear();
|
connectedSSID.clear();
|
||||||
lastHandleClientTime = 0;
|
lastHandleClientTime = 0;
|
||||||
|
currentScreen = FileTransferScreen::WEB_BROWSER;
|
||||||
|
lastStatsRefresh = 0;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|
||||||
xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask",
|
xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask",
|
||||||
@ -339,6 +341,48 @@ void CrossPointWebServerActivity::loop() {
|
|||||||
lastHandleClientTime = millis();
|
lastHandleClientTime = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle screen switching with arrow buttons
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::PageForward)) {
|
||||||
|
// Cycle to next screen: WEB_BROWSER -> COMPANION_APP -> COMPANION_APP_LIBRARY -> WEB_BROWSER
|
||||||
|
switch (currentScreen) {
|
||||||
|
case FileTransferScreen::WEB_BROWSER:
|
||||||
|
currentScreen = FileTransferScreen::COMPANION_APP;
|
||||||
|
break;
|
||||||
|
case FileTransferScreen::COMPANION_APP:
|
||||||
|
currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
||||||
|
break;
|
||||||
|
case FileTransferScreen::COMPANION_APP_LIBRARY:
|
||||||
|
currentScreen = FileTransferScreen::WEB_BROWSER;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
updateRequired = true;
|
||||||
|
Serial.printf("[%lu] [WEBACT] Switched to screen: %d\n", millis(), static_cast<int>(currentScreen));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::PageBack)) {
|
||||||
|
// Cycle to previous screen: WEB_BROWSER -> COMPANION_APP_LIBRARY -> COMPANION_APP -> WEB_BROWSER
|
||||||
|
switch (currentScreen) {
|
||||||
|
case FileTransferScreen::WEB_BROWSER:
|
||||||
|
currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
||||||
|
break;
|
||||||
|
case FileTransferScreen::COMPANION_APP:
|
||||||
|
currentScreen = FileTransferScreen::WEB_BROWSER;
|
||||||
|
break;
|
||||||
|
case FileTransferScreen::COMPANION_APP_LIBRARY:
|
||||||
|
currentScreen = FileTransferScreen::COMPANION_APP;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
updateRequired = true;
|
||||||
|
Serial.printf("[%lu] [WEBACT] Switched to screen: %d\n", millis(), static_cast<int>(currentScreen));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh stats every 30 seconds
|
||||||
|
if (millis() - lastStatsRefresh >= 30000) {
|
||||||
|
lastStatsRefresh = millis();
|
||||||
|
updateRequired = true;
|
||||||
|
Serial.printf("[%lu] [WEBACT] Stats refresh triggered\n", millis());
|
||||||
|
}
|
||||||
|
|
||||||
// Handle exit on Back button (also check outside loop)
|
// Handle exit on Back button (also check outside loop)
|
||||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
onGoBack();
|
onGoBack();
|
||||||
@ -396,11 +440,111 @@ void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void CrossPointWebServerActivity::renderServerRunning() const {
|
// Helper to format bytes into human-readable sizes
|
||||||
// Use consistent line spacing
|
std::string formatBytes(size_t bytes) {
|
||||||
constexpr int LINE_SPACING = 28; // Space between lines
|
if (bytes < 1024) {
|
||||||
|
return std::to_string(bytes) + " B";
|
||||||
|
} else if (bytes < 1024 * 1024) {
|
||||||
|
char buf[32];
|
||||||
|
snprintf(buf, sizeof(buf), "%.1f KB", bytes / 1024.0);
|
||||||
|
return buf;
|
||||||
|
} else {
|
||||||
|
char buf[32];
|
||||||
|
snprintf(buf, sizeof(buf), "%.1f MB", bytes / (1024.0 * 1024.0));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD);
|
// Helper to format uptime in human-readable format
|
||||||
|
std::string formatUptime(unsigned long seconds) {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return std::to_string(seconds) + "s";
|
||||||
|
} else if (seconds < 3600) {
|
||||||
|
unsigned long mins = seconds / 60;
|
||||||
|
unsigned long secs = seconds % 60;
|
||||||
|
return std::to_string(mins) + "m " + std::to_string(secs) + "s";
|
||||||
|
} else {
|
||||||
|
unsigned long hours = seconds / 3600;
|
||||||
|
unsigned long mins = (seconds % 3600) / 60;
|
||||||
|
return std::to_string(hours) + "h " + std::to_string(mins) + "m";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CrossPointWebServerActivity::getCompanionAppUrl() const {
|
||||||
|
// Generate deep link URL for companion Android app - Device/Files tab
|
||||||
|
// Format: crosspoint://files?host=<ip>&port=<port>&wsPort=<wsPort>
|
||||||
|
std::string url = "crosspoint://files?host=" + connectedIP;
|
||||||
|
if (webServer) {
|
||||||
|
url += "&port=" + std::to_string(webServer->getPort());
|
||||||
|
url += "&wsPort=" + std::to_string(webServer->getWsPort());
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CrossPointWebServerActivity::getCompanionAppLibraryUrl() const {
|
||||||
|
// Generate deep link URL for companion Android app - Library tab
|
||||||
|
// Format: crosspoint://library?host=<ip>&port=<port>&wsPort=<wsPort>
|
||||||
|
std::string url = "crosspoint://library?host=" + connectedIP;
|
||||||
|
if (webServer) {
|
||||||
|
url += "&port=" + std::to_string(webServer->getPort());
|
||||||
|
url += "&wsPort=" + std::to_string(webServer->getWsPort());
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::renderServerRunning() const {
|
||||||
|
// Dispatch to the appropriate screen
|
||||||
|
switch (currentScreen) {
|
||||||
|
case FileTransferScreen::WEB_BROWSER:
|
||||||
|
renderWebBrowserScreen();
|
||||||
|
break;
|
||||||
|
case FileTransferScreen::COMPANION_APP:
|
||||||
|
renderCompanionAppScreen();
|
||||||
|
break;
|
||||||
|
case FileTransferScreen::COMPANION_APP_LIBRARY:
|
||||||
|
renderCompanionAppLibraryScreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render stats bar at the bottom (above button hints)
|
||||||
|
renderStats();
|
||||||
|
|
||||||
|
// Draw button hints with arrow navigation
|
||||||
|
const auto labels = mappedInput.mapLabels("« Exit", "", "«", "»");
|
||||||
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::renderStats() const {
|
||||||
|
if (!webServer) return;
|
||||||
|
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
const int statsY = screenHeight - 70; // Position above button hints
|
||||||
|
|
||||||
|
// Get stats from web server
|
||||||
|
const unsigned long uptime = webServer->getServerUptime();
|
||||||
|
const size_t bytesUp = webServer->getTotalBytesUploaded();
|
||||||
|
const size_t bytesDown = webServer->getTotalBytesDownloaded();
|
||||||
|
const size_t filesUp = webServer->getTotalFilesUploaded();
|
||||||
|
const size_t filesDown = webServer->getTotalFilesDownloaded();
|
||||||
|
|
||||||
|
// Format stats string
|
||||||
|
std::string statsLine = "Up: " + formatUptime(uptime);
|
||||||
|
statsLine += " | Recv: " + formatBytes(bytesUp);
|
||||||
|
if (filesUp > 0) {
|
||||||
|
statsLine += " (" + std::to_string(filesUp) + ")";
|
||||||
|
}
|
||||||
|
statsLine += " | Sent: " + formatBytes(bytesDown);
|
||||||
|
if (filesDown > 0) {
|
||||||
|
statsLine += " (" + std::to_string(filesDown) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, statsY, statsLine.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
||||||
|
constexpr int LINE_SPACING = 28;
|
||||||
|
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer - Web Browser", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
if (isApMode) {
|
if (isApMode) {
|
||||||
// AP mode display - center the content block
|
// AP mode display - center the content block
|
||||||
@ -415,7 +559,7 @@ void CrossPointWebServerActivity::renderServerRunning() const {
|
|||||||
|
|
||||||
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3,
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3,
|
||||||
"or scan QR code with your phone to connect to Wifi.");
|
"or scan QR code with your phone to connect to Wifi.");
|
||||||
// Show QR code for URL
|
// Show QR code for WiFi config
|
||||||
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
||||||
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig);
|
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig);
|
||||||
|
|
||||||
@ -433,8 +577,8 @@ void CrossPointWebServerActivity::renderServerRunning() const {
|
|||||||
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:");
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:");
|
||||||
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl);
|
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl);
|
||||||
} else {
|
} else {
|
||||||
// STA mode display (original behavior)
|
// STA mode display
|
||||||
const int startY = 65;
|
const int startY = 55;
|
||||||
|
|
||||||
std::string ssidInfo = "Network: " + connectedSSID;
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
if (ssidInfo.length() > 28) {
|
if (ssidInfo.length() > 28) {
|
||||||
@ -455,11 +599,83 @@ void CrossPointWebServerActivity::renderServerRunning() const {
|
|||||||
|
|
||||||
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser");
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser");
|
||||||
|
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:");
|
||||||
|
|
||||||
// Show QR code for URL
|
// Show QR code for URL
|
||||||
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo);
|
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo);
|
||||||
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:");
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::renderCompanionAppScreen() const {
|
||||||
|
constexpr int LINE_SPACING = 28;
|
||||||
|
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer - Companion App", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
const int startY = 55;
|
||||||
|
|
||||||
|
// Show network info
|
||||||
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
|
if (ssidInfo.length() > 28) {
|
||||||
|
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, startY, ssidInfo.c_str());
|
||||||
|
|
||||||
|
if (isApMode) {
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING,
|
||||||
|
"Connect to this WiFi first, then scan QR code");
|
||||||
|
} else {
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING, "Scan QR code with the CrossPoint Companion app");
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels("« Exit", "", "", "");
|
// Show companion app URL prominently
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
const std::string appUrl = getCompanionAppUrl();
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, "or scan QR code:");
|
||||||
|
|
||||||
|
// Show QR code for app URL (centered)
|
||||||
|
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, appUrl);
|
||||||
|
|
||||||
|
// Show HTTP URL for reference (smaller)
|
||||||
|
const int refY = startY + LINE_SPACING * 4 + 6 * 33 + 15; // Below QR code
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, refY, "Opens to: Device file browser");
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, refY + 20, webUrl.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
|
||||||
|
constexpr int LINE_SPACING = 28;
|
||||||
|
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer - Library Link", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
const int startY = 55;
|
||||||
|
|
||||||
|
// Show network info
|
||||||
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
|
if (ssidInfo.length() > 28) {
|
||||||
|
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, startY, ssidInfo.c_str());
|
||||||
|
|
||||||
|
if (isApMode) {
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING,
|
||||||
|
"Connect to this WiFi first, then scan QR code");
|
||||||
|
} else {
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING, "Scan QR code with the CrossPoint Companion app");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show companion app URL prominently
|
||||||
|
const std::string appUrl = getCompanionAppLibraryUrl();
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, "or scan QR code:");
|
||||||
|
|
||||||
|
// Show QR code for app URL (centered)
|
||||||
|
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, appUrl);
|
||||||
|
|
||||||
|
// Show what this link opens to
|
||||||
|
const int refY = startY + LINE_SPACING * 4 + 6 * 33 + 15; // Below QR code
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, refY, "Opens to: Your local Library");
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
|
renderer.drawCenteredText(SMALL_FONT_ID, refY + 20, webUrl.c_str());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,13 @@ enum class WebServerActivityState {
|
|||||||
SHUTTING_DOWN // Shutting down server and WiFi
|
SHUTTING_DOWN // Shutting down server and WiFi
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// File transfer screen tabs
|
||||||
|
enum class FileTransferScreen {
|
||||||
|
WEB_BROWSER, // Default screen with HTTP URL and QR code
|
||||||
|
COMPANION_APP, // Screen with crosspoint://files deep link URL and QR code
|
||||||
|
COMPANION_APP_LIBRARY // Screen with crosspoint://library deep link URL and QR code
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CrossPointWebServerActivity is the entry point for file transfer functionality.
|
* CrossPointWebServerActivity is the entry point for file transfer functionality.
|
||||||
* It:
|
* It:
|
||||||
@ -51,10 +58,20 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
// Performance monitoring
|
// Performance monitoring
|
||||||
unsigned long lastHandleClientTime = 0;
|
unsigned long lastHandleClientTime = 0;
|
||||||
|
|
||||||
|
// Screen navigation
|
||||||
|
FileTransferScreen currentScreen = FileTransferScreen::WEB_BROWSER;
|
||||||
|
unsigned long lastStatsRefresh = 0;
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render() const;
|
void render() const;
|
||||||
void renderServerRunning() const;
|
void renderServerRunning() const;
|
||||||
|
void renderWebBrowserScreen() const;
|
||||||
|
void renderCompanionAppScreen() const;
|
||||||
|
void renderCompanionAppLibraryScreen() const;
|
||||||
|
void renderStats() const;
|
||||||
|
std::string getCompanionAppUrl() const;
|
||||||
|
std::string getCompanionAppLibraryUrl() const;
|
||||||
|
|
||||||
void onNetworkModeSelected(NetworkMode mode);
|
void onNetworkModeSelected(NetworkMode mode);
|
||||||
void onWifiSelectionComplete(bool connected);
|
void onWifiSelectionComplete(bool connected);
|
||||||
|
|||||||
@ -138,6 +138,7 @@ void CrossPointWebServer::begin() {
|
|||||||
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis());
|
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis());
|
||||||
|
|
||||||
running = true;
|
running = true;
|
||||||
|
serverStartTime = millis();
|
||||||
|
|
||||||
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
|
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
|
||||||
// Show the correct IP based on network mode
|
// Show the correct IP based on network mode
|
||||||
@ -557,6 +558,10 @@ void CrossPointWebServer::handleUpload() const {
|
|||||||
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(),
|
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(),
|
||||||
writeCount, totalWriteTime, writePercent);
|
writeCount, totalWriteTime, writePercent);
|
||||||
|
|
||||||
|
// Update traffic statistics
|
||||||
|
totalBytesUploaded += uploadSize;
|
||||||
|
totalFilesUploaded++;
|
||||||
|
|
||||||
// Clear epub cache to prevent stale metadata issues when overwriting files
|
// Clear epub cache to prevent stale metadata issues when overwriting files
|
||||||
String filePath = uploadPath;
|
String filePath = uploadPath;
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
if (!filePath.endsWith("/")) filePath += "/";
|
||||||
@ -940,6 +945,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
|||||||
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(),
|
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(),
|
||||||
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps);
|
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps);
|
||||||
|
|
||||||
|
// Update traffic statistics
|
||||||
|
totalBytesUploaded += wsUploadSize;
|
||||||
|
totalFilesUploaded++;
|
||||||
|
|
||||||
// Clear epub cache to prevent stale metadata issues when overwriting files
|
// Clear epub cache to prevent stale metadata issues when overwriting files
|
||||||
String filePath = wsUploadPath;
|
String filePath = wsUploadPath;
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
if (!filePath.endsWith("/")) filePath += "/";
|
||||||
@ -1052,6 +1061,12 @@ void CrossPointWebServer::handleDownload() const {
|
|||||||
const float kbps = (elapsed > 0) ? (totalSent / 1024.0) / (elapsed / 1000.0) : 0;
|
const float kbps = (elapsed > 0) ? (totalSent / 1024.0) / (elapsed / 1000.0) : 0;
|
||||||
Serial.printf("[%lu] [WEB] Download complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), filename.c_str(),
|
Serial.printf("[%lu] [WEB] Download complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), filename.c_str(),
|
||||||
totalSent, elapsed, kbps);
|
totalSent, elapsed, kbps);
|
||||||
|
|
||||||
|
// Update traffic statistics (only if download completed successfully)
|
||||||
|
if (totalSent == fileSize) {
|
||||||
|
totalBytesDownloaded += totalSent;
|
||||||
|
totalFilesDownloaded++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void CrossPointWebServer::handleRename() const {
|
void CrossPointWebServer::handleRename() const {
|
||||||
|
|||||||
@ -33,6 +33,16 @@ class CrossPointWebServer {
|
|||||||
// Get the port number
|
// Get the port number
|
||||||
uint16_t getPort() const { return port; }
|
uint16_t getPort() const { return port; }
|
||||||
|
|
||||||
|
// Get the WebSocket port number
|
||||||
|
uint16_t getWsPort() const { return wsPort; }
|
||||||
|
|
||||||
|
// Traffic statistics
|
||||||
|
unsigned long getServerUptime() const { return running ? (millis() - serverStartTime) / 1000 : 0; }
|
||||||
|
size_t getTotalBytesUploaded() const { return totalBytesUploaded; }
|
||||||
|
size_t getTotalBytesDownloaded() const { return totalBytesDownloaded; }
|
||||||
|
size_t getTotalFilesUploaded() const { return totalFilesUploaded; }
|
||||||
|
size_t getTotalFilesDownloaded() const { return totalFilesDownloaded; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unique_ptr<WebServer> server = nullptr;
|
std::unique_ptr<WebServer> server = nullptr;
|
||||||
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
|
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
|
||||||
@ -41,6 +51,13 @@ class CrossPointWebServer {
|
|||||||
uint16_t port = 80;
|
uint16_t port = 80;
|
||||||
uint16_t wsPort = 81; // WebSocket port
|
uint16_t wsPort = 81; // WebSocket port
|
||||||
|
|
||||||
|
// Traffic statistics (mutable to allow updating from const handlers)
|
||||||
|
mutable size_t totalBytesUploaded = 0;
|
||||||
|
mutable size_t totalBytesDownloaded = 0;
|
||||||
|
mutable size_t totalFilesUploaded = 0;
|
||||||
|
mutable size_t totalFilesDownloaded = 0;
|
||||||
|
unsigned long serverStartTime = 0;
|
||||||
|
|
||||||
// WebSocket upload state
|
// WebSocket upload state
|
||||||
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||||
static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user