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
2 changes: 2 additions & 0 deletions android/capacitor/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Local-network access on API 37+. Unknown to older SDKs, safely ignored there. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
</manifest>
28 changes: 25 additions & 3 deletions android/capacitor/src/main/java/com/getcapacitor/Bridge.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1058,6 +1059,19 @@ protected boolean validatePermissions(Plugin plugin, PluginCall savedCall, Map<S
String permString = permission.getKey();
boolean isGranted = permission.getValue();

if (!PermissionHelper.isPermissionEnforced(permString)) {
// Not enforced on this SDK (e.g. local-network permission pre-37).
// Clear any cached state and skip rationale/DENIED caching entirely.
String cachedState = prefs.getString(permString, null);

if (cachedState != null) {
SharedPreferences.Editor nonEnforcedEditor = prefs.edit();
nonEnforcedEditor.remove(permString);
nonEnforcedEditor.apply();
}
continue;
}

if (isGranted) {
// Permission granted. If previously denied, remove cached state
String state = prefs.getString(permString, null);
Expand All @@ -1082,9 +1096,15 @@ protected boolean validatePermissions(Plugin plugin, PluginCall savedCall, Map<S
}
}

String[] permStrings = permissions.keySet().toArray(new String[0]);
ArrayList<String> 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);
Expand Down Expand Up @@ -1125,7 +1145,9 @@ protected Map<String, PermissionState> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, PermissionState> 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<String, Boolean> 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<String, Boolean> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<JSObject> captor = ArgumentCaptor.forClass(JSObject.class);
verify(call).resolve(captor.capture());
assertEquals(PermissionState.GRANTED.toString(), captor.getValue().optString("localNetwork", "missing"));
verify(bridge, never()).getContext();
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading