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..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 @@ -433,6 +435,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()); @@ -713,14 +719,19 @@ 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(); - 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 { + apRetries = 0; // resolved — reset the poll budget String url = "http://" + appIp + ":" + shareConfig.apkPort + "/" + apkFileName; secGetApp.setQr(requireContext(), url, null); } @@ -736,14 +747,61 @@ 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; + } + apRetries = 0; // ADFA-5158: resolved — reset the poll budget 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-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 + 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); + } + /** 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 +1393,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..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 @@ -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,26 @@ 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(); + // 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)) { + hotspotIp = addr.getHostAddress(); + break; + } + } + if (hotspotIp != null) break; + } + } } catch (Exception ignored) { } return new LanIps(wifiIp, hotspotIp);