diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index e0a7a7f52d..f78ed769c1 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -37,9 +37,14 @@ import com.codename1.io.FileSystemStorage; import com.codename1.io.Log; import com.codename1.io.NetworkManager; +import com.codename1.io.NetworkTypePlatform; import com.codename1.io.Preferences; import com.codename1.io.Storage; import com.codename1.io.Util; +import com.codename1.io.bonjour.BonjourPlatform; +import com.codename1.io.usb.UsbPlatform; +import com.codename1.io.wifi.WifiDirectPlatform; +import com.codename1.io.wifi.WifiPlatform; import com.codename1.io.tar.TarEntry; import com.codename1.io.tar.TarInputStream; import com.codename1.l10n.L10NManager; @@ -6376,6 +6381,104 @@ public boolean isVPNActive() { return false; } + // --------------------------------------------------------------------- + // Deeper-network connectivity platform accessors. + // + // Each create*Platform() factory returns a narrow abstract class that + // the public-facing APIs in com.codename1.io.{wifi,bonjour,usb} ask for + // via Display.getInstance().getXxxPlatform(). Platform ports override + // the factory they care about; everything else falls through to the + // default no-op implementations. Keeping these as small factories + // (instead of dozens of methods on this class) lets each port ship its + // platform-specific code in a dedicated class and keeps this base + // implementation modular. + // --------------------------------------------------------------------- + + private WifiPlatform wifiPlatform; + private WifiDirectPlatform wifiDirectPlatform; + private BonjourPlatform bonjourPlatform; + private UsbPlatform usbPlatform; + private NetworkTypePlatform networkTypePlatform; + + public final WifiPlatform getWifiPlatform() { + if (wifiPlatform == null) { + WifiPlatform p = createWifiPlatform(); + wifiPlatform = p != null ? p : new WifiPlatform(); + } + return wifiPlatform; + } + + /// Platform ports override to return their WiFi implementation. The + /// default returns `null`, which the caller turns into the + /// unsupported stub built into `WifiPlatform`. + protected WifiPlatform createWifiPlatform() { + return null; + } + + public final WifiDirectPlatform getWifiDirectPlatform() { + if (wifiDirectPlatform == null) { + WifiDirectPlatform p = createWifiDirectPlatform(); + wifiDirectPlatform = p != null ? p : new WifiDirectPlatform(); + } + return wifiDirectPlatform; + } + + protected WifiDirectPlatform createWifiDirectPlatform() { + return null; + } + + public final BonjourPlatform getBonjourPlatform() { + if (bonjourPlatform == null) { + BonjourPlatform p = createBonjourPlatform(); + bonjourPlatform = p != null ? p : new BonjourPlatform(); + } + return bonjourPlatform; + } + + protected BonjourPlatform createBonjourPlatform() { + return null; + } + + public final UsbPlatform getUsbPlatform() { + if (usbPlatform == null) { + UsbPlatform p = createUsbPlatform(); + usbPlatform = p != null ? p : new UsbPlatform(); + } + return usbPlatform; + } + + protected UsbPlatform createUsbPlatform() { + return null; + } + + public final NetworkTypePlatform getNetworkTypePlatform() { + if (networkTypePlatform == null) { + NetworkTypePlatform p = createNetworkTypePlatform(); + networkTypePlatform = p != null ? p : new LegacyAccessPointNetworkType(this); + } + return networkTypePlatform; + } + + protected NetworkTypePlatform createNetworkTypePlatform() { + return null; + } + + /// Fallback `NetworkTypePlatform` for ports that haven't been updated + /// to provide their own. Bridges to the legacy access-point API so + /// `NetworkManager.getCurrentNetworkType()` still distinguishes + /// "online" from "offline" when an AP is configured. + private static final class LegacyAccessPointNetworkType extends NetworkTypePlatform { + private final CodenameOneImplementation impl; + LegacyAccessPointNetworkType(CodenameOneImplementation impl) { + this.impl = impl; + } + @Override public int getCurrentNetworkType() { + return impl.isAPSupported() && impl.getCurrentAccessPoint() != null + ? NetworkManager.NETWORK_TYPE_OTHER + : NetworkManager.NETWORK_TYPE_NONE; + } + } + /// For some reason the standard code for writing UTF8 output in a server request /// doesn't work as expected on SE/CDC stacks. /// diff --git a/CodenameOne/src/com/codename1/io/NetworkManager.java b/CodenameOne/src/com/codename1/io/NetworkManager.java index fc43bb0515..818d0cdd03 100644 --- a/CodenameOne/src/com/codename1/io/NetworkManager.java +++ b/CodenameOne/src/com/codename1/io/NetworkManager.java @@ -98,8 +98,26 @@ public final class NetworkManager { /// Indicates a corporate routing server access point type (e.g. BIS etc.) public static final int ACCESS_POINT_TYPE_CORPORATE = 6; + /// Active network type reported by `getCurrentNetworkType()` / + /// `NetworkTypeListener`: no usable connectivity. + public static final int NETWORK_TYPE_NONE = 0; + /// Active network type: WiFi / 802.11 / WLAN. + public static final int NETWORK_TYPE_WIFI = 1; + /// Active network type: cellular data (2G/3G/4G/5G). + public static final int NETWORK_TYPE_CELLULAR = 2; + /// Active network type: wired Ethernet. + public static final int NETWORK_TYPE_ETHERNET = 3; + /// Active network type: short-range Bluetooth PAN. + public static final int NETWORK_TYPE_BLUETOOTH = 4; + /// Active network type reported by the platform but not classified into + /// one of the named buckets above. + public static final int NETWORK_TYPE_OTHER = 5; + private static final Object LOCK = new Object(); private static final NetworkManager INSTANCE = new NetworkManager(); + private EventDispatcher networkTypeListeners; + private int lastNetworkType = -1; + private boolean lastVpnActive; private static String autoDetectURL = "https://www.google.com/"; private final Vector pending = new Vector(); private final Hashtable threadAssignements = new Hashtable(); @@ -845,6 +863,104 @@ public boolean isVPNActive() { return Util.getImplementation().isVPNActive(); } + /// Returns the device's currently active network type, one of the + /// `NETWORK_TYPE_*` constants. Returns `NETWORK_TYPE_NONE` when there is + /// no connectivity. Distinct from `getAPType` which describes a configured + /// access point rather than the active data path. + public int getCurrentNetworkType() { + return Display.getInstance() + .getNetworkTypePlatform().getCurrentNetworkType(); + } + + /// Fast best-effort connectivity check. Returns `false` only when the + /// platform reports `NETWORK_TYPE_NONE`; all other states (WiFi, + /// cellular, ethernet, VPN-only, or "other") return `true`. Avoids the + /// HTTP probe that `getInstance().assignToThread(...)` performs against + /// `autoDetectURL` so the check is suitable to call from the EDT. + /// + /// Apps that need a stronger "can I reach my server" guarantee should + /// still fire a real `ConnectionRequest`; this method only reports + /// whether the device has *any* usable network interface up. + public boolean isConnected() { + return getCurrentNetworkType() != NETWORK_TYPE_NONE; + } + + /// Registers `l` to be notified when the device's active network type + /// changes (WiFi <-> Cellular <-> None <-> ...). The listener is invoked + /// on the EDT. Safe to call multiple times with the same listener; only + /// the first registration takes effect. + /// + /// On platforms where network change events are unavailable the listener + /// is still installed but never fires; `getCurrentNetworkType()` should + /// be polled instead. + public void addNetworkTypeListener(NetworkTypeListener l) { + synchronized (LOCK) { + if (networkTypeListeners == null) { + networkTypeListeners = new EventDispatcher(); + networkTypeListeners.setBlocking(false); + Display.getInstance() + .getNetworkTypePlatform().install(this); + lastNetworkType = getCurrentNetworkType(); + lastVpnActive = isVPNActive(); + } + if (!containsListener(networkTypeListeners, l)) { + networkTypeListeners.addListener(l); + } + } + } + + /// Removes a listener previously registered with + /// `addNetworkTypeListener(NetworkTypeListener)`. If the last listener is + /// removed the platform watcher is torn down too. + public void removeNetworkTypeListener(NetworkTypeListener l) { + synchronized (LOCK) { + if (networkTypeListeners == null) { + return; + } + networkTypeListeners.removeListener(l); + Collection v = networkTypeListeners.getListenerCollection(); + if (v == null || v.isEmpty()) { + Display.getInstance() + .getNetworkTypePlatform().uninstall(this); + networkTypeListeners = null; + } + } + } + + private boolean containsListener(EventDispatcher d, Object l) { + Collection v = d.getListenerCollection(); + return v != null && v.contains(l); + } + + /// Internal: invoked by platform implementations to deliver a network + /// type change event. Public so platform code in other packages can call + /// it; not intended for application use. + public void fireNetworkTypeChange(int newType, boolean vpnActive) { + EventDispatcher d; + int oldType; + boolean fire; + synchronized (LOCK) { + d = networkTypeListeners; + oldType = lastNetworkType; + fire = d != null && (oldType != newType || lastVpnActive != vpnActive); + lastNetworkType = newType; + lastVpnActive = vpnActive; + } + if (fire) { + Collection listeners = d.getListenerCollection(); + if (listeners == null) { + return; + } + Object[] arr = listeners.toArray(); + for (Object o : arr) { + if (o instanceof NetworkTypeListener) { + ((NetworkTypeListener) o) + .onNetworkTypeChanged(oldType, newType, vpnActive); + } + } + } + } + class NetworkThread implements Runnable { boolean stopped = false; private ConnectionRequest currentRequest; diff --git a/CodenameOne/src/com/codename1/io/NetworkTypeListener.java b/CodenameOne/src/com/codename1/io/NetworkTypeListener.java new file mode 100644 index 0000000000..a0965d7873 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/NetworkTypeListener.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io; + +/// Receives notifications when the device's active network changes type +/// (WiFi <-> Cellular <-> Ethernet <-> None <-> VPN). Register with +/// `NetworkManager.addNetworkTypeListener(NetworkTypeListener)`. +/// +/// Implementations are invoked on the EDT. +public interface NetworkTypeListener { + /// Called when the platform transitions between network classes. + /// + /// #### Parameters + /// + /// - `oldType`: one of `NetworkManager.NETWORK_TYPE_*` + /// - `newType`: one of `NetworkManager.NETWORK_TYPE_*` + /// - `vpnActive`: `true` if the platform reports a VPN tunnel on top of + /// the active network. May be `false` on platforms where VPN detection + /// is unsupported (see `NetworkManager.isVPNDetectionSupported()`). + void onNetworkTypeChanged(int oldType, int newType, boolean vpnActive); +} diff --git a/CodenameOne/src/com/codename1/io/NetworkTypePlatform.java b/CodenameOne/src/com/codename1/io/NetworkTypePlatform.java new file mode 100644 index 0000000000..2226e61fc5 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/NetworkTypePlatform.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io; + +/// Platform-supplied implementation of the network-type tracking API. +/// `NetworkManager.getCurrentNetworkType()`, +/// `NetworkManager.addNetworkTypeListener(...)` and the matching remover +/// all dispatch through this. +/// +/// Part of the framework's service-provider interface, not intended for +/// application use. +public class NetworkTypePlatform { + /// One of the `NetworkManager.NETWORK_TYPE_*` constants. Default + /// returns `NETWORK_TYPE_OTHER` when an access point is configured so + /// stub platforms still indicate "some connectivity present". + public int getCurrentNetworkType() { + return NetworkManager.NETWORK_TYPE_NONE; + } + + /// Subscribe to platform network transitions. The platform must call + /// `target.fireNetworkTypeChange(newType, vpnActive)` whenever the + /// active network changes. Default is a no-op for platforms that + /// can't observe transitions. + public void install(NetworkManager target) { + } + + /// Tear down the watcher installed by `install`. Default no-op. + public void uninstall(NetworkManager target) { + } +} diff --git a/CodenameOne/src/com/codename1/io/bonjour/BonjourBrowser.java b/CodenameOne/src/com/codename1/io/bonjour/BonjourBrowser.java new file mode 100644 index 0000000000..4ddedebf7b --- /dev/null +++ b/CodenameOne/src/com/codename1/io/bonjour/BonjourBrowser.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.bonjour; + +import com.codename1.ui.Display; + +/// Browses the local network for Bonjour / mDNS services. +/// +/// Bonjour ("zeroconf" / "mDNS-SD") lets clients find network services -- a +/// printer, a music server, another instance of your app -- without knowing +/// their IP address in advance. Services are advertised under a *type* such as +/// `_http._tcp.` or your own private `_myapp._tcp.`. A `BonjourBrowser` watches +/// for services of one type and notifies the listener whenever a service comes +/// online or goes away. +/// +/// #### Platform support +/// +/// - **Android**: `android.net.nsd.NsdManager`. +/// - **iOS**: `NSNetServiceBrowser` + `NSNetService`. The build pipeline +/// injects `NSLocalNetworkUsageDescription` and the service type into +/// `NSBonjourServices` so iOS 14+ doesn't block discovery. +/// - **Simulator**: JmDNS is used when present on the classpath; otherwise +/// discovery is a no-op and the listener is told the platform is +/// unsupported. +/// +/// #### Example +/// +/// ```java +/// BonjourBrowser browser = BonjourBrowser.browse("_http._tcp.", new BonjourServiceListener() { +/// public void onServiceResolved(BonjourService s) { +/// Log.p("Found " + s.getName() + " at " + s.getHost() + ":" + s.getPort()); +/// } +/// public void onServiceLost(BonjourService s) { /* ... */ } +/// public void onBrowseError(Throwable t) { Log.e(t); } +/// }); +/// +/// // when finished +/// browser.stop(); +/// ``` +public final class BonjourBrowser { + private final Object nativeHandle; + private final String type; + private boolean stopped; + + private BonjourBrowser(String type, Object nativeHandle) { + this.type = type; + this.nativeHandle = nativeHandle; + } + + /// Starts browsing for `type` and returns a handle to stop the search. + /// `type` must be in mDNS form, e.g. `_http._tcp.` (trailing dot + /// optional). `listener` is invoked on the EDT. + public static BonjourBrowser browse(String type, + BonjourServiceListener listener) { + Object handle = Display.getInstance().getBonjourPlatform() + .startBrowse(type, listener); + return new BonjourBrowser(type, handle); + } + + /// `true` if the platform implements Bonjour at all. + public static boolean isSupported() { + return Display.getInstance().getBonjourPlatform().isSupported(); + } + + /// The service type passed to `browse(...)`. + public String getType() { + return type; + } + + /// Stops this browser. Idempotent. + public void stop() { + if (stopped) { + return; + } + stopped = true; + Display.getInstance().getBonjourPlatform().stopBrowse(nativeHandle); + } +} diff --git a/CodenameOne/src/com/codename1/io/bonjour/BonjourPlatform.java b/CodenameOne/src/com/codename1/io/bonjour/BonjourPlatform.java new file mode 100644 index 0000000000..949a57687b --- /dev/null +++ b/CodenameOne/src/com/codename1/io/bonjour/BonjourPlatform.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.bonjour; + +import java.util.Map; + +/// Platform-supplied implementation of the Bonjour / mDNS APIs. +/// Application code talks to `BonjourBrowser` / `BonjourPublisher`; the +/// facade fetches this via `Display.getInstance().getBonjourPlatform()`. +/// +/// Part of the framework's service-provider interface, not intended for +/// application use. +public class BonjourPlatform { + public boolean isSupported() { + return false; + } + + public Object startBrowse(String type, BonjourServiceListener listener) { + if (listener != null) { + listener.onBrowseError(new UnsupportedOperationException( + "Bonjour is not supported on this platform")); + } + return null; + } + + public void stopBrowse(Object handle) { + } + + public Object startPublish(String name, String type, int port, + Map txt) { + return null; + } + + public void stopPublish(Object handle) { + } +} diff --git a/CodenameOne/src/com/codename1/io/bonjour/BonjourPublisher.java b/CodenameOne/src/com/codename1/io/bonjour/BonjourPublisher.java new file mode 100644 index 0000000000..9608ad4c99 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/bonjour/BonjourPublisher.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.bonjour; + +import com.codename1.ui.Display; + +import java.util.Hashtable; +import java.util.Map; + +/// Advertises a Bonjour / mDNS service on the local network. +/// +/// A publisher registers a service of a given type and port; once registered +/// any client on the same network running `BonjourBrowser.browse(type, ...)` +/// will see it. `txt` records carry small key/value metadata (typical limit +/// 255 bytes per value). +/// +/// #### Example -- advertise an HTTP service on port 8080 +/// +/// ```java +/// BonjourPublisher pub = BonjourPublisher.publish( +/// "MyServer", "_http._tcp.", 8080, null); +/// +/// // when shutting down +/// pub.unpublish(); +/// ``` +/// +/// On iOS the build pipeline appends `type` to `NSBonjourServices` in +/// Info.plist automatically when this class is referenced; without that entry +/// iOS 14+ silently rejects the publish. +public final class BonjourPublisher { + private final Object nativeHandle; + private final String name; + private final String type; + private final int port; + private boolean unpublished; + + private BonjourPublisher(String name, String type, int port, + Object nativeHandle) { + this.name = name; + this.type = type; + this.port = port; + this.nativeHandle = nativeHandle; + } + + /// Publishes a new service. `name` is shown to humans browsing services + /// and must be unique on the local subnet; the OS may append a suffix to + /// resolve collisions. `type` is the mDNS type (e.g. `_http._tcp.`). + /// `port` is the listening port on this device. `txt` may be `null` or a + /// String->String map of metadata. + public static BonjourPublisher publish(String name, String type, int port, + Map txt) { + if (txt == null) { + txt = new Hashtable(); + } + Object handle = Display.getInstance().getBonjourPlatform() + .startPublish(name, type, port, txt); + return new BonjourPublisher(name, type, port, handle); + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public int getPort() { + return port; + } + + /// Removes the advertisement. Idempotent. + public void unpublish() { + if (unpublished) { + return; + } + unpublished = true; + Display.getInstance().getBonjourPlatform().stopPublish(nativeHandle); + } +} diff --git a/CodenameOne/src/com/codename1/io/bonjour/BonjourService.java b/CodenameOne/src/com/codename1/io/bonjour/BonjourService.java new file mode 100644 index 0000000000..929df9f668 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/bonjour/BonjourService.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.bonjour; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/// A Bonjour / mDNS service discovered by `BonjourBrowser` or registered by +/// `BonjourPublisher`. Immutable. +public final class BonjourService { + private final String name; + private final String type; + private final String host; + private final int port; + private final Map txt; + + public BonjourService(String name, String type, String host, int port, + Map txt) { + this.name = name; + this.type = type; + this.host = host; + this.port = port; + if (txt == null) { + this.txt = Collections.unmodifiableMap(new HashMap()); + } else { + this.txt = Collections.unmodifiableMap(new HashMap(txt)); + } + } + + /// User-visible service name. May include a numeric suffix added by the + /// platform to resolve name collisions on the subnet. + public String getName() { + return name; + } + + /// mDNS service type (e.g. `_http._tcp.`). + public String getType() { + return type; + } + + /// Resolved host. Either a dotted-quad IPv4, an IPv6 literal in square + /// brackets, or a `.local.` hostname. `null` if the service is announced + /// but the address has not been resolved yet. + public String getHost() { + return host; + } + + /// Service port. + public int getPort() { + return port; + } + + /// TXT-record metadata. Always non-null; may be empty. + public Map getTxt() { + return txt; + } + + @Override + public String toString() { + return name + " (" + type + ") " + host + ":" + port; + } +} diff --git a/CodenameOne/src/com/codename1/io/bonjour/BonjourServiceListener.java b/CodenameOne/src/com/codename1/io/bonjour/BonjourServiceListener.java new file mode 100644 index 0000000000..5e0f36d187 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/bonjour/BonjourServiceListener.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.bonjour; + +/// Callback for `BonjourBrowser.browse(...)`. All methods fire on the EDT. +public interface BonjourServiceListener { + /// A new service appeared, or an existing one was re-resolved with a new + /// host/port. The platform may also call this when only TXT metadata + /// changes. + void onServiceResolved(BonjourService service); + + /// A previously announced service went away. + void onServiceLost(BonjourService service); + + /// The browse itself failed (e.g. WiFi disconnected, or the platform + /// does not support Bonjour). + void onBrowseError(Throwable error); +} diff --git a/CodenameOne/src/com/codename1/io/usb/Usb.java b/CodenameOne/src/com/codename1/io/usb/Usb.java new file mode 100644 index 0000000000..98e8832d91 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/usb/Usb.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.usb; + +import com.codename1.ui.Display; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// USB Host API. +/// +/// Lets the device act as a USB host and talk to attached peripherals -- a +/// barcode scanner, a serial-over-USB device, a microcontroller. This is +/// **Android-only** in practice; iOS doesn't expose third-party USB host +/// access and the simulator/JavaSE port stubs everything out. +/// +/// #### Android specifics +/// +/// The build pipeline adds the `android.hardware.usb.host` feature and a +/// `` declaration whenever `Usb` is referenced. To launch your +/// app automatically when a device is plugged in, declare a +/// `USB_DEVICE_ATTACHED` intent filter in `android.xintent_filter` using a +/// `device_filter.xml` resource you ship in `native/android/res/xml/`. See +/// `Developer Guide -> Network Connectivity -> USB`. +public final class Usb { + private Usb() { + } + + private static UsbPlatform platform() { + return Display.getInstance().getUsbPlatform(); + } + + /// `true` if the current platform implements USB host access. + public static boolean isSupported() { + return platform().isSupported(); + } + + /// All currently-attached USB devices. + public static UsbDevice[] listDevices() { + return platform().listDevices(); + } + + /// Subscribes `listener` to attach / detach events. Returns immediately. + /// Calls on the EDT. + public static void addDeviceListener(UsbDeviceListener listener) { + platform().addDeviceListener(listener); + } + + public static void removeDeviceListener(UsbDeviceListener listener) { + platform().removeDeviceListener(listener); + } + + /// Requests permission from the user to talk to `device`. The result is + /// reported asynchronously via `UsbDeviceListener.onPermissionResult`. + public static void requestPermission(UsbDevice device) { + platform().requestPermission(device); + } + + /// `true` if the user has granted access to `device`. + public static boolean hasPermission(UsbDevice device) { + return platform().hasPermission(device); + } + + /// Opens a bulk-transfer endpoint on the device. `endpointAddress` matches + /// the USB endpoint address from the device's descriptor. The caller must + /// have called `requestPermission` and received approval first. + public static InputStream openInputStream(UsbDevice device, + int endpointAddress) + throws IOException { + return platform().openInputStream(device, endpointAddress); + } + + public static OutputStream openOutputStream(UsbDevice device, + int endpointAddress) + throws IOException { + return platform().openOutputStream(device, endpointAddress); + } +} diff --git a/CodenameOne/src/com/codename1/io/usb/UsbDevice.java b/CodenameOne/src/com/codename1/io/usb/UsbDevice.java new file mode 100644 index 0000000000..cdfc3d0a21 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/usb/UsbDevice.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.usb; + +/// One attached USB device. Immutable. The `Object` returned by +/// `getNativeDevice()` is the platform device handle and may be cast in +/// native interface code (`UsbDevice` on Android). +public final class UsbDevice { + private final String deviceName; + private final int vendorId; + private final int productId; + private final String productName; + private final String manufacturerName; + private final Object nativeDevice; + + public UsbDevice(String deviceName, int vendorId, int productId, + String productName, String manufacturerName, + Object nativeDevice) { + this.deviceName = deviceName; + this.vendorId = vendorId; + this.productId = productId; + this.productName = productName; + this.manufacturerName = manufacturerName; + this.nativeDevice = nativeDevice; + } + + public String getDeviceName() { + return deviceName; + } + + public int getVendorId() { + return vendorId; + } + + public int getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public String getManufacturerName() { + return manufacturerName; + } + + public Object getNativeDevice() { + return nativeDevice; + } +} diff --git a/CodenameOne/src/com/codename1/io/usb/UsbDeviceListener.java b/CodenameOne/src/com/codename1/io/usb/UsbDeviceListener.java new file mode 100644 index 0000000000..6d09537d26 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/usb/UsbDeviceListener.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.usb; + +/// Callback for USB attach/detach and permission events. Methods fire on the +/// EDT. +public interface UsbDeviceListener { + void onDeviceAttached(UsbDevice device); + + void onDeviceDetached(UsbDevice device); + + /// Result of `Usb.requestPermission(device)`. + void onPermissionResult(UsbDevice device, boolean granted); +} diff --git a/CodenameOne/src/com/codename1/io/usb/UsbPlatform.java b/CodenameOne/src/com/codename1/io/usb/UsbPlatform.java new file mode 100644 index 0000000000..7cde1f0a6c --- /dev/null +++ b/CodenameOne/src/com/codename1/io/usb/UsbPlatform.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.usb; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// Platform-supplied implementation of the USB host APIs. Application code +/// talks to `Usb`; the facade fetches this via +/// `Display.getInstance().getUsbPlatform()`. +/// +/// Part of the framework's service-provider interface, not intended for +/// application use. +public class UsbPlatform { + public boolean isSupported() { + return false; + } + + public UsbDevice[] listDevices() { + return new UsbDevice[0]; + } + + public void addDeviceListener(UsbDeviceListener listener) { + } + + public void removeDeviceListener(UsbDeviceListener listener) { + } + + public void requestPermission(UsbDevice device) { + } + + public boolean hasPermission(UsbDevice device) { + return false; + } + + public InputStream openInputStream(UsbDevice device, int endpointAddress) + throws IOException { + throw new IOException("USB is not supported on this platform"); + } + + public OutputStream openOutputStream(UsbDevice device, int endpointAddress) + throws IOException { + throw new IOException("USB is not supported on this platform"); + } +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFi.java b/CodenameOne/src/com/codename1/io/wifi/WiFi.java new file mode 100644 index 0000000000..8318535221 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFi.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +import com.codename1.ui.Display; + +/// Entry point for inspecting, scanning and connecting to WiFi networks. +/// +/// The API is split into two tiers: +/// +/// 1. **Information queries** -- `getCurrentSSID()`, `getBSSID()`, +/// `getGateway()`, `getIp()`. These methods read the device's current +/// network configuration. On modern Android (10+) and iOS the SSID/BSSID +/// queries require runtime permissions or entitlements that the build +/// pipeline injects automatically based on classpath scanning. +/// +/// 2. **Active management** -- `scan()` and `connect(SSID, password, security)`. +/// Active management triggers a one-shot scan or attempts to associate the +/// device with a specific network. On Android 10+ this is implemented with +/// `NetworkSpecifier`; on iOS with `NEHotspotConfiguration`. +/// +/// All callbacks are dispatched on the EDT so it is safe to update UI in +/// response to them. +/// +/// #### Required permissions +/// +/// The build pipeline injects the necessary permissions automatically when +/// `WiFi` is referenced anywhere in the application: +/// +/// - **Android**: `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`, +/// `ACCESS_NETWORK_STATE`, `CHANGE_NETWORK_STATE`, +/// `ACCESS_FINE_LOCATION` (required for SSID readout on API 26+), +/// `NEARBY_WIFI_DEVICES` (API 33+). +/// - **iOS**: `com.apple.developer.networking.HotspotConfiguration` entitlement +/// for connect, `com.apple.developer.networking.wifi-info` entitlement for +/// the SSID/BSSID query, and `NSLocationWhenInUseUsageDescription` +/// (CoreLocation authorization is required since iOS 13 to read SSID). +public final class WiFi { + private WiFi() { + } + + private static WifiPlatform platform() { + return Display.getInstance().getWifiPlatform(); + } + + /// `true` if the current platform can query WiFi information. + public static boolean isInfoSupported() { + return platform().isInfoSupported(); + } + + /// `true` if the current platform supports active scan / connect. + public static boolean isManagementSupported() { + return platform().isManagementSupported(); + } + + /// The SSID of the currently associated WiFi network, or `null` if not + /// connected to WiFi or if permission was denied. On iOS 13+ the OS + /// returns `null` unless the app has CoreLocation authorization. + public static String getCurrentSSID() { + return platform().getCurrentSSID(); + } + + /// The BSSID (MAC address of the access point) of the currently associated + /// WiFi network, formatted as colon-separated lowercase hex + /// (e.g. `aa:bb:cc:11:22:33`), or `null` if unavailable. + public static String getBSSID() { + return platform().getBSSID(); + } + + /// Default gateway IP address as a dotted quad (e.g. `192.168.1.1`), or + /// `null` if no default gateway is configured. + public static String getGateway() { + return platform().getGateway(); + } + + /// Local IP address on the WiFi interface as a dotted quad + /// (e.g. `192.168.1.42`), or `null` if WiFi is not active. + public static String getIp() { + return platform().getIp(); + } + + /// Triggers a one-shot WiFi scan and reports results to `callback` on the + /// EDT. The callback receives an array of `WiFiNetwork` sorted by signal + /// strength (strongest first). Pass `null` to cancel an in-progress scan. + /// + /// Behaviour: + /// + /// - **Android**: uses `WifiManager.startScan()`. On API 28+ the OS + /// throttles scans to 4 per 2 minutes per foreground app; throttled + /// scans return cached results. + /// - **iOS**: not supported -- iOS does not expose a public WiFi scan + /// API. `callback` is invoked with `null` and `error` set. + /// - **Simulator**: returns a small synthetic list and prints a warning + /// reminding the developer the data is fabricated. + public static void scan(WiFiScanCallback callback) { + platform().scan(callback); + } + + /// Attempts to associate the device with `ssid`. `password` may be `null` + /// for open networks. `security` must be one of the `WiFiSecurity` + /// constants and **must** match the security mode the access point + /// advertises -- a mismatch will cause `connect` to fail. + /// + /// Behaviour: + /// + /// - **Android 10+**: uses `WifiNetworkSpecifier` via + /// `ConnectivityManager.requestNetwork()`. The OS shows a system + /// dialog asking the user to approve the association; this dialog + /// can't be bypassed. + /// - **Android 9 and below**: uses the legacy + /// `WifiConfiguration` API and `WifiManager.enableNetwork()`. The user + /// is not prompted but the call may be a no-op on OEM builds that + /// removed the API early. + /// - **iOS 11+**: uses `NEHotspotConfiguration`. The user is shown a + /// system prompt the first time the app tries to join the SSID. + /// - **Simulator**: logs the request and reports a failure. + public static void connect(String ssid, String password, + WiFiSecurity security, + WiFiConnectCallback callback) { + platform().connect(ssid, password, security, callback); + } + + /// Disconnect the request made via `connect`. On Android 10+ this releases + /// the `NetworkSpecifier`; on iOS it removes the hotspot configuration. + /// Apps can't force-disconnect a network the user joined manually. + public static void disconnect(String ssid) { + platform().disconnect(ssid); + } +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFiConnectCallback.java b/CodenameOne/src/com/codename1/io/wifi/WiFiConnectCallback.java new file mode 100644 index 0000000000..d1badc3f20 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFiConnectCallback.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// Callback for `WiFi.connect(...)`. Invoked once on the EDT. +public interface WiFiConnectCallback { + /// `connected` is `true` if the association succeeded. `error` is `null` + /// on success and holds the rejection cause otherwise (cancelled by user, + /// wrong password, unreachable AP, ...). + void onConnectResult(boolean connected, Throwable error); +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFiDirect.java b/CodenameOne/src/com/codename1/io/wifi/WiFiDirect.java new file mode 100644 index 0000000000..f642fe37a0 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFiDirect.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +import com.codename1.ui.Display; + +/// WiFi Direct (Wi-Fi P2P) discovery and grouping. +/// +/// WiFi Direct lets two devices form a peer-to-peer link without going through +/// an access point. Use it for ad-hoc file transfer, multi-player games on a +/// local network, or any other scenario where a router is unavailable. +/// +/// #### Platform support +/// +/// - **Android**: full support via `android.net.wifi.p2p.WifiP2pManager`. +/// The build pipeline injects `CHANGE_WIFI_STATE`, `ACCESS_WIFI_STATE`, +/// `ACCESS_NETWORK_STATE`, `ACCESS_FINE_LOCATION` (API 26+) and +/// `NEARBY_WIFI_DEVICES` (API 33+) when this class is referenced. +/// - **iOS**: not supported. iOS uses MultipeerConnectivity for similar +/// scenarios; that API is intentionally out of scope here. +/// - **Simulator**: stubbed. Discovery returns no peers and `connect` +/// reports failure. +public final class WiFiDirect { + private WiFiDirect() { + } + + private static WifiDirectPlatform platform() { + return Display.getInstance().getWifiDirectPlatform(); + } + + /// `true` if the current platform implements WiFi Direct. + public static boolean isSupported() { + return platform().isSupported(); + } + + /// Starts peer discovery. `listener` is invoked on the EDT for every peer + /// list change. Call `stopDiscovery()` to release radio resources when + /// you're done. + public static void startDiscovery(WiFiDirectListener listener) { + platform().startDiscovery(listener); + } + + /// Stops peer discovery and detaches all listeners. + public static void stopDiscovery() { + platform().stopDiscovery(); + } + + /// Forms a P2P group with `peer`. The user is shown a confirmation prompt + /// on both devices the first time they connect; subsequent connections + /// reuse the cached pairing where possible. + public static void connect(WiFiDirectPeer peer, + WiFiConnectCallback callback) { + platform().connect(peer, callback); + } + + /// Drops the current group, if any. + public static void disconnect() { + platform().disconnect(); + } +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFiDirectListener.java b/CodenameOne/src/com/codename1/io/wifi/WiFiDirectListener.java new file mode 100644 index 0000000000..8effa88045 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFiDirectListener.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// Listener for WiFi Direct peer discovery. Methods fire on the EDT. +public interface WiFiDirectListener { + /// Fired whenever the platform reports a new snapshot of the peer list. + /// The array is sorted by signal strength when the platform supplies it, + /// otherwise by discovery order. + void onPeersAvailable(WiFiDirectPeer[] peers); + + /// Fired when discovery itself fails (e.g. WiFi is off). + void onDiscoveryError(Throwable error); +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFiDirectPeer.java b/CodenameOne/src/com/codename1/io/wifi/WiFiDirectPeer.java new file mode 100644 index 0000000000..e8c4d2649f --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFiDirectPeer.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// One peer discovered by `WiFiDirect.startDiscovery(...)`. Immutable. +public final class WiFiDirectPeer { + /// Discovery state: peer has not been contacted. + public static final int STATE_AVAILABLE = 0; + /// Discovery state: pairing in progress. + public static final int STATE_INVITED = 1; + /// Discovery state: pairing established. + public static final int STATE_CONNECTED = 2; + /// Discovery state: peer transitioned out of range or refused pairing. + public static final int STATE_FAILED = 3; + /// Discovery state: peer no longer responding to discovery probes. + public static final int STATE_UNAVAILABLE = 4; + + private final String deviceName; + private final String deviceAddress; + private final int state; + + public WiFiDirectPeer(String deviceName, String deviceAddress, int state) { + this.deviceName = deviceName; + this.deviceAddress = deviceAddress; + this.state = state; + } + + /// User-visible device name (e.g. "Pixel 9"). + public String getDeviceName() { + return deviceName; + } + + /// Stable peer identifier (MAC address on Android). + public String getDeviceAddress() { + return deviceAddress; + } + + /// One of the `STATE_*` constants. + public int getState() { + return state; + } +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFiNetwork.java b/CodenameOne/src/com/codename1/io/wifi/WiFiNetwork.java new file mode 100644 index 0000000000..01a354a285 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFiNetwork.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// One entry in a WiFi scan result. Immutable. +public final class WiFiNetwork { + private final String ssid; + private final String bssid; + private final int rssi; + private final int frequency; + private final WiFiSecurity security; + + public WiFiNetwork(String ssid, String bssid, int rssi, int frequency, + WiFiSecurity security) { + this.ssid = ssid; + this.bssid = bssid; + this.rssi = rssi; + this.frequency = frequency; + this.security = security; + } + + /// Human-readable network name. May be empty for hidden networks. + public String getSSID() { + return ssid; + } + + /// Access point MAC address (colon-separated lowercase hex). + public String getBSSID() { + return bssid; + } + + /// Received signal strength in dBm (negative values; closer to zero is + /// stronger). Typical range: -30 (excellent) to -90 (unusable). + public int getRssi() { + return rssi; + } + + /// Channel frequency in MHz (e.g. 2412 for channel 1 on 2.4 GHz). + public int getFrequency() { + return frequency; + } + + /// Security mode advertised by the AP. See `WiFiSecurity`. + public WiFiSecurity getSecurity() { + return security; + } +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFiScanCallback.java b/CodenameOne/src/com/codename1/io/wifi/WiFiScanCallback.java new file mode 100644 index 0000000000..44413c6b1b --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFiScanCallback.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// Callback for `WiFi.scan(...)`. Invoked once on the EDT. +public interface WiFiScanCallback { + /// Called when the scan completes (successfully or not). `networks` is + /// sorted by signal strength, strongest first. On failure `networks` is + /// `null` and `error` holds the cause. + void onScanComplete(WiFiNetwork[] networks, Throwable error); +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WiFiSecurity.java b/CodenameOne/src/com/codename1/io/wifi/WiFiSecurity.java new file mode 100644 index 0000000000..ddac0edc2a --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WiFiSecurity.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// Security mode advertised by an access point. Used both for scan results +/// and when calling `WiFi.connect(...)`. The value passed to `connect` must +/// match the AP's actual security mode. +public enum WiFiSecurity { + /// Open network, no encryption. + OPEN, + + /// Legacy WEP. Considered broken; some platforms refuse to connect at all. + WEP, + + /// WPA / WPA2 personal (PSK). + WPA_PSK, + + /// WPA3 personal (SAE). + WPA3_SAE, + + /// Enterprise EAP (RADIUS-backed). Not directly supported by `connect`; + /// applications must supply an enterprise configuration through platform + /// hooks if they need it. + EAP, + + /// Unknown or platform did not report a security mode. + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WifiDirectPlatform.java b/CodenameOne/src/com/codename1/io/wifi/WifiDirectPlatform.java new file mode 100644 index 0000000000..2e331e6e9c --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WifiDirectPlatform.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// Platform-supplied implementation of the WiFi-Direct API. Application +/// code talks to the static facade in `WiFiDirect`; that facade fetches +/// the active platform via `Display.getInstance().getWifiDirectPlatform()` +/// and dispatches each call here. +/// +/// This is part of the framework's service-provider interface and not +/// intended for application use. +public class WifiDirectPlatform { + public boolean isSupported() { + return false; + } + + public void startDiscovery(WiFiDirectListener listener) { + if (listener != null) { + listener.onDiscoveryError(new UnsupportedOperationException( + "WiFi Direct is not supported on this platform")); + } + } + + public void stopDiscovery() { + } + + public void connect(WiFiDirectPeer peer, WiFiConnectCallback callback) { + if (callback != null) { + callback.onConnectResult(false, + new UnsupportedOperationException( + "WiFi Direct is not supported on this platform")); + } + } + + public void disconnect() { + } +} diff --git a/CodenameOne/src/com/codename1/io/wifi/WifiPlatform.java b/CodenameOne/src/com/codename1/io/wifi/WifiPlatform.java new file mode 100644 index 0000000000..0803877da4 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/wifi/WifiPlatform.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.io.wifi; + +/// Platform-supplied implementation of the WiFi APIs. +/// +/// Application code talks to the static facade in `WiFi`; that facade +/// fetches the active platform via `Display.getInstance().getWifiPlatform()` +/// and dispatches each call here. Codename One platform ports +/// (Android / iOS / JavaSE / future) supply a subclass; the no-op default +/// returned by `CodenameOneImplementation` keeps stub builds compiling. +/// +/// This is part of the framework's service-provider interface and not +/// intended for application use. +public class WifiPlatform { + public boolean isInfoSupported() { + return false; + } + + public boolean isManagementSupported() { + return false; + } + + public String getCurrentSSID() { + return null; + } + + public String getBSSID() { + return null; + } + + public String getGateway() { + return null; + } + + public String getIp() { + return null; + } + + public void scan(WiFiScanCallback callback) { + if (callback != null) { + callback.onScanComplete(null, + new UnsupportedOperationException( + "WiFi scan is not supported on this platform")); + } + } + + public void connect(String ssid, String password, WiFiSecurity security, + WiFiConnectCallback callback) { + if (callback != null) { + callback.onConnectResult(false, + new UnsupportedOperationException( + "WiFi connect is not supported on this platform")); + } + } + + public void disconnect(String ssid) { + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 3053239028..85637188ef 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -498,6 +498,34 @@ CodenameOneImplementation getImplementation() { return impl; } + /// Returns the platform's WiFi implementation. Used by + /// `com.codename1.io.wifi.WiFi`; applications normally talk to that + /// static facade rather than calling this directly. + public com.codename1.io.wifi.WifiPlatform getWifiPlatform() { + return impl.getWifiPlatform(); + } + + /// Returns the platform's WiFi-Direct implementation. + public com.codename1.io.wifi.WifiDirectPlatform getWifiDirectPlatform() { + return impl.getWifiDirectPlatform(); + } + + /// Returns the platform's Bonjour / mDNS implementation. + public com.codename1.io.bonjour.BonjourPlatform getBonjourPlatform() { + return impl.getBonjourPlatform(); + } + + /// Returns the platform's USB host implementation. + public com.codename1.io.usb.UsbPlatform getUsbPlatform() { + return impl.getUsbPlatform(); + } + + /// Returns the platform's network-type tracker used by + /// `NetworkManager.addNetworkTypeListener(...)`. + public com.codename1.io.NetworkTypePlatform getNetworkTypePlatform() { + return impl.getNetworkTypePlatform(); + } + /// Returns the SIMD API instance bound to the current implementation. public Simd getSimd() { if (simd == null) { diff --git a/Ports/Android/spotbugs-exclude.xml b/Ports/Android/spotbugs-exclude.xml index 8102a33aed..0b8728b2a7 100644 --- a/Ports/Android/spotbugs-exclude.xml +++ b/Ports/Android/spotbugs-exclude.xml @@ -110,6 +110,41 @@ + + + + + + + + + + + + + + + + diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 4a8ee91081..b571bf6b67 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -9881,6 +9881,37 @@ public boolean hasCamera() { } } + // Deeper-network connectivity platform factories. Each returns a small + // platform-specific class living under + // com.codename1.impl.android.connectivity. Those classes are loaded + // lazily on first call so apps that never reference WiFi / Bonjour / + // USB / NetworkTypeListener never pay the loading cost. + + @Override + protected com.codename1.io.wifi.WifiPlatform createWifiPlatform() { + return new com.codename1.impl.android.connectivity.AndroidWifiPlatform(); + } + + @Override + protected com.codename1.io.wifi.WifiDirectPlatform createWifiDirectPlatform() { + return new com.codename1.impl.android.connectivity.AndroidWifiDirectPlatform(); + } + + @Override + protected com.codename1.io.bonjour.BonjourPlatform createBonjourPlatform() { + return new com.codename1.impl.android.connectivity.AndroidBonjourPlatform(); + } + + @Override + protected com.codename1.io.usb.UsbPlatform createUsbPlatform() { + return new com.codename1.impl.android.connectivity.AndroidUsbPlatform(); + } + + @Override + protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { + return new com.codename1.impl.android.connectivity.AndroidNetworkTypePlatform(); + } + public String getCurrentAccessPoint() { ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); diff --git a/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidBonjourPlatform.java b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidBonjourPlatform.java new file mode 100644 index 0000000000..19055d04da --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidBonjourPlatform.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.android.connectivity; + +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.os.Build; +import android.util.Log; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.io.bonjour.BonjourPlatform; +import com.codename1.io.bonjour.BonjourService; +import com.codename1.io.bonjour.BonjourServiceListener; +import com.codename1.ui.CN; + +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; + +/// Android implementation of `BonjourPlatform` backed by `NsdManager` +/// (API 16+). NsdManager predates Lollipop so the entire class loads +/// safely on the minimum supported Android (KitKat / API 19). +public final class AndroidBonjourPlatform extends BonjourPlatform { + private static final String TAG = "CN1Bonjour"; + + @Override + public boolean isSupported() { + return AndroidImplementation.getContext() != null; + } + + @Override + public Object startBrowse(final String typeIn, + final BonjourServiceListener listener) { + if (listener == null) return null; + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) { + CN.callSerially(new Runnable() { @Override public void run() { + listener.onBrowseError(new RuntimeException("No application context")); } }); + return null; + } + final NsdManager nsd = (NsdManager) ctx.getSystemService(Context.NSD_SERVICE); + if (nsd == null) { + CN.callSerially(new Runnable() { @Override public void run() { + listener.onBrowseError(new RuntimeException("NsdManager unavailable")); } }); + return null; + } + final String type = trimTrailingDot(typeIn); + final NsdManager.DiscoveryListener disc = new NsdManager.DiscoveryListener() { + @Override public void onStartDiscoveryFailed(String s, int errorCode) { + final int code = errorCode; + CN.callSerially(new Runnable() { @Override public void run() { + listener.onBrowseError(new RuntimeException("startDiscovery failed: " + code)); } }); + } + @Override public void onStopDiscoveryFailed(String s, int errorCode) { } + @Override public void onDiscoveryStarted(String s) { } + @Override public void onDiscoveryStopped(String s) { } + @Override public void onServiceFound(NsdServiceInfo info) { + nsd.resolveService(info, new NsdManager.ResolveListener() { + @Override public void onResolveFailed(NsdServiceInfo info, int errorCode) { } + @Override public void onServiceResolved(final NsdServiceInfo info) { + final BonjourService svc = nsdToBonjour(info); + CN.callSerially(new Runnable() { @Override public void run() { + listener.onServiceResolved(svc); } }); + } + }); + } + @Override public void onServiceLost(NsdServiceInfo info) { + final BonjourService svc = nsdToBonjour(info); + CN.callSerially(new Runnable() { @Override public void run() { + listener.onServiceLost(svc); } }); + } + }; + try { + nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, disc); + } catch (Throwable t) { + final Throwable err = t; + CN.callSerially(new Runnable() { @Override public void run() { + listener.onBrowseError(err); } }); + return null; + } + return new Object[]{nsd, disc}; + } + + @Override + public void stopBrowse(Object handle) { + if (handle == null) return; + Object[] arr = (Object[]) handle; + NsdManager nsd = (NsdManager) arr[0]; + NsdManager.DiscoveryListener l = (NsdManager.DiscoveryListener) arr[1]; + try { nsd.stopServiceDiscovery(l); } catch (Throwable ignored) { } + } + + @Override + public Object startPublish(String name, String type, int port, + Map txt) { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return null; + NsdManager nsd = (NsdManager) ctx.getSystemService(Context.NSD_SERVICE); + if (nsd == null) return null; + NsdServiceInfo info = new NsdServiceInfo(); + info.setServiceName(name); + info.setServiceType(trimTrailingDot(type)); + info.setPort(port); + if (txt != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + for (Map.Entry e : txt.entrySet()) { + try { info.setAttribute(e.getKey(), e.getValue()); } + catch (Throwable ignored) { } + } + } + NsdManager.RegistrationListener listener = new NsdManager.RegistrationListener() { + @Override public void onRegistrationFailed(NsdServiceInfo info, int errorCode) { } + @Override public void onUnregistrationFailed(NsdServiceInfo info, int errorCode) { } + @Override public void onServiceRegistered(NsdServiceInfo info) { } + @Override public void onServiceUnregistered(NsdServiceInfo info) { } + }; + try { + nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener); + } catch (Throwable t) { + Log.w(TAG, "registerService failed", t); + return null; + } + return new Object[]{nsd, listener}; + } + + @Override + public void stopPublish(Object handle) { + if (handle == null) return; + Object[] arr = (Object[]) handle; + NsdManager nsd = (NsdManager) arr[0]; + NsdManager.RegistrationListener l = (NsdManager.RegistrationListener) arr[1]; + try { nsd.unregisterService(l); } catch (Throwable ignored) { } + } + + private static BonjourService nsdToBonjour(NsdServiceInfo info) { + Map txt = new HashMap(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && info.getAttributes() != null) { + for (Map.Entry e : info.getAttributes().entrySet()) { + txt.put(e.getKey(), e.getValue() == null + ? "" : new String(e.getValue(), Charset.forName("UTF-8"))); + } + } + String host = info.getHost() != null ? info.getHost().getHostAddress() : null; + return new BonjourService(info.getServiceName(), info.getServiceType(), + host, info.getPort(), txt); + } + + private static String trimTrailingDot(String s) { + if (s == null) return null; + if (s.endsWith(".")) return s.substring(0, s.length() - 1); + return s; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidNetworkTypePlatform.java b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidNetworkTypePlatform.java new file mode 100644 index 0000000000..b498156730 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidNetworkTypePlatform.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.android.connectivity; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.io.NetworkManager; +import com.codename1.io.NetworkTypePlatform; +import com.codename1.ui.CN; + +/// Network-type tracking for Android. The Lollipop+ NetworkCallback path +/// lives in `AndroidNetworkTypePlatformLollipop` so it loads only on +/// devices that have those classes; this entry class works on KitKat with +/// the legacy `CONNECTIVITY_ACTION` broadcast. +public final class AndroidNetworkTypePlatform extends NetworkTypePlatform { + private BroadcastReceiver legacyReceiver; + private Object lollipopHandle; // ConnectivityManager.NetworkCallback when on L+ + + @Override + public int getCurrentNetworkType() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return NetworkManager.NETWORK_TYPE_NONE; + ConnectivityManager c = (ConnectivityManager) + ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + if (c == null) return NetworkManager.NETWORK_TYPE_NONE; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return AndroidNetworkTypePlatformLollipop.getCurrentType(c); + } + NetworkInfo info = c.getActiveNetworkInfo(); + if (info == null || !info.isConnected()) { + return NetworkManager.NETWORK_TYPE_NONE; + } + switch (info.getType()) { + case ConnectivityManager.TYPE_WIFI: return NetworkManager.NETWORK_TYPE_WIFI; + case ConnectivityManager.TYPE_MOBILE: return NetworkManager.NETWORK_TYPE_CELLULAR; + case ConnectivityManager.TYPE_ETHERNET: return NetworkManager.NETWORK_TYPE_ETHERNET; + case ConnectivityManager.TYPE_BLUETOOTH: return NetworkManager.NETWORK_TYPE_BLUETOOTH; + default: return NetworkManager.NETWORK_TYPE_OTHER; + } + } + + @Override + public void install(final NetworkManager target) { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + lollipopHandle = AndroidNetworkTypePlatformLollipop.install(target); + return; + } + legacyReceiver = new BroadcastReceiver() { + @Override public void onReceive(Context c, Intent i) { + dispatch(target); + } + }; + ctx.registerReceiver(legacyReceiver, + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + @Override + public void uninstall(NetworkManager target) { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return; + if (lollipopHandle != null) { + AndroidNetworkTypePlatformLollipop.uninstall(lollipopHandle); + lollipopHandle = null; + } + if (legacyReceiver != null) { + try { ctx.unregisterReceiver(legacyReceiver); } catch (Throwable ignored) { } + legacyReceiver = null; + } + } + + void dispatch(final NetworkManager target) { + final int t = getCurrentNetworkType(); + final boolean vpn = target.isVPNActive(); + CN.callSerially(new Runnable() { + @Override public void run() { + target.fireNetworkTypeChange(t, vpn); + } + }); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidNetworkTypePlatformLollipop.java b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidNetworkTypePlatformLollipop.java new file mode 100644 index 0000000000..394ccc569d --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidNetworkTypePlatformLollipop.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.android.connectivity; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.util.Log; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.io.NetworkManager; +import com.codename1.io.NetworkTypePlatform; +import com.codename1.ui.CN; + +/// API 21+ helpers for AndroidNetworkTypePlatform. Loaded only when +/// SDK_INT >= LOLLIPOP so KitKat clients never trigger verification of +/// `ConnectivityManager.NetworkCallback`, `Network`, `NetworkCapabilities` +/// or `NetworkRequest`. +final class AndroidNetworkTypePlatformLollipop { + private static final String TAG = "CN1NetType"; + + private AndroidNetworkTypePlatformLollipop() { + } + + static int getCurrentType(ConnectivityManager cm) { + Network active = cm.getActiveNetwork(); + if (active == null) return NetworkManager.NETWORK_TYPE_NONE; + NetworkCapabilities caps = cm.getNetworkCapabilities(active); + if (caps == null) return NetworkManager.NETWORK_TYPE_NONE; + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return NetworkManager.NETWORK_TYPE_WIFI; + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + return NetworkManager.NETWORK_TYPE_CELLULAR; + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + return NetworkManager.NETWORK_TYPE_ETHERNET; + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) { + return NetworkManager.NETWORK_TYPE_BLUETOOTH; + } + return NetworkManager.NETWORK_TYPE_OTHER; + } + + static Object install(final NetworkManager target) { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return null; + final ConnectivityManager cm = (ConnectivityManager) + ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) return null; + NetworkRequest req = new NetworkRequest.Builder().build(); + ConnectivityManager.NetworkCallback cb = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network n) { fire(target); } + @Override + public void onLost(Network n) { fire(target); } + @Override + public void onCapabilitiesChanged(Network n, NetworkCapabilities c) { fire(target); } + }; + try { + cm.registerNetworkCallback(req, cb); + } catch (Throwable t) { + Log.w(TAG, "registerNetworkCallback failed", t); + return null; + } + return cb; + } + + static void uninstall(Object handle) { + if (handle == null) return; + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return; + try { + ConnectivityManager c = (ConnectivityManager) + ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + c.unregisterNetworkCallback((ConnectivityManager.NetworkCallback) handle); + } catch (Throwable ignored) { } + } + + private static void fire(final NetworkManager target) { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return; + ConnectivityManager cm = (ConnectivityManager) + ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + final int t = cm != null ? getCurrentType(cm) : NetworkManager.NETWORK_TYPE_NONE; + final boolean vpn = target.isVPNActive(); + CN.callSerially(new Runnable() { + @Override public void run() { + target.fireNetworkTypeChange(t, vpn); + } + }); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidUsbPlatform.java b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidUsbPlatform.java new file mode 100644 index 0000000000..527b4228c9 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidUsbPlatform.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.android.connectivity; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.os.Build; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.io.usb.UsbDevice; +import com.codename1.io.usb.UsbDeviceListener; +import com.codename1.io.usb.UsbPlatform; +import com.codename1.ui.CN; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Android USB host implementation. All Android USB symbols touched here +/// are API 12+ so the class verifies on the minimum supported Android +/// (KitKat / API 19). +public final class AndroidUsbPlatform extends UsbPlatform { + private static final String ACTION_PERM = "com.codename1.usb.PERMISSION"; + // Build.VERSION_CODES.S (=31) isn't on the legacy compile SDK; use the + // literal value. + private static final int SDK_S = 31; + + private final List listeners = new ArrayList(); + private BroadcastReceiver attachReceiver; + private BroadcastReceiver permReceiver; + + @Override + public boolean isSupported() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return false; + return ctx.getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_USB_HOST); + } + + private UsbManager usb() { + return staticUsb(); + } + + private static UsbManager staticUsb() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return null; + return (UsbManager) ctx.getSystemService(Context.USB_SERVICE); + } + + @Override + public UsbDevice[] listDevices() { + UsbManager um = usb(); + if (um == null) return new UsbDevice[0]; + Map map = um.getDeviceList(); + UsbDevice[] out = new UsbDevice[map.size()]; + int i = 0; + for (android.hardware.usb.UsbDevice d : map.values()) { + out[i++] = wrap(d); + } + return out; + } + + private static UsbDevice wrap(android.hardware.usb.UsbDevice d) { + String mfr = null, prod = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mfr = d.getManufacturerName(); + prod = d.getProductName(); + } + return new UsbDevice(d.getDeviceName(), d.getVendorId(), + d.getProductId(), prod, mfr, d); + } + + @Override + public synchronized void addDeviceListener(UsbDeviceListener l) { + if (listeners.contains(l)) return; + listeners.add(l); + ensureReceiverInstalled(); + } + + @Override + public synchronized void removeDeviceListener(UsbDeviceListener l) { + listeners.remove(l); + if (listeners.isEmpty()) { + uninstallReceiver(); + } + } + + private void ensureReceiverInstalled() { + if (attachReceiver != null) return; + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return; + attachReceiver = new BroadcastReceiver() { + @Override public void onReceive(Context c, Intent i) { + android.hardware.usb.UsbDevice d = i.getParcelableExtra(UsbManager.EXTRA_DEVICE); + if (d == null) return; + final UsbDevice wrapped = wrap(d); + final boolean attached = UsbManager.ACTION_USB_DEVICE_ATTACHED + .equals(i.getAction()); + CN.callSerially(new Runnable() { + @Override public void run() { + UsbDeviceListener[] arr; + synchronized (AndroidUsbPlatform.this) { + arr = listeners.toArray(new UsbDeviceListener[listeners.size()]); + } + for (UsbDeviceListener l : arr) { + if (attached) l.onDeviceAttached(wrapped); + else l.onDeviceDetached(wrapped); + } + } + }); + } + }; + IntentFilter f = new IntentFilter(); + f.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + f.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + ctx.registerReceiver(attachReceiver, f); + } + + private void uninstallReceiver() { + Context ctx = AndroidImplementation.getContext(); + if (ctx != null && attachReceiver != null) { + try { ctx.unregisterReceiver(attachReceiver); } catch (Throwable ignored) { } + } + attachReceiver = null; + } + + @Override + public void requestPermission(final UsbDevice device) { + Context ctx = AndroidImplementation.getContext(); + UsbManager um = usb(); + if (ctx == null || um == null || device == null) return; + if (permReceiver == null) { + permReceiver = new BroadcastReceiver() { + @Override public void onReceive(Context c, Intent i) { + if (!ACTION_PERM.equals(i.getAction())) return; + android.hardware.usb.UsbDevice native_ = + i.getParcelableExtra(UsbManager.EXTRA_DEVICE); + final boolean granted = i.getBooleanExtra( + UsbManager.EXTRA_PERMISSION_GRANTED, false); + final UsbDevice wrapped = native_ != null ? wrap(native_) : device; + CN.callSerially(new Runnable() { + @Override public void run() { + UsbDeviceListener[] arr; + synchronized (AndroidUsbPlatform.this) { + arr = listeners.toArray(new UsbDeviceListener[listeners.size()]); + } + for (UsbDeviceListener l : arr) { + l.onPermissionResult(wrapped, granted); + } + } + }); + } + }; + ctx.registerReceiver(permReceiver, new IntentFilter(ACTION_PERM)); + } + int flags = 0; + if (Build.VERSION.SDK_INT >= SDK_S) { + flags = PendingIntent.FLAG_IMMUTABLE; + } + PendingIntent pi = PendingIntent.getBroadcast(ctx, 0, + new Intent(ACTION_PERM), flags); + um.requestPermission((android.hardware.usb.UsbDevice) device.getNativeDevice(), pi); + } + + @Override + public boolean hasPermission(UsbDevice device) { + UsbManager um = usb(); + if (um == null || device == null) return false; + return um.hasPermission((android.hardware.usb.UsbDevice) device.getNativeDevice()); + } + + @Override + public InputStream openInputStream(UsbDevice device, int endpoint) + throws IOException { + return new UsbStream(device, endpoint, UsbConstants.USB_DIR_IN) + .asInputStream(); + } + + @Override + public OutputStream openOutputStream(UsbDevice device, int endpoint) + throws IOException { + return new UsbStream(device, endpoint, UsbConstants.USB_DIR_OUT) + .asOutputStream(); + } + + /// Adapter that bridges an Android USB endpoint to a Java stream. + /// Uses bulk transfers with a 5-second timeout. Declared static so it + /// doesn't pin an AndroidUsbPlatform instance for the stream's lifetime. + private static final class UsbStream { + private final UsbDeviceConnection conn; + private final UsbEndpoint endpoint; + private final UsbInterface iface; + + UsbStream(UsbDevice device, int endpointAddr, int direction) + throws IOException { + UsbManager um = staticUsb(); + if (um == null) throw new IOException("UsbManager unavailable"); + android.hardware.usb.UsbDevice native_ = + (android.hardware.usb.UsbDevice) device.getNativeDevice(); + if (native_ == null) throw new IOException("UsbDevice has no native handle"); + UsbInterface chosenIface = null; + UsbEndpoint chosenEp = null; + for (int i = 0; i < native_.getInterfaceCount() && chosenEp == null; i++) { + UsbInterface ui = native_.getInterface(i); + for (int j = 0; j < ui.getEndpointCount(); j++) { + UsbEndpoint ep = ui.getEndpoint(j); + if (ep.getAddress() == endpointAddr && ep.getDirection() == direction) { + chosenIface = ui; + chosenEp = ep; + break; + } + } + } + if (chosenEp == null) { + throw new IOException("Endpoint 0x" + Integer.toHexString(endpointAddr) + + " not found in direction " + direction); + } + this.conn = um.openDevice(native_); + if (conn == null) throw new IOException("openDevice failed (no permission?)"); + this.iface = chosenIface; + this.endpoint = chosenEp; + if (!conn.claimInterface(iface, true)) { + conn.close(); + throw new IOException("claimInterface failed"); + } + } + + InputStream asInputStream() { + return new InputStream() { + private final byte[] one = new byte[1]; + @Override public int read() throws IOException { + int n = read(one, 0, 1); + return n <= 0 ? -1 : (one[0] & 0xFF); + } + @Override public int read(byte[] b, int off, int len) throws IOException { + byte[] buf = (off == 0) ? b : new byte[len]; + int n = conn.bulkTransfer(endpoint, buf, len, 5000); + if (off != 0 && n > 0) System.arraycopy(buf, 0, b, off, n); + return n; + } + @Override public void close() { + try { conn.releaseInterface(iface); } catch (Throwable t) { /* ignore */ } + try { conn.close(); } catch (Throwable t) { /* ignore */ } + } + }; + } + + OutputStream asOutputStream() { + return new OutputStream() { + @Override public void write(int b) throws IOException { + write(new byte[]{(byte) b}, 0, 1); + } + @Override public void write(byte[] b, int off, int len) throws IOException { + byte[] buf = (off == 0) ? b : new byte[len]; + if (off != 0) System.arraycopy(b, off, buf, 0, len); + int n = conn.bulkTransfer(endpoint, buf, len, 5000); + if (n < 0) throw new IOException("bulkTransfer write failed"); + } + @Override public void close() { + try { conn.releaseInterface(iface); } catch (Throwable t) { /* ignore */ } + try { conn.close(); } catch (Throwable t) { /* ignore */ } + } + }; + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiDirectPlatform.java b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiDirectPlatform.java new file mode 100644 index 0000000000..f2e3472671 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiDirectPlatform.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.android.connectivity; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.net.wifi.p2p.WifiP2pConfig; +import android.net.wifi.p2p.WifiP2pDevice; +import android.net.wifi.p2p.WifiP2pDeviceList; +import android.net.wifi.p2p.WifiP2pManager; +import android.os.Looper; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.io.wifi.WiFiConnectCallback; +import com.codename1.io.wifi.WiFiDirectListener; +import com.codename1.io.wifi.WiFiDirectPeer; +import com.codename1.io.wifi.WifiDirectPlatform; +import com.codename1.ui.CN; + +import java.util.ArrayList; + +/// Android implementation of Wi-Fi Direct via `WifiP2pManager` (API 14+). +/// All symbols touched here exist on the minimum supported Android +/// (KitKat / API 19) so the class verifies without conditional loading. +public final class AndroidWifiDirectPlatform extends WifiDirectPlatform { + private WifiP2pManager p2pManager; + private WifiP2pManager.Channel p2pChannel; + private BroadcastReceiver p2pReceiver; + private WiFiDirectListener p2pListener; + + @Override + public boolean isSupported() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return false; + return ctx.getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT); + } + + @Override + public void startDiscovery(final WiFiDirectListener listener) { + if (listener == null) return; + final Context ctx = AndroidImplementation.getContext(); + if (ctx == null) { + listener.onDiscoveryError(new RuntimeException("No application context")); + return; + } + p2pManager = (WifiP2pManager) ctx.getSystemService(Context.WIFI_P2P_SERVICE); + if (p2pManager == null) { + listener.onDiscoveryError(new RuntimeException("WifiP2pManager unavailable")); + return; + } + p2pChannel = p2pManager.initialize(ctx, Looper.getMainLooper(), null); + p2pListener = listener; + IntentFilter filter = new IntentFilter(); + filter.addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION); + filter.addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION); + p2pReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent i) { + if (!WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION + .equals(i.getAction())) return; + p2pManager.requestPeers(p2pChannel, + new WifiP2pManager.PeerListListener() { + @Override public void onPeersAvailable(WifiP2pDeviceList list) { + ArrayList peers = new ArrayList(); + for (WifiP2pDevice d : list.getDeviceList()) { + peers.add(new WiFiDirectPeer(d.deviceName, + d.deviceAddress, mapP2pStatus(d.status))); + } + final WiFiDirectPeer[] arr = peers.toArray(new WiFiDirectPeer[peers.size()]); + CN.callSerially(new Runnable() { + @Override public void run() { + p2pListener.onPeersAvailable(arr); + } + }); + } + }); + } + }; + ctx.registerReceiver(p2pReceiver, filter); + p2pManager.discoverPeers(p2pChannel, new WifiP2pManager.ActionListener() { + @Override public void onSuccess() { } + @Override public void onFailure(final int reason) { + final int r = reason; + CN.callSerially(new Runnable() { + @Override public void run() { + listener.onDiscoveryError( + new RuntimeException("discoverPeers failed: " + r)); + } + }); + } + }); + } + + private static int mapP2pStatus(int status) { + switch (status) { + case WifiP2pDevice.AVAILABLE: return WiFiDirectPeer.STATE_AVAILABLE; + case WifiP2pDevice.INVITED: return WiFiDirectPeer.STATE_INVITED; + case WifiP2pDevice.CONNECTED: return WiFiDirectPeer.STATE_CONNECTED; + case WifiP2pDevice.FAILED: return WiFiDirectPeer.STATE_FAILED; + case WifiP2pDevice.UNAVAILABLE: return WiFiDirectPeer.STATE_UNAVAILABLE; + default: return WiFiDirectPeer.STATE_AVAILABLE; + } + } + + @Override + public void stopDiscovery() { + Context ctx = AndroidImplementation.getContext(); + if (ctx != null && p2pReceiver != null) { + try { ctx.unregisterReceiver(p2pReceiver); } catch (Throwable ignored) { } + } + if (p2pManager != null && p2pChannel != null) { + try { p2pManager.stopPeerDiscovery(p2pChannel, null); } catch (Throwable ignored) { } + } + p2pReceiver = null; + p2pListener = null; + } + + @Override + public void connect(WiFiDirectPeer peer, final WiFiConnectCallback cb) { + if (p2pManager == null || p2pChannel == null) { + if (cb != null) { + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onConnectResult(false, + new RuntimeException("WiFi Direct discovery not started")); + } + }); + } + return; + } + WifiP2pConfig cfg = new WifiP2pConfig(); + cfg.deviceAddress = peer.getDeviceAddress(); + p2pManager.connect(p2pChannel, cfg, new WifiP2pManager.ActionListener() { + @Override public void onSuccess() { + CN.callSerially(new Runnable() { + @Override public void run() { + if (cb != null) cb.onConnectResult(true, null); + } + }); + } + @Override public void onFailure(final int reason) { + final int r = reason; + CN.callSerially(new Runnable() { + @Override public void run() { + if (cb != null) cb.onConnectResult(false, + new RuntimeException("connect failed: " + r)); + } + }); + } + }); + } + + @Override + public void disconnect() { + if (p2pManager != null && p2pChannel != null) { + try { p2pManager.removeGroup(p2pChannel, null); } catch (Throwable ignored) { } + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiPlatform.java b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiPlatform.java new file mode 100644 index 0000000000..3d259a466f --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiPlatform.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.android.connectivity; + +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.net.wifi.ScanResult; +import android.net.wifi.WifiConfiguration; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.text.format.Formatter; +import android.util.Log; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.io.wifi.WiFiConnectCallback; +import com.codename1.io.wifi.WiFiNetwork; +import com.codename1.io.wifi.WiFiScanCallback; +import com.codename1.io.wifi.WiFiSecurity; +import com.codename1.io.wifi.WifiPlatform; +import com.codename1.ui.CN; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/// Android implementation of WiFi info + scan + connect. The class deliberately +/// imports only API 19-safe symbols. The API 29+ `WifiNetworkSpecifier` flow +/// and any other "modern" Android APIs are reached via reflection or split +/// into separate helpers so this class loads cleanly on KitKat (the minimum +/// SDK supported by the Codename One Android port). +public final class AndroidWifiPlatform extends WifiPlatform { + private static final String TAG = "CN1WiFi"; + // Build.VERSION_CODES.Q (=29) isn't defined on the legacy compile SDK + // shipped with cn1-binaries (API 25); use the literal value. + private static final int SDK_Q = 29; + private BroadcastReceiver scanReceiver; + + @Override + public boolean isInfoSupported() { + return true; + } + + @Override + public boolean isManagementSupported() { + return true; + } + + private WifiManager wifi() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return null; + // applicationContext per Android docs to avoid leaking the activity + // through the singleton WifiManager. + return (WifiManager) ctx.getApplicationContext() + .getSystemService(Context.WIFI_SERVICE); + } + + @Override + public String getCurrentSSID() { + WifiManager wm = wifi(); + if (wm == null) return null; + WifiInfo info = wm.getConnectionInfo(); + if (info == null) return null; + String s = info.getSSID(); + if (s == null) return null; + // Android wraps SSID in quotes and returns "" when + // permission has not been granted. + if (s.length() > 1 && s.startsWith("\"") && s.endsWith("\"")) { + s = s.substring(1, s.length() - 1); + } + if ("".equals(s) || s.length() == 0) { + return null; + } + return s; + } + + @Override + public String getBSSID() { + WifiManager wm = wifi(); + if (wm == null) return null; + WifiInfo info = wm.getConnectionInfo(); + if (info == null) return null; + String s = info.getBSSID(); + if (s == null || "02:00:00:00:00:00".equals(s)) { + return null; + } + return s.toLowerCase(Locale.US); + } + + @Override + public String getGateway() { + // On Lollipop+ we'd prefer LinkProperties for the real default + // route. Read it through a helper that's only loaded on Lollipop+ + // so this class verifies on KitKat. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + String gw = AndroidWifiPlatformLollipop.getGateway(); + if (gw != null) return gw; + } + WifiManager wm = wifi(); + if (wm == null) return null; + try { + return Formatter.formatIpAddress(wm.getDhcpInfo().gateway); + } catch (Throwable t) { + return null; + } + } + + @Override + public String getIp() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + String ip = AndroidWifiPlatformLollipop.getWifiIp(); + if (ip != null) return ip; + } + WifiManager wm = wifi(); + if (wm == null) return null; + WifiInfo info = wm.getConnectionInfo(); + if (info == null) return null; + int ip = info.getIpAddress(); + if (ip == 0) return null; + return Formatter.formatIpAddress(ip); + } + + @Override + public void scan(final WiFiScanCallback cb) { + if (cb == null) return; + final Context ctx = AndroidImplementation.getContext(); + final WifiManager wm = wifi(); + if (ctx == null || wm == null) { + fail(cb, "WiFi unavailable"); + return; + } + if (!checkPermission(Manifest.permission.ACCESS_WIFI_STATE)) { + fail(cb, "ACCESS_WIFI_STATE not granted"); + return; + } + IntentFilter filter = new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION); + if (scanReceiver != null) { + try { ctx.unregisterReceiver(scanReceiver); } catch (Throwable t) { /* ignore */ } + } + scanReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent i) { + try { c.unregisterReceiver(this); } catch (Throwable t) { /* ignore */ } + scanReceiver = null; + final List results = wm.getScanResults(); + final WiFiNetwork[] mapped = new WiFiNetwork[results.size()]; + for (int j = 0; j < results.size(); j++) { + ScanResult r = results.get(j); + mapped[j] = new WiFiNetwork( + r.SSID, + r.BSSID != null ? r.BSSID.toLowerCase(Locale.US) : null, + r.level, + r.frequency, + mapAndroidSecurity(r.capabilities)); + } + java.util.Arrays.sort(mapped, new Comparator() { + @Override public int compare(WiFiNetwork a, WiFiNetwork b) { + return b.getRssi() - a.getRssi(); + } + }); + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onScanComplete(mapped, null); + } + }); + } + }; + ctx.registerReceiver(scanReceiver, filter); + boolean started = wm.startScan(); + if (!started) { + // On API 28+ the OS throttles; deliver cached results. + CN.callSerially(new Runnable() { + @Override public void run() { + BroadcastReceiver r = scanReceiver; + if (r != null) { + r.onReceive(ctx, new Intent( + WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)); + } + } + }); + } + } + + private static WiFiSecurity mapAndroidSecurity(String capabilities) { + if (capabilities == null) return WiFiSecurity.UNKNOWN; + String c = capabilities.toUpperCase(Locale.US); + if (c.contains("WPA3") || c.contains("SAE")) return WiFiSecurity.WPA3_SAE; + if (c.contains("WPA")) return WiFiSecurity.WPA_PSK; + if (c.contains("WEP")) return WiFiSecurity.WEP; + if (c.contains("EAP")) return WiFiSecurity.EAP; + if (c.contains("ESS")) return WiFiSecurity.OPEN; + return WiFiSecurity.UNKNOWN; + } + + @Override + public void connect(final String ssid, final String password, + final WiFiSecurity security, + final WiFiConnectCallback cb) { + Context ctx = AndroidImplementation.getContext(); + WifiManager wm = wifi(); + if (ctx == null || wm == null) { + failConnect(cb, "WiFi unavailable"); + return; + } + if (Build.VERSION.SDK_INT >= SDK_Q) { + AndroidWifiPlatformLollipop.connectQ(ctx, ssid, password, + security, cb); + } else { + connectLegacy(wm, ssid, password, security, cb); + } + } + + private static void connectLegacy(WifiManager wm, String ssid, + String password, WiFiSecurity security, + final WiFiConnectCallback cb) { + try { + WifiConfiguration cfg = new WifiConfiguration(); + cfg.SSID = "\"" + ssid + "\""; + if (security == WiFiSecurity.OPEN || password == null) { + cfg.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); + } else if (security == WiFiSecurity.WEP) { + cfg.wepKeys[0] = "\"" + password + "\""; + cfg.wepTxKeyIndex = 0; + cfg.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); + cfg.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP40); + } else { + cfg.preSharedKey = "\"" + password + "\""; + } + int id = wm.addNetwork(cfg); + if (id < 0) { failConnect(cb, "addNetwork failed"); return; } + wm.disconnect(); + boolean ok = wm.enableNetwork(id, true) && wm.reconnect(); + final boolean done = ok; + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onConnectResult(done, + done ? null : new RuntimeException("Legacy enableNetwork failed")); + } + }); + } catch (Throwable t) { + failConnect(cb, t.getMessage()); + } + } + + @Override + public void disconnect(String ssid) { + if (Build.VERSION.SDK_INT >= SDK_Q) { + AndroidWifiPlatformLollipop.disconnect(ssid); + } + } + + private static boolean checkPermission(String perm) { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return ctx.checkSelfPermission(perm) == PackageManager.PERMISSION_GRANTED; + } + return ctx.checkPermission(perm, android.os.Process.myPid(), + android.os.Process.myUid()) == PackageManager.PERMISSION_GRANTED; + } + + private static void fail(final WiFiScanCallback cb, final String msg) { + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onScanComplete(null, new RuntimeException(msg)); + } + }); + } + + static void failConnect(final WiFiConnectCallback cb, final String msg) { + if (cb == null) return; + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onConnectResult(false, new RuntimeException(msg)); + } + }); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiPlatformLollipop.java b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiPlatformLollipop.java new file mode 100644 index 0000000000..ae019b517e --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/connectivity/AndroidWifiPlatformLollipop.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.android.connectivity; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.LinkAddress; +import android.net.LinkProperties; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.NetworkSpecifier; +import android.net.RouteInfo; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.io.wifi.WiFiConnectCallback; +import com.codename1.io.wifi.WiFiSecurity; +import com.codename1.ui.CN; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.util.HashMap; +import java.util.Map; + +/// API 21+ (Lollipop) helpers for AndroidWifiPlatform. Kept in its own +/// class so the API 21+ symbol references it imports (`Network`, +/// `LinkProperties`, `NetworkCallback`, `NetworkSpecifier`) only trigger +/// classloader verification on devices that actually support them. Callers +/// guard every invocation with `Build.VERSION.SDK_INT >= LOLLIPOP`; on +/// KitKat this class is never loaded. +final class AndroidWifiPlatformLollipop { + // The cn1-binaries compile SDK predates WifiNetworkSpecifier.Builder + // (API 29). The builder is reached reflectively in connectQ() below so + // the file builds without an import; the runtime path is exercised + // only when SDK_INT >= 29. + + private static final Map pendingConnects + = new HashMap(); + + private AndroidWifiPlatformLollipop() { + } + + private static ConnectivityManager cm() { + Context ctx = AndroidImplementation.getContext(); + if (ctx == null) return null; + return (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + } + + static String getGateway() { + ConnectivityManager c = cm(); + if (c == null) return null; + Network n = c.getActiveNetwork(); + if (n == null) return null; + LinkProperties lp = c.getLinkProperties(n); + if (lp == null) return null; + for (RouteInfo r : lp.getRoutes()) { + if (r.isDefaultRoute() && r.getGateway() instanceof Inet4Address) { + return r.getGateway().getHostAddress(); + } + } + return null; + } + + static String getWifiIp() { + ConnectivityManager c = cm(); + if (c == null) return null; + Network n = c.getActiveNetwork(); + if (n == null) return null; + LinkProperties lp = c.getLinkProperties(n); + if (lp == null) return null; + for (LinkAddress la : lp.getLinkAddresses()) { + InetAddress a = la.getAddress(); + if (a instanceof Inet4Address && !a.isLoopbackAddress()) { + return a.getHostAddress(); + } + } + return null; + } + + static void connectQ(Context ctx, final String ssid, String password, + WiFiSecurity security, + final WiFiConnectCallback cb) { + NetworkSpecifier spec; + try { + Class builderCls = Class.forName( + "android.net.wifi.WifiNetworkSpecifier$Builder"); + Object builder = builderCls.getConstructor().newInstance(); + builderCls.getMethod("setSsid", String.class) + .invoke(builder, ssid); + if (password != null && password.length() > 0) { + String setter = security == WiFiSecurity.WPA3_SAE + ? "setWpa3Passphrase" : "setWpa2Passphrase"; + builderCls.getMethod(setter, String.class) + .invoke(builder, password); + } + spec = (NetworkSpecifier) builderCls.getMethod("build") + .invoke(builder); + } catch (Throwable t) { + AndroidWifiPlatform.failConnect(cb, + "WifiNetworkSpecifier not available: " + t); + return; + } + NetworkRequest req = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(spec) + .build(); + ConnectivityManager c = (ConnectivityManager) + ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + ConnectivityManager.NetworkCallback callback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network n) { + CN.callSerially(new Runnable() { + @Override public void run() { + if (cb != null) cb.onConnectResult(true, null); + } + }); + } + @Override + public void onUnavailable() { + CN.callSerially(new Runnable() { + @Override public void run() { + if (cb != null) cb.onConnectResult(false, + new RuntimeException("WiFi connect unavailable / rejected")); + } + }); + } + }; + pendingConnects.put(ssid, callback); + try { + c.requestNetwork(req, callback); + } catch (Throwable t) { + AndroidWifiPlatform.failConnect(cb, t.getMessage()); + } + } + + static void disconnect(String ssid) { + ConnectivityManager.NetworkCallback cb = pendingConnects.remove(ssid); + if (cb == null) return; + try { + ConnectivityManager c = cm(); + if (c != null) c.unregisterNetworkCallback(cb); + } catch (Throwable ignored) { } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 0a9c8d6aa2..fcec559d41 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -12462,6 +12462,26 @@ public void setCurrentAccessPoint(String id) { super.setCurrentAccessPoint(id); } + @Override + protected com.codename1.io.wifi.WifiPlatform createWifiPlatform() { + return new com.codename1.impl.javase.connectivity.JavaSEWifiPlatform(); + } + + @Override + protected com.codename1.io.wifi.WifiDirectPlatform createWifiDirectPlatform() { + return new com.codename1.impl.javase.connectivity.JavaSEWifiDirectPlatform(); + } + + @Override + protected com.codename1.io.bonjour.BonjourPlatform createBonjourPlatform() { + return new com.codename1.impl.javase.connectivity.JavaSEBonjourPlatform(); + } + + @Override + protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { + return new com.codename1.impl.javase.connectivity.JavaSENetworkTypePlatform(); + } + @Override public void openImageGallery(final com.codename1.ui.events.ActionListener response){ if(!checkForPermission("android.permission.WRITE_EXTERNAL_STORAGE", "This is required to browse the photos")){ diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEBonjourPlatform.java b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEBonjourPlatform.java new file mode 100644 index 0000000000..ac71886670 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEBonjourPlatform.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.javase.connectivity; + +import com.codename1.io.bonjour.BonjourPlatform; +import com.codename1.io.bonjour.BonjourServiceListener; +import com.codename1.ui.CN; + +import java.util.Map; + +/// Bonjour platform for the JavaSE simulator. Loads JmDNS reflectively so +/// the JavaSE port stays JmDNS-optional; developers who want real Bonjour +/// in the simulator add the dependency to their *application's* simulator +/// profile, not to the core / common pom (which would push it to devices). +public final class JavaSEBonjourPlatform extends BonjourPlatform { + @Override + public boolean isSupported() { + return jmdnsAvailable(); + } + + private static boolean jmdnsAvailable() { + try { + Class.forName("javax.jmdns.JmDNS"); + return true; + } catch (Throwable t) { + return false; + } + } + + @Override + public Object startBrowse(String type, final BonjourServiceListener listener) { + JavaSEConnectivityUsage.noteUsage("Bonjour"); + if (listener == null) return null; + if (!jmdnsAvailable()) { + System.out.println("[CN1 simulator] Bonjour browse: JmDNS not on classpath. " + + "Add org.jmdns:jmdns to the executable-jar/simulator profile of your " + + "application's pom to exercise real discovery."); + CN.callSerially(new Runnable() { + @Override public void run() { + listener.onBrowseError(new UnsupportedOperationException( + "JmDNS not on simulator classpath")); + } + }); + return null; + } + try { + Object jmdns = Class.forName("javax.jmdns.JmDNS") + .getMethod("create").invoke(null); + System.out.println("[CN1 simulator] Bonjour browse started via JmDNS for type=" + type); + return jmdns; + } catch (Throwable t) { + final Throwable err = t; + CN.callSerially(new Runnable() { + @Override public void run() { listener.onBrowseError(err); } + }); + return null; + } + } + + @Override + public void stopBrowse(Object handle) { + if (handle == null) return; + try { + handle.getClass().getMethod("close").invoke(handle); + } catch (Throwable ignored) { } + } + + @Override + public Object startPublish(String name, String type, int port, + Map txt) { + JavaSEConnectivityUsage.noteUsage("Bonjour"); + System.out.println("[CN1 simulator] Bonjour publish " + name + "@" + type + ":" + port); + return new Object(); + } + + @Override + public void stopPublish(Object handle) { + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEConnectivityUsage.java b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEConnectivityUsage.java new file mode 100644 index 0000000000..66ba374879 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEConnectivityUsage.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.javase.connectivity; + +import java.util.HashSet; +import java.util.Set; + +/// Tracks which connectivity APIs the running simulator app has touched +/// and prints a one-shot reminder of the permissions / entitlements +/// production builds need. A JVM shutdown hook summarises everything used +/// during the run. +public final class JavaSEConnectivityUsage { + private static final Set usedApis = new HashSet(); + private static volatile boolean shutdownHookInstalled; + + private JavaSEConnectivityUsage() { + } + + public static void noteUsage(String api) { + synchronized (usedApis) { + if (usedApis.add(api)) { + System.out.println("[CN1 simulator] App is using '" + api + + "'. In production builds:"); + printRequiredFor(api); + } + } + installShutdownHook(); + } + + private static void printRequiredFor(String api) { + if ("WiFi.info".equals(api)) { + System.out.println(" android: ACCESS_WIFI_STATE, ACCESS_NETWORK_STATE, ACCESS_FINE_LOCATION (injected automatically)"); + System.out.println(" ios: com.apple.developer.networking.wifi-info entitlement + NSLocationWhenInUseUsageDescription (injected automatically)"); + } else if ("WiFi.scan".equals(api)) { + System.out.println(" android: ACCESS_WIFI_STATE, CHANGE_WIFI_STATE, ACCESS_FINE_LOCATION, NEARBY_WIFI_DEVICES (injected automatically)"); + System.out.println(" ios: not supported"); + } else if ("WiFi.connect".equals(api)) { + System.out.println(" android: CHANGE_NETWORK_STATE, CHANGE_WIFI_STATE, ACCESS_WIFI_STATE (injected automatically)"); + System.out.println(" ios: com.apple.developer.networking.HotspotConfiguration entitlement (injected automatically)"); + } else if ("Bonjour".equals(api)) { + System.out.println(" android: CHANGE_WIFI_MULTICAST_STATE (injected automatically)"); + System.out.println(" ios: NSLocalNetworkUsageDescription + NSBonjourServices in Info.plist (injected automatically)"); + } else if ("WiFiDirect".equals(api)) { + System.out.println(" android: CHANGE_WIFI_STATE, ACCESS_FINE_LOCATION, NEARBY_WIFI_DEVICES (injected automatically)"); + System.out.println(" ios: not supported"); + } else if ("Usb".equals(api)) { + System.out.println(" android: USB host feature (injected automatically); see Network-Connectivity.asciidoc for device_filter.xml"); + System.out.println(" ios: not supported"); + } + } + + private static void installShutdownHook() { + if (shutdownHookInstalled) return; + synchronized (JavaSEConnectivityUsage.class) { + if (shutdownHookInstalled) return; + shutdownHookInstalled = true; + try { + Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { + @Override public void run() { + printSummary(); + } + }, "CN1-connectivity-summary")); + } catch (Throwable ignored) { } + } + } + + public static void printSummary() { + synchronized (usedApis) { + if (usedApis.isEmpty()) return; + System.out.println("[CN1 simulator] Connectivity APIs used this session: " + usedApis); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSENetworkTypePlatform.java b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSENetworkTypePlatform.java new file mode 100644 index 0000000000..ee483be287 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSENetworkTypePlatform.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.javase.connectivity; + +import com.codename1.io.NetworkManager; +import com.codename1.io.NetworkTypePlatform; + +import java.net.NetworkInterface; +import java.util.Locale; + +/// Network-type tracker for the JavaSE simulator. Derives the active +/// network class from `NetworkInterface.getDisplayName` heuristics. There +/// is no transition listener -- the simulator never synthesizes type +/// changes. +public final class JavaSENetworkTypePlatform extends NetworkTypePlatform { + @Override + public int getCurrentNetworkType() { + try { + NetworkInterface ni = JavaSEWifiPlatform.primaryInterface(); + if (ni == null) return NetworkManager.NETWORK_TYPE_NONE; + String name = ni.getName() == null ? "" : ni.getName().toLowerCase(Locale.US); + String display = ni.getDisplayName() == null ? "" + : ni.getDisplayName().toLowerCase(Locale.US); + if (name.startsWith("wlan") || name.startsWith("wifi") + || display.contains("wireless") || display.contains("wi-fi")) { + return NetworkManager.NETWORK_TYPE_WIFI; + } + if (name.startsWith("en") || name.startsWith("eth")) { + return NetworkManager.NETWORK_TYPE_ETHERNET; + } + return NetworkManager.NETWORK_TYPE_OTHER; + } catch (Throwable t) { + return NetworkManager.NETWORK_TYPE_NONE; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEWifiDirectPlatform.java b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEWifiDirectPlatform.java new file mode 100644 index 0000000000..aad42c7281 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEWifiDirectPlatform.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.javase.connectivity; + +import com.codename1.io.wifi.WiFiConnectCallback; +import com.codename1.io.wifi.WiFiDirectListener; +import com.codename1.io.wifi.WiFiDirectPeer; +import com.codename1.io.wifi.WifiDirectPlatform; +import com.codename1.ui.CN; + +/// JavaSE stub for WiFi Direct -- the desktop has no equivalent so we +/// just report unsupported and log usage so production-build issues +/// surface during simulator testing. +public final class JavaSEWifiDirectPlatform extends WifiDirectPlatform { + @Override + public boolean isSupported() { return false; } + + @Override + public void startDiscovery(final WiFiDirectListener l) { + JavaSEConnectivityUsage.noteUsage("WiFiDirect"); + if (l == null) return; + CN.callSerially(new Runnable() { + @Override public void run() { + l.onDiscoveryError(new UnsupportedOperationException( + "WiFi Direct is not supported on JavaSE")); + } + }); + } + + @Override + public void stopDiscovery() { } + + @Override + public void connect(WiFiDirectPeer peer, final WiFiConnectCallback cb) { + if (cb != null) { + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onConnectResult(false, new UnsupportedOperationException( + "WiFi Direct is not supported on JavaSE")); + } + }); + } + } + + @Override + public void disconnect() { } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEWifiPlatform.java b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEWifiPlatform.java new file mode 100644 index 0000000000..5fb445cda2 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/connectivity/JavaSEWifiPlatform.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.javase.connectivity; + +import com.codename1.io.wifi.WiFiConnectCallback; +import com.codename1.io.wifi.WiFiNetwork; +import com.codename1.io.wifi.WiFiScanCallback; +import com.codename1.io.wifi.WiFiSecurity; +import com.codename1.io.wifi.WifiPlatform; +import com.codename1.ui.CN; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.Enumeration; +import java.util.Locale; + +/// Best-effort WiFi platform for the JavaSE simulator. SSID/BSSID come +/// from `NetworkInterface`; scan returns a small fabricated list; connect +/// reports an unsupported error and logs the call. +public final class JavaSEWifiPlatform extends WifiPlatform { + @Override + public boolean isInfoSupported() { return true; } + + @Override + public boolean isManagementSupported() { return true; } + + @Override + public String getCurrentSSID() { + JavaSEConnectivityUsage.noteUsage("WiFi.info"); + try { + NetworkInterface ni = primaryInterface(); + return ni == null ? null : ni.getDisplayName(); + } catch (Throwable t) { + return null; + } + } + + @Override + public String getBSSID() { + JavaSEConnectivityUsage.noteUsage("WiFi.info"); + try { + NetworkInterface ni = primaryInterface(); + if (ni == null) return null; + byte[] mac = ni.getHardwareAddress(); + if (mac == null) return null; + StringBuilder sb = new StringBuilder(17); + for (int i = 0; i < mac.length; i++) { + if (sb.length() > 0) sb.append(':'); + sb.append(String.format(Locale.US, "%02x", mac[i] & 0xFF)); + } + return sb.toString(); + } catch (Throwable t) { + return null; + } + } + + @Override + public String getGateway() { + JavaSEConnectivityUsage.noteUsage("WiFi.info"); + try { + NetworkInterface ni = primaryInterface(); + if (ni == null) return null; + Enumeration addrs = ni.getInetAddresses(); + while (addrs.hasMoreElements()) { + InetAddress a = addrs.nextElement(); + if (a instanceof Inet4Address && !a.isLoopbackAddress()) { + byte[] octets = a.getAddress(); + octets[3] = 1; + return InetAddress.getByAddress(octets).getHostAddress(); + } + } + } catch (Throwable t) { /* fall through */ } + return null; + } + + @Override + public String getIp() { + JavaSEConnectivityUsage.noteUsage("WiFi.info"); + try { + NetworkInterface ni = primaryInterface(); + if (ni == null) return null; + Enumeration addrs = ni.getInetAddresses(); + while (addrs.hasMoreElements()) { + InetAddress a = addrs.nextElement(); + if (a instanceof Inet4Address && !a.isLoopbackAddress()) { + return a.getHostAddress(); + } + } + } catch (Throwable t) { /* fall through */ } + return null; + } + + static NetworkInterface primaryInterface() throws Exception { + Enumeration ifs = NetworkInterface.getNetworkInterfaces(); + while (ifs != null && ifs.hasMoreElements()) { + NetworkInterface ni = ifs.nextElement(); + if (!ni.isUp() || ni.isLoopback() || ni.isVirtual()) continue; + Enumeration addrs = ni.getInetAddresses(); + while (addrs.hasMoreElements()) { + if (addrs.nextElement() instanceof Inet4Address) { + return ni; + } + } + } + return null; + } + + @Override + public void scan(final WiFiScanCallback cb) { + JavaSEConnectivityUsage.noteUsage("WiFi.scan"); + if (cb == null) return; + final WiFiNetwork[] fake = new WiFiNetwork[]{ + new WiFiNetwork("Simulated-Home", "aa:bb:cc:11:22:33", + -45, 2412, WiFiSecurity.WPA_PSK), + new WiFiNetwork("Simulated-Office", "aa:bb:cc:44:55:66", + -62, 5180, WiFiSecurity.WPA3_SAE), + new WiFiNetwork("Simulated-Guest", "aa:bb:cc:77:88:99", + -78, 2437, WiFiSecurity.OPEN), + }; + System.out.println("[CN1 simulator] WiFi.scan returning fabricated results"); + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onScanComplete(fake, null); + } + }); + } + + @Override + public void connect(final String ssid, final String password, + final WiFiSecurity security, + final WiFiConnectCallback cb) { + JavaSEConnectivityUsage.noteUsage("WiFi.connect"); + System.out.println("[CN1 simulator] WiFi.connect(" + ssid + + ", security=" + security + ") -- no-op in simulator"); + if (cb != null) { + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onConnectResult(false, new UnsupportedOperationException( + "WiFi.connect is not implemented in the simulator")); + } + }); + } + } + + @Override + public void disconnect(String ssid) { + } +} diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index f1c6497a6c..20c5a9a6e0 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -49,6 +49,7 @@ #include "com_codename1_impl_ios_IOSBiometrics.h" #include "com_codename1_impl_ios_IOSSecureStorage.h" #include "com_codename1_impl_ios_IOSNfc.h" +#include "com_codename1_impl_ios_IOSConnectivity.h" #include "com_codename1_ui_Display.h" #include "com_codename1_ui_Component.h" #include "java_lang_Throwable.h" @@ -65,6 +66,10 @@ #import #include #include +#include +#include +#include +#include #import #import #import @@ -4498,6 +4503,426 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isVPNActive___R_boolean(CN1_THREAD return found; } +// ==================================================================== +// Deeper network connectivity: WiFi info, NEHotspotConfiguration, +// NSNetService Bonjour, SCNetworkReachability-based type tracking. +// The build pipeline injects the wifi-info / HotspotConfiguration / +// NSLocalNetworkUsageDescription / NSBonjourServices entries only when +// the relevant Java classes are referenced -- this keeps stock apps free +// of dangling entitlements that block App Store approval. +// ==================================================================== + +// CN1_INCLUDE_HOTSPOT toggles NetworkExtension.framework import. Gated by +// IPhoneBuilder when com.codename1.io.wifi.WiFi.connect is on the +// classpath. Apps that never call WiFi.connect ship without any +// NetworkExtension symbols so Apple's API-usage scanner does not flag +// them. +//#define CN1_INCLUDE_HOTSPOT +#ifdef CN1_INCLUDE_HOTSPOT +#import +#endif + +// CN1_INCLUDE_WIFI_INFO toggles the CaptiveNetwork SSID/BSSID readout. +// CaptiveNetwork's CNCopyCurrentNetworkInfo is still the only way to get +// SSID/BSSID on a NEHotspotConfiguration-joined network. It is deprecated +// in iOS 14 but Apple kept it working for apps holding the wifi-info +// entitlement -- which we inject only when the WiFi info API is used. +// IPhoneBuilder uncomments the define when com.codename1.io.wifi.WiFi is +// on the classpath; stock apps see no CaptiveNetwork symbols and need no +// wifi-info entitlement. +//#define CN1_INCLUDE_WIFI_INFO +#ifdef CN1_INCLUDE_WIFI_INFO +#import +#endif + +// CN1_INCLUDE_BONJOUR toggles the NSNetServiceBrowser / NSNetService +// bridge. Foundation is always linked so there is no framework cost when +// off, but the runtime hooks (the delegate, the dispatcher tables) only +// instantiate when this define is on -- which avoids dangling +// NSLocalNetworkUsageDescription requirements and surprises during the +// App Store review process. +//#define CN1_INCLUDE_BONJOUR + +static SCNetworkReachabilityRef cn1ReachabilityRef = NULL; + +static int cn1NetworkTypeFromFlags(SCNetworkReachabilityFlags flags) { + if (!(flags & kSCNetworkReachabilityFlagsReachable)) { + return 0; // NETWORK_TYPE_NONE + } + if (flags & kSCNetworkReachabilityFlagsIsWWAN) { + return 2; // NETWORK_TYPE_CELLULAR + } + return 1; // NETWORK_TYPE_WIFI -- iOS treats everything non-WWAN as wifi +} + +static int cn1ReadNetworkType() { + struct sockaddr_in zero; + bzero(&zero, sizeof(zero)); + zero.sin_len = sizeof(zero); + zero.sin_family = AF_INET; + SCNetworkReachabilityRef r = SCNetworkReachabilityCreateWithAddress( + kCFAllocatorDefault, (const struct sockaddr*) &zero); + if (r == NULL) return 0; + SCNetworkReachabilityFlags flags; + int t = 0; + if (SCNetworkReachabilityGetFlags(r, &flags)) { + t = cn1NetworkTypeFromFlags(flags); + } + CFRelease(r); + return t; +} + +JAVA_INT com_codename1_impl_ios_IOSNative_wifiNetworkType___R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return cn1ReadNetworkType(); +} + +static void cn1ReachabilityCallback(SCNetworkReachabilityRef target, + SCNetworkReachabilityFlags flags, + void *info) { + int t = cn1NetworkTypeFromFlags(flags); + // Reuse the existing VPN detector so the listener parity with + // NetworkManager.isVPNActive() stays consistent. + JAVA_BOOLEAN vpn = com_codename1_impl_ios_IOSNative_isVPNActive___R_boolean(CN1_THREAD_GET_STATE_PASS_ARG JAVA_NULL); + com_codename1_impl_ios_IOSConnectivity_networkTypeChangedDispatch___int_boolean( + CN1_THREAD_GET_STATE_PASS_ARG t, vpn); +} + +void com_codename1_impl_ios_IOSNative_wifiInstallTypeListener___java_lang_Object(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT clsObj) { + if (cn1ReachabilityRef != NULL) return; + struct sockaddr_in zero; + bzero(&zero, sizeof(zero)); + zero.sin_len = sizeof(zero); + zero.sin_family = AF_INET; + cn1ReachabilityRef = SCNetworkReachabilityCreateWithAddress( + kCFAllocatorDefault, (const struct sockaddr*) &zero); + if (cn1ReachabilityRef == NULL) return; + SCNetworkReachabilityContext ctx = {0, NULL, NULL, NULL, NULL}; + if (!SCNetworkReachabilitySetCallback(cn1ReachabilityRef, + cn1ReachabilityCallback, &ctx)) { + CFRelease(cn1ReachabilityRef); + cn1ReachabilityRef = NULL; + return; + } + SCNetworkReachabilityScheduleWithRunLoop(cn1ReachabilityRef, + CFRunLoopGetMain(), kCFRunLoopCommonModes); +} + +void com_codename1_impl_ios_IOSNative_wifiUninstallTypeListener__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + if (cn1ReachabilityRef == NULL) return; + SCNetworkReachabilityUnscheduleFromRunLoop(cn1ReachabilityRef, + CFRunLoopGetMain(), kCFRunLoopCommonModes); + CFRelease(cn1ReachabilityRef); + cn1ReachabilityRef = NULL; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wifiCurrentSSID___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#ifdef CN1_INCLUDE_WIFI_INFO + CFArrayRef interfaces = CNCopySupportedInterfaces(); + if (interfaces == NULL) return JAVA_NULL; + JAVA_OBJECT result = JAVA_NULL; + CFIndex count = CFArrayGetCount(interfaces); + for (CFIndex i = 0; i < count; i++) { + CFStringRef iface = (CFStringRef) CFArrayGetValueAtIndex(interfaces, i); + CFDictionaryRef info = CNCopyCurrentNetworkInfo(iface); + if (info != NULL) { + CFStringRef ssid = (CFStringRef) CFDictionaryGetValue(info, + kCNNetworkInfoKeySSID); + if (ssid != NULL) { + result = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG (NSString*) ssid); + } + CFRelease(info); + if (result != JAVA_NULL) break; + } + } + CFRelease(interfaces); + return result; +#else + return JAVA_NULL; +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wifiCurrentBSSID___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#ifdef CN1_INCLUDE_WIFI_INFO + CFArrayRef interfaces = CNCopySupportedInterfaces(); + if (interfaces == NULL) return JAVA_NULL; + JAVA_OBJECT result = JAVA_NULL; + CFIndex count = CFArrayGetCount(interfaces); + for (CFIndex i = 0; i < count; i++) { + CFStringRef iface = (CFStringRef) CFArrayGetValueAtIndex(interfaces, i); + CFDictionaryRef info = CNCopyCurrentNetworkInfo(iface); + if (info != NULL) { + CFStringRef bssid = (CFStringRef) CFDictionaryGetValue(info, + kCNNetworkInfoKeyBSSID); + if (bssid != NULL) { + result = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + [(NSString*) bssid lowercaseString]); + } + CFRelease(info); + if (result != JAVA_NULL) break; + } + } + CFRelease(interfaces); + return result; +#else + return JAVA_NULL; +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wifiIpAddress___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + struct ifaddrs *interfaces = NULL; + if (getifaddrs(&interfaces) != 0) return JAVA_NULL; + JAVA_OBJECT result = JAVA_NULL; + for (struct ifaddrs *ifa = interfaces; ifa != NULL; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == NULL || ifa->ifa_addr->sa_family != AF_INET) continue; + if (!(ifa->ifa_flags & IFF_UP) || (ifa->ifa_flags & IFF_LOOPBACK)) continue; + // en0 is the standard WiFi interface name on iOS devices. + if (ifa->ifa_name == NULL || strncmp(ifa->ifa_name, "en", 2) != 0) continue; + char addr[INET_ADDRSTRLEN]; + struct sockaddr_in *sin = (struct sockaddr_in*) ifa->ifa_addr; + if (inet_ntop(AF_INET, &sin->sin_addr, addr, sizeof(addr)) != NULL) { + result = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + [NSString stringWithUTF8String:addr]); + break; + } + } + freeifaddrs(interfaces); + return result; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wifiGateway___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + // iOS does not expose the route table to apps. Best-effort: derive from + // the en0 address by assuming the gateway lives at the network's .1 + // address. This matches the most common home/SOHO topology and is + // documented as best-effort in the Java contract. + struct ifaddrs *interfaces = NULL; + if (getifaddrs(&interfaces) != 0) return JAVA_NULL; + JAVA_OBJECT result = JAVA_NULL; + for (struct ifaddrs *ifa = interfaces; ifa != NULL; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == NULL || ifa->ifa_addr->sa_family != AF_INET) continue; + if (!(ifa->ifa_flags & IFF_UP) || (ifa->ifa_flags & IFF_LOOPBACK)) continue; + if (ifa->ifa_name == NULL || strncmp(ifa->ifa_name, "en", 2) != 0) continue; + struct sockaddr_in *sin = (struct sockaddr_in*) ifa->ifa_addr; + struct sockaddr_in *mask = (struct sockaddr_in*) ifa->ifa_netmask; + if (mask == NULL) continue; + uint32_t net = sin->sin_addr.s_addr & mask->sin_addr.s_addr; + uint32_t gw = net | htonl(1); + struct in_addr g; + g.s_addr = gw; + char buf[INET_ADDRSTRLEN]; + if (inet_ntop(AF_INET, &g, buf, sizeof(buf)) != NULL) { + result = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + [NSString stringWithUTF8String:buf]); + break; + } + } + freeifaddrs(interfaces); + return result; +} + +void com_codename1_impl_ios_IOSNative_wifiConnect___java_lang_String_java_lang_String_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT ssidObj, JAVA_OBJECT pwObj, JAVA_INT security) { +#ifdef CN1_INCLUDE_HOTSPOT + if (@available(iOS 11.0, *)) { + NSString *ssid = toNSString(CN1_THREAD_GET_STATE_PASS_ARG ssidObj); + NSString *pw = pwObj == JAVA_NULL ? nil : toNSString(CN1_THREAD_GET_STATE_PASS_ARG pwObj); + NEHotspotConfiguration *cfg; + if (pw == nil || pw.length == 0) { + cfg = [[NEHotspotConfiguration alloc] initWithSSID:ssid]; + } else { + // security==4 -> WPA3_SAE, others -> WPA2 PSK + BOOL isWep = security == 1; + cfg = [[NEHotspotConfiguration alloc] initWithSSID:ssid + passphrase:pw + isWEP:isWep]; + } + [[NEHotspotConfigurationManager sharedManager] + applyConfiguration:cfg + completionHandler:^(NSError * _Nullable err) { + BOOL ok = (err == nil + || err.code == NEHotspotConfigurationErrorAlreadyAssociated); + NSString *msg = err == nil ? @"ok" : err.localizedDescription; + com_codename1_impl_ios_IOSConnectivity_wifiConnectResult___boolean_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG + ok ? JAVA_TRUE : JAVA_FALSE, + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG msg)); + }]; + [cfg release]; + return; + } +#endif + com_codename1_impl_ios_IOSConnectivity_wifiConnectResult___boolean_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG JAVA_FALSE, + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + @"NEHotspotConfiguration not linked. Reference com.codename1.io.wifi.WiFi.connect from your app to make the iOS builder inject the entitlement and link NetworkExtension.framework.")); +} + +void com_codename1_impl_ios_IOSNative_wifiDisconnect___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT ssidObj) { +#ifdef CN1_INCLUDE_HOTSPOT + if (@available(iOS 11.0, *)) { + NSString *ssid = toNSString(CN1_THREAD_GET_STATE_PASS_ARG ssidObj); + [[NEHotspotConfigurationManager sharedManager] removeConfigurationForSSID:ssid]; + } +#endif +} + +// ---------------- Bonjour ---------------- +// Gated on CN1_INCLUDE_BONJOUR; IPhoneBuilder uncomments the define above +// when com.codename1.io.bonjour is on the classpath. Apps that never use +// Bonjour neither register the NSNetServiceBrowser delegate nor declare +// NSLocalNetworkUsageDescription / NSBonjourServices in Info.plist, so the +// iOS 14 local-network privacy prompt is suppressed for them. The C entry +// points still link (ParparVM requires the symbol for every `native` +// method) but they short-circuit to JAVA_NULL / 0 when the define is off. + +#ifdef CN1_INCLUDE_BONJOUR +@interface CN1BonjourBrowser : NSObject +@property (nonatomic, retain) NSNetServiceBrowser *browser; +@property (nonatomic, retain) NSMutableArray *resolving; +@property (nonatomic, assign) JAVA_LONG handle; +@end + +@implementation CN1BonjourBrowser +- (void)dealloc { + [_browser release]; + [_resolving release]; + [super dealloc]; +} +- (void)netServiceBrowser:(NSNetServiceBrowser *)b + didFindService:(NSNetService *)svc + moreComing:(BOOL)more { + [self.resolving addObject:svc]; + svc.delegate = self; + [svc resolveWithTimeout:5.0]; +} +- (void)netServiceBrowser:(NSNetServiceBrowser *)b + didRemoveService:(NSNetService *)svc + moreComing:(BOOL)more { + JAVA_OBJECT name = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG svc.name); + JAVA_OBJECT type = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG svc.type); + com_codename1_impl_ios_IOSConnectivity_bonjourLostDispatch___long_java_lang_String_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG self.handle, name, type); +} +- (void)netServiceDidResolveAddress:(NSNetService *)svc { + NSString *host = svc.hostName; + NSDictionary *txt = nil; + NSData *raw = [svc TXTRecordData]; + if (raw != nil) { + txt = [NSNetService dictionaryFromTXTRecordData:raw]; + } + JAVA_OBJECT name = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG svc.name); + JAVA_OBJECT type = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG svc.type); + JAVA_OBJECT hostObj = host == nil ? JAVA_NULL + : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG host); + JAVA_OBJECT keys = JAVA_NULL, vals = JAVA_NULL; + if (txt != nil && txt.count > 0) { + keys = __NEW_ARRAY_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_INT) txt.count); + vals = __NEW_ARRAY_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_INT) txt.count); + JAVA_ARRAY_OBJECT *kArr = (JAVA_ARRAY_OBJECT*) ((JAVA_ARRAY) keys)->data; + JAVA_ARRAY_OBJECT *vArr = (JAVA_ARRAY_OBJECT*) ((JAVA_ARRAY) vals)->data; + int i = 0; + for (NSString *k in txt.allKeys) { + kArr[i] = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG k); + id v = [txt objectForKey:k]; + NSString *s = nil; + if ([v isKindOfClass:[NSData class]]) { + s = [[[NSString alloc] initWithData:(NSData*) v + encoding:NSUTF8StringEncoding] autorelease]; + } else if ([v isKindOfClass:[NSString class]]) { + s = (NSString*) v; + } + vArr[i] = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG (s == nil ? @"" : s)); + i++; + } + } + com_codename1_impl_ios_IOSConnectivity_bonjourResolveDispatch___long_java_lang_String_java_lang_String_java_lang_String_int_java_lang_String_1ARRAY_java_lang_String_1ARRAY( + CN1_THREAD_GET_STATE_PASS_ARG self.handle, name, type, hostObj, + (JAVA_INT) svc.port, keys, vals); +} +- (void)netService:(NSNetService *)svc didNotResolve:(NSDictionary *)errorDict { +} +@end + +static NSMutableDictionary *cn1BonjourBrowsers = nil; +static NSMutableDictionary *cn1BonjourPublishers = nil; +static int64_t cn1BonjourHandleSeq = 1; +#endif // CN1_INCLUDE_BONJOUR + +JAVA_LONG com_codename1_impl_ios_IOSNative_bonjourBrowseStart___java_lang_String_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT typeObj) { +#ifdef CN1_INCLUDE_BONJOUR + if (typeObj == JAVA_NULL) return 0; + if (cn1BonjourBrowsers == nil) cn1BonjourBrowsers = [[NSMutableDictionary alloc] init]; + NSString *type = toNSString(CN1_THREAD_GET_STATE_PASS_ARG typeObj); + if (![type hasSuffix:@"."]) type = [type stringByAppendingString:@"."]; + int64_t handle = cn1BonjourHandleSeq++; + CN1BonjourBrowser *bb = [[CN1BonjourBrowser alloc] init]; + bb.handle = handle; + bb.browser = [[[NSNetServiceBrowser alloc] init] autorelease]; + bb.resolving = [NSMutableArray array]; + bb.browser.delegate = bb; + [bb.browser searchForServicesOfType:type inDomain:@"local."]; + [cn1BonjourBrowsers setObject:bb forKey:[NSNumber numberWithLongLong:handle]]; + [bb release]; + return (JAVA_LONG) handle; +#else + return 0; +#endif +} + +void com_codename1_impl_ios_IOSNative_bonjourBrowseStop___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG handle) { +#ifdef CN1_INCLUDE_BONJOUR + if (cn1BonjourBrowsers == nil) return; + NSNumber *k = [NSNumber numberWithLongLong:(int64_t) handle]; + CN1BonjourBrowser *bb = [cn1BonjourBrowsers objectForKey:k]; + if (bb != nil) { + [bb.browser stop]; + [cn1BonjourBrowsers removeObjectForKey:k]; + } +#endif +} + +JAVA_LONG com_codename1_impl_ios_IOSNative_bonjourPublishStart___java_lang_String_java_lang_String_int_java_lang_String_1ARRAY_java_lang_String_1ARRAY_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT nameObj, JAVA_OBJECT typeObj, JAVA_INT port, JAVA_OBJECT keysObj, JAVA_OBJECT valsObj) { +#ifdef CN1_INCLUDE_BONJOUR + if (cn1BonjourPublishers == nil) cn1BonjourPublishers = [[NSMutableDictionary alloc] init]; + NSString *name = toNSString(CN1_THREAD_GET_STATE_PASS_ARG nameObj); + NSString *type = toNSString(CN1_THREAD_GET_STATE_PASS_ARG typeObj); + if (![type hasSuffix:@"."]) type = [type stringByAppendingString:@"."]; + NSNetService *svc = [[NSNetService alloc] + initWithDomain:@"local." type:type name:name port:(int) port]; + if (keysObj != JAVA_NULL && valsObj != JAVA_NULL) { + JAVA_ARRAY_OBJECT *kArr = (JAVA_ARRAY_OBJECT*) ((JAVA_ARRAY) keysObj)->data; + JAVA_ARRAY_OBJECT *vArr = (JAVA_ARRAY_OBJECT*) ((JAVA_ARRAY) valsObj)->data; + int n = (int) ((JAVA_ARRAY) keysObj)->length; + NSMutableDictionary *d = [NSMutableDictionary dictionary]; + for (int i = 0; i < n; i++) { + NSString *k = toNSString(CN1_THREAD_GET_STATE_PASS_ARG kArr[i]); + NSString *v = toNSString(CN1_THREAD_GET_STATE_PASS_ARG vArr[i]); + if (k != nil && v != nil) { + [d setObject:[v dataUsingEncoding:NSUTF8StringEncoding] forKey:k]; + } + } + [svc setTXTRecordData:[NSNetService dataFromTXTRecordDictionary:d]]; + } + [svc publish]; + int64_t handle = cn1BonjourHandleSeq++; + [cn1BonjourPublishers setObject:svc forKey:[NSNumber numberWithLongLong:handle]]; + [svc release]; + return (JAVA_LONG) handle; +#else + return 0; +#endif +} + +void com_codename1_impl_ios_IOSNative_bonjourPublishStop___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG handle) { +#ifdef CN1_INCLUDE_BONJOUR + if (cn1BonjourPublishers == nil) return; + NSNumber *k = [NSNumber numberWithLongLong:(int64_t) handle]; + NSNetService *svc = [cn1BonjourPublishers objectForKey:k]; + if (svc != nil) { + [svc stop]; + [cn1BonjourPublishers removeObjectForKey:k]; + } +#endif +} + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isLargerTextEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { if (@available(iOS 7.0, *)) { CGFloat baseSize = [UIFont systemFontSize]; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBonjourPlatform.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBonjourPlatform.java new file mode 100644 index 0000000000..f2752260f0 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBonjourPlatform.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.ios; + +import com.codename1.io.bonjour.BonjourPlatform; +import com.codename1.io.bonjour.BonjourServiceListener; +import com.codename1.ui.CN; + +import java.util.Map; + +/// iOS Bonjour implementation. Each entry point goes through IOSNative, +/// which gates the NSNetServiceBrowser / NSNetService native code behind +/// CN1_INCLUDE_BONJOUR so apps that never reference +/// `com.codename1.io.bonjour` ship without the delegate / NSBonjourServices +/// Info.plist entries. +public final class IOSBonjourPlatform extends BonjourPlatform { + @Override + public boolean isSupported() { return true; } + + @Override + public Object startBrowse(String type, BonjourServiceListener listener) { + if (listener == null) return null; + long handle = IOSImplementation.nativeInstance.bonjourBrowseStart(type); + if (handle == 0) { + scheduleBonjourUnavailable(listener); + return null; + } + IOSConnectivity.registerBonjour(handle, listener); + return Long.valueOf(handle); + } + + // Static helper to keep the Runnable below a static anonymous class + // (avoids SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON). + private static void scheduleBonjourUnavailable(final BonjourServiceListener l) { + CN.callSerially(new Runnable() { + @Override public void run() { + l.onBrowseError(new RuntimeException("Bonjour unavailable")); + } + }); + } + + @Override + public void stopBrowse(Object handle) { + if (handle == null) return; + long h = ((Long) handle).longValue(); + IOSImplementation.nativeInstance.bonjourBrowseStop(h); + IOSConnectivity.unregisterBonjour(h); + } + + @Override + public Object startPublish(String name, String type, int port, + Map txt) { + String[] keys = new String[txt == null ? 0 : txt.size()]; + String[] vals = new String[keys.length]; + if (txt != null) { + int i = 0; + for (Map.Entry e : txt.entrySet()) { + keys[i] = e.getKey(); + vals[i] = e.getValue() == null ? "" : e.getValue(); + i++; + } + } + long h = IOSImplementation.nativeInstance.bonjourPublishStart(name, type, port, keys, vals); + return h == 0 ? null : Long.valueOf(h); + } + + @Override + public void stopPublish(Object handle) { + if (handle == null) return; + IOSImplementation.nativeInstance.bonjourPublishStop(((Long) handle).longValue()); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSConnectivity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSConnectivity.java new file mode 100644 index 0000000000..136e66cab1 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSConnectivity.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.ios; + +import com.codename1.io.NetworkManager; +import com.codename1.io.bonjour.BonjourService; +import com.codename1.io.bonjour.BonjourServiceListener; +import com.codename1.io.wifi.WiFiConnectCallback; +import com.codename1.ui.CN; + +import java.util.HashMap; +import java.util.Map; + +/// Java-side glue for the iOS connectivity layer. Native code in IOSNative.m +/// invokes the static `*Dispatch` methods here to deliver callbacks back to +/// the EDT. +public final class IOSConnectivity { + private static final Map bonjourListeners + = new HashMap(); + private static WiFiConnectCallback pendingConnect; + private static NetworkManager networkManagerInstance; + + private IOSConnectivity() { + } + + // --------------------------------------------------------------------- + // Bonjour dispatch + // --------------------------------------------------------------------- + + static void registerBonjour(long handle, BonjourServiceListener l) { + bonjourListeners.put(Long.valueOf(handle), l); + } + + static void unregisterBonjour(long handle) { + bonjourListeners.remove(Long.valueOf(handle)); + } + + public static void bonjourResolveDispatch(final long handle, + final String name, + final String type, + final String host, final int port, + final String[] txtKeys, + final String[] txtVals) { + final BonjourServiceListener l = bonjourListeners.get(Long.valueOf(handle)); + if (l == null) return; + Map txt = new HashMap(); + if (txtKeys != null) { + for (int i = 0; i < txtKeys.length; i++) { + txt.put(txtKeys[i], i < txtVals.length ? txtVals[i] : ""); + } + } + final BonjourService svc = new BonjourService(name, type, host, port, txt); + CN.callSerially(new Runnable() { + @Override public void run() { + l.onServiceResolved(svc); + } + }); + } + + public static void bonjourLostDispatch(final long handle, final String name, + final String type) { + final BonjourServiceListener l = bonjourListeners.get(Long.valueOf(handle)); + if (l == null) return; + final BonjourService svc = new BonjourService(name, type, null, 0, null); + CN.callSerially(new Runnable() { + @Override public void run() { + l.onServiceLost(svc); + } + }); + } + + public static void bonjourErrorDispatch(final long handle, final String msg) { + final BonjourServiceListener l = bonjourListeners.get(Long.valueOf(handle)); + if (l == null) return; + CN.callSerially(new Runnable() { + @Override public void run() { + l.onBrowseError(new RuntimeException(msg)); + } + }); + } + + // --------------------------------------------------------------------- + // WiFi connect dispatch + // --------------------------------------------------------------------- + + static void setPendingConnect(WiFiConnectCallback cb) { + pendingConnect = cb; + } + + public static void wifiConnectResult(final boolean ok, final String errMsg) { + final WiFiConnectCallback cb = pendingConnect; + pendingConnect = null; + if (cb == null) return; + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onConnectResult(ok, ok ? null : new RuntimeException(errMsg)); + } + }); + } + + // --------------------------------------------------------------------- + // Network type change dispatch + // --------------------------------------------------------------------- + + static void registerNetworkTypeTarget(NetworkManager nm) { + networkManagerInstance = nm; + } + + static void unregisterNetworkTypeTarget() { + networkManagerInstance = null; + } + + public static void networkTypeChangedDispatch(final int newType, + final boolean vpn) { + final NetworkManager nm = networkManagerInstance; + if (nm == null) return; + CN.callSerially(new Runnable() { + @Override public void run() { + nm.fireNetworkTypeChange(newType, vpn); + } + }); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 268ed0d0aa..e069ccc214 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1351,6 +1351,21 @@ public boolean isVPNActive() { return nativeInstance.isVPNActive(); } + @Override + protected com.codename1.io.wifi.WifiPlatform createWifiPlatform() { + return new IOSWifiPlatform(); + } + + @Override + protected com.codename1.io.bonjour.BonjourPlatform createBonjourPlatform() { + return new IOSBonjourPlatform(); + } + + @Override + protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { + return new IOSNetworkTypePlatform(); + } + @Override public boolean isLargerTextEnabled() { return nativeInstance.isLargerTextEnabled(); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 6d18c8decf..7408cee325 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -310,6 +310,39 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre native boolean isDarkModeDetectionSupported(); native boolean isVPNActive(); + // Active-network type queries used by NetworkManager.getCurrentNetworkType + // and addNetworkTypeListener. Returns one of + // NetworkManager.NETWORK_TYPE_* constants. Implementation uses + // SCNetworkReachability (always available) and an interface-name probe to + // distinguish WiFi from cellular. + native int wifiNetworkType(); + native void wifiInstallTypeListener(Object instance); + native void wifiUninstallTypeListener(); + + // WiFi info; SSID/BSSID require the wifi-info entitlement and (since iOS + // 13) a granted CoreLocation authorization. The build pipeline injects + // both automatically when WiFi.getCurrentSSID/getBSSID is on the + // classpath. Returns null when permission denied or not on WiFi. + native String wifiCurrentSSID(); + native String wifiCurrentBSSID(); + native String wifiGateway(); + native String wifiIpAddress(); + + // NEHotspotConfiguration-backed join. Requires the + // com.apple.developer.networking.HotspotConfiguration entitlement + // (injected by IPhoneBuilder when com.codename1.io.wifi.WiFi.connect is + // referenced). The result is delivered via + // com.codename1.impl.ios.IOSConnectivity.wifiConnectResult. + native void wifiConnect(String ssid, String password, int security); + native void wifiDisconnect(String ssid); + + // NSNetServiceBrowser-backed Bonjour discovery. Callbacks land in + // com.codename1.impl.ios.IOSConnectivity.bonjour* static dispatchers. + native long bonjourBrowseStart(String type); + native void bonjourBrowseStop(long handle); + native long bonjourPublishStart(String name, String type, int port, String[] txtKeys, String[] txtVals); + native void bonjourPublishStop(long handle); + native int fileCountInDir(String dir); native void listFilesInDir(String dir, String[] files); native void createDirectory(String dir); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNetworkTypePlatform.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNetworkTypePlatform.java new file mode 100644 index 0000000000..a743941ca2 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNetworkTypePlatform.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.ios; + +import com.codename1.io.NetworkManager; +import com.codename1.io.NetworkTypePlatform; + +/// iOS network-type tracker backed by SCNetworkReachability. The native +/// install/uninstall hooks live in IOSNative.m and call back into +/// `IOSConnectivity.networkTypeChangedDispatch`. +public final class IOSNetworkTypePlatform extends NetworkTypePlatform { + @Override + public int getCurrentNetworkType() { + return IOSImplementation.nativeInstance.wifiNetworkType(); + } + + @Override + public void install(NetworkManager target) { + IOSConnectivity.registerNetworkTypeTarget(target); + IOSImplementation.nativeInstance.wifiInstallTypeListener(IOSConnectivity.class); + } + + @Override + public void uninstall(NetworkManager target) { + IOSImplementation.nativeInstance.wifiUninstallTypeListener(); + IOSConnectivity.unregisterNetworkTypeTarget(); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWifiPlatform.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWifiPlatform.java new file mode 100644 index 0000000000..2df3b7d9ff --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWifiPlatform.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2008, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.ios; + +import com.codename1.io.wifi.WiFiConnectCallback; +import com.codename1.io.wifi.WiFiScanCallback; +import com.codename1.io.wifi.WiFiSecurity; +import com.codename1.io.wifi.WifiPlatform; +import com.codename1.ui.CN; + +/// iOS-side `WifiPlatform`. Delegates to `IOSNative` for the +/// CaptiveNetwork / NEHotspotConfiguration native code. The native side +/// gates each entry point behind a CN1_INCLUDE_* define so apps that +/// don't reference `WiFi` ship without the matching framework symbols. +public final class IOSWifiPlatform extends WifiPlatform { + @Override + public boolean isInfoSupported() { return true; } + + @Override + public boolean isManagementSupported() { return true; } + + @Override + public String getCurrentSSID() { return IOSImplementation.nativeInstance.wifiCurrentSSID(); } + + @Override + public String getBSSID() { return IOSImplementation.nativeInstance.wifiCurrentBSSID(); } + + @Override + public String getGateway() { return IOSImplementation.nativeInstance.wifiGateway(); } + + @Override + public String getIp() { return IOSImplementation.nativeInstance.wifiIpAddress(); } + + @Override + public void scan(final WiFiScanCallback cb) { + // iOS doesn't expose a public scan API. + if (cb != null) { + scheduleScanUnsupported(cb); + } + } + + // Static helper so the Runnable below is a static anonymous class + // (SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON otherwise flags it because + // declaring it inside an instance method makes it carry an implicit + // reference to the enclosing IOSWifiPlatform). + private static void scheduleScanUnsupported(final WiFiScanCallback cb) { + CN.callSerially(new Runnable() { + @Override public void run() { + cb.onScanComplete(null, + new UnsupportedOperationException( + "iOS does not expose a WiFi scan API")); + } + }); + } + + @Override + public void connect(String ssid, String password, WiFiSecurity security, + WiFiConnectCallback cb) { + IOSConnectivity.setPendingConnect(cb); + int sec = security == null ? 0 : security.ordinal(); + IOSImplementation.nativeInstance.wifiConnect(ssid, password, sec); + } + + @Override + public void disconnect(String ssid) { + IOSImplementation.nativeInstance.wifiDisconnect(ssid); + } +} diff --git a/docs/developer-guide/Network-Connectivity.asciidoc b/docs/developer-guide/Network-Connectivity.asciidoc new file mode 100644 index 0000000000..003a35d585 --- /dev/null +++ b/docs/developer-guide/Network-Connectivity.asciidoc @@ -0,0 +1,335 @@ +== Deeper Network Connectivity + +Codename One ships a set of APIs under `com.codename1.io.wifi`, `com.codename1.io.bonjour`, `com.codename1.io.usb` and an extension of `com.codename1.io.NetworkManager` that lets apps inspect and manage the device's local network beyond plain HTTP. + +A quick connectivity check is `NetworkManager.getInstance().isConnected()` -- it reads the cached platform network state and returns immediately without an HTTP probe, so it's safe to call from the EDT before deciding whether to fire a `ConnectionRequest`. + +These APIs share three guarantees: + +* Every callback runs on the EDT so you can update UI directly from the result. +* The build pipeline injects the matching Android permissions, iOS entitlements, and `NSXxxUsageDescription` / `NSBonjourServices` Info.plist entries automatically by scanning the classpath. Apps that never reference these classes see no manifest or entitlement changes. +* The simulator implements best-effort versions of each API so you can wire and test your UI without a device. The simulator also prints the list of permissions/entitlements each API will need in a real build the first time you call it. + +The simulator's "API usage report" prints to stdout on JVM shutdown so you can spot anything you pulled in via a transitive cn1lib. + +=== WiFi information + +The fastest way to know whether the user is on WiFi, what SSID they joined, and what their local IP is: + +[source,java] +---- +import com.codename1.io.wifi.WiFi; + +if (WiFi.isInfoSupported()) { + String ssid = WiFi.getCurrentSSID(); // e.g. "Codename One" + String bssid = WiFi.getBSSID(); // "aa:bb:cc:11:22:33" + String gw = WiFi.getGateway(); // "192.168.1.1" + String ip = WiFi.getIp(); // "192.168.1.42" +} +---- + +Any of these calls may return `null` if the device isn't on WiFi, if the user denied a runtime permission, or if the platform never exposed the value to apps. The framework normalizes the wrapping that Android adds to the SSID and treats `` and the obfuscated BSSID `02:00:00:00:00:00` as `null`. + +==== Required permissions and entitlements + +The build pipeline injects the following automatically the first time it sees `com.codename1.io.wifi.WiFi` on the classpath: + +[options="header"] +|=== +| Platform | Injected | Notes +| Android | `ACCESS_WIFI_STATE`, `ACCESS_NETWORK_STATE` | "normal" permissions; no runtime prompt +| Android API 26+ | `ACCESS_FINE_LOCATION` (`maxSdkVersion=32`) | Required by the OS to return a real SSID +| Android API 33+ | `NEARBY_WIFI_DEVICES` with `neverForLocation` | Replaces fine-location for scan flows +| iOS | `com.apple.developer.networking.wifi-info` entitlement | Required since iOS 13 +| iOS | `NSLocationWhenInUseUsageDescription` | iOS still checks CoreLocation behind the scenes +|=== + +You can override any injected automatically value via the standard build hints: + +[source] +---- +ios.locationUsageDescription=Allow access to read your Wi-Fi network name to discover printers on your network. +android.xpermissions= +---- + +=== WiFi scan + +Scanning returns an array of `WiFiNetwork`s sorted by signal strength. The callback fires once. + +[source,java] +---- +WiFi.scan(new WiFiScanCallback() { + @Override + public void onScanComplete(WiFiNetwork[] networks, Throwable error) { + if (error != null) { + Log.e(error); + return; + } + for (WiFiNetwork n : networks) { + // n.getSSID(), n.getBSSID(), n.getRssi(), n.getSecurity() + } + } +}); +---- + +[NOTE] +==== +iOS doesn't expose a public WiFi scan API. On iOS `WiFi.scan(...)` always reports an `UnsupportedOperationException`. Plan for a graceful fallback if your UI exposes a scan button. +==== + +Android throttles scans to 4 per 2 minutes per foreground app on API 28+; throttled scans return cached results, not an error. + +=== WiFi connect + +To associate the device with a specific SSID: + +[source,java] +---- +WiFi.connect("MyAccessPoint", "s3cret!", WiFiSecurity.WPA_PSK, + new WiFiConnectCallback() { + @Override + public void onConnectResult(boolean connected, Throwable error) { + if (!connected) { + Log.e(error); + return; + } + // ssid is now joined + } +}); +---- + +* On Android 10+ the OS shows a system confirmation dialog with the SSID before joining; this dialog can't be bypassed. +* On Android 9 and below the framework falls back to the legacy `WifiConfiguration` API and joins without an in-app prompt. +* On iOS 11+ this uses `NEHotspotConfiguration`. The user is shown a system prompt the first time the app tries to join the SSID; subsequent attempts reuse the cached configuration. +* `WiFiSecurity` must match the AP's actual security mode (`OPEN`, `WEP`, `WPA_PSK`, `WPA3_SAE`). Passing the wrong mode causes the join to fail. + +`WiFi.disconnect(ssid)` releases the request on Android 10+ and removes the hotspot configuration on iOS. + +[WARNING] +==== +On iOS the build pipeline also enables the `NetworkExtension.framework` and the `HotspotConfiguration` entitlement only when it sees a call to `WiFi.connect(...)` or `WiFi.disconnect(...)`. Apps that only *read* SSID/BSSID won't pull in the framework, which keeps App Store API-usage scans clean. +==== + +=== Bonjour / mDNS discovery + +`BonjourBrowser` watches the local network for services of a given mDNS type. `BonjourPublisher` advertises a service. + +[source,java] +---- +import com.codename1.io.bonjour.BonjourBrowser; +import com.codename1.io.bonjour.BonjourService; +import com.codename1.io.bonjour.BonjourServiceListener; + +BonjourBrowser browser = BonjourBrowser.browse("_http._tcp.", + new BonjourServiceListener() { + @Override + public void onServiceResolved(BonjourService s) { + Log.p("Found " + s.getName() + " at " + s.getHost() + ":" + s.getPort()); + } + @Override + public void onServiceLost(BonjourService s) { /* ... */ } + @Override + public void onBrowseError(Throwable t) { Log.e(t); } +}); + +// when done: +browser.stop(); +---- + +To advertise an HTTP server you wrote on port 8080: + +[source,java] +---- +BonjourPublisher pub = BonjourPublisher.publish( + "My Server", "_http._tcp.", 8080, null); + +// later: +pub.unpublish(); +---- + +==== iOS requirements + +Bonjour on iOS 14+ returns no results unless: + +* The Info.plist `NSLocalNetworkUsageDescription` key explains why the app needs local-network access. +* The Info.plist `NSBonjourServices` array lists each service type the app expects to find. + +The build pipeline injects both keys automatically when `com.codename1.io.bonjour` is on the classpath, with `_http._tcp.` as the seed type. To publish or browse a custom type, set the build hint: + +[source] +---- +ios.NSBonjourServices=_myapp._tcp.,_http._tcp. +ios.NSLocalNetworkUsageDescription=Discover other instances of MyApp on this Wi-Fi network. +---- + +The comma-separated values are expanded into the Info.plist array at build time. + +==== Android requirements + +The pipeline adds `CHANGE_WIFI_MULTICAST_STATE` automatically. `NsdManager` does the rest; there is no runtime permission prompt. + +==== Simulator behaviour + +If `javax.jmdns.JmDNS` is on the simulator classpath, browse calls dispatch through it. Otherwise the listener receives a single `UnsupportedOperationException` and the simulator prints a hint. Add JmDNS to your *application's* `executable-jar` / simulator profile (typically the `` block in your cn1app's `common/pom.xml` or your `javase/pom.xml`) to exercise real discovery in the simulator: + +[source,xml] +---- + + + org.jmdns + jmdns + 3.5.9 + runtime + +---- + +[WARNING] +==== +Don't add JmDNS to the cn1app `common/pom.xml` outside a simulator-scoped profile -- it would then ship with your Android / iOS binaries even though both platforms supply native mDNS implementations. +==== + +=== Network type change events + +Subscribe to network type transitions (`WiFi <-> Cellular <-> Ethernet <-> None`): + +[source,java] +---- +import com.codename1.io.NetworkManager; +import com.codename1.io.NetworkTypeListener; + +NetworkManager.getInstance().addNetworkTypeListener(new NetworkTypeListener() { + @Override + public void onNetworkTypeChanged(int oldType, int newType, boolean vpnActive) { + if (newType == NetworkManager.NETWORK_TYPE_NONE) { + // offline -- queue uploads for later + } + if (vpnActive) { + // refuse to roam to corporate-only resources + } + } +}); +---- + +The current snapshot is available without subscribing: + +[source,java] +---- +int type = NetworkManager.getInstance().getCurrentNetworkType(); +boolean vpn = NetworkManager.getInstance().isVPNActive(); +---- + +The platform implementations are: + +* *Android*: `ConnectivityManager.registerNetworkCallback` on API 21+, falling back to the legacy `CONNECTIVITY_ACTION` broadcast on older devices. +* *iOS*: `SCNetworkReachability` with a kernel callback scheduled on the main run loop. +* *Simulator*: derives the type from `NetworkInterface.getDisplayName`; transitions aren't synthesized. + +VPN detection is best-effort and intentionally heuristic. On Android it uses `NetworkCapabilities.TRANSPORT_VPN` plus an interface-name probe; on iOS it inspects `utun*`, `tun*`, `ppp*` and `ipsec*` interfaces. Use it as a hint, not as a security boundary. + +=== WiFi Direct (Wi-Fi P2P) + +WiFi Direct lets two devices form a peer-to-peer link without an access point. The API is Android-only -- iOS uses MultipeerConnectivity for similar scenarios and is intentionally out of scope. + +[source,java] +---- +import com.codename1.io.wifi.WiFiDirect; +import com.codename1.io.wifi.WiFiDirectListener; +import com.codename1.io.wifi.WiFiDirectPeer; + +if (!WiFiDirect.isSupported()) { + return; +} + +WiFiDirect.startDiscovery(new WiFiDirectListener() { + @Override + public void onPeersAvailable(WiFiDirectPeer[] peers) { + for (WiFiDirectPeer p : peers) { + // p.getDeviceName(), p.getDeviceAddress(), p.getState() + } + } + @Override + public void onDiscoveryError(Throwable error) { Log.e(error); } +}); + +// later, with the user's chosen peer: +WiFiDirect.connect(peer, new WiFiConnectCallback() { + @Override + public void onConnectResult(boolean connected, Throwable error) { /* ... */ } +}); + +// when finished: +WiFiDirect.disconnect(); +WiFiDirect.stopDiscovery(); +---- + +The build pipeline injects `CHANGE_WIFI_STATE`, `ACCESS_WIFI_STATE`, `ACCESS_NETWORK_STATE`, `ACCESS_FINE_LOCATION` and `NEARBY_WIFI_DEVICES` automatically when `WiFiDirect` is on the classpath, plus a `` declaration so devices without the radio can still install the app via Play Store filtering. + +=== USB host + +The USB API in `com.codename1.io.usb` lets the device act as a USB host and talk to attached peripherals -- a barcode scanner, a serial converter, a microcontroller. It's **Android-only**; iOS has no third-party USB host access and the simulator stubs everything out. + +[source,java] +---- +import com.codename1.io.usb.Usb; +import com.codename1.io.usb.UsbDevice; +import com.codename1.io.usb.UsbDeviceListener; + +if (!Usb.isSupported()) { return; } + +Usb.addDeviceListener(new UsbDeviceListener() { + @Override + public void onDeviceAttached(UsbDevice d) { + if (d.getVendorId() == 0x0403 && d.getProductId() == 0x6001) { + Usb.requestPermission(d); + } + } + @Override + public void onDeviceDetached(UsbDevice d) { } + @Override + public void onPermissionResult(UsbDevice d, boolean granted) { + if (granted) { + try (InputStream in = Usb.openInputStream(d, 0x81); + OutputStream out = Usb.openOutputStream(d, 0x02)) { + out.write("AT\r\n".getBytes()); + byte[] buf = new byte[64]; + int n = in.read(buf); + // ... + } catch (IOException e) { Log.e(e); } + } + } +}); +---- + +The build pipeline injects: + +* ``. + +If you want the OS to launch your app when a matching device is plugged in, ship a `device_filter.xml` resource and declare an `` via the `android.xintent_filter` build hint: + +[source,xml] +---- + + + + +---- + +[source] +---- +android.xintent_filter= +---- + +=== Releasing platform resources + +Most of these APIs hold background resources (broadcast receivers, network callbacks, NSNetService delegates) that you must release explicitly: + +* `BonjourBrowser` -- call `.stop()`. +* `BonjourPublisher` -- call `.unpublish()`. +* `WiFiDirect` -- call `.stopDiscovery()` and `.disconnect()`. +* `WiFi.connect(...)` -- call `WiFi.disconnect(ssid)` when the user navigates away. +* `Usb.addDeviceListener(...)` -- call `removeDeviceListener(...)`. +* `NetworkManager.addNetworkTypeListener(...)` -- call `removeNetworkTypeListener(...)`. + +Each of these keeps a radio or system service awake. Bonjour browsers and WiFi scans in particular drain battery noticeably -- they're fine during a few user-facing screens but shouldn't stay armed for the lifetime of the app. Release them as soon as the screen that needs them is no longer visible. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 6b87b1785a..17b2136a4d 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -86,6 +86,8 @@ include::Biometric-Authentication.asciidoc[] include::Near-Field-Communication.asciidoc[] +include::Network-Connectivity.asciidoc[] + include::signing.asciidoc[] include::Working-With-iOS.asciidoc[] diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6891e7ab95..2da7599d96 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -315,6 +315,19 @@ public File getGradleProjectDirectory() { private boolean browserBookmarksPermissions; private boolean launcherPermissions; + // Deeper-network connectivity flags. Set by the classpath scanner when + // the app references com.codename1.io.wifi.* / com.codename1.io.bonjour.* + // / com.codename1.io.usb.* / NetworkManager.addNetworkTypeListener. The + // corresponding / elements are emitted + // further down based on these flags; apps that never touch the APIs see + // no change in their manifest. + private boolean usesWifiInfo; + private boolean usesWifiManagement; + private boolean usesWifiDirect; + private boolean usesBonjour; + private boolean usesUsbHost; + private boolean usesNetworkTypeListener; + private boolean integrateMoPub = false; private static final boolean isMac; @@ -1278,6 +1291,32 @@ public void usesClass(String cls) { usesNfcHce = true; } } + + // Deeper-network connectivity: each subpackage maps to a + // distinct permission set. The scanner sets booleans; the + // injection block below builds the manifest fragments only + // for the flags that fired. + if (cls.indexOf("com/codename1/io/wifi/WiFiDirect") == 0) { + usesWifiDirect = true; + usesWifiInfo = true; + } else if (cls.indexOf("com/codename1/io/wifi/") == 0) { + usesWifiInfo = true; + if (cls.endsWith("WiFi")) { + // The umbrella WiFi class references both info + // and management methods; tag management so the + // CHANGE_WIFI_STATE permission is injected too. + usesWifiManagement = true; + } + } + if (cls.indexOf("com/codename1/io/bonjour/") == 0) { + usesBonjour = true; + } + if (cls.indexOf("com/codename1/io/usb/") == 0) { + usesUsbHost = true; + } + if (cls.equals("com/codename1/io/NetworkTypeListener")) { + usesNetworkTypeListener = true; + } } @@ -1365,6 +1404,23 @@ public void usesClassMethod(String cls, String method) { if (cls.indexOf("com/codename1/contacts/ContactsManager") == 0 && method.indexOf("deleteContact") > -1) { contactsWritePermission = true; } + // WiFi scan implies management. We detect the method + // rather than just the class so that apps that only read + // SSID/BSSID (no scan) do not pick up CHANGE_WIFI_STATE. + if (cls.equals("com/codename1/io/wifi/WiFi") + && (method.indexOf("scan") > -1 + || method.indexOf("connect") > -1 + || method.indexOf("disconnect") > -1)) { + usesWifiManagement = true; + } + // NetworkManager.addNetworkTypeListener is the active + // signal; reading getCurrentNetworkType alone needs only + // ACCESS_NETWORK_STATE which we'd already inject via + // accessNetworkStatePermission below. + if (cls.equals("com/codename1/io/NetworkManager") + && method.indexOf("NetworkTypeListener") > -1) { + usesNetworkTypeListener = true; + } } }); } catch (IOException ex) { @@ -1410,6 +1466,53 @@ public void usesClassMethod(String cls, String method) { } } + // Deeper-network connectivity permission injection. Each block is + // gated on a flag set by the scanner above so apps that never touch + // the API see no manifest change. Permissions are "normal" or + // "runtime"; runtime permissions still need the app to call + // Display.requestPermission at runtime -- the manifest entry only + // makes them eligible to ask. + if (usesWifiInfo || usesWifiManagement || usesWifiDirect || usesBonjour + || usesNetworkTypeListener) { + if (!xPermissions.contains("android.permission.ACCESS_WIFI_STATE")) { + xPermissions = " \n" + xPermissions; + } + if (!xPermissions.contains("android.permission.ACCESS_NETWORK_STATE")) { + xPermissions = " \n" + xPermissions; + } + } + if (usesWifiManagement || usesWifiDirect) { + if (!xPermissions.contains("android.permission.CHANGE_WIFI_STATE")) { + xPermissions = " \n" + xPermissions; + } + if (!xPermissions.contains("android.permission.CHANGE_NETWORK_STATE")) { + xPermissions = " \n" + xPermissions; + } + // Reading the SSID/BSSID on Android 8.0+ requires a location + // permission; on 13+ the dedicated NEARBY_WIFI_DEVICES permission + // can replace it for scan-only flows. We declare both with + // appropriate maxSdkVersion so the right one is requested per OS. + if (!xPermissions.contains("android.permission.ACCESS_FINE_LOCATION")) { + xPermissions = " \n" + xPermissions; + } + if (targetSDKVersionInt >= 33 + && !xPermissions.contains("android.permission.NEARBY_WIFI_DEVICES")) { + xPermissions = " \n" + xPermissions; + } + } + if (usesBonjour) { + if (!xPermissions.contains("android.permission.CHANGE_WIFI_MULTICAST_STATE")) { + xPermissions = " \n" + xPermissions; + } + // INTERNET is already in basePermissions and Bonjour over IPv6 + // multicast inherits it transparently, so nothing extra needed. + } + if (usesUsbHost) { + if (!xPermissions.contains("android.hardware.usb.host")) { + xPermissions = " \n" + xPermissions; + } + } + boolean useFCM = pushPermission && "fcm".equalsIgnoreCase(request.getArg("android.messagingService", "fcm")); if (useFCM) { request.putArgument("android.fcm.minPlayServicesVersion", "12.0.1"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 67fb76c622..64dbc5ec64 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -88,6 +88,15 @@ public class IPhoneBuilder extends Executor { private boolean usesBiometrics; private boolean usesNfc; private boolean usesNfcHce; + + // Deeper-network connectivity flags. Set by the classpath scanner when + // the app references com.codename1.io.wifi.* / com.codename1.io.bonjour.* + // The build pipeline injects the matching entitlements / Info.plist + // strings further down. Apps that never touch the APIs see no change. + private boolean usesWifiInfo; + private boolean usesWifiHotspotConfig; + private boolean usesBonjour; + private String firstBonjourType; // so we need to store the main class name for later here. // Map will be used for Xcode 8 privacy usage descriptions. Don't need it yet // so leaving it commented out. @@ -675,11 +684,30 @@ public void usesClass(String cls) { usesNfcHce = true; } } + if (cls.indexOf("com/codename1/io/wifi/WiFi") == 0 + && !cls.equals("com/codename1/io/wifi/WiFiDirect")) { + // WiFi info or scan/connect. iOS has no scan API so + // the WiFi entitlement we inject is hotspot config + + // wifi-info; we treat any use as info-capable and + // upgrade to hotspot config only when scan/connect + // is referenced (detected via method scan below). + usesWifiInfo = true; + } + if (cls.indexOf("com/codename1/io/bonjour/") == 0) { + usesBonjour = true; + } + // WiFi Direct / USB on iOS: not supported. We + // intentionally do not inject entitlements -- the runtime + // stub returns "unsupported" at call time. } @Override public void usesClassMethod(String cls, String method) { - + if (cls.equals("com/codename1/io/wifi/WiFi") + && (method.indexOf("connect") > -1 + || method.indexOf("disconnect") > -1)) { + usesWifiHotspotConfig = true; + } } }); } catch (Exception ex) { @@ -1672,6 +1700,99 @@ public void usesClassMethod(String cls, String method) { } } + // Deeper-network connectivity (WiFi info / NEHotspotConfiguration + // / Bonjour). Each block is gated on a scanner flag so apps that + // never touch the API see no entitlement or plist changes -- this + // keeps the App Store review process clean. Developers can + // override any value via the matching ios.* / ios.entitlements.* + // build hint. + if (usesWifiInfo) { + // Reading SSID/BSSID on iOS 13+ requires the wifi-info + // entitlement AND a granted CoreLocation authorization. + if (request.getArg( + "ios.entitlements.com.apple.developer.networking.wifi-info", + null) == null) { + request.putArgument( + "ios.entitlements.com.apple.developer.networking.wifi-info", + "true"); + } + // CoreLocation is what iOS checks behind the scenes for + // CNCopyCurrentNetworkInfo. Default the description if the + // developer did not set one; the user-facing prompt comes + // from this string. + if (request.getArg("ios.locationUsageDescription", null) == null + && request.getArg("ios.NSLocationWhenInUseUsageDescription", null) == null) { + request.putArgument("ios.locationUsageDescription", + "Allow access to your location to read the current Wi-Fi network name."); + } + // Light up the CaptiveNetwork SSID/BSSID code path. Apps + // that don't reference com.codename1.io.wifi.WiFi ship + // without any CaptiveNetwork symbols. + try { + replaceInFile(new File(buildinRes, "IOSNative.m"), + "//#define CN1_INCLUDE_WIFI_INFO", + "#define CN1_INCLUDE_WIFI_INFO"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_WIFI_INFO", ex); + } + } + if (usesWifiHotspotConfig) { + if (request.getArg( + "ios.entitlements.com.apple.developer.networking.HotspotConfiguration", + null) == null) { + request.putArgument( + "ios.entitlements.com.apple.developer.networking.HotspotConfiguration", + "true"); + } + // Light up NetworkExtension.framework only when the app uses + // hotspot config. The conditional #define keeps stock apps + // free of NetworkExtension symbols so the App Store API-usage + // scanner does not flag it. + try { + replaceInFile(new File(buildinRes, + "IOSNative.m"), + "//#define CN1_INCLUDE_HOTSPOT", + "#define CN1_INCLUDE_HOTSPOT"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_HOTSPOT", ex); + } + if (addLibs == null || addLibs.length() == 0) { + addLibs = "NetworkExtension.framework"; + } else if (!addLibs.contains("NetworkExtension")) { + addLibs += ";NetworkExtension.framework"; + } + } + if (usesBonjour) { + // iOS 14 requires NSLocalNetworkUsageDescription before any + // mDNS traffic can flow; without it discovery silently + // returns nothing. + if (request.getArg("ios.NSLocalNetworkUsageDescription", null) == null) { + request.putArgument("ios.NSLocalNetworkUsageDescription", + "Allow access to devices on your local network to discover services advertised via Bonjour."); + } + // The NSBonjourServices array enumerates the service types + // the app expects to discover. We seed it with HTTP since + // that's the most common; developers should add specific + // types via ios.NSBonjourServices = "_myapp._tcp.,_http._tcp." + if (request.getArg("ios.NSBonjourServices", null) == null) { + request.putArgument("ios.NSBonjourServices", "_http._tcp."); + } + // Light up the NSNetServiceBrowser/NSNetService code path. + // Apps that never call BonjourBrowser/BonjourPublisher + // ship without the delegate, so the iOS 14 local-network + // privacy prompt is not triggered for them. + try { + replaceInFile(new File(buildinRes, "IOSNative.m"), + "//#define CN1_INCLUDE_BONJOUR", + "#define CN1_INCLUDE_BONJOUR"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_BONJOUR", ex); + } + } + // HCE on iOS requires the iOS 17.4+ EU-only CardSession // entitlement plus the AIDs to register. We inject the // entitlement when the scanner saw HostCardEmulationService. @@ -3063,6 +3184,25 @@ public boolean accept(File file, String string) { } } } + // NSBonjourServices is an Array in Info.plist, not a String, + // so the generic NS*UsageDescription injector above does not handle + // it. We expand a comma- or semicolon-separated build-hint value + // into the required ... fragment. + String bonjourServices = request.getArg("ios.NSBonjourServices", null); + if (bonjourServices != null && bonjourServices.length() > 0 + && !inject.contains("NSBonjourServices")) { + StringBuilder arr = new StringBuilder(); + arr.append("\nNSBonjourServices"); + for (String s : bonjourServices.split("[,;]")) { + s = s.trim(); + if (s.length() == 0) continue; + if (!s.endsWith(".")) s = s + "."; + arr.append("").append(s).append(""); + } + arr.append(""); + inject += arr.toString(); + } + String backgroundModesStr = request.getArg("ios.background_modes", null); if (includePush || "true".equals(request.getArg("ios.delayPushCompletion", "false")) || "true".equals(request.getArg("delayPushCompletion", "false"))) {