From c3dc5b62227498ba28a93bd9d5972bc4a49fc590 Mon Sep 17 00:00:00 2001 From: Junyu Lai Date: Wed, 6 Sep 2023 19:10:02 +0800 Subject: [PATCH] [BR06] Support check whether network is blocked by data saver This change adds a DataSaverStatusTracker, which is a helper class to continuously track data saver status through NPMS public API and intents. ConnectivityManager#isUidNetworkingBlocked would use this cached information along with bpf maps to decide whether networking of an uid is blocked. Test: atest FrameworksNetTests:android.net.connectivity.android.net.BpfNetMapsReaderTest Test: atest ConnectivityCoverageTests:android.net.connectivity.android.net.ConnectivityManagerTest Bug: 297836825 Change-Id: I7e13191759430f3ea1f4dec7facc02f16be7146d --- .../src/android/net/BpfNetMapsReader.java | 16 ++- .../src/android/net/ConnectivityManager.java | 111 +++++++++++++++++- .../java/android/net/BpfNetMapsReaderTest.kt | 74 ++++++++++-- .../android/net/ConnectivityManagerTest.java | 63 ++++++++++ 4 files changed, 246 insertions(+), 18 deletions(-) diff --git a/framework/src/android/net/BpfNetMapsReader.java b/framework/src/android/net/BpfNetMapsReader.java index 9bce9cd0f2..37c58f079d 100644 --- a/framework/src/android/net/BpfNetMapsReader.java +++ b/framework/src/android/net/BpfNetMapsReader.java @@ -17,6 +17,8 @@ package android.net; import static android.net.BpfNetMapsConstants.CONFIGURATION_MAP_PATH; +import static android.net.BpfNetMapsConstants.HAPPY_BOX_MATCH; +import static android.net.BpfNetMapsConstants.PENALTY_BOX_MATCH; import static android.net.BpfNetMapsConstants.UID_OWNER_MAP_PATH; import static android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY; import static android.net.BpfNetMapsUtils.getMatchByFirewallChain; @@ -213,13 +215,16 @@ public class BpfNetMapsReader { * Return whether the network is blocked by firewall chains for the given uid. * * @param uid The target uid. + * @param isNetworkMetered Whether the target network is metered. + * @param isDataSaverEnabled Whether the data saver is enabled. * * @return True if the network is blocked. Otherwise, false. * @throws ServiceSpecificException if the read fails. * * @hide */ - public boolean isUidBlockedByFirewallChains(final int uid) { + public boolean isUidNetworkingBlocked(final int uid, boolean isNetworkMetered, + boolean isDataSaverEnabled) { throwIfPreT("isUidBlockedByFirewallChains is not available on pre-T devices"); final long uidRuleConfig; @@ -235,6 +240,13 @@ public class BpfNetMapsReader { final boolean blockedByAllowChains = 0 != (uidRuleConfig & ~uidMatch & sMaskDropIfUnset); final boolean blockedByDenyChains = 0 != (uidRuleConfig & uidMatch & sMaskDropIfSet); - return blockedByAllowChains || blockedByDenyChains; + if (blockedByAllowChains || blockedByDenyChains) { + return true; + } + + if (!isNetworkMetered) return false; + if ((uidMatch & PENALTY_BOX_MATCH) != 0) return true; + if ((uidMatch & HAPPY_BOX_MATCH) != 0) return false; + return isDataSaverEnabled; } } diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java index ad76012f48..f44fd0e83c 100644 --- a/framework/src/android/net/ConnectivityManager.java +++ b/framework/src/android/net/ConnectivityManager.java @@ -16,6 +16,8 @@ package android.net; import static android.annotation.SystemApi.Client.MODULE_LIBRARIES; +import static android.content.pm.ApplicationInfo.FLAG_PERSISTENT; +import static android.content.pm.ApplicationInfo.FLAG_SYSTEM; import static android.net.NetworkCapabilities.NET_ENTERPRISE_ID_1; import static android.net.NetworkRequest.Type.BACKGROUND_REQUEST; import static android.net.NetworkRequest.Type.LISTEN; @@ -25,6 +27,8 @@ import static android.net.NetworkRequest.Type.TRACK_DEFAULT; import static android.net.NetworkRequest.Type.TRACK_SYSTEM_DEFAULT; import static android.net.QosCallback.QosCallbackRegistrationException; +import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE; + import android.annotation.CallbackExecutor; import android.annotation.FlaggedApi; import android.annotation.IntDef; @@ -37,12 +41,16 @@ import android.annotation.SdkConstant.SdkConstantType; import android.annotation.SuppressLint; import android.annotation.SystemApi; import android.annotation.SystemService; +import android.annotation.TargetApi; +import android.app.Application; import android.app.PendingIntent; import android.app.admin.DevicePolicyManager; import android.compat.annotation.UnsupportedAppUsage; +import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.net.ConnectivityDiagnosticsManager.DataStallReport.DetectionMethod; import android.net.IpSecManager.UdpEncapsulationSocket; import android.net.SocketKeepalive.Callback; @@ -74,6 +82,7 @@ import android.util.Range; import android.util.SparseIntArray; import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; import libcore.net.event.NetworkEventDispatcher; @@ -95,6 +104,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; /** * Class that answers queries about the state of network connectivity. It also @@ -6199,13 +6209,99 @@ public class ConnectivityManager { } /** - * Return whether the network is blocked for the given uid. + * Helper class to track data saver status. + * + * The class will fetch current data saver status from {@link NetworkPolicyManager} when + * initialized, and listening for status changed intent to cache the latest status. + * + * @hide + */ + @TargetApi(Build.VERSION_CODES.TIRAMISU) // RECEIVER_NOT_EXPORTED requires T. + @VisibleForTesting(visibility = PRIVATE) + public static class DataSaverStatusTracker extends BroadcastReceiver { + private static final Object sDataSaverStatusTrackerLock = new Object(); + + private static volatile DataSaverStatusTracker sInstance; + + /** + * Gets a static instance of the class. + * + * @param context A {@link Context} for initialization. Note that since the data saver + * status is global on a device, passing any context is equivalent. + * @return The static instance of a {@link DataSaverStatusTracker}. + */ + public static DataSaverStatusTracker getInstance(@NonNull Context context) { + if (sInstance == null) { + synchronized (sDataSaverStatusTrackerLock) { + if (sInstance == null) { + sInstance = new DataSaverStatusTracker(context); + } + } + } + return sInstance; + } + + private final NetworkPolicyManager mNpm; + // The value updates on the caller's binder thread or UI thread. + private final AtomicBoolean mIsDataSaverEnabled; + + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) + public DataSaverStatusTracker(final Context context) { + // To avoid leaks, take the application context. + final Context appContext; + if (context instanceof Application) { + appContext = context; + } else { + appContext = context.getApplicationContext(); + } + + if ((appContext.getApplicationInfo().flags & FLAG_PERSISTENT) == 0 + && (appContext.getApplicationInfo().flags & FLAG_SYSTEM) == 0) { + throw new IllegalStateException("Unexpected caller: " + + appContext.getApplicationInfo().packageName); + } + + mNpm = appContext.getSystemService(NetworkPolicyManager.class); + final IntentFilter filter = new IntentFilter( + ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED); + // The receiver should not receive broadcasts from other Apps. + appContext.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED); + mIsDataSaverEnabled = new AtomicBoolean(); + updateDataSaverEnabled(); + } + + // Runs on caller's UI thread. + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED)) { + updateDataSaverEnabled(); + } else { + throw new IllegalStateException("Unexpected intent " + intent); + } + } + + public boolean getDataSaverEnabled() { + return mIsDataSaverEnabled.get(); + } + + private void updateDataSaverEnabled() { + // Uid doesn't really matter, but use a fixed UID to make things clearer. + final int dataSaverForCallerUid = mNpm.getRestrictBackgroundStatus(Process.SYSTEM_UID); + mIsDataSaverEnabled.set(dataSaverForCallerUid + != ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED); + } + } + + /** + * Return whether the network is blocked for the given uid and metered condition. * * Similar to {@link NetworkPolicyManager#isUidNetworkingBlocked}, but directly reads the BPF * maps and therefore considerably faster. For use by the NetworkStack process only. * * @param uid The target uid. - * @return True if all networking is blocked. Otherwise, false. + * @param isNetworkMetered Whether the target network is metered. + * + * @return True if all networking with the given condition is blocked. Otherwise, false. * @throws IllegalStateException if the map cannot be opened. * @throws ServiceSpecificException if the read fails. * @hide @@ -6219,13 +6315,16 @@ public class ConnectivityManager { // @SystemApi(client = MODULE_LIBRARIES) @RequiresApi(Build.VERSION_CODES.TIRAMISU) // BPF maps were only mainlined in T @RequiresPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK) - public boolean isUidNetworkingBlocked(int uid) { + public boolean isUidNetworkingBlocked(int uid, boolean isNetworkMetered) { final BpfNetMapsReader reader = BpfNetMapsReader.getInstance(); - return reader.isUidBlockedByFirewallChains(uid); + final boolean isDataSaverEnabled; + // TODO: For U-QPR3+ devices, get data saver status from bpf configuration map directly. + final DataSaverStatusTracker dataSaverStatusTracker = + DataSaverStatusTracker.getInstance(mContext); + isDataSaverEnabled = dataSaverStatusTracker.getDataSaverEnabled(); - // TODO: If isNetworkMetered is true, check the data saver switch, penalty box - // and happy box rules. + return reader.isUidNetworkingBlocked(uid, isNetworkMetered, isDataSaverEnabled); } /** @hide */ diff --git a/tests/unit/java/android/net/BpfNetMapsReaderTest.kt b/tests/unit/java/android/net/BpfNetMapsReaderTest.kt index 86a0acbd80..258e4224f4 100644 --- a/tests/unit/java/android/net/BpfNetMapsReaderTest.kt +++ b/tests/unit/java/android/net/BpfNetMapsReaderTest.kt @@ -17,6 +17,8 @@ package android.net import android.net.BpfNetMapsConstants.DOZABLE_MATCH +import android.net.BpfNetMapsConstants.HAPPY_BOX_MATCH +import android.net.BpfNetMapsConstants.PENALTY_BOX_MATCH import android.net.BpfNetMapsConstants.STANDBY_MATCH import android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY import android.net.BpfNetMapsUtils.getMatchByFirewallChain @@ -36,6 +38,7 @@ import org.junit.runner.RunWith private const val TEST_UID1 = 1234 private const val TEST_UID2 = TEST_UID1 + 1 +private const val TEST_UID3 = TEST_UID2 + 1 private const val NO_IIF = 0 // pre-T devices does not support Bpf. @@ -101,23 +104,26 @@ class BpfNetMapsReaderTest { testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(newConfig)) } + fun isUidNetworkingBlocked(uid: Int, metered: Boolean = false, dataSaver: Boolean = false) = + bpfNetMapsReader.isUidNetworkingBlocked(uid, metered, dataSaver) + @Test fun testIsUidNetworkingBlockedByFirewallChains_allowChain() { // With everything disabled by default, verify the return value is false. testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0)) - assertFalse(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID1)) + assertFalse(isUidNetworkingBlocked(TEST_UID1)) // Enable dozable chain but does not provide allowed list. Verify the network is blocked // for all uids. mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, true) - assertTrue(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID1)) - assertTrue(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID2)) + assertTrue(isUidNetworkingBlocked(TEST_UID1)) + assertTrue(isUidNetworkingBlocked(TEST_UID2)) // Add uid1 to dozable allowed list. Verify the network is not blocked for uid1, while // uid2 is blocked. testUidOwnerMap.updateEntry(S32(TEST_UID1), UidOwnerValue(NO_IIF, DOZABLE_MATCH)) - assertFalse(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID1)) - assertTrue(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID2)) + assertFalse(isUidNetworkingBlocked(TEST_UID1)) + assertTrue(isUidNetworkingBlocked(TEST_UID2)) } @Test @@ -126,14 +132,14 @@ class BpfNetMapsReaderTest { // for all uids. testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0)) mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_STANDBY, true) - assertFalse(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID1)) - assertFalse(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID2)) + assertFalse(isUidNetworkingBlocked(TEST_UID1)) + assertFalse(isUidNetworkingBlocked(TEST_UID2)) // Add uid1 to standby allowed list. Verify the network is blocked for uid1, while // uid2 is not blocked. testUidOwnerMap.updateEntry(S32(TEST_UID1), UidOwnerValue(NO_IIF, STANDBY_MATCH)) - assertTrue(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID1)) - assertFalse(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID2)) + assertTrue(isUidNetworkingBlocked(TEST_UID1)) + assertFalse(isUidNetworkingBlocked(TEST_UID2)) } @Test @@ -143,6 +149,54 @@ class BpfNetMapsReaderTest { testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0)) mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_POWERSAVE, true) mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_STANDBY, true) - assertTrue(bpfNetMapsReader.isUidBlockedByFirewallChains(TEST_UID1)) + assertTrue(isUidNetworkingBlocked(TEST_UID1)) + } + + @IgnoreUpTo(VERSION_CODES.S_V2) + @Test + fun testIsUidNetworkingBlockedByDataSaver() { + // With everything disabled by default, verify the return value is false. + testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0)) + assertFalse(isUidNetworkingBlocked(TEST_UID1, metered = true)) + + // Add uid1 to penalty box, verify the network is blocked for uid1, while uid2 is not + // affected. + testUidOwnerMap.updateEntry(S32(TEST_UID1), UidOwnerValue(NO_IIF, PENALTY_BOX_MATCH)) + assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true)) + assertFalse(isUidNetworkingBlocked(TEST_UID2, metered = true)) + + // Enable data saver, verify the network is blocked for uid1, uid2, but uid3 in happy box + // is not affected. + testUidOwnerMap.updateEntry(S32(TEST_UID3), UidOwnerValue(NO_IIF, HAPPY_BOX_MATCH)) + assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true, dataSaver = true)) + assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true, dataSaver = true)) + assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true, dataSaver = true)) + + // Add uid1 to happy box as well, verify nothing is changed because penalty box has higher + // priority. + testUidOwnerMap.updateEntry( + S32(TEST_UID1), + UidOwnerValue(NO_IIF, PENALTY_BOX_MATCH or HAPPY_BOX_MATCH) + ) + assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true, dataSaver = true)) + assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true, dataSaver = true)) + assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true, dataSaver = true)) + + // Enable doze mode, verify uid3 is blocked even if it is in happy box. + mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, true) + assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true, dataSaver = true)) + assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true, dataSaver = true)) + assertTrue(isUidNetworkingBlocked(TEST_UID3, metered = true, dataSaver = true)) + + // Disable doze mode and data saver, only uid1 which is in penalty box is blocked. + mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, false) + assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true)) + assertFalse(isUidNetworkingBlocked(TEST_UID2, metered = true)) + assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true)) + + // Make the network non-metered, nothing is blocked. + assertFalse(isUidNetworkingBlocked(TEST_UID1)) + assertFalse(isUidNetworkingBlocked(TEST_UID2)) + assertFalse(isUidNetworkingBlocked(TEST_UID3)) } } diff --git a/tests/unit/java/android/net/ConnectivityManagerTest.java b/tests/unit/java/android/net/ConnectivityManagerTest.java index 45a9dbc4b3..b8c5447779 100644 --- a/tests/unit/java/android/net/ConnectivityManagerTest.java +++ b/tests/unit/java/android/net/ConnectivityManagerTest.java @@ -16,6 +16,13 @@ package android.net; +import static android.content.Context.RECEIVER_NOT_EXPORTED; +import static android.content.pm.ApplicationInfo.FLAG_PERSISTENT; +import static android.content.pm.ApplicationInfo.FLAG_SYSTEM; +import static android.net.ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED; +import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED; +import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED; +import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED; import static android.net.ConnectivityManager.TYPE_NONE; import static android.net.NetworkCapabilities.NET_CAPABILITY_CBS; import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN; @@ -39,6 +46,7 @@ import static android.net.NetworkRequest.Type.TRACK_SYSTEM_DEFAULT; import static com.android.testutils.MiscAsserts.assertThrows; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -51,6 +59,7 @@ import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.after; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; @@ -61,7 +70,10 @@ import static org.mockito.Mockito.when; import android.app.PendingIntent; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.ApplicationInfo; +import android.net.ConnectivityManager.DataSaverStatusTracker; import android.net.ConnectivityManager.NetworkCallback; import android.os.Build.VERSION_CODES; import android.os.Bundle; @@ -95,6 +107,7 @@ public class ConnectivityManagerTest { @Mock Context mCtx; @Mock IConnectivityManager mService; + @Mock NetworkPolicyManager mNpm; @Before public void setUp() { @@ -510,4 +523,54 @@ public class ConnectivityManagerTest { assertNull("ConnectivityManager weak reference still not null after " + attempts + " attempts", ref.get()); } + + @Test + public void testDataSaverStatusTracker() { + mockService(NetworkPolicyManager.class, Context.NETWORK_POLICY_SERVICE, mNpm); + // Mock proper application info. + doReturn(mCtx).when(mCtx).getApplicationContext(); + final ApplicationInfo mockAppInfo = new ApplicationInfo(); + mockAppInfo.flags = FLAG_PERSISTENT | FLAG_SYSTEM; + doReturn(mockAppInfo).when(mCtx).getApplicationInfo(); + // Enable data saver. + doReturn(RESTRICT_BACKGROUND_STATUS_ENABLED).when(mNpm) + .getRestrictBackgroundStatus(anyInt()); + + final DataSaverStatusTracker tracker = new DataSaverStatusTracker(mCtx); + // Verify the data saver status is correct right after initialization. + assertTrue(tracker.getDataSaverEnabled()); + + // Verify the tracker register receiver with expected intent filter. + final ArgumentCaptor intentFilterCaptor = + ArgumentCaptor.forClass(IntentFilter.class); + verify(mCtx).registerReceiver( + any(), intentFilterCaptor.capture(), eq(RECEIVER_NOT_EXPORTED)); + assertEquals(ACTION_RESTRICT_BACKGROUND_CHANGED, + intentFilterCaptor.getValue().getAction(0)); + + // Mock data saver status changed event and verify the tracker tracks the + // status accordingly. + doReturn(RESTRICT_BACKGROUND_STATUS_DISABLED).when(mNpm) + .getRestrictBackgroundStatus(anyInt()); + tracker.onReceive(mCtx, new Intent(ACTION_RESTRICT_BACKGROUND_CHANGED)); + assertFalse(tracker.getDataSaverEnabled()); + + doReturn(RESTRICT_BACKGROUND_STATUS_WHITELISTED).when(mNpm) + .getRestrictBackgroundStatus(anyInt()); + tracker.onReceive(mCtx, new Intent(ACTION_RESTRICT_BACKGROUND_CHANGED)); + assertTrue(tracker.getDataSaverEnabled()); + } + + private void mockService(Class clazz, String name, T service) { + doReturn(service).when(mCtx).getSystemService(name); + doReturn(name).when(mCtx).getSystemServiceName(clazz); + + // If the test suite uses the inline mock maker library, such as for coverage tests, + // then the final version of getSystemService must also be mocked, as the real + // method will not be called by the test and null object is returned since no mock. + // Otherwise, mocking a final method will fail the test. + if (mCtx.getSystemService(clazz) == null) { + doReturn(service).when(mCtx).getSystemService(clazz); + } + } }