From d62b6c871fc60e8b9a2030f4ebd2ccbf34e5655a Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Sat, 15 Aug 2026 19:10:14 -0600 Subject: [PATCH 1/2] ADFA-5158 fix(clone): resolve the Send AP IP by polling, not a one-shot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get-app QR (Send page 1, section 2) sat on "Starting the service…" for up to a minute on a hotspot: renderPrepare read the host IP with a single NetworkInterfaces.discover(), and on a LocalOnlyHotspot the AP interface's IPv4 lands with latency, so the first read was null and the QR only redrew on the next incidental render. - Poll for the AP IP (~1s) while a Send page needs it and redraw the moment it resolves; the "Starting the service…" placeholder stays honest meanwhile. - Broaden NetworkInterfaces.discover(): if the name whitelist misses the AP iface (OEM-specific names), fall back to the up, non-loopback, site-local IPv4 that isn't wlan0. - Resolve the IP through one helper (peerReachableIp) for both get-app and the Copy daemon; drop Copy's hardcoded 192.168.49.1 (not universal). ApkServer already binds all interfaces, so this is only about which IP to advertise. - Guard ensureHotspot against re-requesting a start while one is in flight. No new strings. --- .../controller/redesign/CloneFragment.java | 47 ++++++++++++++++--- .../sync/transport/NetworkInterfaces.java | 19 ++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java index d9969a1f4..c90d027c7 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java @@ -433,6 +433,10 @@ private void setMode(Mode m) { private void ensureHotspot() { if (!LocalHotspotManager.isSupported() || hs.isOn()) return; + // ADFA-5158: renderPrepare runs on every render; don't re-request a start while one is in flight + // (the "Caller already has an active LocalOnlyHotspot request" log spam). + LocalHotspotManager.State st = hs.state().getValue(); + if (st != null && st.phase == LocalHotspotManager.Phase.STARTING) return; if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { hs.start(requireContext().getApplicationContext()); @@ -716,10 +720,10 @@ private void renderPrepare() { // ---- Section ② : Get the app ---- if (mode == Mode.HOTSPOT) ensureHotspot(); startApkServer(); - NetworkInterfaces.LanIps net = NetworkInterfaces.discover(); - String appIp = (mode == Mode.HOTSPOT) ? net.hotspotIp : net.wifiIp; + String appIp = peerReachableIp(); if (appIp == null || apkServer == null) { secGetApp.setQr(requireContext(), null, getString(R.string.k2go_clone_starting_service)); + scheduleNetRetry(); // ADFA-5158: the AP IP lands with latency — poll and redraw when it does } else { String url = "http://" + appIp + ":" + shareConfig.apkPort + "/" + apkFileName; secGetApp.setQr(requireContext(), url, null); @@ -736,14 +740,44 @@ private void renderPrepare() { * point of no easy return: the confinement callback (armed at acceptance) keeps the user here. */ private void renderCopy() { - NetworkInterfaces.LanIps net = NetworkInterfaces.discover(); - String ip = (mode == Mode.HOTSPOT) ? net.hotspotIp : net.wifiIp; - if (mode == Mode.HOTSPOT && ip == null) ip = "192.168.49.1"; - if (ip == null) { simpleState(getString(R.string.k2go_connect_no_wifi), getString(R.string.k2go_connect_join_wifi)); return; } + String ip = peerReachableIp(); + if (ip == null) { + // ADFA-5158: no IP to advertise. Wi-Fi -> genuinely no network. Hotspot -> the AP IP just + // hasn't been assigned yet; wait and poll instead of guessing a fixed address that isn't + // universal across OEMs. + if (mode == Mode.HOTSPOT) { simpleState(getString(R.string.k2go_clone_starting_service), ""); scheduleNetRetry(); } + else simpleState(getString(R.string.k2go_connect_no_wifi), getString(R.string.k2go_connect_join_wifi)); + return; + } ensureDaemon(ip); renderStartState(ip, mode == Mode.HOTSPOT); } + /** + * ADFA-5158: the IP the other phone reaches this one at, per mode — one source for both the get-app + * URL and the Copy daemon (they had diverged: Copy hardcoded 192.168.49.1, get-app had no fallback). + * Returns null when the hotspot AP IP is not assigned yet; callers poll via {@link #scheduleNetRetry()}. + */ + private String peerReachableIp() { + NetworkInterfaces.LanIps net = NetworkInterfaces.discover(); + return (mode == Mode.HOTSPOT) ? net.hotspotIp : net.wifiIp; + } + + // ADFA-5158: while a Send page needs the AP IP and it isn't up yet, re-render shortly so the QR is + // drawn the moment the interface gets its address — instead of waiting for an incidental render. + private final android.os.Handler netHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + private static final long AP_IP_POLL_MS = 1000L; + private final Runnable netRetry = new Runnable() { + @Override public void run() { + if (!isAdded() || atFork || side != Side.SEND) return; // left Send -> stop; no self-reschedule + render(); // re-resolves the IP; renderPrepare/renderCopy reschedule only while still pending + } + }; + private void scheduleNetRetry() { + netHandler.removeCallbacks(netRetry); + netHandler.postDelayed(netRetry, AP_IP_POLL_MS); + } + /** Copy state: nothing-to-share -> starting -> stopped (Start sharing) -> running (QR + Stop). */ private void renderStartState(String ip, boolean twoCode) { stepTitle.setVisibility(View.VISIBLE); @@ -1335,6 +1369,7 @@ public void onDestroyView() { exitHandler.removeCallbacks(exitTick); exitHandler.removeCallbacks(exitPollRunnable); if (exitDots != null) exitDots.stop(); + netHandler.removeCallbacks(netRetry); // ADFA-5158: stop the AP-IP poll // ADFA-4782: release protection only when nothing is running; an active share daemon or pull // keeps the (app-scoped) CloneShareService alive so leaving the tab doesn't cut the transfer. // ADFA-4956: same gate for the deep-env lock — only boot the server back + drop the lock when diff --git a/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java b/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java index e20fec10f..e89390340 100644 --- a/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java +++ b/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java @@ -41,6 +41,7 @@ public static LanIps discover() { String hotspotIp = null; try { List interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + // Pass 1: known interface names (precise when they match). for (NetworkInterface intf : interfaces) { String name = intf.getName(); if (!intf.isUp()) continue; @@ -59,6 +60,24 @@ public static LanIps discover() { } } } + // ADFA-5158: Pass 2 fallback — some OEMs name the LocalOnlyHotspot interface outside the + // whitelist above, so pass 1 misses it and the get-app QR never gets an IP. Take an up, + // non-loopback, site-local IPv4 that is not wlan0 and not the Wi-Fi address: that is the AP, + // whatever it is called. Site-local (10/172.16-31/192.168) excludes cellular/CGNAT. + if (hotspotIp == null) { + for (NetworkInterface intf : interfaces) { + String name = intf.getName(); + if (!intf.isUp() || name.equals("wlan0")) continue; + for (InetAddress addr : Collections.list(intf.getInetAddresses())) { + if (addr.isLoopbackAddress() || !(addr instanceof Inet4Address)) continue; + if (addr.isSiteLocalAddress() && !addr.getHostAddress().equals(wifiIp)) { + hotspotIp = addr.getHostAddress(); + break; + } + } + if (hotspotIp != null) break; + } + } } catch (Exception ignored) { } return new LanIps(wifiIp, hotspotIp); From e8a3609bc210a71177783773c54a1972ea663243 Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Sat, 15 Aug 2026 19:36:35 -0600 Subject: [PATCH 2/2] ADFA-5158 fix(clone): resolve the Send AP IP by polling, not a one-shot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get-app QR (Send page 1) sat on "Starting the service…" for up to a minute on a hotspot: renderPrepare read the host IP with a single NetworkInterfaces.discover(), and on a LocalOnlyHotspot the AP interface's IPv4 lands with latency, so the first read was null and the QR only redrew on the next incidental render. - Poll for the AP IP and redraw only the get-app section the moment it resolves. The poll is bounded: it runs only while the IP can still arrive (hotspot, not FAILED/unsupported; Wi-Fi is covered by the network observer) and caps at ~2 min, so it never spins — and no longer re-requests the hotspot every second on failure. - Broaden NetworkInterfaces.discover(): if the name whitelist misses the AP iface (OEM names), fall back to the up, non-loopback, site-local IPv4 that isn't wlan0; skip tun*/ppp* (VPN) interfaces. - Resolve the IP through one helper (peerReachableIp) for both get-app and the Copy daemon; drop Copy's hardcoded 192.168.49.1 (not universal). ApkServer already binds all interfaces, so this is only about which IP to advertise. - Guard ensureHotspot against re-requesting a start while one is in flight. No new strings. --- .../controller/redesign/CloneFragment.java | 32 ++++++++++++++++--- .../sync/transport/NetworkInterfaces.java | 4 ++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java index c90d027c7..20316598e 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java @@ -422,6 +422,8 @@ private void requestMode(Mode target) { private void setMode(Mode m) { mode = m; + apRetries = 0; // ADFA-5158: fresh AP-IP poll budget on a mode switch + netHandler.removeCallbacks(netRetry); if (secJoin != null) secJoin.fbOpen = false; // ADFA-4815: each mode starts with ①'s fallback collapsed if (m == Mode.HOTSPOT) ensureHotspot(); render(); // ADFA-4785: keep the current step; switching Hotspot/Wi-Fi no longer resets to step 1 @@ -717,7 +719,11 @@ private void renderPrepare() { shareWifi.setVisibility(View.VISIBLE); } - // ---- Section ② : Get the app ---- + // ---- Section ② : Get the app ---- (own method so the AP-IP poll can redraw just this) + renderGetAppSection(); + } + + private void renderGetAppSection() { if (mode == Mode.HOTSPOT) ensureHotspot(); startApkServer(); String appIp = peerReachableIp(); @@ -725,6 +731,7 @@ private void renderPrepare() { secGetApp.setQr(requireContext(), null, getString(R.string.k2go_clone_starting_service)); scheduleNetRetry(); // ADFA-5158: the AP IP lands with latency — poll and redraw when it does } else { + apRetries = 0; // resolved — reset the poll budget String url = "http://" + appIp + ":" + shareConfig.apkPort + "/" + apkFileName; secGetApp.setQr(requireContext(), url, null); } @@ -749,6 +756,7 @@ private void renderCopy() { else simpleState(getString(R.string.k2go_connect_no_wifi), getString(R.string.k2go_connect_join_wifi)); return; } + apRetries = 0; // ADFA-5158: resolved — reset the poll budget ensureDaemon(ip); renderStartState(ip, mode == Mode.HOTSPOT); } @@ -763,17 +771,33 @@ private String peerReachableIp() { return (mode == Mode.HOTSPOT) ? net.hotspotIp : net.wifiIp; } - // ADFA-5158: while a Send page needs the AP IP and it isn't up yet, re-render shortly so the QR is - // drawn the moment the interface gets its address — instead of waiting for an incidental render. + // ADFA-5158: while a Send page needs the AP IP and it isn't up yet, re-draw shortly so the QR appears + // the moment the interface gets its address — instead of waiting for an incidental render. Re-renders + // only the affected section, not the whole screen. private final android.os.Handler netHandler = new android.os.Handler(android.os.Looper.getMainLooper()); private static final long AP_IP_POLL_MS = 1000L; + private static final int AP_IP_MAX_RETRIES = 120; // safety cap (~2 min) so the poll can't spin forever + private int apRetries = 0; private final Runnable netRetry = new Runnable() { @Override public void run() { if (!isAdded() || atFork || side != Side.SEND) return; // left Send -> stop; no self-reschedule - render(); // re-resolves the IP; renderPrepare/renderCopy reschedule only while still pending + if (page == Page.PREPARE) renderGetAppSection(); else renderCopy(); // reschedule only if still pending } }; + /** + * Only worth polling when the AP IP can still plausibly arrive: hotspot mode, not FAILED/unsupported. + * Wi-Fi doesn't need it (the IP is present once joined; the network observer re-renders on join), and a + * failed hotspot won't ever provide one — so we don't spin (which also stopped ensureHotspot being + * re-requested every second on failure). + */ + private boolean apIpMayArrive() { + if (mode != Mode.HOTSPOT || !LocalHotspotManager.isSupported()) return false; + LocalHotspotManager.State st = hs.state().getValue(); + return st == null || st.phase != LocalHotspotManager.Phase.FAILED; + } private void scheduleNetRetry() { + if (!apIpMayArrive() || apRetries >= AP_IP_MAX_RETRIES) return; + apRetries++; netHandler.removeCallbacks(netRetry); netHandler.postDelayed(netRetry, AP_IP_POLL_MS); } diff --git a/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java b/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java index e89390340..8914f1562 100644 --- a/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java +++ b/controller/app/src/main/java/org/iiab/controller/sync/transport/NetworkInterfaces.java @@ -67,7 +67,9 @@ public static LanIps discover() { if (hotspotIp == null) { for (NetworkInterface intf : interfaces) { String name = intf.getName(); - if (!intf.isUp() || name.equals("wlan0")) continue; + // Skip wlan0 (Wi-Fi) and VPN/point-to-point ifaces, which can also carry a site-local + // IPv4 but are not the AP the other phone joins. + if (!intf.isUp() || name.equals("wlan0") || name.startsWith("tun") || name.startsWith("ppp")) continue; for (InetAddress addr : Collections.list(intf.getInetAddresses())) { if (addr.isLoopbackAddress() || !(addr instanceof Inet4Address)) continue; if (addr.isSiteLocalAddress() && !addr.getHostAddress().equals(wifiIp)) {