Report satellite coarse usage

Test: atest ConnectivityCoverageTests:android.net.connectivity.com.android.metrics.SatelliteCoarseUsageMetricsCollectorTest \
      --update-device
Test: atest ConnectivityCoverageTests --test-filter 'CSTest*' --update-device
Test: atest ConnectivityCoverageTests --test-filter 'ConnectivityService*' --update-device
Test: Manual:
  1. Change code to use TRANSPORT_CELLULAR
  2. m statsd_testdrive && statsd_testdrive -e 1102
  3. Turn off mobile data.
Flag: EXEMPT controlled with kill switch
Bug: 419123367
Change-Id: I264efa6802612809fdbff22901f31447f370bbe5
This commit is contained in:
Junyu Lai 2025-05-28 17:03:02 +08:00
commit bb589cf33c
6 changed files with 515 additions and 0 deletions

View file

@ -164,6 +164,7 @@ java_library {
"framework-bluetooth.stubs.module_lib",
"framework-configinfrastructure.stubs.module_lib",
"framework-connectivity-pre-jarjar",
"framework-connectivity-t-pre-jarjar",
// The framework-connectivity-t library is only available on T+ platforms
// so any calls to it must be protected with a check to ensure that it is
// available. The linter will detect any unprotected calls through an API

View file

@ -0,0 +1,320 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.metrics;
import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
import static android.net.NetworkCapabilities.TRANSPORT_SATELLITE;
import static android.net.NetworkTemplate.MATCH_MOBILE;
import static com.android.net.module.util.FrameworkConnectivityStatsLog.CORE_NETWORKING_CRITICAL_BYTES_EVENT_OCCURRED;
import static com.android.net.module.util.FrameworkConnectivityStatsLog.CORE_NETWORKING_CRITICAL_BYTES_EVENT_OCCURRED__EVENT_TYPE__CRITICAL_BYTES_EVENT_TYPE_SATELLITE_COARSE_RX_USAGE;
import static com.android.net.module.util.FrameworkConnectivityStatsLog.CORE_NETWORKING_CRITICAL_BYTES_EVENT_OCCURRED__EVENT_TYPE__CRITICAL_BYTES_EVENT_TYPE_SATELLITE_COARSE_TX_USAGE;
import static java.util.concurrent.TimeUnit.HOURS;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.usage.NetworkStats;
import android.app.usage.NetworkStatsManager;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkRequest;
import android.net.NetworkTemplate;
import android.os.Handler;
import android.os.Process;
import android.os.SystemClock;
import android.util.Log;
import androidx.annotation.VisibleForTesting;
import com.android.internal.util.IndentingPrintWriter;
import com.android.modules.utils.BackgroundThread;
import com.android.modules.utils.build.SdkLevel;
import com.android.net.module.util.FrameworkConnectivityStatsLog;
import javax.annotation.CheckReturnValue;
/**
* Monitors satellite network connections to collect overall usage during active
* satellite sessions. It uses NetworkStatsManager to query snapshots upon
* connection and disconnection, then logs the difference.
*
* This class does not provide thread safety and should be only accessed on the same thread.
*/
public class SatelliteCoarseUsageMetricsCollector {
private static final String TAG = SatelliteCoarseUsageMetricsCollector.class.getSimpleName();
private static final NetworkTemplate SATELLITE_TEMPLATE;
static {
// To prevent NetworkTemplate#Builder not found error when
// initializing CSTest on S-based image.
if (SdkLevel.isAtLeastU()) {
SATELLITE_TEMPLATE = new NetworkTemplate.Builder(MATCH_MOBILE)
.setTransportTypes(new int[]{TRANSPORT_SATELLITE}).build();
} else {
SATELLITE_TEMPLATE = null;
}
}
/**
* The max bucket duration used when storing usage in NetworkStatsService.
* See NetworkStatsService#DefaultNetworkStatsSettings#getUidConfig.
*/
private static final long MAX_NETSTATS_BUCKET_DURATION_MS = HOURS.toMillis(2);
private ConnectivityManager mCm;
private NetworkStatsManager mNsm;
private final Context mContext;
private final Handler mHandler;
private final SatelliteNetworkCallback mNetworkCallback = new SatelliteNetworkCallback();
// Null when no satellite network is connected.
@Nullable
private MyStatsEntry mSatelliteBaseline = null;
private final MyStatsEntry mReportedUsage = new MyStatsEntry();
private final Dependencies mDeps;
// The start timestamp used to query the snapshots of data usage.
final long mStartTime;
/**
* Constructs a helper to monitor satellite network usage.
*
* @param context The context.
*/
public SatelliteCoarseUsageMetricsCollector(@NonNull Context context) {
this(context, new Dependencies());
}
/**
* Constructs a helper to monitor satellite network usage.
*
* @param context The context.
*/
public SatelliteCoarseUsageMetricsCollector(@NonNull Context context,
@NonNull Dependencies deps) {
mContext = context;
mDeps = deps;
mHandler = mDeps.getBackgroundThreadHandler();
mStartTime = getStartTimestamp();
}
/**
* Helper class for test injection.
*/
@VisibleForTesting
public static class Dependencies {
/**
* Reports the given network usage entry to system metrics.
*/
public void reportUsage(MyStatsEntry entry) {
FrameworkConnectivityStatsLog.write_non_chained(CORE_NETWORKING_CRITICAL_BYTES_EVENT_OCCURRED,
Process.SYSTEM_UID,
TAG,
CORE_NETWORKING_CRITICAL_BYTES_EVENT_OCCURRED__EVENT_TYPE__CRITICAL_BYTES_EVENT_TYPE_SATELLITE_COARSE_RX_USAGE,
entry.rxBytes);
FrameworkConnectivityStatsLog.write_non_chained(CORE_NETWORKING_CRITICAL_BYTES_EVENT_OCCURRED,
Process.SYSTEM_UID,
TAG,
CORE_NETWORKING_CRITICAL_BYTES_EVENT_OCCURRED__EVENT_TYPE__CRITICAL_BYTES_EVENT_TYPE_SATELLITE_COARSE_TX_USAGE,
entry.txBytes);
}
/**
* Queries the network stats summary for a given network template.
*/
@NonNull
public MyStatsEntry getSummary(@NonNull NetworkStatsManager nsm, long startTime) {
final MyStatsEntry ret = new MyStatsEntry();
final NetworkStats stats = nsm.querySummary(SATELLITE_TEMPLATE,
startTime, Long.MAX_VALUE);
// This null check simplifies testing by avoiding the need to mock
// the complex NetworkStats object. In production, this object
// is not expected to be null. See ConnectivityServiceIntegrationTest
// for context.
if (stats == null) {
return ret;
}
final NetworkStats.Bucket recycle = new NetworkStats.Bucket();
while (stats.hasNextBucket()) {
stats.getNextBucket(recycle);
ret.add(new MyStatsEntry(recycle));
}
return ret;
}
/** Get the background thread handler */
@NonNull
public Handler getBackgroundThreadHandler() {
return BackgroundThread.getHandler();
}
}
@VisibleForTesting
public static class MyStatsEntry {
public long rxBytes;
public long txBytes;
public MyStatsEntry() {
this.rxBytes = 0;
this.txBytes = 0;
}
public MyStatsEntry(@NonNull NetworkStats.Bucket bucket) {
this.rxBytes = bucket.getRxBytes();
this.txBytes = bucket.getTxBytes();
}
public MyStatsEntry(long rxBytes, long txBytes) {
this.rxBytes = rxBytes;
this.txBytes = txBytes;
}
/**
* Adds the stats from another {@link MyStatsEntry} to this entry.
*
* This method modifies the current object.
*/
public void add(@NonNull MyStatsEntry other) {
this.rxBytes += other.rxBytes;
this.txBytes += other.txBytes;
}
/**
* Subtracts the stats from another {@link MyStatsEntry} to this entry.
*
* This method modifies the current object.
*/
public void subtract(@NonNull MyStatsEntry other) {
this.rxBytes -= other.rxBytes;
this.txBytes -= other.txBytes;
}
/**
* Adds the stats from another {@link MyStatsEntry} from this entry.
*
* This method does not modify the current object but returns a new one.
*/
@CheckReturnValue
@NonNull
public MyStatsEntry plus(@NonNull MyStatsEntry other) {
return new MyStatsEntry(rxBytes + other.rxBytes, txBytes + other.txBytes);
}
/**
* Subtracts the stats from another {@link MyStatsEntry} from this entry.
*
* This method does not modify the current object but returns a new one.
*/
@CheckReturnValue
@NonNull
public MyStatsEntry minus(@NonNull MyStatsEntry other) {
return new MyStatsEntry(rxBytes - other.rxBytes, txBytes - other.txBytes);
}
@Override
public String toString() {
return "{rxBytes=" + rxBytes + ", txBytes=" + txBytes + "}";
}
}
private class SatelliteNetworkCallback extends ConnectivityManager.NetworkCallback {
/**
* Called when a satellite network is lost. Collects and reports the satellite coarse usage
* difference.
*
* Note that multiple satellite networks could occur, for example, when
* transitioning from one subscription to another. The amount of usage might
* be slightly inaccurate in such cases.
*
* @param network The Network object that was just lost.
*/
@Override
public void onLost(Network network) {
final MyStatsEntry snapshot = mDeps.getSummary(mNsm, mStartTime);
// Report diff with metrics.
final MyStatsEntry diff = snapshot.minus(mSatelliteBaseline);
mDeps.reportUsage(diff);
mReportedUsage.add(diff);
mSatelliteBaseline = snapshot;
Log.d(TAG, "onLost: Last satellite network " + network + ", Reported usage: " + diff);
}
}
// Calculate the start timestamp used for querying data usage snapshots.
// This timestamp needs to be early enough to ensure the query window
// starts at or before the beginning of the usage bucket in which the
// device boot event occurred. This is achieved by setting the start time
// to one bucket duration prior to the calculated bootTimeMillis.
//
// It is acceptable if this timestamp is not perfectly aligned with exact
// bucket boundaries or results in covering some redundant data from before
// the boot event. Any such duplicated or irrelevant data will be effectively
// subtracted out when calculating usage from the differences of two snapshots,
// provided both snapshots are queried using this same consistent start timestamp.
private long getStartTimestamp() {
final long elapsedMillisSinceBoot = SystemClock.elapsedRealtime();
final long currentTimeMillis = System.currentTimeMillis();
final long bootTimeMillis = currentTimeMillis - elapsedMillisSinceBoot;
return bootTimeMillis - MAX_NETSTATS_BUCKET_DURATION_MS;
}
/**
* Starts monitoring for satellite network connections.
*/
public void startMonitoring() {
if (!SdkLevel.isAtLeastU()) {
throw new UnsupportedOperationException(TAG + " is not supported below U");
}
mHandler.post(() -> handleStartMonitoring());
}
private void handleStartMonitoring() {
// Note that in the constructor it is too early to get the managers.
mCm = mContext.getSystemService(ConnectivityManager.class);
mNsm = mContext.getSystemService(NetworkStatsManager.class);
// Note: This might create heavy workload on NetworkStatsService if queried with high
// frequency, as the calling UID is system. Each query made with this manager
// updates usage stored in files before returning the newest result.
// This behavior is not rate-limited because the calling uid is the system UID.
mNsm.setPollOnOpen(true);
// Get the first baseline.
mSatelliteBaseline = mDeps.getSummary(mNsm, mStartTime);
final NetworkRequest satelliteRequest = new NetworkRequest.Builder()
.clearCapabilities()
.addCapability(NET_CAPABILITY_INTERNET)
.addTransportType(TRANSPORT_SATELLITE)
.build();
// The callback uses the shared background thread. Potential delays of
// dozens of seconds are acceptable because this class measures coarse
// usage after a connection is lost, so the metric's accuracy is not
// that sensitive to the timing.
// A dedicated thread would be needed if it turns out inaccurate.
mCm.registerNetworkCallback(satelliteRequest, mNetworkCallback, mHandler);
}
/** Dump info to dumpsys */
public void dump(@NonNull IndentingPrintWriter pw) {
pw.println(TAG + ":");
pw.increaseIndent();
pw.println("Reported usage " + mReportedUsage);
pw.decreaseIndent();
pw.println();
}
}

View file

@ -344,6 +344,7 @@ import com.android.metrics.NetworkList;
import com.android.metrics.NetworkRequestCount;
import com.android.metrics.RequestCountForType;
import com.android.metrics.SatelliteAccessInfo;
import com.android.metrics.SatelliteCoarseUsageMetricsCollector;
import com.android.modules.utils.BasicShellCommandHandler;
import com.android.modules.utils.build.SdkLevel;
import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
@ -1066,6 +1067,7 @@ public class ConnectivityService extends IConnectivityManager.Stub
private final NetworkNotificationManager mNotifier;
private final LingerMonitor mLingerMonitor;
private final SatelliteAccessController mSatelliteAccessController;
private final SatelliteCoarseUsageMetricsCollector mSatelliteCoarseUsageMetricsCollector;
private final L2capNetworkProvider mL2capNetworkProvider;
@ -1707,6 +1709,15 @@ public class ConnectivityService extends IConnectivityManager.Stub
connectivityServiceInternalHandler);
}
/**
* @see SatelliteCoarseUsageMetricsCollector
*/
@Nullable
public SatelliteCoarseUsageMetricsCollector makeSatelliteCoarseUsageMetricsCollector(
@NonNull final Context context) {
return new SatelliteCoarseUsageMetricsCollector(context);
}
/** Creates an L2capNetworkProvider */
public L2capNetworkProvider makeL2capNetworkProvider(Context context) {
return new L2capNetworkProvider(context);
@ -2090,6 +2101,12 @@ public class ConnectivityService extends IConnectivityManager.Stub
}
mConstrainedDataSatelliteMetrics = (mSatelliteAccessController != null)
&& mDeps.isFeatureNotChickenedOut(mContext, CONSTRAINED_DATA_SATELLITE_METRICS);
if (mConstrainedDataSatelliteMetrics) {
mSatelliteCoarseUsageMetricsCollector =
mDeps.makeSatelliteCoarseUsageMetricsCollector(mContext);
} else {
mSatelliteCoarseUsageMetricsCollector = null;
}
if (mDeps.flagConnectivityServiceDestroySocket()) {
mIpToNetworksMap = new HashMap<>();
@ -4363,6 +4380,10 @@ public class ConnectivityService extends IConnectivityManager.Stub
// forever, this replaces a synchronous call to PermissionMonitor#initialize, which
// could have blocked forever too.
permissionMonitorInitializeDone.block();
if (mConstrainedDataSatelliteMetrics) {
mSatelliteCoarseUsageMetricsCollector.startMonitoring();
}
}
/**
@ -4689,6 +4710,9 @@ public class ConnectivityService extends IConnectivityManager.Stub
if (mSatelliteAccessController != null) {
mSatelliteAccessController.dump(pw);
}
if (mConstrainedDataSatelliteMetrics) {
mSatelliteCoarseUsageMetricsCollector.dump(pw);
}
pw.println();
pw.println("Legacy network activity:");

View file

@ -0,0 +1,156 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.metrics
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.android.metrics.SatelliteCoarseUsageMetricsCollector.MyStatsEntry
import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
import com.android.testutils.DevSdkIgnoreRunner
import com.android.testutils.waitForIdle
import kotlin.test.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.Mockito.clearInvocations
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
/**
* Unit tests for [SatelliteCoarseUsageMetricsCollector].
* These tests mock Android system services and the custom Dependencies class
* to verify the behavior of the collector during satellite network events,
* using MockK for mocking and argument capturing.
*/
@RunWith(DevSdkIgnoreRunner::class)
@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
class SatelliteCoarseUsageMetricsCollectorTest {
companion object {
const val THREAD_BLOCK_TIMEOUT_MS = 1000L
}
private val mockCm = mock(ConnectivityManager::class.java)
private val mockNsm = mock(NetworkStatsManager::class.java)
private val mockCtx = mock(Context::class.java).also {
doReturn(mockCm).`when`(it).getSystemService(ConnectivityManager::class.java)
doReturn(mockNsm).`when`(it).getSystemService(NetworkStatsManager::class.java)
}
private val handler = Handler(Looper.getMainLooper())
private val mockDeps = mock(SatelliteCoarseUsageMetricsCollector.Dependencies::class.java)
.also {
doReturn(handler).`when`(it).backgroundThreadHandler
}
private val collector = SatelliteCoarseUsageMetricsCollector(mockCtx, mockDeps)
// Return the network callback for listening network changes.
private fun startMonitoring(): ConnectivityManager.NetworkCallback {
val cbCaptor = ArgumentCaptor.forClass(ConnectivityManager.NetworkCallback::class.java)
collector.startMonitoring()
handler.waitForIdle(THREAD_BLOCK_TIMEOUT_MS)
verify(mockCm).registerNetworkCallback(any(), cbCaptor.capture(), any())
return cbCaptor.value.also {
assertNotNull(it)
}
}
private fun mockGetSummary(rxBytes: Long, txBytes: Long) {
val statsEntry = MyStatsEntry(rxBytes, txBytes)
doReturn(statsEntry).`when`(mockDeps).getSummary(any(), anyLong())
}
private fun assertReportedUsage(rxBytes: Long, txBytes: Long) {
val reportedUsageCaptor = ArgumentCaptor.forClass(MyStatsEntry::class.java)
verify(mockDeps).reportUsage(reportedUsageCaptor.capture())
val reportedUsage = reportedUsageCaptor.value
assertEquals(rxBytes, reportedUsage.rxBytes)
assertEquals(txBytes, reportedUsage.txBytes)
}
@Test
fun testSingleNetwork() {
val mockNet = mock(Network::class.java)
mockGetSummary(100, 50)
val cb = startMonitoring()
mockGetSummary(300, 150)
cb.onLost(mockNet)
assertReportedUsage(200, 100)
}
// Test multiple satellite networks. This could happen when telephony creates
// satellite networks with different types of services (e.g., MMS, internet).
@Test
fun testMultipleNetworks() {
// Mock multiple networks, ensuring they are identified as distinct.
val mockNet1 = Network(1)
val mockNet2 = Network(2)
// Simulate baseline.
mockGetSummary(100, 50)
val cb = startMonitoring()
// Simulate onAvailable events, verify nothing happens.
cb.onAvailable(mockNet1)
cb.onAvailable(mockNet2)
verify(mockDeps, never()).reportUsage(any())
// Simulate first network lost (not the last), simulate no change at all,
// verify reportUsage is invoked with zero bytes.
cb.onLost(mockNet1)
assertReportedUsage(0L, 0L)
// Simulate second network lost (the last one).
clearInvocations(mockDeps)
mockGetSummary(300, 150)
cb.onLost(mockNet2)
assertReportedUsage(200L, 100L)
}
@Test
fun testMyStatsEntryPlusSubtract() {
val entry1 = MyStatsEntry(10, 20)
val entry2 = MyStatsEntry(5, 10)
val result = entry1.plus(entry2)
assertEquals(15L, result.rxBytes)
assertEquals(30L, result.txBytes)
entry1.subtract(entry2)
assertEquals(5L, entry1.rxBytes)
assertEquals(10L, entry1.txBytes)
}
@Test
fun testMyStatsEntryMinusAdd() {
val entry1 = MyStatsEntry(100, 200)
val entry2 = MyStatsEntry(10, 20)
val result = entry1.minus(entry2)
assertEquals(90L, result.rxBytes)
assertEquals(180L, result.txBytes)
entry1.add(entry2)
assertEquals(110L, entry1.rxBytes)
assertEquals(220L, entry1.txBytes)
}
}

View file

@ -391,6 +391,7 @@ import com.android.internal.net.VpnConfig;
import com.android.internal.util.WakeupMessage;
import com.android.internal.util.test.BroadcastInterceptingContext;
import com.android.internal.util.test.FakeSettingsProvider;
import com.android.metrics.SatelliteCoarseUsageMetricsCollector;
import com.android.modules.utils.build.SdkLevel;
import com.android.net.module.util.ArrayTrackRecord;
import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
@ -650,6 +651,7 @@ public class ConnectivityServiceTest {
@Mock SubscriptionManager mSubscriptionManager;
@Mock KeepaliveTracker.Dependencies mMockKeepaliveTrackerDependencies;
@Mock SatelliteAccessController mSatelliteAccessController;
@Mock SatelliteCoarseUsageMetricsCollector mSatelliteCoarseUsageMetricsCollector;
// BatteryStatsManager is final and cannot be mocked with regular mockito, so just mock the
// underlying binder calls.
@ -2093,6 +2095,12 @@ public class ConnectivityServiceTest {
return mSatelliteAccessController;
}
@Override
public SatelliteCoarseUsageMetricsCollector makeSatelliteCoarseUsageMetricsCollector(
@NonNull final Context context) {
return mSatelliteCoarseUsageMetricsCollector;
}
@Override
public boolean intentFilterEquals(final PendingIntent a, final PendingIntent b) {
return runAsShell(GET_INTENT_SENDER_INTENT, () -> a.intentFilterEquals(b));

View file

@ -66,6 +66,7 @@ import android.util.SparseArray
import androidx.test.platform.app.InstrumentationRegistry
import com.android.internal.app.IBatteryStats
import com.android.internal.util.test.BroadcastInterceptingContext
import com.android.metrics.SatelliteCoarseUsageMetricsCollector
import com.android.modules.utils.build.SdkLevel
import com.android.net.module.util.ArrayTrackRecord
import com.android.net.module.util.SharedLog
@ -233,6 +234,7 @@ open class CSTest {
val multicastRoutingCoordinatorService = mock<MulticastRoutingCoordinatorService>()
val satelliteAccessController = mock<SatelliteAccessController>()
val satelliteCoarseUsageMetricsCollector = mock<SatelliteCoarseUsageMetricsCollector>()
val quicConnectionCloser = mock<QuicConnectionCloser>()
val destroySocketsWrapper = mock<DestroySocketsWrapper>()
val dnsResolver = mock<IDnsResolver>()
@ -345,6 +347,10 @@ open class CSTest {
return satelliteAccessController
}
override fun makeSatelliteCoarseUsageMetricsCollector(
context: Context
) = satelliteCoarseUsageMetricsCollector
private inner class AOOKTDeps(c: Context) : AutomaticOnOffKeepaliveTracker.Dependencies(c) {
override fun isTetheringFeatureNotChickenedOut(name: String): Boolean {
return isFeatureEnabled(context, name)