diff --git a/android/capacitor/src/main/AndroidManifest.xml b/android/capacitor/src/main/AndroidManifest.xml
index 74b7379f73..d436515b78 100644
--- a/android/capacitor/src/main/AndroidManifest.xml
+++ b/android/capacitor/src/main/AndroidManifest.xml
@@ -1,3 +1,5 @@
+
+
\ No newline at end of file
diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java
index 6122565b37..3344dd3cca 100644
--- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java
@@ -598,6 +598,7 @@ private void registerAllPlugins() {
this.registerPlugin(com.getcapacitor.plugin.WebView.class);
this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class);
this.registerPlugin(com.getcapacitor.plugin.SystemBars.class);
+ this.registerPlugin(com.getcapacitor.plugin.LocalNetwork.class);
for (Class extends Plugin> pluginClass : this.initialPlugins) {
this.registerPlugin(pluginClass);
@@ -1058,6 +1059,19 @@ protected boolean validatePermissions(Plugin plugin, PluginCall savedCall, Map enforcedPerms = new ArrayList<>();
+ for (String permString : permissions.keySet()) {
+ if (PermissionHelper.isPermissionEnforced(permString)) {
+ enforcedPerms.add(permString);
+ }
+ }
+ String[] permStrings = enforcedPerms.toArray(new String[0]);
- if (!PermissionHelper.hasDefinedPermissions(getContext(), permStrings)) {
+ if (permStrings.length > 0 && !PermissionHelper.hasDefinedPermissions(getContext(), permStrings)) {
StringBuilder builder = new StringBuilder();
builder.append("Missing the following permissions in AndroidManifest.xml:\n");
String[] missing = PermissionHelper.getUndefinedPermissions(getContext(), permStrings);
@@ -1125,7 +1145,9 @@ protected Map getPermissionStates(Plugin plugin) {
for (String permString : perm.strings()) {
String key = perm.alias().isEmpty() ? permString : perm.alias();
PermissionState permissionStatus;
- if (ActivityCompat.checkSelfPermission(this.getContext(), permString) == PackageManager.PERMISSION_GRANTED) {
+ if (!PermissionHelper.isPermissionEnforced(permString)) {
+ permissionStatus = PermissionState.GRANTED;
+ } else if (ActivityCompat.checkSelfPermission(this.getContext(), permString) == PackageManager.PERMISSION_GRANTED) {
permissionStatus = PermissionState.GRANTED;
} else {
permissionStatus = PermissionState.PROMPT;
diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/LocalNetwork.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/LocalNetwork.java
new file mode 100644
index 0000000000..9fc96279a9
--- /dev/null
+++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/LocalNetwork.java
@@ -0,0 +1,23 @@
+package com.getcapacitor.plugin;
+
+import com.getcapacitor.Plugin;
+import com.getcapacitor.annotation.CapacitorPlugin;
+import com.getcapacitor.annotation.Permission;
+import com.getcapacitor.util.PermissionHelper;
+
+/**
+ * First-party helper exposing the standard checkPermissions()/requestPermissions()
+ * flow for Android's local-network runtime permission (ACCESS_LOCAL_NETWORK, API 37+).
+ *
+ * On older SDKs the Bridge reports the permission as granted, so no prompt ever
+ * appears there.
+ *
+ * @since 9.0.0
+ */
+@CapacitorPlugin(
+ name = "LocalNetwork",
+ permissions = {
+ @Permission(alias = "localNetwork", strings = { PermissionHelper.ACCESS_LOCAL_NETWORK })
+ }
+)
+public class LocalNetwork extends Plugin {}
\ No newline at end of file
diff --git a/android/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java b/android/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java
index e7b8332104..37c58d40d0 100644
--- a/android/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java
@@ -3,6 +3,7 @@
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
+import android.os.Build;
import androidx.core.app.ActivityCompat;
import java.util.ArrayList;
import java.util.Arrays;
@@ -15,6 +16,90 @@
*/
public class PermissionHelper {
+ /**
+ * Local-network access runtime permission introduced in API 37.
+ * Kept as a string literal even though the default compile SDK is 37,
+ * because consumers can override the compile SDK below API 37 via the
+ * compileSdkVersion Gradle property, in which case
+ * Manifest.permission.ACCESS_LOCAL_NETWORK is not available at compile time.
+ *
+ * @since 9.0.0
+ */
+ public static final String ACCESS_LOCAL_NETWORK = "android.permission.ACCESS_LOCAL_NETWORK";
+
+ /**
+ * API level that introduced the local-network runtime permission.
+ * Kept as a literal because there is no compile-time guarantee that a
+ * Build.VERSION_CODES constant exists for API 37 (builds may use a compile
+ * SDK below 37 via the compileSdkVersion Gradle property).
+ *
+ * @since 9.0.0
+ */
+ public static final int ANDROID_API_LOCAL_NETWORK_PERMISSION = 37;
+
+ /**
+ * Checks whether the given permission string is the local-network permission.
+ *
+ * @since 9.0.0
+ * @param permission A permission string to check.
+ * @return True if it matches the local-network permission, false otherwise (including null).
+ */
+ public static boolean isLocalNetworkPermission(String permission) {
+ return ACCESS_LOCAL_NETWORK.equals(permission);
+ }
+
+ /**
+ * Checks whether the local-network runtime permission is enforced on this device.
+ *
+ * @since 9.0.0
+ * @return True on API 37+, false on older SDKs where the permission is unknown.
+ */
+ public static boolean isLocalNetworkPermissionEnforced() {
+ return isLocalNetworkPermissionEnforced(Build.VERSION.SDK_INT);
+ }
+
+ /**
+ * Checks whether the local-network runtime permission is enforced for the given API level.
+ * Exists so both sides of the API-37 gate are unit-testable on the JVM.
+ *
+ * @since 9.0.0
+ * @param sdkInt An API level, e.g. Build.VERSION.SDK_INT.
+ * @return True on API 37+, false on older SDKs where the permission is unknown.
+ */
+ public static boolean isLocalNetworkPermissionEnforced(int sdkInt) {
+ return sdkInt >= ANDROID_API_LOCAL_NETWORK_PERMISSION;
+ }
+
+ /**
+ * Checks whether the given permission is enforced on this device.
+ * The local-network permission is only enforced on API 37+; on older SDKs it
+ * is unknown to PackageManager and must be treated as granted.
+ * All other permissions are always enforced.
+ *
+ * @since 9.0.0
+ * @param permission A permission string to check.
+ * @return False only for the local-network permission on SDK < 37, true otherwise.
+ */
+ public static boolean isPermissionEnforced(String permission) {
+ return isPermissionEnforced(permission, Build.VERSION.SDK_INT);
+ }
+
+ /**
+ * Checks whether the given permission is enforced for the given API level.
+ * Exists so both sides of the API-37 gate are unit-testable on the JVM.
+ *
+ * @since 9.0.0
+ * @param permission A permission string to check.
+ * @param sdkInt An API level, e.g. Build.VERSION.SDK_INT.
+ * @return False only for the local-network permission on SDK < 37, true otherwise.
+ */
+ public static boolean isPermissionEnforced(String permission, int sdkInt) {
+ if (isLocalNetworkPermission(permission) && !isLocalNetworkPermissionEnforced(sdkInt)) {
+ return false;
+ }
+ return true;
+ }
+
/**
* Checks if a list of given permissions are all granted by the user
*
diff --git a/android/capacitor/src/test/java/com/getcapacitor/BridgePermissionTest.java b/android/capacitor/src/test/java/com/getcapacitor/BridgePermissionTest.java
new file mode 100644
index 0000000000..871381724c
--- /dev/null
+++ b/android/capacitor/src/test/java/com/getcapacitor/BridgePermissionTest.java
@@ -0,0 +1,85 @@
+package com.getcapacitor;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import com.getcapacitor.annotation.CapacitorPlugin;
+import com.getcapacitor.annotation.Permission;
+import com.getcapacitor.util.PermissionHelper;
+import java.util.Collections;
+import java.util.Map;
+import org.junit.Test;
+
+public class BridgePermissionTest {
+
+ private Plugin pluginWithLocalNetworkAlias() {
+ Permission permission = mock(Permission.class);
+ when(permission.alias()).thenReturn("localNetwork");
+ when(permission.strings()).thenReturn(new String[] { PermissionHelper.ACCESS_LOCAL_NETWORK });
+
+ CapacitorPlugin annotation = mock(CapacitorPlugin.class);
+ when(annotation.permissions()).thenReturn(new Permission[] { permission });
+
+ PluginHandle handle = mock(PluginHandle.class);
+ when(handle.getPluginAnnotation()).thenReturn(annotation);
+
+ Plugin plugin = mock(Plugin.class);
+ when(plugin.getPluginHandle()).thenReturn(handle);
+ return plugin;
+ }
+
+ @Test
+ public void getPermissionStatesGrantsLocalNetworkWithoutPackageManager() {
+ // Unit tests stub Build.VERSION.SDK_INT to 0, exercising the pre-37 path.
+ Bridge bridge = mock(Bridge.class);
+ Plugin plugin = pluginWithLocalNetworkAlias();
+ when(bridge.getPermissionStates(plugin)).thenCallRealMethod();
+
+ Map states = bridge.getPermissionStates(plugin);
+
+ assertEquals(PermissionState.GRANTED, states.get("localNetwork"));
+ verify(bridge, never()).getContext();
+ }
+
+ @Test
+ public void validatePermissionsSwallowsDeniedResultWhenNotEnforced() {
+ // Even a DENIED result from the OS must not reject or cache state pre-37.
+ Bridge bridge = mock(Bridge.class);
+ Context context = mock(Context.class);
+ SharedPreferences prefs = mock(SharedPreferences.class);
+ PluginCall savedCall = mock(PluginCall.class);
+ when(bridge.getContext()).thenReturn(context);
+ when(context.getSharedPreferences(anyString(), anyInt())).thenReturn(prefs);
+ Map results = Collections.singletonMap(PermissionHelper.ACCESS_LOCAL_NETWORK, Boolean.FALSE);
+ when(bridge.validatePermissions(any(Plugin.class), eq(savedCall), eq(results))).thenCallRealMethod();
+
+ assertTrue(bridge.validatePermissions(mock(Plugin.class), savedCall, results));
+
+ verifyNoInteractions(savedCall);
+ verify(bridge, never()).getActivity();
+ }
+
+ @Test
+ public void validatePermissionsClearsStaleCacheWhenNotEnforced() {
+ Bridge bridge = mock(Bridge.class);
+ Context context = mock(Context.class);
+ SharedPreferences prefs = mock(SharedPreferences.class);
+ SharedPreferences.Editor editor = mock(SharedPreferences.Editor.class);
+ PluginCall savedCall = mock(PluginCall.class);
+ when(bridge.getContext()).thenReturn(context);
+ when(context.getSharedPreferences(anyString(), anyInt())).thenReturn(prefs);
+ when(prefs.getString(eq(PermissionHelper.ACCESS_LOCAL_NETWORK), isNull())).thenReturn(PermissionState.DENIED.toString());
+ when(prefs.edit()).thenReturn(editor);
+ Map results = Collections.singletonMap(PermissionHelper.ACCESS_LOCAL_NETWORK, Boolean.TRUE);
+ when(bridge.validatePermissions(any(Plugin.class), eq(savedCall), eq(results))).thenCallRealMethod();
+
+ assertTrue(bridge.validatePermissions(mock(Plugin.class), savedCall, results));
+
+ verify(editor).remove(PermissionHelper.ACCESS_LOCAL_NETWORK);
+ verify(editor).apply();
+ verifyNoInteractions(savedCall);
+ }
+}
\ No newline at end of file
diff --git a/android/capacitor/src/test/java/com/getcapacitor/plugin/LocalNetworkTest.java b/android/capacitor/src/test/java/com/getcapacitor/plugin/LocalNetworkTest.java
new file mode 100644
index 0000000000..f962530b4e
--- /dev/null
+++ b/android/capacitor/src/test/java/com/getcapacitor/plugin/LocalNetworkTest.java
@@ -0,0 +1,56 @@
+package com.getcapacitor.plugin;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+import com.getcapacitor.Bridge;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.PermissionState;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginHandle;
+import com.getcapacitor.annotation.CapacitorPlugin;
+import com.getcapacitor.annotation.Permission;
+import com.getcapacitor.util.PermissionHelper;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+public class LocalNetworkTest {
+
+ @Test
+ public void annotatedWithLocalNetworkPermission() {
+ CapacitorPlugin annotation = LocalNetwork.class.getAnnotation(CapacitorPlugin.class);
+
+ assertNotNull(annotation);
+ assertEquals("LocalNetwork", annotation.name());
+
+ Permission[] permissions = annotation.permissions();
+ assertEquals(1, permissions.length);
+ assertEquals("localNetwork", permissions[0].alias());
+ assertArrayEquals(new String[] { PermissionHelper.ACCESS_LOCAL_NETWORK }, permissions[0].strings());
+ }
+
+ @Test
+ public void checkPermissionsReportsGrantedOnPreApi37() {
+ // Local JVM unit tests stub Build.VERSION.SDK_INT to 0, exercising the pre-37 path:
+ // the bridge must report the localNetwork alias as GRANTED without touching
+ // PackageManager (no getContext call), so no prompt can ever appear.
+ // CALLS_REAL_METHODS runs Bridge.getPermissionStates() for real while
+ // keeping the rest of the mock inert (the pre-37 path never reaches them).
+ Bridge bridge = mock(Bridge.class, CALLS_REAL_METHODS);
+
+ PluginHandle handle = mock(PluginHandle.class);
+ when(handle.getPluginAnnotation()).thenReturn(LocalNetwork.class.getAnnotation(CapacitorPlugin.class));
+
+ LocalNetwork plugin = new LocalNetwork();
+ plugin.setBridge(bridge);
+ plugin.setPluginHandle(handle);
+
+ PluginCall call = mock(PluginCall.class);
+ plugin.checkPermissions(call);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(JSObject.class);
+ verify(call).resolve(captor.capture());
+ assertEquals(PermissionState.GRANTED.toString(), captor.getValue().optString("localNetwork", "missing"));
+ verify(bridge, never()).getContext();
+ }
+}
\ No newline at end of file
diff --git a/android/capacitor/src/test/java/com/getcapacitor/util/PermissionHelperTest.java b/android/capacitor/src/test/java/com/getcapacitor/util/PermissionHelperTest.java
new file mode 100644
index 0000000000..544a262a18
--- /dev/null
+++ b/android/capacitor/src/test/java/com/getcapacitor/util/PermissionHelperTest.java
@@ -0,0 +1,59 @@
+package com.getcapacitor.util;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+public class PermissionHelperTest {
+
+ @Test
+ public void localNetworkConstants() {
+ assertEquals("android.permission.ACCESS_LOCAL_NETWORK", PermissionHelper.ACCESS_LOCAL_NETWORK);
+ assertEquals(37, PermissionHelper.ANDROID_API_LOCAL_NETWORK_PERMISSION);
+ }
+
+ @Test
+ public void isLocalNetworkPermission() {
+ assertTrue(PermissionHelper.isLocalNetworkPermission(PermissionHelper.ACCESS_LOCAL_NETWORK));
+ assertTrue(PermissionHelper.isLocalNetworkPermission("android.permission.ACCESS_LOCAL_NETWORK"));
+ assertFalse(PermissionHelper.isLocalNetworkPermission("android.permission.INTERNET"));
+ assertFalse(PermissionHelper.isLocalNetworkPermission("android.permission.CAMERA"));
+ assertFalse(PermissionHelper.isLocalNetworkPermission(""));
+ assertFalse(PermissionHelper.isLocalNetworkPermission(null));
+ }
+
+ @Test
+ public void localNetworkNotEnforcedBelowApi37() {
+ assertFalse(PermissionHelper.isLocalNetworkPermissionEnforced(24));
+ assertFalse(PermissionHelper.isLocalNetworkPermissionEnforced(36));
+ assertFalse(PermissionHelper.isPermissionEnforced(PermissionHelper.ACCESS_LOCAL_NETWORK, 24));
+ assertFalse(PermissionHelper.isPermissionEnforced(PermissionHelper.ACCESS_LOCAL_NETWORK, 36));
+ }
+
+ @Test
+ public void localNetworkEnforcedOnApi37Plus() {
+ assertTrue(PermissionHelper.isLocalNetworkPermissionEnforced(37));
+ assertTrue(PermissionHelper.isLocalNetworkPermissionEnforced(38));
+ assertTrue(PermissionHelper.isPermissionEnforced(PermissionHelper.ACCESS_LOCAL_NETWORK, 37));
+ assertTrue(PermissionHelper.isPermissionEnforced(PermissionHelper.ACCESS_LOCAL_NETWORK, 38));
+ }
+
+ @Test
+ public void otherPermissionsAlwaysEnforced() {
+ assertTrue(PermissionHelper.isPermissionEnforced("android.permission.INTERNET", 24));
+ assertTrue(PermissionHelper.isPermissionEnforced("android.permission.INTERNET", 37));
+ assertTrue(PermissionHelper.isPermissionEnforced("android.permission.CAMERA", 24));
+ assertTrue(PermissionHelper.isPermissionEnforced("android.permission.CAMERA", 37));
+ assertTrue(PermissionHelper.isPermissionEnforced(null, 24));
+ assertTrue(PermissionHelper.isPermissionEnforced("", 36));
+ }
+
+ @Test
+ public void deviceMethodsReflectUnitTestSdk() {
+ // Local JVM unit tests stub Build.VERSION.SDK_INT to 0 (pre-37),
+ // so the local-network permission must report as not enforced here.
+ assertFalse(PermissionHelper.isLocalNetworkPermissionEnforced());
+ assertFalse(PermissionHelper.isPermissionEnforced(PermissionHelper.ACCESS_LOCAL_NETWORK));
+ assertTrue(PermissionHelper.isPermissionEnforced("android.permission.CAMERA"));
+ }
+}
\ No newline at end of file
diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts
index a1efe9c901..6459b5431f 100644
--- a/core/src/core-plugins.ts
+++ b/core/src/core-plugins.ts
@@ -1,4 +1,4 @@
-import type { Plugin } from './definitions';
+import type { PermissionResult, Plugin } from './definitions';
import { registerPlugin } from './global';
import { WebPlugin } from './web-plugin';
@@ -659,3 +659,29 @@ export const SystemBars = registerPlugin('SystemBars', {
});
/******** END SYSTEM BARS PLUGIN ********/
+
+/******** LOCAL NETWORK PLUGIN ********/
+export interface LocalNetworkPlugin {
+ checkPermissions(): Promise;
+ requestPermissions(): Promise;
+}
+
+export class LocalNetworkPluginWeb extends WebPlugin implements LocalNetworkPlugin {
+ async checkPermissions(): Promise {
+ return {
+ localNetwork: 'granted',
+ };
+ }
+
+ async requestPermissions(): Promise {
+ return {
+ localNetwork: 'granted',
+ };
+ }
+}
+
+export const LocalNetwork = registerPlugin('LocalNetwork', {
+ web: () => new LocalNetworkPluginWeb(),
+});
+
+/******** END LOCAL NETWORK PLUGIN ********/
diff --git a/core/src/definitions.ts b/core/src/definitions.ts
index a8259607cc..c6ecfb0897 100644
--- a/core/src/definitions.ts
+++ b/core/src/definitions.ts
@@ -78,6 +78,16 @@ export interface Plugin {
export type PermissionState = 'prompt' | 'prompt-with-rationale' | 'granted' | 'denied';
+/**
+ * A mapping of permission aliases to their current state, as returned by the
+ * standard `checkPermissions()` / `requestPermissions()` plugin flow.
+ *
+ * @since 9.0.0
+ */
+export type PermissionResult = {
+ [key: string]: PermissionState;
+};
+
export interface PluginListenerHandle {
remove: () => Promise;
}
diff --git a/core/src/index.ts b/core/src/index.ts
index 5afa512cf3..d498f62956 100644
--- a/core/src/index.ts
+++ b/core/src/index.ts
@@ -1,6 +1,7 @@
// Type Definitions
export type {
CapacitorGlobal,
+ PermissionResult,
PermissionState,
Plugin,
PluginCallback,
@@ -24,6 +25,7 @@ export {
SystemBarsAnimation,
CapacitorCookies,
CapacitorHttp,
+ LocalNetwork,
WebView,
buildRequestInit,
} from './core-plugins';
@@ -38,6 +40,7 @@ export type {
HttpParams,
HttpResponse,
HttpResponseType,
+ LocalNetworkPlugin,
WebViewPath,
WebViewPlugin,
SystemBarsVisibilityOptions,