diff --git a/network/README.md b/network/README.md index 48496869f..254a0cc49 100644 --- a/network/README.md +++ b/network/README.md @@ -99,6 +99,8 @@ Represents the state and type of the network connection. | -------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----- | | **`connected`** | boolean | Whether there is an active connection or not. | 1.0.0 | | **`connectionType`** | ConnectionType | The type of network connection currently in use. If there is no active network connection, `connectionType` will be `'none'`. | 1.0.0 | +| **`constrained`** | boolean | Whether the active connection is constrained by platform data-saving or bandwidth-reduction signals. | 8.1.0 | +| **`expensive`** | boolean | Whether the active connection is considered expensive or metered by the platform. | 8.1.0 | #### PluginListenerHandle diff --git a/network/android/src/main/java/com/capacitorjs/plugins/network/Network.java b/network/android/src/main/java/com/capacitorjs/plugins/network/Network.java index 8f8842fee..0a0592783 100644 --- a/network/android/src/main/java/com/capacitorjs/plugins/network/Network.java +++ b/network/android/src/main/java/com/capacitorjs/plugins/network/Network.java @@ -2,9 +2,12 @@ import android.content.BroadcastReceiver; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.net.ConnectivityManager; import android.net.ConnectivityManager.NetworkCallback; import android.net.NetworkCapabilities; +import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -38,7 +41,7 @@ public void onCapabilitiesChanged(@NonNull android.net.Network network, @NonNull private ConnectivityCallback connectivityCallback; private Context context; private ConnectivityManager connectivityManager; - private BroadcastReceiver receiver; + private BroadcastReceiver restrictBackgroundReceiver; /** * Create network monitoring object. @@ -48,6 +51,14 @@ public Network(@NonNull Context context) { this.context = context; this.connectivityManager = (ConnectivityManager) this.context.getSystemService(Context.CONNECTIVITY_SERVICE); this.connectivityCallback = new ConnectivityCallback(); + this.restrictBackgroundReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED.equals(intent.getAction()) && statusChangeListener != null) { + statusChangeListener.onNetworkStatusChanged(false); + } + } + }; } /** @@ -75,11 +86,17 @@ public NetworkStatus getNetworkStatus() { NetworkStatus networkStatus = new NetworkStatus(); if (this.connectivityManager != null) { android.net.Network activeNetwork = this.connectivityManager.getActiveNetwork(); - NetworkCapabilities capabilities = this.connectivityManager.getNetworkCapabilities(this.connectivityManager.getActiveNetwork()); + NetworkCapabilities capabilities = this.connectivityManager.getNetworkCapabilities(activeNetwork); if (activeNetwork != null && capabilities != null) { networkStatus.connected = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + networkStatus.expensive = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED); + networkStatus.constrained = isConstrained( + connectivityManager.getRestrictBackgroundStatus(), + networkStatus.expensive, + isBandwidthConstrained(capabilities) + ); if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { networkStatus.connectionType = NetworkStatus.ConnectionType.WIFI; } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { @@ -92,6 +109,17 @@ public NetworkStatus getNetworkStatus() { return networkStatus; } + static boolean isConstrained(int restrictBackgroundStatus, boolean expensive, boolean bandwidthConstrained) { + return (expensive && restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED) || bandwidthConstrained; + } + + private static boolean isBandwidthConstrained(NetworkCapabilities capabilities) { + return ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && + !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED) + ); + } + @SuppressWarnings("deprecation") private NetworkStatus getAndParseNetworkInfo() { NetworkStatus networkStatus = new NetworkStatus(); @@ -113,6 +141,12 @@ private NetworkStatus getAndParseNetworkInfo() { */ public void startMonitoring() { connectivityManager.registerDefaultNetworkCallback(connectivityCallback); + IntentFilter filter = new IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(restrictBackgroundReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + context.registerReceiver(restrictBackgroundReceiver, filter); + } } /** @@ -120,5 +154,6 @@ public void startMonitoring() { */ public void stopMonitoring() { connectivityManager.unregisterNetworkCallback(connectivityCallback); + context.unregisterReceiver(restrictBackgroundReceiver); } } diff --git a/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkPlugin.java b/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkPlugin.java index d1fe63ef8..0f1b1a1ad 100644 --- a/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkPlugin.java +++ b/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkPlugin.java @@ -1,6 +1,5 @@ package com.capacitorjs.plugins.network; -import android.os.Build; import android.util.Log; import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; @@ -26,6 +25,8 @@ public void load() { JSObject jsObject = new JSObject(); jsObject.put("connected", false); jsObject.put("connectionType", "none"); + jsObject.put("constrained", false); + jsObject.put("expensive", false); notifyListeners(NETWORK_CHANGE_EVENT, jsObject); } else { updateNetworkStatus(); @@ -89,6 +90,8 @@ private JSObject parseNetworkStatus(NetworkStatus networkStatus) { JSObject jsObject = new JSObject(); jsObject.put("connected", networkStatus.connected); jsObject.put("connectionType", networkStatus.connectionType.getConnectionType()); + jsObject.put("constrained", networkStatus.constrained); + jsObject.put("expensive", networkStatus.expensive); return jsObject; } } diff --git a/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkStatus.java b/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkStatus.java index ac0a7a121..c150bc648 100644 --- a/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkStatus.java +++ b/network/android/src/main/java/com/capacitorjs/plugins/network/NetworkStatus.java @@ -21,4 +21,6 @@ public String getConnectionType() { public boolean connected = false; public ConnectionType connectionType = ConnectionType.NONE; + public boolean constrained = false; + public boolean expensive = false; } diff --git a/network/android/src/test/java/com/capacitorjs/plugins/network/NetworkTest.java b/network/android/src/test/java/com/capacitorjs/plugins/network/NetworkTest.java new file mode 100644 index 000000000..be0a89d38 --- /dev/null +++ b/network/android/src/test/java/com/capacitorjs/plugins/network/NetworkTest.java @@ -0,0 +1,31 @@ +package com.capacitorjs.plugins.network; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.net.ConnectivityManager; +import org.junit.Test; + +public class NetworkTest { + + @Test + public void isConstrainedReturnsTrueWhenMeteredBackgroundDataIsRestricted() { + assertTrue(Network.isConstrained(ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED, true, false)); + } + + @Test + public void isConstrainedReturnsFalseWhenUnmeteredBackgroundDataIsRestricted() { + assertFalse(Network.isConstrained(ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED, false, false)); + } + + @Test + public void isConstrainedReturnsTrueWhenBandwidthIsConstrained() { + assertTrue(Network.isConstrained(ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED, false, true)); + } + + @Test + public void isConstrainedReturnsFalseWhenBackgroundDataIsUnrestricted() { + assertFalse(Network.isConstrained(ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED, true, false)); + assertFalse(Network.isConstrained(ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED, true, false)); + } +} diff --git a/network/ios/Sources/NetworkPlugin/Network.swift b/network/ios/Sources/NetworkPlugin/Network.swift index 255b214c8..0895b5853 100644 --- a/network/ios/Sources/NetworkPlugin/Network.swift +++ b/network/ios/Sources/NetworkPlugin/Network.swift @@ -1,4 +1,5 @@ import Foundation +import Network public typealias NetworkConnectionChangedObserver = (Network.Connection) -> Void @@ -9,9 +10,22 @@ public class Network { public enum Connection { case unavailable, wifi, cellular } + struct ConnectionDetails { + var constrained = false + var expensive = false + } internal private(set) var reachability: Reachability? + private let pathMonitor = NWPathMonitor() + private let pathMonitorQueue = DispatchQueue(label: "capacitor.network.pathMonitor") + private let connectionDetailsQueue = DispatchQueue(label: "capacitor.network.connectionDetails") + private let observerQueue = DispatchQueue(label: "capacitor.network.observer") + private var details = ConnectionDetails() + private var observerNotificationPending = false var statusObserver: NetworkConnectionChangedObserver? + var connectionDetails: ConnectionDetails { + return connectionDetailsQueue.sync { details } + } init() throws { reachability = try Reachability() @@ -19,18 +33,64 @@ public class Network { throw NetworkError.initializationFailed } // setup our callback(s) and start notifications - reachability?.whenReachable = { [weak self] reachable in - self?.statusObserver?(reachable.connection.equivalentEnum) + reachability?.whenReachable = { [weak self] _ in + self?.notifyStatusObserver() } reachability?.whenUnreachable = { [weak self] _ in - self?.statusObserver?(Connection.unavailable) + self?.notifyStatusObserver() } + pathMonitor.pathUpdateHandler = { [weak self] path in + if self?.updateConnectionDetails(path) == true { + self?.notifyStatusObserver() + } + } + pathMonitor.start(queue: pathMonitorQueue) + _ = updateConnectionDetails(pathMonitor.currentPath) try reachability?.startNotifier() } + deinit { + pathMonitor.cancel() + } + func currentStatus() -> Network.Connection { return reachability?.connection.equivalentEnum ?? Connection.unavailable } + + private func updateConnectionDetails(_ path: NWPath) -> Bool { + let nextDetails = ConnectionDetails(constrained: path.isConstrained, expensive: path.isExpensive) + return connectionDetailsQueue.sync { + guard details.constrained != nextDetails.constrained || details.expensive != nextDetails.expensive else { + return false + } + details = nextDetails + return true + } + } + + private func notifyStatusObserver() { + var shouldNotify = false + observerQueue.sync { + if !observerNotificationPending { + observerNotificationPending = true + shouldNotify = true + } + } + + guard shouldNotify else { + return + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return + } + self.observerQueue.sync { + self.observerNotificationPending = false + } + self.statusObserver?(self.currentStatus()) + } + } } fileprivate extension Reachability.Connection { diff --git a/network/ios/Sources/NetworkPlugin/NetworkPlugin.swift b/network/ios/Sources/NetworkPlugin/NetworkPlugin.swift index 061dce4e4..be532cec7 100644 --- a/network/ios/Sources/NetworkPlugin/NetworkPlugin.swift +++ b/network/ios/Sources/NetworkPlugin/NetworkPlugin.swift @@ -16,10 +16,7 @@ public class NetworkPlugin: CAPPlugin, CAPBridgedPlugin { implementation = try Network() implementation?.statusObserver = { [weak self] status in CAPLog.print(status.logMessage) - self?.notifyListeners("networkStatusChange", data: [ - "connected": status.isConnected, - "connectionType": status.jsStringValue - ]) + self?.notifyListeners("networkStatusChange", data: self?.statusData(status) ?? [:]) } } catch let error { CAPLog.print("Unable to start network monitor: \(error)") @@ -28,7 +25,17 @@ public class NetworkPlugin: CAPPlugin, CAPBridgedPlugin { @objc func getStatus(_ call: CAPPluginCall) { let status = implementation?.currentStatus() ?? Network.Connection.unavailable - call.resolve(["connected": status.isConnected, "connectionType": status.jsStringValue]) + call.resolve(statusData(status)) + } + + private func statusData(_ status: Network.Connection) -> [String: Any] { + let details = status.isConnected ? (implementation?.connectionDetails ?? Network.ConnectionDetails()) : Network.ConnectionDetails() + return [ + "connected": status.isConnected, + "connectionType": status.jsStringValue, + "constrained": details.constrained, + "expensive": details.expensive + ] } } diff --git a/network/ios/Tests/NetworkPluginTests/PluginTests.swift b/network/ios/Tests/NetworkPluginTests/PluginTests.swift index 06f507c1a..0ee493119 100644 --- a/network/ios/Tests/NetworkPluginTests/PluginTests.swift +++ b/network/ios/Tests/NetworkPluginTests/PluginTests.swift @@ -34,4 +34,14 @@ class NetworkTests: XCTestCase { XCTFail("Network initialization failed! \(error)") } } + + func testConnectionConditionDefaults() { + do { + let implementation = try Network() + XCTAssertFalse(implementation.connectionDetails.constrained) + XCTAssertFalse(implementation.connectionDetails.expensive) + } catch let error { + XCTFail("Network initialization failed! \(error)") + } + } } diff --git a/network/src/definitions.ts b/network/src/definitions.ts index ea2a3a1a5..56c921009 100644 --- a/network/src/definitions.ts +++ b/network/src/definitions.ts @@ -47,6 +47,20 @@ export interface ConnectionStatus { * @since 1.0.0 */ connectionType: ConnectionType; + + /** + * Whether the active connection is constrained by platform data-saving or bandwidth-reduction signals. + * + * @since 8.1.0 + */ + constrained?: boolean; + + /** + * Whether the active connection is considered expensive or metered by the platform. + * + * @since 8.1.0 + */ + expensive?: boolean; } /** diff --git a/network/src/web.ts b/network/src/web.ts index 7057184f7..781da0f2b 100644 --- a/network/src/web.ts +++ b/network/src/web.ts @@ -49,6 +49,14 @@ function translatedConnection(): ConnectionType { return result; } +function connectionDetails(): Pick { + const connection = window.navigator.connection || window.navigator.mozConnection || window.navigator.webkitConnection; + return { + constrained: !!connection?.saveData, + expensive: false, + }; +} + export class NetworkWeb extends WebPlugin implements NetworkPlugin { constructor() { super(); @@ -69,6 +77,7 @@ export class NetworkWeb extends WebPlugin implements NetworkPlugin { const status: ConnectionStatus = { connected, connectionType: connected ? connectionType : 'none', + ...connectionDetails(), }; return status; @@ -80,6 +89,7 @@ export class NetworkWeb extends WebPlugin implements NetworkPlugin { const status: ConnectionStatus = { connected: true, connectionType: connectionType, + ...connectionDetails(), }; this.notifyListeners('networkStatusChange', status); @@ -89,6 +99,8 @@ export class NetworkWeb extends WebPlugin implements NetworkPlugin { const status: ConnectionStatus = { connected: false, connectionType: 'none', + constrained: false, + expensive: false, }; this.notifyListeners('networkStatusChange', status);