diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index b7836f90..8b21277c 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -534,3 +534,7 @@ STR_STAR_PAGE: "Star Page" STR_NO_STARRED_PAGES: "No starred pages" STR_RENAME: "Rename" STR_PAGE_PREFIX: "p" +STR_CAPTIVE_PORTAL_DETECTED: "Login Required" +STR_CAPTIVE_PORTAL_HINT_1: "Network requires browser login. On another device," +STR_CAPTIVE_PORTAL_HINT_2: "visit the URL below to authorize, then press OK." +STR_CAPTIVE_PORTAL_DONE: "I'm authorized" diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 66eed43f..5235ea4c 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -1,8 +1,10 @@ #include "WifiSelectionActivity.h" #include +#include #include #include +#include #include #include @@ -13,6 +15,7 @@ #include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/QrUtils.h" namespace { @@ -263,6 +266,30 @@ void WifiSelectionActivity::attemptConnection() { } } +bool WifiSelectionActivity::checkCaptivePortal() { + // Probe a known HTTP endpoint that returns 204 on open internet. + // Captive portals intercept this and return a redirect (3xx) or 200 with a login page. + NetworkClient client; + HTTPClient http; + http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS); + http.setTimeout(5000); + if (!http.begin(client, "http://connectivitycheck.gstatic.com/generate_204")) { + return false; + } + const int code = http.GET(); + String location = http.getLocation(); + http.end(); + + if (code == 204) { + return false; // Open internet, no captive portal + } + + // Any redirect or unexpected 200 means a captive portal is intercepting. + captivePortalUrl = location.length() > 0 ? location.c_str() : "http://connectivitycheck.gstatic.com/generate_204"; + LOG_DBG("WIFI", "Captive portal detected (HTTP %d), URL: %s", code, captivePortalUrl.c_str()); + return true; +} + void WifiSelectionActivity::checkConnectionStatus() { if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING) { return; @@ -285,6 +312,13 @@ void WifiSelectionActivity::checkConnectionStatus() { WIFI_STORE.setLastConnectedSsid(selectedSSID); } + // Check for captive portal before declaring success + if (checkCaptivePortal()) { + state = WifiSelectionState::CAPTIVE_PORTAL; + requestUpdate(); + return; + } + // If we entered a new password, ask if user wants to save it // Otherwise, immediately complete so parent can start web server if (!usedSavedPassword && !enteredPassword.empty()) { @@ -404,6 +438,24 @@ void WifiSelectionActivity::loop() { return; } + // Handle captive portal state - user must authorize on another device + if (state == WifiSelectionState::CAPTIVE_PORTAL) { + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + // User says they've completed browser auth - proceed as connected + if (!usedSavedPassword && !enteredPassword.empty()) { + state = WifiSelectionState::SAVE_PROMPT; + savePromptSelection = 0; + requestUpdate(); + } else { + onComplete(true); + } + } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + WiFi.disconnect(); + startWifiScan(); + } + return; + } + // Handle connected state (should not normally be reached - connection // completes immediately) if (state == WifiSelectionState::CONNECTED) { @@ -539,6 +591,9 @@ void WifiSelectionActivity::render(RenderLock&&) { case WifiSelectionState::FORGET_PROMPT: renderForgetPrompt(); break; + case WifiSelectionState::CAPTIVE_PORTAL: + renderCaptivePortal(); + break; } renderer.displayBuffer(); @@ -719,6 +774,90 @@ void WifiSelectionActivity::renderForgetPrompt() const { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } +void WifiSelectionActivity::renderCaptivePortal() const { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, true, false); + + const int pageWidth = renderer.getScreenWidth(); + const int maxWidth = pageWidth - metrics.contentSidePadding * 2; + const int lh12 = renderer.getLineHeight(UI_12_FONT_ID); + const int lh10 = renderer.getLineHeight(UI_10_FONT_ID); + const int lhSmall = renderer.getLineHeight(SMALL_FONT_ID); + const int sp = metrics.verticalSpacing; + constexpr int QR_SIZE = 320; + + // Pre-compute URL line count so we can vertically centre everything + const char* url = captivePortalUrl.c_str(); + int urlLineCount = 0; + { + int rem = static_cast(captivePortalUrl.size()); + int off = 0; + while (rem > 0) { + int lo = 1, hi = rem; + while (lo < hi) { + const int mid = (lo + hi + 1) / 2; + char tmp[512]; + snprintf(tmp, sizeof(tmp), "%.*s", mid, url + off); + if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth) + lo = mid; + else + hi = mid - 1; + } + urlLineCount++; + off += lo; + rem -= lo; + } + } + + const int totalHeight = lh12 + sp // title + + lh10 // hint line 1 + + lh10 + sp // hint line 2 + + QR_SIZE + sp // QR code + + urlLineCount * lhSmall; + + // contentRect covers the full screen minus button hints; subtract the header + // and sub-header that render() always draws above us. + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight; + const int contentBottom = contentRect.y + contentRect.height; + int y = contentTop + (contentBottom - contentTop - totalHeight) / 2; + + renderer.drawCenteredText(UI_12_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_DETECTED), true, EpdFontFamily::BOLD); + y += lh12 + sp; + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_1)); + y += lh10; + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_2)); + y += lh10 + sp; + + const int qrX = contentRect.x + (contentRect.width - QR_SIZE) / 2; + QrUtils::drawQrCode(renderer, Rect{qrX, y, QR_SIZE, QR_SIZE}, captivePortalUrl); + y += QR_SIZE + sp; + + // Split URL into as many lines as needed + int remaining = static_cast(captivePortalUrl.size()); + int offset = 0; + while (remaining > 0) { + int lo = 1, hi = remaining; + while (lo < hi) { + const int mid = (lo + hi + 1) / 2; + char tmp[512]; + snprintf(tmp, sizeof(tmp), "%.*s", mid, url + offset); + if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth) + lo = mid; + else + hi = mid - 1; + } + char line[512]; + snprintf(line, sizeof(line), "%.*s", lo, url + offset); + renderer.drawCenteredText(SMALL_FONT_ID, y, line); + y += lhSmall; + offset += lo; + remaining -= lo; + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CAPTIVE_PORTAL_DONE), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + void WifiSelectionActivity::onComplete(const bool connected) { ActivityResult result; result.isCancelled = !connected; diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index 1ea7fc0a..42d37862 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -28,7 +28,8 @@ enum class WifiSelectionState { CONNECTED, // Successfully connected SAVE_PROMPT, // Asking user if they want to save the password CONNECTION_FAILED, // Connection failed - FORGET_PROMPT // Asking user if they want to forget the network + FORGET_PROMPT, // Asking user if they want to forget the network + CAPTIVE_PORTAL // Connected but network requires web-based login }; /** @@ -87,14 +88,18 @@ class WifiSelectionActivity final : public Activity { void renderSavePrompt() const; void renderConnectionFailed() const; void renderForgetPrompt() const; + void renderCaptivePortal() const; void startWifiScan(); void processWifiScanResults(); void selectNetwork(int index); void attemptConnection(); void checkConnectionStatus(); + bool checkCaptivePortal(); std::string getSignalStrengthIndicator(int32_t rssi) const; + std::string captivePortalUrl; + void onComplete(bool connected); public: