From 264a58d730fd2e90a6430ff3cc7ea6b4a9f61d04 Mon Sep 17 00:00:00 2001 From: Fabio Martino Date: Wed, 23 Sep 2026 21:18:49 +0200 Subject: [PATCH 1/2] fix(android): add support for ACCESS_LOCAL_NETWORK permission (API 37+) --- .../capacitor/src/main/AndroidManifest.xml | 2 + .../main/java/com/getcapacitor/Bridge.java | 27 +++++- .../getcapacitor/util/PermissionHelper.java | 85 +++++++++++++++++++ .../getcapacitor/BridgePermissionTest.java | 85 +++++++++++++++++++ .../util/PermissionHelperTest.java | 59 +++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 android/capacitor/src/test/java/com/getcapacitor/BridgePermissionTest.java create mode 100644 android/capacitor/src/test/java/com/getcapacitor/util/PermissionHelperTest.java 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..460bbd1b59 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -1058,6 +1058,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 +1144,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/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/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 From 99c542e30bf0a2b614021872ac1a610af9e3050f Mon Sep 17 00:00:00 2001 From: Fabio Martino Date: Wed, 23 Sep 2026 21:18:54 +0200 Subject: [PATCH 2/2] feat(core): add LocalNetwork permissions plugin --- .../main/java/com/getcapacitor/Bridge.java | 1 + .../com/getcapacitor/plugin/LocalNetwork.java | 23 ++++++++ .../getcapacitor/plugin/LocalNetworkTest.java | 56 +++++++++++++++++++ core/src/core-plugins.ts | 28 +++++++++- core/src/definitions.ts | 10 ++++ core/src/index.ts | 3 + 6 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/LocalNetwork.java create mode 100644 android/capacitor/src/test/java/com/getcapacitor/plugin/LocalNetworkTest.java diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 460bbd1b59..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 pluginClass : this.initialPlugins) { this.registerPlugin(pluginClass); 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/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/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,