Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
441 changes: 441 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ tracing = { version = "0.1.41", default-features = false, features = ["std", "lo
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62.2", features = ["Networking_Connectivity"] }

[target.'cfg(target_os = "linux")'.dependencies]
zbus = "=5.14.0"

[build-dependencies]
tauri-plugin = { version = "2.5.1", features = ["build"] }
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ decisions.
* Detect connection type (WiFi, Ethernet, Cellular)
* Query metered and constrained status for network policy decisions
* Check internet reachability
* Cross-platform support (Windows, iOS, Android)
* Cross-platform support (Windows, Linux, iOS, Android)

| Platform | Supported |
| -------- | --------- |
| Windows | Yes |
| Linux | Yes |
| macOS | Planned |
| Android | Planned |
| iOS | Planned |
Expand Down Expand Up @@ -68,6 +69,12 @@ Run Rust tests only:
cargo test --workspace --lib
```

### Manual Linux scenario testing

See [Linux Connectivity Manual Testing](docs/linux-connectivity-manual-testing.md)
for WSL2, VirtualBox, NetworkManager, ModemManager, metered, constrained, and
transport-type test scenarios.

## Install

_This plugin requires a Rust version of at least **1.94.0**_
Expand Down Expand Up @@ -180,11 +187,12 @@ The `connectionStatus()` function returns a `ConnectionStatus` object:

#### Platform mapping

| Field | Windows | iOS | Android |
| ---------------- | ----------------------------------------------------------------------------------- | --------------------------- | ---------------------------------- |
| `metered` | `NetworkCostType` Unknown/Fixed/Variable | `NWPath.isExpensive` | absence of `NOT_METERED` |
| `constrained` | `ConstrainedInternetAccess`, data-limit, roaming, or background data restrictions | `NWPath.isConstrained` | Data Saver / `RESTRICT_BACKGROUND` |
| `connectionType` | WWAN/WLAN/IANA interface type | `NWInterface.InterfaceType` | `TRANSPORT_*` capabilities |
| Field | Windows | Linux | iOS | Android |
| ---------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------- | ---------------------------------- |
| `connected` | `InternetAccess` or `ConstrainedInternetAccess` | NetworkManager `FULL`/`PORTAL` or up default route fallback | `NWPath.status` satisfied | active network with internet capability |
| `metered` | `NetworkCostType` Unknown/Fixed/Variable | NetworkManager primary device `Metered` | `NWPath.isExpensive` | absence of `NOT_METERED` |
| `constrained` | `ConstrainedInternetAccess`, data-limit, roaming, or background data restrictions | NetworkManager portal/metered or cellular roaming; fallback defaults to `false` | `NWPath.isConstrained` | Data Saver / `RESTRICT_BACKGROUND` |
| `connectionType` | WWAN/WLAN/IANA interface type | NetworkManager device type or sysfs fallback | `NWInterface.InterfaceType` | `TRANSPORT_*` capabilities |

## Development Standards

Expand Down
3 changes: 3 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**/build/
/.gradle
/.tauri
40 changes: 40 additions & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}

android {
namespace = "org.silvermine.plugin.connectivity"
compileSdk = 36

defaultConfig {
minSdk = 23

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}

dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation(project(":tauri-android"))
}
21 changes: 21 additions & 0 deletions android/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
31 changes: 31 additions & 0 deletions android/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
google()
}
resolutionStrategy {
eachPlugin {
switch (requested.id.id) {
case "com.android.library":
useVersion("8.0.2")
break
case "org.jetbrains.kotlin.android":
useVersion("1.8.20")
break
}
}
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
google()

}
}

include ':tauri-android'
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
4 changes: 4 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package org.silvermine.plugin.connectivity

import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import app.tauri.plugin.JSObject

class Connectivity(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager

fun connectionStatus(): JSObject {
val manager = connectivityManager ?: return NativeConnectionStatus.disconnected().toJSObject()
val activeNetwork = manager.activeNetwork ?: return NativeConnectionStatus.disconnected().toJSObject()
val capabilities = manager.getNetworkCapabilities(activeNetwork)
?: return NativeConnectionStatus.disconnected().toJSObject()

if (!AndroidConnectivityMapper.isConnected(
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
)) {
return NativeConnectionStatus.disconnected().toJSObject()
}

// `TEMPORARILY_NOT_METERED` means the active network should be treated as
// effectively unmetered while Android exposes that capability.
val hasTemporarilyNotMetered = hasTemporarilyNotMetered(capabilities)
val status = NativeConnectionStatus(
connected = true,
metered = AndroidConnectivityMapper.isMetered(
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED),
hasTemporarilyNotMetered
),
constrained = AndroidConnectivityMapper.isConstrained(isBackgroundRestricted(manager)),
connectionType = AndroidConnectivityMapper.connectionType(
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI),
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET),
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
)
)

return status.toJSObject()
}

private fun isBackgroundRestricted(manager: ConnectivityManager): Boolean {
// Data Saver's restrict-background status was added after API 23.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return false
}

return manager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
}

private fun hasTemporarilyNotMetered(capabilities: NetworkCapabilities): Boolean {
// Guard the API 30 capability so the plugin still supports API 23+.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return false
}

return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED)
}
}

data class NativeConnectionStatus(
val connected: Boolean,
val metered: Boolean,
val constrained: Boolean,
val connectionType: ConnectionType
) {
fun toJSObject(): JSObject {
val status = JSObject()

status.put("connected", connected)
status.put("metered", metered)
status.put("constrained", constrained)
status.put("connectionType", connectionType.serializedName)

return status
}

companion object {
fun disconnected(): NativeConnectionStatus {
return NativeConnectionStatus(
connected = false,
metered = false,
constrained = false,
connectionType = ConnectionType.UNKNOWN
)
}
}
}

enum class ConnectionType(val serializedName: String) {
WIFI("wifi"),
ETHERNET("ethernet"),
CELLULAR("cellular"),
UNKNOWN("unknown")
}

object AndroidConnectivityMapper {
fun isConnected(hasInternet: Boolean, isValidated: Boolean): Boolean {
// `INTERNET` alone can include captive portals or unvalidated networks.
return hasInternet && isValidated
}

fun isMetered(hasNotMetered: Boolean, hasTemporarilyNotMetered: Boolean): Boolean {
return !hasNotMetered && !hasTemporarilyNotMetered
}

fun isConstrained(isBackgroundRestricted: Boolean): Boolean {
return isBackgroundRestricted
}

fun connectionType(
hasWifi: Boolean,
hasEthernet: Boolean,
hasCellular: Boolean
): ConnectionType {
if (hasWifi) {
return ConnectionType.WIFI
}

if (hasEthernet) {
return ConnectionType.ETHERNET
}

if (hasCellular) {
return ConnectionType.CELLULAR
}

return ConnectionType.UNKNOWN
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.silvermine.plugin.connectivity

import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.Plugin

// Android side of the Rust `register_android_plugin(..., "ConnectivityPlugin")`
// bridge.
@TauriPlugin
class ConnectivityPlugin(activity: Activity) : Plugin(activity) {
private val connectivity = Connectivity(activity.applicationContext)

@Command
fun connectionStatus(invoke: Invoke) {
invoke.resolve(connectivity.connectionStatus())
}
}
Loading
Loading