summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apex/jobscheduler/service/aconfig/app_idle.aconfig11
-rw-r--r--apex/jobscheduler/service/java/com/android/server/usage/AppIdleHistory.java63
-rw-r--r--apex/jobscheduler/service/java/com/android/server/usage/AppStandbyController.java34
-rw-r--r--core/java/android/app/ActivityThread.java24
-rw-r--r--core/java/android/app/LoadedApk.java14
-rw-r--r--core/java/android/content/BroadcastReceiver.java4
-rw-r--r--core/java/android/content/pm/ActivityInfo.java2
-rw-r--r--core/java/android/os/PerfettoTrackEventExtra.java5
-rw-r--r--core/java/android/view/ImeBackAnimationController.java25
-rw-r--r--core/java/android/view/InsetsController.java8
-rw-r--r--core/java/android/widget/TextView.java10
-rw-r--r--core/java/android/window/flags/lse_desktop_experience.aconfig2
-rw-r--r--core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java3
-rw-r--r--core/java/com/android/internal/os/DEBUG_STORE_OWNERS2
-rw-r--r--core/java/com/android/internal/os/DebugStore.java134
-rw-r--r--core/java/com/android/internal/os/OWNERS1
-rw-r--r--core/res/res/drawable/ic_accessibility_autoclick.xml8
-rw-r--r--core/res/res/drawable/ic_accessibility_autoclick_foreground.xml17
-rw-r--r--core/res/res/values/colors.xml1
-rw-r--r--core/res/res/values/symbols.xml1
-rw-r--r--core/tests/coretests/src/com/android/internal/os/DebugStoreTest.java354
-rw-r--r--core/tests/coretests/src/com/android/internal/os/OWNERS1
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/docs/dagger.md66
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java1
-rw-r--r--packages/SystemUI/Android.bp1
-rw-r--r--packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt6
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt193
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt201
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeOverlay.kt31
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorTest.kt4
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepositoryTest.kt12
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt173
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt285
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt465
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt244
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt544
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt1228
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt1350
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt868
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt1046
-rw-r--r--packages/SystemUI/res/drawable/media_output_dialog_seekbar_background.xml14
-rw-r--r--packages/SystemUI/res/drawable/settingslib_track_off_background.xml1
-rw-r--r--packages/SystemUI/res/layout/media_output_list_item_advanced.xml2
-rw-r--r--packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt8
-rw-r--r--packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractor.kt7
-rw-r--r--packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepository.kt15
-rw-r--r--packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt2
-rw-r--r--packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogLogger.kt74
-rw-r--r--packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/dagger/AudioSharingModule.kt3
-rw-r--r--packages/SystemUI/src/com/android/systemui/brightness/ui/compose/BrightnessSlider.kt9
-rw-r--r--packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt96
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapterLegacy.java13
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt7
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/scroll/LongScreenshotActivity.java61
-rw-r--r--packages/SystemUI/src/com/android/systemui/settings/brightness/ComposeDialogComposableProvider.kt7
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt87
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt53
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt178
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt122
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt165
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt6
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt171
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt270
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt255
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt157
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt11
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt208
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt254
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt37
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt97
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt485
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt584
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt380
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt452
-rw-r--r--packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt22
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorTest.kt10
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt32
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt8
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt18
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt22
-rw-r--r--packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt22
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorKosmos.kt1
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/FakeAudioSharingRepository.kt2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt106
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt69
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt41
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt87
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt134
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt149
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt (renamed from packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt)7
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt22
-rw-r--r--services/accessibility/java/com/android/server/accessibility/gestures/TouchExplorer.java3
-rw-r--r--services/core/java/com/android/server/am/SettingsToPropertiesMapper.java1
-rw-r--r--services/core/java/com/android/server/appop/AppOpsUidStateTrackerImpl.java10
-rw-r--r--services/core/java/com/android/server/clipboard/OWNERS2
-rw-r--r--services/core/java/com/android/server/locksettings/recoverablekeystore/OWNERS1
-rw-r--r--services/core/java/com/android/server/security/advancedprotection/OWNERS1
-rw-r--r--services/core/java/com/android/server/wm/SurfaceAnimationRunner.java269
-rw-r--r--services/core/java/com/android/server/wm/WindowContainer.java1
-rw-r--r--services/core/java/com/android/server/wm/WindowManagerService.java2
-rw-r--r--services/core/jni/stats/OWNERS1
-rw-r--r--services/credentials/java/com/android/server/credentials/CredentialManagerService.java30
-rw-r--r--tests/Input/Android.bp1
-rw-r--r--tests/Input/src/com/android/server/input/InputManagerServiceTests.kt6
-rw-r--r--tests/Input/src/com/android/test/input/AnrTest.kt152
-rw-r--r--tests/Input/src/com/android/test/input/IAnrTestService.aidl17
-rw-r--r--tests/Input/src/com/android/test/input/UnresponsiveGestureMonitorActivity.kt25
109 files changed, 12328 insertions, 683 deletions
diff --git a/apex/jobscheduler/service/aconfig/app_idle.aconfig b/apex/jobscheduler/service/aconfig/app_idle.aconfig
index 74d2a590086f..899341016148 100644
--- a/apex/jobscheduler/service/aconfig/app_idle.aconfig
+++ b/apex/jobscheduler/service/aconfig/app_idle.aconfig
@@ -28,3 +28,14 @@ flag {
description: "Adjust the default bucket evaluation parameters"
bug: "379909479"
}
+
+flag {
+ name: "persist_restore_to_rare_apps_list"
+ namespace: "backstage_power"
+ description: "Persist the list of apps which are put in the RARE bucket upon restore."
+ is_fixed_read_only: true
+ bug: "383766428"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/usage/AppIdleHistory.java b/apex/jobscheduler/service/java/com/android/server/usage/AppIdleHistory.java
index a8641ae43509..4acfebc536eb 100644
--- a/apex/jobscheduler/service/java/com/android/server/usage/AppIdleHistory.java
+++ b/apex/jobscheduler/service/java/com/android/server/usage/AppIdleHistory.java
@@ -38,6 +38,7 @@ import android.app.usage.AppStandbyInfo;
import android.app.usage.UsageStatsManager;
import android.os.SystemClock;
import android.util.ArrayMap;
+import android.util.ArraySet;
import android.util.AtomicFile;
import android.util.IndentingPrintWriter;
import android.util.Slog;
@@ -83,6 +84,9 @@ public class AppIdleHistory {
private SparseArray<ArrayMap<String,AppUsageHistory>> mIdleHistory = new SparseArray<>();
private static final long ONE_MINUTE = 60 * 1000;
+ // Only keep the persisted restore-to-rare apps list for 2 days.
+ static final long RESTORE_TO_RARE_APPS_LIST_EXPIRY = ONE_MINUTE * 60 * 24 * 2;
+
static final int STANDBY_BUCKET_UNKNOWN = -1;
/**
@@ -277,6 +281,58 @@ public class AppIdleHistory {
writeScreenOnTime();
}
+ private File getRestoreToRareAppsListFile(int userId) {
+ return new File(getUserDirectory(userId), "restore_to_rare_apps_list");
+ }
+
+ public ArraySet<String> readRestoreToRareAppsList(int userId) {
+ File restoreToRareAppsListFile = getRestoreToRareAppsListFile(userId);
+ if (!restoreToRareAppsListFile.exists()) {
+ return null;
+ }
+
+ try (BufferedReader reader =
+ new BufferedReader(new FileReader(restoreToRareAppsListFile))) {
+ final ArraySet<String> appsList = new ArraySet<>();
+ final long restoreTime = Long.parseLong(reader.readLine());
+ if (System.currentTimeMillis() - restoreTime > RESTORE_TO_RARE_APPS_LIST_EXPIRY) {
+ // the apps list should only be kept around for 2 days
+ reader.close();
+ restoreToRareAppsListFile.delete();
+ return null;
+ }
+ String pkgName;
+ while ((pkgName = reader.readLine()) != null) {
+ appsList.add(pkgName);
+ }
+ return appsList;
+ } catch (IOException | NumberFormatException e) {
+ return null;
+ }
+ }
+
+ public void writeRestoreToRareAppsList(int userId, ArraySet<String> restoreAppsToRare) {
+ File fileHandle = getRestoreToRareAppsListFile(userId);
+ if (fileHandle.exists()) {
+ // don't update the persisted file - it should only be written once.
+ return;
+ }
+ AtomicFile restoreToRareAppsListFile = new AtomicFile(fileHandle);
+ FileOutputStream fos = null;
+ try {
+ fos = restoreToRareAppsListFile.startWrite();
+ final StringBuilder sb = new StringBuilder();
+ sb.append(System.currentTimeMillis()).append("\n");
+ for (String pkgName : restoreAppsToRare) {
+ sb.append(pkgName).append("\n");
+ }
+ fos.write(sb.toString().getBytes());
+ restoreToRareAppsListFile.finishWrite(fos);
+ } catch (IOException ioe) {
+ restoreToRareAppsListFile.failWrite(fos);
+ }
+ }
+
/**
* Mark the app as used and update the bucket if necessary. If there is a expiry time specified
* that's in the future, then the usage event is temporary and keeps the app in the specified
@@ -694,10 +750,13 @@ public class AppIdleHistory {
return appUsageHistory.bucketExpiryTimesMs.get(bucket, 0);
}
+ private File getUserDirectory(int userId) {
+ return new File(new File(mStorageDir, "users"), Integer.toString(userId));
+ }
+
@VisibleForTesting
File getUserFile(int userId) {
- return new File(new File(new File(mStorageDir, "users"),
- Integer.toString(userId)), APP_IDLE_FILENAME);
+ return new File(getUserDirectory(userId), APP_IDLE_FILENAME);
}
void clearLastUsedTimestamps(String packageName, int userId) {
diff --git a/apex/jobscheduler/service/java/com/android/server/usage/AppStandbyController.java b/apex/jobscheduler/service/java/com/android/server/usage/AppStandbyController.java
index ab8131ba5126..b87b5ceebd98 100644
--- a/apex/jobscheduler/service/java/com/android/server/usage/AppStandbyController.java
+++ b/apex/jobscheduler/service/java/com/android/server/usage/AppStandbyController.java
@@ -1706,10 +1706,18 @@ public class AppStandbyController
restoreAppToRare(packageName, userId, nowElapsed, reason);
}
// Clear out the list of restored apps that need to have their standby buckets adjusted
- // if they still haven't been installed eight hours after restore.
- // Note: if the device reboots within these first 8 hours, this list will be lost since it's
- // not persisted - this is the expected behavior for now and may be updated in the future.
- mHandler.postDelayed(() -> mAppsToRestoreToRare.remove(userId), 8 * ONE_HOUR);
+ // if they still haven't been installed two days after initial restore.
+ final long delayMillis = Flags.persistRestoreToRareAppsList()
+ ? AppIdleHistory.RESTORE_TO_RARE_APPS_LIST_EXPIRY : 8 * ONE_HOUR;
+ mHandler.postDelayed(() -> mAppsToRestoreToRare.remove(userId), delayMillis);
+
+ // Persist the file in case the device reboots within 2 days after the initial restore.
+ if (Flags.persistRestoreToRareAppsList()) {
+ synchronized (mAppIdleLock) {
+ mAppIdleHistory.writeRestoreToRareAppsList(
+ userId, mAppsToRestoreToRare.get(userId));
+ }
+ }
}
/** Adjust the standby bucket of the given package for the user to RARE. */
@@ -2272,6 +2280,22 @@ public class AppStandbyController
} else if (!Intent.ACTION_PACKAGE_ADDED.equals(action)) {
clearAppIdleForPackage(pkgName, userId);
} else {
+ // Do a lazy read of the persisted list, if necessary.
+ if (Flags.persistRestoreToRareAppsList()
+ && mAppsToRestoreToRare.get(userId) == null) {
+ synchronized (mAppIdleLock) {
+ final ArraySet<String> restoredApps =
+ mAppIdleHistory.readRestoreToRareAppsList(userId);
+ if (restoredApps != null) {
+ mAppsToRestoreToRare.addAll(userId, restoredApps);
+ // Clear out the list of restored apps if they still haven't been
+ // installed in two days - at worst, we are allowing for up to
+ // 4 days for reinstallation (device reboots just before 2 days)
+ mHandler.postDelayed(() -> mAppsToRestoreToRare.remove(userId),
+ AppIdleHistory.RESTORE_TO_RARE_APPS_LIST_EXPIRY);
+ }
+ }
+ }
// Package was just added and it's not being replaced.
if (mAppsToRestoreToRare.contains(userId, pkgName)) {
restoreAppToRare(pkgName, userId, mInjector.elapsedRealtime(),
@@ -2454,6 +2478,8 @@ public class AppStandbyController
+ ": " + Flags.avoidIdleCheck());
pw.println(" " + Flags.FLAG_ADJUST_DEFAULT_BUCKET_ELEVATION_PARAMS
+ ": " + Flags.adjustDefaultBucketElevationParams());
+ pw.println(" " + Flags.FLAG_PERSIST_RESTORE_TO_RARE_APPS_LIST
+ + ": " + Flags.persistRestoreToRareAppsList());
pw.println();
synchronized (mCarrierPrivilegedLock) {
diff --git a/core/java/android/app/ActivityThread.java b/core/java/android/app/ActivityThread.java
index c765298035e0..4c9116b02c1d 100644
--- a/core/java/android/app/ActivityThread.java
+++ b/core/java/android/app/ActivityThread.java
@@ -1137,12 +1137,19 @@ public final class ActivityThread extends ClientTransactionHandler
CompatibilityInfo compatInfo, int resultCode, String data, Bundle extras,
boolean ordered, boolean assumeDelivered, int sendingUser, int processState,
int sendingUid, String sendingPackage) {
+ long debugStoreId = -1;
+ if (DEBUG_STORE_ENABLED) {
+ debugStoreId = DebugStore.recordScheduleReceiver();
+ }
updateProcessState(processState, false);
ReceiverData r = new ReceiverData(intent, resultCode, data, extras,
ordered, false, assumeDelivered, mAppThread.asBinder(), sendingUser,
sendingUid, sendingPackage);
r.info = info;
sendMessage(H.RECEIVER, r);
+ if (DEBUG_STORE_ENABLED) {
+ DebugStore.recordEventEnd(debugStoreId);
+ }
}
public final void scheduleReceiverList(List<ReceiverInfo> info) throws RemoteException {
@@ -1490,6 +1497,10 @@ public final class ActivityThread extends ClientTransactionHandler
boolean sticky, boolean assumeDelivered, int sendingUser, int processState,
int sendingUid, String sendingPackage)
throws RemoteException {
+ long debugStoreId = -1;
+ if (DEBUG_STORE_ENABLED) {
+ debugStoreId = DebugStore.recordScheduleRegisteredReceiver();
+ }
updateProcessState(processState, false);
// We can't modify IIntentReceiver due to UnsupportedAppUsage, so
@@ -1514,6 +1525,9 @@ public final class ActivityThread extends ClientTransactionHandler
receiver.performReceive(intent, resultCode, dataStr, extras, ordered, sticky,
sendingUser);
}
+ if (DEBUG_STORE_ENABLED) {
+ DebugStore.recordEventEnd(debugStoreId);
+ }
}
@Override
@@ -2505,8 +2519,15 @@ public final class ActivityThread extends ClientTransactionHandler
switch (msg.what) {
case BIND_APPLICATION:
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "bindApplication");
+ if (DEBUG_STORE_ENABLED) {
+ debugStoreId =
+ DebugStore.recordHandleBindApplication();
+ }
AppBindData data = (AppBindData)msg.obj;
handleBindApplication(data);
+ if (DEBUG_STORE_ENABLED) {
+ DebugStore.recordEventEnd(debugStoreId);
+ }
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
break;
case EXIT_APPLICATION:
@@ -2529,7 +2550,8 @@ public final class ActivityThread extends ClientTransactionHandler
ReceiverData receiverData = (ReceiverData) msg.obj;
if (DEBUG_STORE_ENABLED) {
debugStoreId =
- DebugStore.recordBroadcastHandleReceiver(receiverData.intent);
+ DebugStore.recordBroadcastReceive(
+ receiverData.intent, System.identityHashCode(receiverData));
}
try {
diff --git a/core/java/android/app/LoadedApk.java b/core/java/android/app/LoadedApk.java
index ffd235f91e09..d0949ad86e04 100644
--- a/core/java/android/app/LoadedApk.java
+++ b/core/java/android/app/LoadedApk.java
@@ -61,6 +61,7 @@ import android.view.DisplayAdjustments;
import com.android.internal.R;
import com.android.internal.annotations.GuardedBy;
+import com.android.internal.os.DebugStore;
import com.android.internal.util.ArrayUtils;
import dalvik.system.BaseDexClassLoader;
@@ -107,6 +108,9 @@ public final class LoadedApk {
static final String TAG = "LoadedApk";
static final boolean DEBUG = false;
+ private static final boolean DEBUG_STORE_ENABLED =
+ com.android.internal.os.Flags.debugStoreEnabled();
+
@UnsupportedAppUsage
private final ActivityThread mActivityThread;
@UnsupportedAppUsage
@@ -1816,6 +1820,12 @@ public final class LoadedApk {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER,
"broadcastReceiveReg: " + intent.getAction());
}
+ long debugStoreId = -1;
+ if (DEBUG_STORE_ENABLED) {
+ debugStoreId =
+ DebugStore.recordBroadcastReceiveReg(
+ intent, System.identityHashCode(this));
+ }
try {
ClassLoader cl = mReceiver.getClass().getClassLoader();
@@ -1838,6 +1848,10 @@ public final class LoadedApk {
"Error receiving broadcast " + intent
+ " in " + mReceiver, e);
}
+ } finally {
+ if (DEBUG_STORE_ENABLED) {
+ DebugStore.recordEventEnd(debugStoreId);
+ }
}
if (receiver.getPendingResult() != null) {
diff --git a/core/java/android/content/BroadcastReceiver.java b/core/java/android/content/BroadcastReceiver.java
index cf684876d33f..357baa3c1099 100644
--- a/core/java/android/content/BroadcastReceiver.java
+++ b/core/java/android/content/BroadcastReceiver.java
@@ -263,7 +263,7 @@ public abstract class BroadcastReceiver {
1);
}
if (DEBUG_STORE_ENABLED) {
- DebugStore.recordFinish(mReceiverClassName);
+ DebugStore.recordFinish(System.identityHashCode(this));
}
if (mType == TYPE_COMPONENT) {
@@ -444,7 +444,7 @@ public abstract class BroadcastReceiver {
PendingResult res = mPendingResult;
mPendingResult = null;
if (DEBUG_STORE_ENABLED) {
- DebugStore.recordGoAsync(getClass().getName());
+ DebugStore.recordGoAsync(System.identityHashCode(res));
}
if (res != null && Trace.isTagEnabled(Trace.TRACE_TAG_ACTIVITY_MANAGER)) {
res.mReceiverClassName = getClass().getName();
diff --git a/core/java/android/content/pm/ActivityInfo.java b/core/java/android/content/pm/ActivityInfo.java
index fd59ea9d18fb..f61d695bbfc2 100644
--- a/core/java/android/content/pm/ActivityInfo.java
+++ b/core/java/android/content/pm/ActivityInfo.java
@@ -950,6 +950,8 @@ public class ActivityInfo extends ComponentInfo implements Parcelable {
CONFIG_COLOR_MODE,
CONFIG_FONT_SCALE,
CONFIG_GRAMMATICAL_GENDER,
+ CONFIG_FONT_WEIGHT_ADJUSTMENT,
+ CONFIG_WINDOW_CONFIGURATION,
CONFIG_ASSETS_PATHS,
CONFIG_RESOURCES_UNUSED,
})
diff --git a/core/java/android/os/PerfettoTrackEventExtra.java b/core/java/android/os/PerfettoTrackEventExtra.java
index 8a3a5be9c934..07b44a87ef88 100644
--- a/core/java/android/os/PerfettoTrackEventExtra.java
+++ b/core/java/android/os/PerfettoTrackEventExtra.java
@@ -674,6 +674,7 @@ public final class PerfettoTrackEventExtra {
/**
* Resets the track event extra.
*/
+ @android.ravenwood.annotation.RavenwoodReplace
public void reset() {
native_clear_args(mPtr);
mPendingPointers.clear();
@@ -1303,4 +1304,8 @@ public final class PerfettoTrackEventExtra {
// Tracing currently completely disabled under Ravenwood
return null;
}
+
+ private void reset$ravenwood() {
+ // Tracing currently completely disabled under Ravenwood
+ }
}
diff --git a/core/java/android/view/ImeBackAnimationController.java b/core/java/android/view/ImeBackAnimationController.java
index 19e0913fbc65..c39456a3b172 100644
--- a/core/java/android/view/ImeBackAnimationController.java
+++ b/core/java/android/view/ImeBackAnimationController.java
@@ -16,7 +16,11 @@
package android.view;
+import static android.view.InsetsController.ANIMATION_DURATION_SYNC_IME_MS;
+import static android.view.InsetsController.ANIMATION_DURATION_UNSYNC_IME_MS;
import static android.view.InsetsController.ANIMATION_TYPE_USER;
+import static android.view.InsetsController.FAST_OUT_LINEAR_IN_INTERPOLATOR;
+import static android.view.InsetsController.SYNC_IME_INTERPOLATOR;
import static android.view.WindowInsets.Type.ime;
import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN;
import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
@@ -56,8 +60,6 @@ public class ImeBackAnimationController implements OnBackAnimationCallback {
private static final Interpolator BACK_GESTURE = new BackGestureInterpolator();
private static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator(
0.05f, 0.7f, 0.1f, 1f);
- private static final Interpolator STANDARD_ACCELERATE = new PathInterpolator(0.3f, 0f, 1f, 1f);
-
private final InsetsController mInsetsController;
private final ViewRootImpl mViewRoot;
private WindowInsetsAnimationController mWindowInsetsAnimationController = null;
@@ -183,8 +185,21 @@ public class ImeBackAnimationController implements OnBackAnimationCallback {
float targetProgress = triggerBack ? 1f : 0f;
mPostCommitAnimator = ValueAnimator.ofFloat(
BACK_GESTURE.getInterpolation(mLastProgress) * PEEK_FRACTION, targetProgress);
- mPostCommitAnimator.setInterpolator(
- triggerBack ? STANDARD_ACCELERATE : EMPHASIZED_DECELERATE);
+ Interpolator interpolator;
+ long duration;
+ if (triggerBack && mViewRoot.mView.hasWindowInsetsAnimationCallback()
+ && mWindowInsetsAnimationController.getShownStateInsets().bottom != 0) {
+ interpolator = SYNC_IME_INTERPOLATOR;
+ duration = ANIMATION_DURATION_SYNC_IME_MS;
+ } else if (triggerBack) {
+ interpolator = FAST_OUT_LINEAR_IN_INTERPOLATOR;
+ duration = ANIMATION_DURATION_UNSYNC_IME_MS;
+ } else {
+ interpolator = EMPHASIZED_DECELERATE;
+ duration = POST_COMMIT_CANCEL_DURATION_MS;
+ }
+ mPostCommitAnimator.setInterpolator(interpolator);
+ mPostCommitAnimator.setDuration(duration);
mPostCommitAnimator.addUpdateListener(animation -> {
if (mWindowInsetsAnimationController != null) {
setInterpolatedProgress((float) animation.getAnimatedValue());
@@ -207,8 +222,6 @@ public class ImeBackAnimationController implements OnBackAnimationCallback {
reset();
}
});
- mPostCommitAnimator.setDuration(
- triggerBack ? POST_COMMIT_DURATION_MS : POST_COMMIT_CANCEL_DURATION_MS);
mPostCommitAnimator.start();
if (triggerBack) {
mInsetsController.setPredictiveBackImeHideAnimInProgress(true);
diff --git a/core/java/android/view/InsetsController.java b/core/java/android/view/InsetsController.java
index 462c5c630759..6f346bdae70a 100644
--- a/core/java/android/view/InsetsController.java
+++ b/core/java/android/view/InsetsController.java
@@ -235,8 +235,8 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
private static final int ANIMATION_DELAY_DIM_MS = 500;
- private static final int ANIMATION_DURATION_SYNC_IME_MS = 285;
- private static final int ANIMATION_DURATION_UNSYNC_IME_MS = 200;
+ static final int ANIMATION_DURATION_SYNC_IME_MS = 285;
+ static final int ANIMATION_DURATION_UNSYNC_IME_MS = 200;
private static final int PENDING_CONTROL_TIMEOUT_MS = 2000;
@@ -256,11 +256,11 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
return 1f - SYSTEM_BARS_ALPHA_INTERPOLATOR.getInterpolation(innerFraction);
}
};
- private static final Interpolator SYNC_IME_INTERPOLATOR =
+ static final Interpolator SYNC_IME_INTERPOLATOR =
new PathInterpolator(0.2f, 0f, 0f, 1f);
private static final Interpolator LINEAR_OUT_SLOW_IN_INTERPOLATOR =
new PathInterpolator(0, 0, 0.2f, 1f);
- private static final Interpolator FAST_OUT_LINEAR_IN_INTERPOLATOR =
+ static final Interpolator FAST_OUT_LINEAR_IN_INTERPOLATOR =
new PathInterpolator(0.4f, 0f, 1f, 1f);
/** Visible for WindowManagerWrapper */
diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java
index 5e828ba46df7..99fe0cbdca25 100644
--- a/core/java/android/widget/TextView.java
+++ b/core/java/android/widget/TextView.java
@@ -5211,11 +5211,7 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
*/
@Nullable
public String getFontVariationSettings() {
- if (Flags.typefaceRedesignReadonly()) {
- return mTextPaint.getFontVariationOverride();
- } else {
- return mTextPaint.getFontVariationSettings();
- }
+ return mTextPaint.getFontVariationSettings();
}
/**
@@ -5571,10 +5567,10 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
Math.clamp(400 + mFontWeightAdjustment,
FontStyle.FONT_WEIGHT_MIN, FontStyle.FONT_WEIGHT_MAX)));
}
- mTextPaint.setFontVariationOverride(
+ mTextPaint.setFontVariationSettings(
FontVariationAxis.toFontVariationSettings(axes));
} else {
- mTextPaint.setFontVariationOverride(fontVariationSettings);
+ mTextPaint.setFontVariationSettings(fontVariationSettings);
}
effective = true;
} else {
diff --git a/core/java/android/window/flags/lse_desktop_experience.aconfig b/core/java/android/window/flags/lse_desktop_experience.aconfig
index 2f2a09a9fac3..831a9dfc141b 100644
--- a/core/java/android/window/flags/lse_desktop_experience.aconfig
+++ b/core/java/android/window/flags/lse_desktop_experience.aconfig
@@ -816,7 +816,7 @@ flag {
name: "enable_taskbar_overflow"
namespace: "lse_desktop_experience"
description: "Show recent apps in the taskbar overflow."
- bug: "368119679"
+ bug: "375627272"
}
flag {
diff --git a/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java b/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java
index 6498c53a63e2..a2d6c2b51835 100644
--- a/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java
+++ b/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java
@@ -215,7 +215,6 @@ public final class AccessibilityTargetHelper {
Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED);
targets.add(colorInversion);
- // TODO(b/394683600): Update Icon with the autoclick asset.
final ToggleAllowListingFeatureTarget autoclick =
new ToggleAllowListingFeatureTarget(context,
shortcutType,
@@ -224,7 +223,7 @@ public final class AccessibilityTargetHelper {
AUTOCLICK_COMPONENT_NAME.flattenToString(),
uid,
context.getString(R.string.autoclick_feature_name),
- context.getDrawable(R.drawable.ic_accessibility_generic),
+ context.getDrawable(R.drawable.ic_accessibility_autoclick),
Settings.Secure.ACCESSIBILITY_AUTOCLICK_ENABLED);
targets.add(autoclick);
diff --git a/core/java/com/android/internal/os/DEBUG_STORE_OWNERS b/core/java/com/android/internal/os/DEBUG_STORE_OWNERS
new file mode 100644
index 000000000000..c8e22b70ff77
--- /dev/null
+++ b/core/java/com/android/internal/os/DEBUG_STORE_OWNERS
@@ -0,0 +1,2 @@
+benmiles@google.com
+mohamadmahmoud@google.com
diff --git a/core/java/com/android/internal/os/DebugStore.java b/core/java/com/android/internal/os/DebugStore.java
index 4c45feed8511..3dca786a82af 100644
--- a/core/java/com/android/internal/os/DebugStore.java
+++ b/core/java/com/android/internal/os/DebugStore.java
@@ -20,6 +20,7 @@ import android.annotation.Nullable;
import android.compat.annotation.UnsupportedAppUsage;
import android.content.Intent;
import android.content.pm.ServiceInfo;
+import android.util.Log;
import com.android.internal.annotations.VisibleForTesting;
@@ -43,6 +44,9 @@ import java.util.Objects;
* @hide
*/
public class DebugStore {
+ private static final boolean DEBUG_EVENTS = false;
+ private static final String TAG = "DebugStore";
+
private static DebugStoreNative sDebugStoreNative = new DebugStoreNativeImpl();
@UnsupportedAppUsage
@@ -120,7 +124,7 @@ public class DebugStore {
* @param receiverClassName The class name of the broadcast receiver.
*/
@UnsupportedAppUsage
- public static void recordGoAsync(String receiverClassName) {
+ public static void recordGoAsync(int pendingResultId) {
sDebugStoreNative.recordEvent(
"GoAsync",
List.of(
@@ -128,8 +132,8 @@ public class DebugStore {
Thread.currentThread().getName(),
"tid",
String.valueOf(Thread.currentThread().getId()),
- "rcv",
- Objects.toString(receiverClassName)));
+ "prid",
+ Integer.toHexString(pendingResultId)));
}
/**
@@ -138,7 +142,7 @@ public class DebugStore {
* @param receiverClassName The class of the broadcast receiver that completed the operation.
*/
@UnsupportedAppUsage
- public static void recordFinish(String receiverClassName) {
+ public static void recordFinish(int pendingResultId) {
sDebugStoreNative.recordEvent(
"Finish",
List.of(
@@ -146,9 +150,10 @@ public class DebugStore {
Thread.currentThread().getName(),
"tid",
String.valueOf(Thread.currentThread().getId()),
- "rcv",
- Objects.toString(receiverClassName)));
+ "prid",
+ Integer.toHexString(pendingResultId)));
}
+
/**
* Records the completion of a long-running looper message.
*
@@ -172,21 +177,92 @@ public class DebugStore {
/**
- * Records the reception of a broadcast.
+ * Records the reception of a broadcast by a manifest-declared receiver.
*
* @param intent The Intent associated with the broadcast.
* @return A unique ID for the recorded event.
*/
@UnsupportedAppUsage
- public static long recordBroadcastHandleReceiver(@Nullable Intent intent) {
+ public static long recordBroadcastReceive(@Nullable Intent intent, int pendingResultId) {
+ return sDebugStoreNative.beginEvent(
+ "BcRcv",
+ List.of(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()),
+ "act",
+ Objects.toString(intent != null ? intent.getAction() : null),
+ "cmp",
+ Objects.toString(intent != null ? intent.getComponent() : null),
+ "pkg",
+ Objects.toString(intent != null ? intent.getPackage() : null),
+ "prid",
+ Integer.toHexString(pendingResultId)));
+ }
+
+ /**
+ * Records the reception of a broadcast by a context-registered receiver.
+ *
+ * @param intent The Intent associated with the broadcast.
+ * @param pendingResultId The object ID of the PendingResult associated with the broadcast.
+ * @return A unique ID for the recorded event.
+ */
+ @UnsupportedAppUsage
+ public static long recordBroadcastReceiveReg(@Nullable Intent intent, int pendingResultId) {
+ return sDebugStoreNative.beginEvent(
+ "BcRcvReg",
+ List.of(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()),
+ "act",
+ Objects.toString(intent != null ? intent.getAction() : null),
+ "cmp",
+ Objects.toString(intent != null ? intent.getComponent() : null),
+ "pkg",
+ Objects.toString(intent != null ? intent.getPackage() : null),
+ "prid",
+ Integer.toHexString(pendingResultId)));
+ }
+
+ /**
+ * Records the binding of an application.
+ *
+ * @return A unique ID for the recorded event.
+ */
+ @UnsupportedAppUsage
+ public static long recordHandleBindApplication() {
+ return sDebugStoreNative.beginEvent("BindApp", List.of());
+ }
+
+ /**
+ * Records the scheduling of a receiver.
+ *
+ * @return A unique ID for the recorded event.
+ */
+ @UnsupportedAppUsage
+ public static long recordScheduleReceiver() {
return sDebugStoreNative.beginEvent(
- "HandleReceiver",
+ "SchRcv",
List.of(
"tname", Thread.currentThread().getName(),
- "tid", String.valueOf(Thread.currentThread().getId()),
- "act", Objects.toString(intent != null ? intent.getAction() : null),
- "cmp", Objects.toString(intent != null ? intent.getComponent() : null),
- "pkg", Objects.toString(intent != null ? intent.getPackage() : null)));
+ "tid", String.valueOf(Thread.currentThread().getId())));
+ }
+
+ /**
+ * Records the scheduling of a registered receiver.
+ *
+ * @return A unique ID for the recorded event.
+ */
+ @UnsupportedAppUsage
+ public static long recordScheduleRegisteredReceiver() {
+ return sDebugStoreNative.beginEvent(
+ "SchRcvReg",
+ List.of(
+ "tname", Thread.currentThread().getName(),
+ "tid", String.valueOf(Thread.currentThread().getId())));
}
/**
@@ -225,18 +301,48 @@ public class DebugStore {
private static class DebugStoreNativeImpl implements DebugStoreNative {
@Override
public long beginEvent(String eventName, List<String> attributes) {
- return DebugStore.beginEventNative(eventName, attributes);
+ long id = DebugStore.beginEventNative(eventName, attributes);
+ if (DEBUG_EVENTS) {
+ Log.i(
+ TAG,
+ "beginEvent: " + id + " " + eventName + " " + attributeString(attributes));
+ }
+ return id;
}
@Override
public void endEvent(long id, List<String> attributes) {
+ if (DEBUG_EVENTS) {
+ Log.i(TAG, "endEvent: " + id + " " + attributeString(attributes));
+ }
DebugStore.endEventNative(id, attributes);
}
@Override
public void recordEvent(String eventName, List<String> attributes) {
+ if (DEBUG_EVENTS) {
+ Log.i(TAG, "recordEvent: " + eventName + " " + attributeString(attributes));
+ }
DebugStore.recordEventNative(eventName, attributes);
}
+
+ /**
+ * Returns a string like "[key1=foo, key2=bar]"
+ */
+ private String attributeString(List<String> attributes) {
+ StringBuilder sb = new StringBuilder().append("[");
+
+ for (int i = 0; i < attributes.size(); i++) {
+ sb.append(attributes.get(i));
+
+ if (i % 2 == 0) {
+ sb.append("=");
+ } else if (i < attributes.size() - 1) {
+ sb.append(", ");
+ }
+ }
+ return sb.append("]").toString();
+ }
}
private static native long beginEventNative(String eventName, List<String> attributes);
diff --git a/core/java/com/android/internal/os/OWNERS b/core/java/com/android/internal/os/OWNERS
index ffd4499f929a..a5aa9618db32 100644
--- a/core/java/com/android/internal/os/OWNERS
+++ b/core/java/com/android/internal/os/OWNERS
@@ -2,6 +2,7 @@ per-file *Power* = file:/services/core/java/com/android/server/power/OWNERS
per-file *Zygote* = file:/ZYGOTE_OWNERS
per-file *Cpu* = file:CPU_OWNERS
per-file *Binder* = file:BINDER_OWNERS
+per-file *DebugStore* = file:DEBUG_STORE_OWNERS
per-file *BinaryTransparency* = file:/core/java/android/transparency/OWNERS
# BatteryStats
diff --git a/core/res/res/drawable/ic_accessibility_autoclick.xml b/core/res/res/drawable/ic_accessibility_autoclick.xml
new file mode 100644
index 000000000000..44d34d33e98e
--- /dev/null
+++ b/core/res/res/drawable/ic_accessibility_autoclick.xml
@@ -0,0 +1,8 @@
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/accessibility_autoclick_background" />
+ <foreground>
+ <inset
+ android:drawable="@drawable/ic_accessibility_autoclick_foreground"
+ android:inset="@dimen/accessibility_icon_foreground_padding_ratio" />
+ </foreground>
+</adaptive-icon>
diff --git a/core/res/res/drawable/ic_accessibility_autoclick_foreground.xml b/core/res/res/drawable/ic_accessibility_autoclick_foreground.xml
new file mode 100644
index 000000000000..0a76a1b989e0
--- /dev/null
+++ b/core/res/res/drawable/ic_accessibility_autoclick_foreground.xml
@@ -0,0 +1,17 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="40dp"
+ android:height="41dp"
+ android:viewportWidth="40"
+ android:viewportHeight="41">
+
+ <path
+ android:fillColor="#67D4FF"
+ android:pathData="M0 20.3789C0 9.33321 8.95431 0.378906 20 0.378906C31.0457 0.378906 40 9.33321 40 20.3789C40 31.4246 31.0457 40.3789 20 40.3789C8.95431 40.3789 0 31.4246 0 20.3789Z" />
+ <path
+ android:fillColor="#04409F"
+ android:pathData="M28.9564 32.0587L24.0973 27.1996L22.6765 31.4904L19.2666 20.124L30.633 23.5339L26.3422 24.9547L31.2013 29.8138L28.9564 32.0587Z" />
+ <path
+ android:fillColor="#04409F"
+ android:fillType="evenOdd"
+ android:pathData="M8.01596 20.8918C8.10753 22.8222 8.63239 24.6433 9.60868 26.3343C10.9014 28.5733 12.8274 30.221 15.1666 31.2713C16.9169 32.0453 18.7173 32.3866 20.5679 32.2953L19.9158 30.1502C18.5615 30.1389 17.2456 29.8364 15.9682 29.2427C14.0941 28.3832 12.6373 27.0532 11.5977 25.2526C10.7841 23.8434 10.3491 22.3305 10.2925 20.7139C10.2607 19.104 10.2783 17.806 10.9916 16.3295C10.9916 16.3295 11.4971 15.2881 11.6974 14.9932C11.8978 14.6983 12.7644 13.5882 12.7644 13.5882L11.3665 11.8494C11.3665 11.8494 10.6288 12.6605 10.291 13.1593C10.1116 13.4242 9.64185 14.1332 9.64185 14.1332L9.07295 15.2304C8.24895 17.0214 7.92073 18.8842 8.01596 20.8918ZM31.9633 21.2755L29.8026 20.6995C29.8208 20.1549 29.797 19.6129 29.7312 19.0733C29.6285 18.1723 29.3858 17.2999 29.0031 16.4562L30.8819 15.3714C31.3937 16.4747 31.7295 17.6169 31.8895 18.798C32.0056 19.6202 32.0302 20.4461 31.9633 21.2755ZM18.4931 8.55776C17.312 8.71775 16.1653 9.04578 15.053 9.54184L16.1513 11.4442C16.995 11.0615 17.8674 10.8188 18.7684 10.7161C19.6851 10.6043 20.6091 10.6137 21.5403 10.7441L22.1061 8.63249C20.8942 8.41364 19.6899 8.38872 18.4931 8.55776ZM24.1807 9.18837L23.6149 11.3C24.4865 11.6526 25.2869 12.0987 26.0158 12.6382C26.7448 13.1776 27.379 13.824 27.9183 14.5773L29.7972 13.4925C29.1223 12.5043 28.3055 11.6502 27.347 10.9302C26.4042 10.2011 25.3487 9.62046 24.1807 9.18837Z" />
+</vector>
diff --git a/core/res/res/values/colors.xml b/core/res/res/values/colors.xml
index 4e93c44e44bd..bf1423bda7e4 100644
--- a/core/res/res/values/colors.xml
+++ b/core/res/res/values/colors.xml
@@ -557,6 +557,7 @@
<color name="accessibility_magnification_background">#F50D60</color>
<color name="accessibility_daltonizer_background">#00BCD4</color>
<color name="accessibility_color_inversion_background">#546E7A</color>
+ <color name="accessibility_autoclick_background">#67D4FF</color>
<!-- Fullscreen magnification thumbnail color -->
<color name="accessibility_magnification_thumbnail_stroke_color">#0C0C0C</color>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index c8c1e73bfba2..a18c1d4df98b 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -5652,6 +5652,7 @@
<java-symbol type="id" name="accessibility_autoclick_position_button" />
<java-symbol type="drawable" name="accessibility_autoclick_pause" />
<java-symbol type="drawable" name="accessibility_autoclick_resume" />
+ <java-symbol type="drawable" name="ic_accessibility_autoclick" />
<!-- For HapticFeedbackConstants configurability defined at HapticFeedbackCustomization -->
<java-symbol type="string" name="config_hapticFeedbackCustomizationFile" />
diff --git a/core/tests/coretests/src/com/android/internal/os/DebugStoreTest.java b/core/tests/coretests/src/com/android/internal/os/DebugStoreTest.java
index 786c2fc63018..bc452e7689e0 100644
--- a/core/tests/coretests/src/com/android/internal/os/DebugStoreTest.java
+++ b/core/tests/coretests/src/com/android/internal/os/DebugStoreTest.java
@@ -47,21 +47,17 @@ import java.util.List;
/**
* Test class for {@link DebugStore}.
*
- * To run it:
- * atest FrameworksCoreTests:com.android.internal.os.DebugStoreTest
+ * <p>To run it: atest FrameworksCoreTests:com.android.internal.os.DebugStoreTest
*/
@RunWith(AndroidJUnit4.class)
@DisabledOnRavenwood(blockedBy = DebugStore.class)
@SmallTest
public class DebugStoreTest {
- @Rule
- public final RavenwoodRule mRavenwood = new RavenwoodRule();
+ @Rule public final RavenwoodRule mRavenwood = new RavenwoodRule();
- @Mock
- private DebugStore.DebugStoreNative mDebugStoreNativeMock;
+ @Mock private DebugStore.DebugStoreNative mDebugStoreNativeMock;
- @Captor
- private ArgumentCaptor<List<String>> mListCaptor;
+ @Captor private ArgumentCaptor<List<String>> mListCaptor;
@Before
public void setUp() {
@@ -79,16 +75,14 @@ public class DebugStoreTest {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(1L);
long eventId = DebugStore.recordServiceOnStart(1, 0, intent);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("SvcStart"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "stId", "1",
- "flg", "0",
- "act", "com.android.ACTION",
- "comp", "ComponentInfo{com.android/androidService}",
- "pkg", "com.android"
- ).inOrder();
+ assertThat(paramsForBeginEvent("SvcStart"))
+ .containsExactly(
+ "stId", "1",
+ "flg", "0",
+ "act", "com.android.ACTION",
+ "comp", "ComponentInfo{com.android/androidService}",
+ "pkg", "com.android")
+ .inOrder();
assertThat(eventId).isEqualTo(1L);
}
@@ -101,13 +95,11 @@ public class DebugStoreTest {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(2L);
long eventId = DebugStore.recordServiceCreate(serviceInfo);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("SvcCreate"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "name", "androidService",
- "pkg", "com.android"
- ).inOrder();
+ assertThat(paramsForBeginEvent("SvcCreate"))
+ .containsExactly(
+ "name", "androidService",
+ "pkg", "com.android")
+ .inOrder();
assertThat(eventId).isEqualTo(2L);
}
@@ -121,59 +113,60 @@ public class DebugStoreTest {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(3L);
long eventId = DebugStore.recordServiceBind(true, intent);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("SvcBind"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "rebind", "true",
- "act", "com.android.ACTION",
- "cmp", "ComponentInfo{com.android/androidService}",
- "pkg", "com.android"
- ).inOrder();
+ assertThat(paramsForBeginEvent("SvcBind"))
+ .containsExactly(
+ "rebind", "true",
+ "act", "com.android.ACTION",
+ "cmp", "ComponentInfo{com.android/androidService}",
+ "pkg", "com.android")
+ .inOrder();
assertThat(eventId).isEqualTo(3L);
}
@Test
public void testRecordGoAsync() {
- DebugStore.recordGoAsync("androidReceiver");
-
- verify(mDebugStoreNativeMock).recordEvent(eq("GoAsync"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "tname", Thread.currentThread().getName(),
- "tid", String.valueOf(Thread.currentThread().getId()),
- "rcv", "androidReceiver"
- ).inOrder();
+ DebugStore.recordGoAsync(3840 /* 0xf00 */);
+
+ assertThat(paramsForRecordEvent("GoAsync"))
+ .containsExactly(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()),
+ "prid",
+ "f00")
+ .inOrder();
}
@Test
public void testRecordFinish() {
- DebugStore.recordFinish("androidReceiver");
-
- verify(mDebugStoreNativeMock).recordEvent(eq("Finish"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "tname", Thread.currentThread().getName(),
- "tid", String.valueOf(Thread.currentThread().getId()),
- "rcv", "androidReceiver"
- ).inOrder();
+ DebugStore.recordFinish(3840 /* 0xf00 */);
+
+ assertThat(paramsForRecordEvent("Finish"))
+ .containsExactly(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()),
+ "prid",
+ "f00")
+ .inOrder();
}
@Test
public void testRecordLongLooperMessage() {
DebugStore.recordLongLooperMessage(100, "androidHandler", 500L);
- verify(mDebugStoreNativeMock).recordEvent(eq("LooperMsg"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "code", "100",
- "trgt", "androidHandler",
- "elapsed", "500"
- ).inOrder();
+ assertThat(paramsForRecordEvent("LooperMsg"))
+ .containsExactly(
+ "code", "100",
+ "trgt", "androidHandler",
+ "elapsed", "500")
+ .inOrder();
}
@Test
- public void testRecordBroadcastHandleReceiver() {
+ public void testRecordBroadcastReceive() {
Intent intent = new Intent();
intent.setAction("com.android.ACTION");
intent.setComponent(new ComponentName("com.android", "androidReceiver"));
@@ -181,21 +174,87 @@ public class DebugStoreTest {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(4L);
- long eventId = DebugStore.recordBroadcastHandleReceiver(intent);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("HandleReceiver"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "tname", Thread.currentThread().getName(),
- "tid", String.valueOf(Thread.currentThread().getId()),
- "act", "com.android.ACTION",
- "cmp", "ComponentInfo{com.android/androidReceiver}",
- "pkg", "com.android"
- ).inOrder();
+ long eventId = DebugStore.recordBroadcastReceive(intent, 3840 /* 0xf00 */);
+ assertThat(paramsForBeginEvent("BcRcv"))
+ .containsExactly(
+ "tname", Thread.currentThread().getName(),
+ "tid", String.valueOf(Thread.currentThread().getId()),
+ "act", "com.android.ACTION",
+ "cmp", "ComponentInfo{com.android/androidReceiver}",
+ "pkg", "com.android",
+ "prid", "f00")
+ .inOrder();
assertThat(eventId).isEqualTo(4L);
}
@Test
+ public void testRecordBroadcastReceiveReg() {
+ Intent intent = new Intent();
+ intent.setAction("com.android.ACTION");
+ intent.setComponent(new ComponentName("com.android", "androidReceiver"));
+ intent.setPackage("com.android");
+
+ when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(5L);
+
+ long eventId = DebugStore.recordBroadcastReceiveReg(intent, 3840 /* 0xf00 */);
+ assertThat(paramsForBeginEvent("BcRcvReg"))
+ .containsExactly(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()),
+ "act",
+ "com.android.ACTION",
+ "cmp",
+ "ComponentInfo{com.android/androidReceiver}",
+ "pkg",
+ "com.android",
+ "prid",
+ "f00")
+ .inOrder();
+ assertThat(eventId).isEqualTo(5L);
+ }
+
+ @Test
+ public void testRecordHandleBindApplication() {
+ when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(6L);
+ long eventId = DebugStore.recordHandleBindApplication();
+
+ assertThat(paramsForBeginEvent("BindApp")).isEmpty();
+ assertThat(eventId).isEqualTo(6L);
+ }
+
+ @Test
+ public void testRecordScheduleReceiver() {
+ when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(7L);
+ long eventId = DebugStore.recordScheduleReceiver();
+
+ assertThat(paramsForBeginEvent("SchRcv"))
+ .containsExactly(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()))
+ .inOrder();
+ assertThat(eventId).isEqualTo(7L);
+ }
+
+ @Test
+ public void testRecordScheduleRegisteredReceiver() {
+ when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(8L);
+ long eventId = DebugStore.recordScheduleRegisteredReceiver();
+
+ assertThat(paramsForBeginEvent("SchRcvReg"))
+ .containsExactly(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()))
+ .inOrder();
+ assertThat(eventId).isEqualTo(8L);
+ }
+
+ @Test
public void testRecordEventEnd() {
DebugStore.recordEventEnd(1L);
@@ -203,109 +262,124 @@ public class DebugStoreTest {
}
@Test
- public void testRecordServiceOnStartWithNullIntent() {
+ public void testRecordServiceOnStart_withNullIntent() {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(5L);
long eventId = DebugStore.recordServiceOnStart(1, 0, null);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("SvcStart"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "stId", "1",
- "flg", "0",
- "act", "null",
- "comp", "null",
- "pkg", "null"
- ).inOrder();
+ assertThat(paramsForBeginEvent("SvcStart"))
+ .containsExactly(
+ "stId", "1",
+ "flg", "0",
+ "act", "null",
+ "comp", "null",
+ "pkg", "null")
+ .inOrder();
assertThat(eventId).isEqualTo(5L);
}
@Test
- public void testRecordServiceCreateWithNullServiceInfo() {
+ public void testRecordServiceCreate_withNullServiceInfo() {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(6L);
long eventId = DebugStore.recordServiceCreate(null);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("SvcCreate"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "name", "null",
- "pkg", "null"
- ).inOrder();
+ assertThat(paramsForBeginEvent("SvcCreate"))
+ .containsExactly(
+ "name", "null",
+ "pkg", "null")
+ .inOrder();
assertThat(eventId).isEqualTo(6L);
}
@Test
- public void testRecordServiceBindWithNullIntent() {
+ public void testRecordServiceBind_withNullIntent() {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(7L);
long eventId = DebugStore.recordServiceBind(false, null);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("SvcBind"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "rebind", "false",
- "act", "null",
- "cmp", "null",
- "pkg", "null"
- ).inOrder();
+ assertThat(paramsForBeginEvent("SvcBind"))
+ .containsExactly(
+ "rebind", "false",
+ "act", "null",
+ "cmp", "null",
+ "pkg", "null")
+ .inOrder();
assertThat(eventId).isEqualTo(7L);
}
@Test
- public void testRecordBroadcastHandleReceiverWithNullIntent() {
+ public void testRecordBroadcastReceive_withNullIntent() {
when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(8L);
- long eventId = DebugStore.recordBroadcastHandleReceiver(null);
-
- verify(mDebugStoreNativeMock).beginEvent(eq("HandleReceiver"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "tname", Thread.currentThread().getName(),
- "tid", String.valueOf(Thread.currentThread().getId()),
- "act", "null",
- "cmp", "null",
- "pkg", "null"
- ).inOrder();
+ long eventId = DebugStore.recordBroadcastReceive(null, 3840 /* 0xf00 */);
+ assertThat(paramsForBeginEvent("BcRcv"))
+ .containsExactly(
+ "tname", Thread.currentThread().getName(),
+ "tid", String.valueOf(Thread.currentThread().getId()),
+ "act", "null",
+ "cmp", "null",
+ "pkg", "null",
+ "prid", "f00")
+ .inOrder();
assertThat(eventId).isEqualTo(8L);
}
@Test
- public void testRecordGoAsyncWithNullReceiverClassName() {
- DebugStore.recordGoAsync(null);
-
- verify(mDebugStoreNativeMock).recordEvent(eq("GoAsync"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "tname", Thread.currentThread().getName(),
- "tid", String.valueOf(Thread.currentThread().getId()),
- "rcv", "null"
- ).inOrder();
+ public void testRecordBroadcastReceiveReg_withNullIntent() {
+ when(mDebugStoreNativeMock.beginEvent(anyString(), anyList())).thenReturn(8L);
+
+ long eventId = DebugStore.recordBroadcastReceiveReg(null, 3840 /* 0xf00 */);
+ assertThat(paramsForBeginEvent("BcRcvReg"))
+ .containsExactly(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()),
+ "act",
+ "null",
+ "cmp",
+ "null",
+ "pkg",
+ "null",
+ "prid",
+ "f00")
+ .inOrder();
+ assertThat(eventId).isEqualTo(8L);
}
@Test
- public void testRecordFinishWithNullReceiverClassName() {
- DebugStore.recordFinish(null);
-
- verify(mDebugStoreNativeMock).recordEvent(eq("Finish"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "tname", Thread.currentThread().getName(),
- "tid", String.valueOf(Thread.currentThread().getId()),
- "rcv", "null"
- ).inOrder();
+ public void testRecordFinish_withNullReceiverClassName() {
+ DebugStore.recordFinish(3840 /* 0xf00 */);
+
+ assertThat(paramsForRecordEvent("Finish"))
+ .containsExactly(
+ "tname",
+ Thread.currentThread().getName(),
+ "tid",
+ String.valueOf(Thread.currentThread().getId()),
+ "prid",
+ "f00")
+ .inOrder();
}
@Test
- public void testRecordLongLooperMessageWithNullTargetClass() {
+ public void testRecordLongLooperMessage_withNullTargetClass() {
DebugStore.recordLongLooperMessage(200, null, 1000L);
- verify(mDebugStoreNativeMock).recordEvent(eq("LooperMsg"), mListCaptor.capture());
- List<String> capturedList = mListCaptor.getValue();
- assertThat(capturedList).containsExactly(
- "code", "200",
- "trgt", "null",
- "elapsed", "1000"
- ).inOrder();
+ assertThat(paramsForRecordEvent("LooperMsg"))
+ .containsExactly(
+ "code", "200",
+ "trgt", "null",
+ "elapsed", "1000")
+ .inOrder();
}
+
+ private List<String> paramsForBeginEvent(String eventName) {
+ verify(mDebugStoreNativeMock).beginEvent(eq(eventName), mListCaptor.capture());
+ return mListCaptor.getValue();
+ }
+
+ private List<String> paramsForRecordEvent(String eventName) {
+ verify(mDebugStoreNativeMock).recordEvent(eq(eventName), mListCaptor.capture());
+ return mListCaptor.getValue();
+ }
+
}
diff --git a/core/tests/coretests/src/com/android/internal/os/OWNERS b/core/tests/coretests/src/com/android/internal/os/OWNERS
index 3f8f9e29c6a0..ec236ca7fb32 100644
--- a/core/tests/coretests/src/com/android/internal/os/OWNERS
+++ b/core/tests/coretests/src/com/android/internal/os/OWNERS
@@ -2,3 +2,4 @@ include /BATTERY_STATS_OWNERS
# CPU
per-file *Cpu* = file:/core/java/com/android/internal/os/CPU_OWNERS
+per-file *DebugStore* = file:/core/java/com/android/internal/os/DEBUG_STORE_OWNERS
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/docs/dagger.md b/libs/WindowManager/Shell/src/com/android/wm/shell/docs/dagger.md
index 9b09904527bf..84525a741480 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/docs/dagger.md
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/docs/dagger.md
@@ -1,4 +1,5 @@
# Usage of Dagger in the Shell library
+
[Back to home](README.md)
---
@@ -16,36 +17,69 @@ As such, the Shell also tries to provide some reasonable out-of-the-box modules
## Modules
All the Dagger related code in the Shell can be found in the `com.android.wm.shell.dagger` package,
-this is intentional as it keeps the "magic" in a single location. The explicit nature of how
+this is intentional as it keeps the "magic" in a single location. The explicit nature of how
components in the shell are provided is as a result a bit more verbose, but it makes it easy for
developers to jump into a few select files and understand how different components are provided
(especially as products override components).
The module dependency tree looks a bit like:
+
- [WMShellConcurrencyModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellConcurrencyModule.java)
(provides threading-related components)
- - [WMShellBaseModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java)
- (provides components that are likely common to all products, ie. DisplayController,
- Transactions, etc.)
- - [WMShellModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java)
- (phone/tablet specific components only)
- - [TvPipModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java)
- (PIP specific components for TV)
- - [TvWMShellModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvWMShellModule.java)
- (TV specific components only)
- - etc.
+ - [WMShellBaseModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java)
+ (provides components that are common to many products, ie. DisplayController, Transactions,
+ etc.)
+ - [WMShellModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java)
+ (phone/tablet specific components only)
+ - [TvPipModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java)
+ (PIP specific components for TV)
+ - [TvWMShellModule](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvWMShellModule.java)
+ (TV specific components only)
+ - etc.
Ideally features could be abstracted out into their own modules and included as needed by each
product.
+## Changing WMShellBaseModule
+
+Because all products will include WMShellBaseModule, we don't want it to provide instances for
+features that aren't used across multiple products (ie. Handheld, TV, Auto, Wear). This module
+should generally only provide:
+
+- Concrete implementations that are needed for startup
+ (see `provideIndependentShellComponentsToCreate()`)
+- Things used directly/indirectly by interfaces
+ exposed to SysUI
+ in [WMComponent.java](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMComponent.java).
+
+For the latter, not every feature will be enabled on the SysUI form factor including the base
+module, so the recommendation is to have an `@BindsOptionalOf` for the interface, and have the
+actual implementation provided in the form-factor specific module (ie. `WMShellModule`).
+
## Overriding base components
In some rare cases, there are base components that can change behavior depending on which
-product it runs on. If there are hooks that can be added to the component, that is the
+product it runs on. If there are hooks that can be added to the component, that is the
preferable approach.
-The alternative is to use the [@DynamicOverride](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/DynamicOverride.java)
+The alternative is to use
+the [@DynamicOverride](/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/DynamicOverride.java)
annotation to allow the product module to provide an implementation that the base module can
-reference. This is most useful if the existence of the entire component is controlled by the
-product and the override implementation is optional (there is a default implementation). More
-details can be found in the class's javadoc. \ No newline at end of file
+reference. This is most useful if the existence of the entire component is controlled by the
+product and the override implementation is optional (there is a default implementation). More
+details can be found in the class's javadoc.
+
+## Starting up Shell components aren't dependencies for other components
+
+With Dagger, objects are created in dependency order and individual components can register with
+`ShellInit` (see [Component initialization](changes.md#component-initialization)) to initialize in
+dependency order as well. However, if there is code that needs to run on startup but has nothing
+dependent on it (imagine a background error detector for example), then
+`provideIndependentShellComponentsToCreate()` can serve as the artificial dependent object (itself
+a dependency for `ShellInterface`) to trigger creation of such a component.
+
+This can be declared within each module, so if a product includes `WMShellModule`, all the
+components in `provideIndependentShellComponentsToCreate()` for both it and `WMShellBaseModule` will
+be created.
+
+Note that long term we are looking to move to a `CoreStartable` like infrastructure. \ No newline at end of file
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java
index 60f1d271c3af..d829c6afb787 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java
@@ -104,6 +104,7 @@ public class ExpandedAnimationControllerTest extends PhysicsAnimationLayoutTestC
@Test
public void testExpansionAndCollapse() throws Exception {
expand();
+ waitForAnimation();
testBubblesInCorrectExpandedPositions();
waitForMainThread();
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index b53cb27dd73b..5b48566d92f9 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -727,6 +727,7 @@ android_library {
"TraceurCommon",
"Traceur-res",
"aconfig_settings_flags_lib",
+ "kairos",
],
}
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt b/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
index 20efea513b3a..df50eb8fa3e8 100644
--- a/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
+++ b/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
@@ -31,6 +31,7 @@ import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@@ -40,12 +41,15 @@ fun PlatformButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: ButtonColors = filledButtonColors(),
+ contentPadding: PaddingValues = ButtonPaddings,
+ shape: Shape = ButtonDefaults.shape,
content: @Composable RowScope.() -> Unit,
) {
androidx.compose.material3.Button(
modifier = modifier.heightIn(min = 36.dp),
colors = colors,
- contentPadding = ButtonPaddings,
+ contentPadding = contentPadding,
+ shape = shape,
onClick = onClick,
enabled = enabled,
) {
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
new file mode 100644
index 000000000000..d7740a426513
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
@@ -0,0 +1,193 @@
+/*
+ * 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.systemui.notifications.ui.composable.row
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMap
+import androidx.compose.ui.util.fastMaxOfOrDefault
+import androidx.compose.ui.util.fastSumBy
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.SceneKey
+import com.android.compose.animation.scene.SceneTransitionLayout
+import com.android.compose.theme.PlatformTheme
+import com.android.compose.ui.graphics.painter.rememberDrawablePainter
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.BundleHeaderViewModel
+
+object BundleHeader {
+ object Scenes {
+ val Collapsed = SceneKey("Collapsed")
+ val Expanded = SceneKey("Expanded")
+ }
+
+ object Elements {
+ val PreviewIcon1 = ElementKey("PreviewIcon1")
+ val PreviewIcon2 = ElementKey("PreviewIcon2")
+ val PreviewIcon3 = ElementKey("PreviewIcon3")
+ val TitleText = ElementKey("TitleText")
+ }
+}
+
+fun createComposeView(viewModel: BundleHeaderViewModel, context: Context): ComposeView {
+ // TODO(b/399588047): Check if we can init PlatformTheme once instead of once per ComposeView
+ return ComposeView(context).apply { setContent { PlatformTheme { BundleHeader(viewModel) } } }
+}
+
+@Composable
+fun BundleHeader(viewModel: BundleHeaderViewModel, modifier: Modifier = Modifier) {
+ Box(modifier) {
+ Background(background = viewModel.backgroundDrawable, modifier = Modifier.matchParentSize())
+ val scope = rememberCoroutineScope()
+ SceneTransitionLayout(
+ state = viewModel.state,
+ modifier =
+ Modifier.clickable(
+ onClick = { viewModel.onHeaderClicked(scope) },
+ interactionSource = null,
+ indication = null,
+ ),
+ ) {
+ scene(BundleHeader.Scenes.Collapsed) {
+ BundleHeaderContent(viewModel, collapsed = true)
+ }
+ scene(BundleHeader.Scenes.Expanded) {
+ BundleHeaderContent(viewModel, collapsed = false)
+ }
+ }
+ }
+}
+
+@Composable
+private fun Background(background: Drawable?, modifier: Modifier = Modifier) {
+ if (background != null) {
+ val painter = rememberDrawablePainter(drawable = background)
+ Image(
+ painter = painter,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = modifier,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun ContentScope.BundleHeaderContent(
+ viewModel: BundleHeaderViewModel,
+ collapsed: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.padding(vertical = 16.dp),
+ ) {
+ BundleIcon(viewModel.bundleIcon, modifier = Modifier.padding(horizontal = 16.dp))
+ Text(
+ text = viewModel.titleText,
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ color = MaterialTheme.colorScheme.primary,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier.element(BundleHeader.Elements.TitleText).weight(1f),
+ )
+
+ if (collapsed && viewModel.previewIcons.isNotEmpty()) {
+ BundlePreviewIcons(
+ previewDrawables = viewModel.previewIcons,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+
+ ExpansionControl(
+ collapsed = collapsed,
+ hasUnread = viewModel.hasUnreadMessages,
+ numberToShow = viewModel.numberOfChildren,
+ modifier = Modifier.padding(start = 8.dp, end = 16.dp),
+ )
+ }
+}
+
+@Composable
+private fun ContentScope.BundlePreviewIcons(
+ previewDrawables: List<Drawable>,
+ modifier: Modifier = Modifier,
+) {
+ check(previewDrawables.isNotEmpty())
+ val iconSize = 32.dp
+ HalfOverlappingReversedRow(modifier = modifier) {
+ PreviewIcon(
+ drawable = previewDrawables[0],
+ modifier = Modifier.element(BundleHeader.Elements.PreviewIcon1).size(iconSize),
+ )
+ if (previewDrawables.size < 2) return@HalfOverlappingReversedRow
+ PreviewIcon(
+ drawable = previewDrawables[1],
+ modifier = Modifier.element(BundleHeader.Elements.PreviewIcon2).size(iconSize),
+ )
+ if (previewDrawables.size < 3) return@HalfOverlappingReversedRow
+ PreviewIcon(
+ drawable = previewDrawables[2],
+ modifier = Modifier.element(BundleHeader.Elements.PreviewIcon3).size(iconSize),
+ )
+ }
+}
+
+@Composable
+private fun HalfOverlappingReversedRow(
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Layout(modifier = modifier, content = content) { measurables, constraints ->
+ val placeables = measurables.fastMap { measurable -> measurable.measure(constraints) }
+
+ if (placeables.isEmpty())
+ return@Layout layout(constraints.minWidth, constraints.minHeight) {}
+ val width = placeables.fastSumBy { it.width / 2 } + placeables.first().width / 2
+ val childHeight = placeables.fastMaxOfOrDefault(0) { it.height }
+
+ layout(constraints.constrainWidth(width), constraints.constrainHeight(childHeight)) {
+ // Start in the middle of the right-most placeable
+ var currentXPosition = placeables.fastSumBy { it.width / 2 }
+ placeables.fastForEach { placeable ->
+ currentXPosition -= placeable.width / 2
+ placeable.placeRelative(x = currentXPosition, y = 0)
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
new file mode 100644
index 000000000000..c9ffa401002a
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
@@ -0,0 +1,201 @@
+/*
+ * 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.systemui.notifications.ui.composable.row
+
+import android.graphics.drawable.Drawable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.LowestZIndexContentPicker
+import com.android.compose.animation.scene.ValueKey
+import com.android.compose.animation.scene.animateElementColorAsState
+import com.android.compose.animation.scene.animateElementFloatAsState
+import com.android.compose.ui.graphics.painter.rememberDrawablePainter
+
+object NotificationRowPrimitives {
+ object Elements {
+ val PillBackground = ElementKey("PillBackground", contentPicker = LowestZIndexContentPicker)
+ val NotificationIconBackground = ElementKey("NotificationIconBackground")
+ val Chevron = ElementKey("Chevron")
+ }
+
+ object Values {
+ val ChevronRotation = ValueKey("NotificationChevronRotation")
+ val PillBackgroundColor = ValueKey("PillBackgroundColor")
+ }
+}
+
+/** The Icon displayed at the start of any notification row. */
+@Composable
+fun ContentScope.BundleIcon(drawable: Drawable?, modifier: Modifier = Modifier) {
+ val surfaceColor = notificationElementSurfaceColor()
+ Box(
+ modifier =
+ modifier
+ // Has to be a shared element because we may have semi-transparent background color
+ .element(NotificationRowPrimitives.Elements.NotificationIconBackground)
+ .size(40.dp)
+ .background(color = surfaceColor, shape = CircleShape)
+ ) {
+ if (drawable == null) return@Box
+ val painter = rememberDrawablePainter(drawable)
+ Image(
+ painter = painter,
+ contentDescription = null,
+ modifier = Modifier.padding(10.dp).fillMaxSize(),
+ contentScale = ContentScale.Fit,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
+ )
+ }
+}
+
+/** The Icon used to display a preview of contained child notifications in a Bundle. */
+@Composable
+fun PreviewIcon(drawable: Drawable, modifier: Modifier = Modifier) {
+ val surfaceColor = notificationElementSurfaceColor()
+ Box(
+ modifier =
+ modifier
+ .background(color = surfaceColor, shape = CircleShape)
+ .border(0.5.dp, surfaceColor, CircleShape)
+ ) {
+ val painter = rememberDrawablePainter(drawable)
+ Image(
+ painter = painter,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize().clip(CircleShape),
+ contentScale = ContentScale.Fit,
+ )
+ }
+}
+
+/** The ExpansionControl of any expandable notification row, containing a Chevron. */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ContentScope.ExpansionControl(
+ collapsed: Boolean,
+ hasUnread: Boolean,
+ numberToShow: Int?,
+ modifier: Modifier = Modifier,
+) {
+ val textColor =
+ if (hasUnread) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onSurface
+ Box(modifier = modifier) {
+ // The background is a shared Element and therefore can't be the parent of a different
+ // shared Element (the chevron), otherwise the child can't be animated.
+ PillBackground(hasUnread, modifier = Modifier.matchParentSize())
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 2.dp, horizontal = 6.dp),
+ ) {
+ val iconSizeDp = with(LocalDensity.current) { 16.sp.toDp() }
+
+ if (numberToShow != null) {
+ Text(
+ text = numberToShow.toString(),
+ style = MaterialTheme.typography.labelSmallEmphasized,
+ color = textColor,
+ modifier = Modifier.padding(end = 2.dp),
+ )
+ }
+ Chevron(collapsed = collapsed, modifier = Modifier.size(iconSizeDp), color = textColor)
+ }
+ }
+}
+
+@Composable
+private fun ContentScope.PillBackground(hasUnread: Boolean, modifier: Modifier = Modifier) {
+ ElementWithValues(NotificationRowPrimitives.Elements.PillBackground, modifier) {
+ val bgColorNoUnread = notificationElementSurfaceColor()
+ val surfaceColor by
+ animateElementColorAsState(
+ if (hasUnread) MaterialTheme.colorScheme.tertiary else bgColorNoUnread,
+ NotificationRowPrimitives.Values.PillBackgroundColor,
+ )
+ content {
+ Box(
+ modifier =
+ Modifier.drawBehind {
+ drawRoundRect(
+ color = surfaceColor,
+ cornerRadius = CornerRadius(100.dp.toPx(), 100.dp.toPx()),
+ )
+ }
+ )
+ }
+ }
+}
+
+@Composable
+@ReadOnlyComposable
+private fun notificationElementSurfaceColor(): Color {
+ return if (isSystemInDarkTheme()) {
+ Color.White.copy(alpha = 0.15f)
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ }
+}
+
+@Composable
+private fun ContentScope.Chevron(collapsed: Boolean, color: Color, modifier: Modifier = Modifier) {
+ val key = NotificationRowPrimitives.Elements.Chevron
+ ElementWithValues(key, modifier) {
+ val rotation by
+ animateElementFloatAsState(
+ if (collapsed) 0f else 180f,
+ NotificationRowPrimitives.Values.ChevronRotation,
+ )
+ content {
+ Icon(
+ imageVector = Icons.Default.ExpandMore,
+ contentDescription = null,
+ modifier = Modifier.graphicsLayer { rotationZ = rotation },
+ tint = color,
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeOverlay.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeOverlay.kt
index 0f1cb409439f..547461e5faf2 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeOverlay.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeOverlay.kt
@@ -26,16 +26,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onPlaced
@@ -59,6 +62,7 @@ import com.android.systemui.qs.panels.ui.compose.EditMode
import com.android.systemui.qs.panels.ui.compose.TileDetails
import com.android.systemui.qs.panels.ui.compose.TileGrid
import com.android.systemui.qs.panels.ui.compose.toolbar.Toolbar
+import com.android.systemui.qs.ui.composable.QuickSettingsShade.systemGestureExclusionInShade
import com.android.systemui.qs.ui.viewmodel.QuickSettingsContainerViewModel
import com.android.systemui.qs.ui.viewmodel.QuickSettingsShadeOverlayActionsViewModel
import com.android.systemui.qs.ui.viewmodel.QuickSettingsShadeOverlayContentViewModel
@@ -254,9 +258,7 @@ fun ContentScope.QuickSettingsLayout(
BrightnessSliderContainer(
viewModel = viewModel.brightnessSliderViewModel,
containerColor = OverlayShade.Colors.PanelBackground,
- modifier =
- Modifier.fillMaxWidth()
- .height(QuickSettingsShade.Dimensions.BrightnessSliderHeight),
+ modifier = Modifier.systemGestureExclusionInShade().fillMaxWidth(),
)
Box {
@@ -280,6 +282,25 @@ object QuickSettingsShade {
object Dimensions {
val Padding = 16.dp
val ToolbarHeight = 48.dp
- val BrightnessSliderHeight = 64.dp
+ }
+
+ /**
+ * Applies system gesture exclusion to a component adding [Dimensions.Padding] to left and
+ * right.
+ */
+ @Composable
+ fun Modifier.systemGestureExclusionInShade(): Modifier {
+ val density = LocalDensity.current
+ return systemGestureExclusion { layoutCoordinates ->
+ val sidePadding = with(density) { Dimensions.Padding.toPx() }
+ Rect(
+ offset = Offset(x = -sidePadding, y = 0f),
+ size =
+ Size(
+ width = layoutCoordinates.size.width.toFloat() + 2 * sidePadding,
+ height = layoutCoordinates.size.height.toFloat(),
+ ),
+ )
+ }
}
}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorTest.kt
index 682ad0c5c48b..754011be7622 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorTest.kt
@@ -125,7 +125,9 @@ class AudioSharingInteractorTest : SysuiTestCase() {
bluetoothTileDialogAudioSharingRepository.emitAudioSourceStateUpdate()
runCurrent()
- assertThat(value).isNull()
+ assertThat(value).isEqualTo(Unit)
+ verify(bluetoothTileDialogLogger).logAudioSharingStateChanged(true)
+ verify(bluetoothTileDialogLogger).logAudioSourceStateUpdate()
}
}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepositoryTest.kt
index 587f3cc8357c..159c15b31069 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepositoryTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepositoryTest.kt
@@ -62,6 +62,7 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
AudioSharingRepositoryImpl(
kosmos.localBluetoothManager,
kosmos.audioSharingRepository,
+ kosmos.bluetoothTileDialogLogger,
kosmos.testDispatcher,
)
}
@@ -95,6 +96,8 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
audioSharingRepository.setAudioSharingAvailable(true)
underTest.startAudioSharing()
verify(leAudioBroadcastProfile).startPrivateBroadcast()
+ verify(bluetoothTileDialogLogger)
+ .logAudioSharingRequest(AudioSharingRequest.START_BROADCAST)
}
}
@@ -105,6 +108,8 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
audioSharingRepository.setAudioSharingAvailable(false)
underTest.startAudioSharing()
verify(leAudioBroadcastProfile, never()).startPrivateBroadcast()
+ verify(bluetoothTileDialogLogger, never())
+ .logAudioSharingRequest(AudioSharingRequest.START_BROADCAST)
}
}
@@ -117,6 +122,8 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
audioSharingRepository.setAudioSharingAvailable(true)
underTest.stopAudioSharing()
verify(leAudioBroadcastProfile).stopLatestBroadcast()
+ verify(bluetoothTileDialogLogger)
+ .logAudioSharingRequest(AudioSharingRequest.STOP_BROADCAST)
}
}
@@ -140,6 +147,7 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
runCurrent()
verify(leAudioBroadcastAssistant, never()).allConnectedDevices
+ verify(bluetoothTileDialogLogger, never()).logAudioSharingRequest(any())
}
}
@@ -157,6 +165,7 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
runCurrent()
verify(leAudioBroadcastAssistant, never()).allConnectedDevices
+ verify(bluetoothTileDialogLogger, never()).logAudioSharingRequest(any())
}
}
@@ -177,6 +186,7 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
runCurrent()
verify(leAudioBroadcastAssistant, never()).addSource(any(), any(), anyBoolean())
+ verify(bluetoothTileDialogLogger, never()).logAudioSharingRequest(any())
}
}
@@ -198,6 +208,8 @@ class AudioSharingRepositoryTest : SysuiTestCase() {
runCurrent()
verify(leAudioBroadcastAssistant).addSource(bluetoothDevice, metadata, false)
+ verify(bluetoothTileDialogLogger)
+ .logAudioSharingRequest(AudioSharingRequest.ADD_SOURCE)
}
}
}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt
new file mode 100644
index 000000000000..80f4b2ce7b10
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.demoModeController
+import com.android.systemui.demomode.DemoMode
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.validMobileEvent
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+
+/**
+ * The switcher acts as a dispatcher to either the `prod` or `demo` versions of the repository
+ * interface it's switching on. These tests just need to verify that the entire interface properly
+ * switches over when the value of `demoMode` changes
+ */
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileRepositorySwitcherKairosTest : SysuiTestCase() {
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ demoModeController.stub {
+ // Never start in demo mode
+ on { isInDemoMode } doReturn false
+ }
+ wifiDataSource.stub { on { wifiEvents } doReturn MutableStateFlow(null) }
+ }
+
+ private val Kosmos.underTest
+ get() = mobileRepositorySwitcherKairos
+
+ private val Kosmos.realRepo
+ get() = mobileConnectionsRepositoryKairosImpl
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun activeRepoMatchesDemoModeSetting() = runTest {
+ demoModeController.stub { on { isInDemoMode } doReturn false }
+
+ val latest by underTest.activeRepo.collectLastValue()
+
+ assertThat(latest).isEqualTo(realRepo)
+
+ startDemoMode()
+
+ assertThat(latest).isInstanceOf(DemoMobileConnectionsRepositoryKairos::class.java)
+
+ finishDemoMode()
+
+ assertThat(latest).isEqualTo(realRepo)
+ }
+
+ @Test
+ fun subscriptionListUpdatesWhenDemoModeChanges() = runTest {
+ demoModeController.stub { on { isInDemoMode } doReturn false }
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+
+ val latest by underTest.subscriptions.collectLastValue()
+
+ // The real subscriptions has 2 subs
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2))
+
+ // Demo mode turns on, and we should see only the demo subscriptions
+ startDemoMode()
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 3))
+
+ // Demo mobile connections repository makes arbitrarily-formed subscription info
+ // objects, so just validate the data we care about
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(3)
+
+ finishDemoMode()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2))
+ }
+
+ private fun KairosTestScope.startDemoMode() {
+ demoModeController.stub { on { isInDemoMode } doReturn true }
+ getDemoModeCallback().onDemoModeStarted()
+ }
+
+ private fun KairosTestScope.finishDemoMode() {
+ demoModeController.stub { on { isInDemoMode } doReturn false }
+ getDemoModeCallback().onDemoModeFinished()
+ }
+
+ private fun KairosTestScope.getSubscriptionCallback():
+ SubscriptionManager.OnSubscriptionsChangedListener =
+ argumentCaptor<SubscriptionManager.OnSubscriptionsChangedListener>()
+ .apply {
+ verify(subscriptionManager).addOnSubscriptionsChangedListener(any(), capture())
+ }
+ .lastValue
+
+ private fun KairosTestScope.getDemoModeCallback(): DemoMode =
+ argumentCaptor<DemoMode>()
+ .apply { verify(demoModeController).addCallback(capture()) }
+ .lastValue
+
+ companion object {
+ private const val SUB_1_ID = 1
+ private const val SUB_1_NAME = "Carrier $SUB_1_ID"
+ private val SUB_1: SubscriptionInfo = mock {
+ on { subscriptionId } doReturn SUB_1_ID
+ on { carrierName } doReturn SUB_1_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = SUB_1_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_2_ID = 2
+ private const val SUB_2_NAME = "Carrier $SUB_2_ID"
+ private val SUB_2: SubscriptionInfo = mock {
+ on { subscriptionId } doReturn SUB_2_ID
+ on { carrierName } doReturn SUB_2_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ carrierName = SUB_2_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt
new file mode 100644
index 000000000000..99cc93d6dc30
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt
@@ -0,0 +1,285 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.demo
+
+import android.telephony.Annotation
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import androidx.test.filters.SmallTest
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoModeMobileConnectionDataSourceKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fake
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.wifiDataSource
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.stub
+import platform.test.runner.parameterized.ParameterizedAndroidJunit4
+import platform.test.runner.parameterized.Parameters
+
+/**
+ * Parameterized test for all of the common values of [FakeNetworkEventModel]. This test simply
+ * verifies that passing the given model to [DemoMobileConnectionsRepositoryKairos] results in the
+ * correct flows emitting from the given connection.
+ */
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(ParameterizedAndroidJunit4::class)
+internal class DemoMobileConnectionKairosParameterizedTest(private val testCase: TestCase) :
+ SysuiTestCase() {
+
+ private val Kosmos.fakeWifiEventFlow by Fixture { MutableStateFlow<FakeWifiEventModel?>(null) }
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ wifiDataSource.stub { on { wifiEvents } doReturn fakeWifiEventFlow }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun demoNetworkData() = runTest {
+ val underTest by
+ demoMobileConnectionsRepositoryKairos.mobileConnectionsBySubId
+ .map { it[subId] }
+ .collectLastValue()
+ val networkModel =
+ FakeNetworkEventModel.Mobile(
+ level = testCase.level,
+ dataType = testCase.dataType,
+ subId = testCase.subId,
+ carrierId = testCase.carrierId,
+ inflateStrength = testCase.inflateStrength,
+ activity = testCase.activity,
+ carrierNetworkChange = testCase.carrierNetworkChange,
+ roaming = testCase.roaming,
+ name = "demo name",
+ slice = testCase.slice,
+ )
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(networkModel)
+ assertConnection(underTest!!, networkModel)
+ }
+
+ private suspend fun KairosTestScope.assertConnection(
+ conn: DemoMobileConnectionRepositoryKairos,
+ model: FakeNetworkEventModel,
+ ) {
+ when (model) {
+ is FakeNetworkEventModel.Mobile -> {
+ kairos.transact {
+ assertThat(conn.subId).isEqualTo(model.subId)
+ assertThat(conn.cdmaLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.primaryLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.dataActivityDirection.sample())
+ .isEqualTo(
+ (model.activity ?: DATA_ACTIVITY_NONE).toMobileDataActivityModel()
+ )
+ assertThat(conn.carrierNetworkChangeActive.sample())
+ .isEqualTo(model.carrierNetworkChange)
+ assertThat(conn.isRoaming.sample()).isEqualTo(model.roaming)
+ assertThat(conn.networkName.sample())
+ .isEqualTo(NetworkNameModel.IntentDerived(model.name))
+ assertThat(conn.carrierName.sample())
+ .isEqualTo(
+ NetworkNameModel.SubscriptionDerived("${model.name} ${model.subId}")
+ )
+ assertThat(conn.hasPrioritizedNetworkCapabilities.sample())
+ .isEqualTo(model.slice)
+ assertThat(conn.isNonTerrestrial.sample()).isEqualTo(model.ntn)
+
+ // TODO(b/261029387): check these once we start handling them
+ assertThat(conn.isEmergencyOnly.sample()).isFalse()
+ assertThat(conn.isGsm.sample()).isFalse()
+ assertThat(conn.dataConnectionState.sample())
+ .isEqualTo(DataConnectionState.Connected)
+ }
+ }
+ // MobileDisabled isn't combinatorial in nature, and is tested in
+ // DemoMobileConnectionsRepositoryTest.kt
+ else -> {}
+ }
+ }
+
+ /** Matches [FakeNetworkEventModel] */
+ internal data class TestCase(
+ val level: Int,
+ val dataType: SignalIcon.MobileIconGroup,
+ val subId: Int,
+ val carrierId: Int,
+ val inflateStrength: Boolean,
+ @Annotation.DataActivityType val activity: Int,
+ val carrierNetworkChange: Boolean,
+ val roaming: Boolean,
+ val name: String,
+ val slice: Boolean,
+ val ntn: Boolean,
+ ) {
+ override fun toString(): String {
+ return "INPUT(level=$level, " +
+ "dataType=${dataType.name}, " +
+ "subId=$subId, " +
+ "carrierId=$carrierId, " +
+ "inflateStrength=$inflateStrength, " +
+ "activity=$activity, " +
+ "carrierNetworkChange=$carrierNetworkChange, " +
+ "roaming=$roaming, " +
+ "name=$name," +
+ "slice=$slice" +
+ "ntn=$ntn)"
+ }
+
+ // Convenience for iterating test data and creating new cases
+ fun modifiedBy(
+ level: Int? = null,
+ dataType: SignalIcon.MobileIconGroup? = null,
+ subId: Int? = null,
+ carrierId: Int? = null,
+ inflateStrength: Boolean? = null,
+ @Annotation.DataActivityType activity: Int? = null,
+ carrierNetworkChange: Boolean? = null,
+ roaming: Boolean? = null,
+ name: String? = null,
+ slice: Boolean? = null,
+ ntn: Boolean? = null,
+ ): TestCase =
+ TestCase(
+ level = level ?: this.level,
+ dataType = dataType ?: this.dataType,
+ subId = subId ?: this.subId,
+ carrierId = carrierId ?: this.carrierId,
+ inflateStrength = inflateStrength ?: this.inflateStrength,
+ activity = activity ?: this.activity,
+ carrierNetworkChange = carrierNetworkChange ?: this.carrierNetworkChange,
+ roaming = roaming ?: this.roaming,
+ name = name ?: this.name,
+ slice = slice ?: this.slice,
+ ntn = ntn ?: this.ntn,
+ )
+ }
+
+ companion object {
+ private val subId = 1
+
+ private val booleanList = listOf(true, false)
+ private val levels = listOf(0, 1, 2, 3)
+ private val dataTypes =
+ listOf(
+ TelephonyIcons.THREE_G,
+ TelephonyIcons.LTE,
+ TelephonyIcons.FOUR_G,
+ TelephonyIcons.NR_5G,
+ TelephonyIcons.NR_5G_PLUS,
+ )
+ private val carrierIds = listOf(1, 10, 100)
+ private val inflateStrength = booleanList
+ private val activity =
+ listOf(
+ TelephonyManager.DATA_ACTIVITY_NONE,
+ TelephonyManager.DATA_ACTIVITY_IN,
+ TelephonyManager.DATA_ACTIVITY_OUT,
+ TelephonyManager.DATA_ACTIVITY_INOUT,
+ )
+ private val carrierNetworkChange = booleanList
+ // false first so the base case doesn't have roaming set (more common)
+ private val roaming = listOf(false, true)
+ private val names = listOf("name 1", "name 2")
+ private val slice = listOf(false, true)
+ private val ntn = listOf(false, true)
+
+ @Parameters(name = "{0}") @JvmStatic fun data() = testData()
+
+ /**
+ * Generate some test data. For the sake of convenience, we'll parameterize only non-null
+ * network event data. So given the lists of test data:
+ * ```
+ * list1 = [1, 2, 3]
+ * list2 = [false, true]
+ * list3 = [a, b, c]
+ * ```
+ *
+ * We'll generate test cases for:
+ *
+ * Test (1, false, a) Test (2, false, a) Test (3, false, a) Test (1, true, a) Test (1,
+ * false, b) Test (1, false, c)
+ *
+ * NOTE: this is not a combinatorial product of all of the possible sets of parameters.
+ * Since this test is built to exercise demo mode, the general approach is to define a
+ * fully-formed "base case", and from there to make sure to use every valid parameter once,
+ * by defining the rest of the test cases against the base case. Specific use-cases can be
+ * added to the non-parameterized test, or manually below the generated test cases.
+ */
+ private fun testData(): List<TestCase> {
+ val testSet = mutableSetOf<TestCase>()
+
+ val baseCase =
+ TestCase(
+ levels.first(),
+ dataTypes.first(),
+ subId,
+ carrierIds.first(),
+ inflateStrength.first(),
+ activity.first(),
+ carrierNetworkChange.first(),
+ roaming.first(),
+ names.first(),
+ slice.first(),
+ ntn.first(),
+ )
+
+ val tail =
+ sequenceOf(
+ levels.map { baseCase.modifiedBy(level = it) },
+ dataTypes.map { baseCase.modifiedBy(dataType = it) },
+ carrierIds.map { baseCase.modifiedBy(carrierId = it) },
+ inflateStrength.map { baseCase.modifiedBy(inflateStrength = it) },
+ activity.map { baseCase.modifiedBy(activity = it) },
+ carrierNetworkChange.map { baseCase.modifiedBy(carrierNetworkChange = it) },
+ roaming.map { baseCase.modifiedBy(roaming = it) },
+ names.map { baseCase.modifiedBy(name = it) },
+ slice.map { baseCase.modifiedBy(slice = it) },
+ ntn.map { baseCase.modifiedBy(ntn = it) },
+ )
+ .flatten()
+
+ testSet.add(baseCase)
+ tail.toCollection(testSet)
+
+ return testSet.toList()
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt
new file mode 100644
index 000000000000..503d561a2234
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt
@@ -0,0 +1,465 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.demo
+
+import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoModeMobileConnectionDataSourceKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fake
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.wifiDataSource
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import junit.framework.Assert
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.stub
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class DemoMobileConnectionsRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.fakeWifiEventFlow by
+ Kosmos.Fixture { MutableStateFlow<FakeWifiEventModel?>(null) }
+
+ private val Kosmos.underTest
+ get() = demoMobileConnectionsRepositoryKairos
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ wifiDataSource.stub { on { wifiEvents } doReturn fakeWifiEventFlow }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun isDefault_defaultsToTrue() = runTest {
+ underTest
+ val isDefault = kairos.transact { underTest.mobileIsDefault.sample() }
+ assertThat(isDefault).isTrue()
+ }
+
+ @Test
+ fun validated_defaultsToTrue() = runTest {
+ underTest
+ val isValidated = kairos.transact { underTest.defaultConnectionIsValidated.sample() }
+ assertThat(isValidated).isTrue()
+ }
+
+ @Test
+ fun networkEvent_createNewSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(1)
+ }
+
+ @Test
+ fun wifiCarrierMergedEvent_createNewSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5)
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(5)
+ }
+
+ @Test
+ fun networkEvent_reusesSubscriptionWhenSameId() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(1)
+
+ // Second network event comes in with the same subId, does not create a new subscription
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 2)
+ )
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(1)
+ }
+
+ @Test
+ fun wifiCarrierMergedEvent_reusesSubscriptionWhenSameId() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5, level = 1)
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(5)
+
+ // Second network event comes in with the same subId, does not create a new subscription
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5, level = 2)
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(5)
+ }
+
+ @Test
+ fun multipleSubscriptions() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 2))
+
+ assertThat(latest).hasSize(2)
+ }
+
+ @Test
+ fun mobileSubscriptionAndCarrierMergedSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5)
+
+ assertThat(latest).hasSize(2)
+ }
+
+ @Test
+ fun multipleMobileSubscriptionsAndCarrierMergedSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 2))
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 3)
+
+ assertThat(latest).hasSize(3)
+ }
+
+ @Test
+ fun mobileDisabledEvent_disablesConnection_subIdSpecified_singleConn() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(MobileDisabled(subId = 1))
+
+ assertThat(latest).hasSize(0)
+ }
+
+ @Test
+ fun mobileDisabledEvent_disablesConnection_subIdNotSpecified_singleConn() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ MobileDisabled(subId = null)
+ )
+
+ assertThat(latest).hasSize(0)
+ }
+
+ @Test
+ fun mobileDisabledEvent_disablesConnection_subIdSpecified_multipleConn() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ assertThat(latest).hasSize(1)
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 2, level = 1)
+ )
+
+ assertThat(latest).hasSize(2)
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(MobileDisabled(subId = 2))
+
+ assertThat(latest).hasSize(1)
+ }
+
+ @Test
+ fun mobileDisabledEvent_subIdNotSpecified_multipleConn_ignoresCommand() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 2, level = 1)
+ )
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ MobileDisabled(subId = null)
+ )
+
+ assertThat(latest).hasSize(2)
+ }
+
+ @Test
+ fun wifiNetworkUpdatesToDisabled_carrierMergedConnectionRemoved() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 1)
+
+ assertThat(latest).hasSize(1)
+
+ fakeWifiEventFlow.value = FakeWifiEventModel.WifiDisabled
+
+ assertThat(latest).isEmpty()
+ }
+
+ @Test
+ fun wifiNetworkUpdatesToActive_carrierMergedConnectionRemoved() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 1)
+
+ assertThat(latest).hasSize(1)
+
+ fakeWifiEventFlow.value =
+ FakeWifiEventModel.Wifi(level = 1, activity = 0, ssid = null, validated = true)
+
+ assertThat(latest).isEmpty()
+ }
+
+ @Test
+ fun mobileSubUpdatesToCarrierMerged_onlyOneConnection() = runTest {
+ val latestSubsList by underTest.subscriptions.collectLastValue()
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 3, level = 2)
+ )
+ assertThat(latestSubsList).hasSize(1)
+
+ val carrierMergedEvent = validCarrierMergedEvent(subId = 3, level = 1)
+ fakeWifiEventFlow.value = carrierMergedEvent
+ assertThat(latestSubsList).hasSize(1)
+ val connection = connections!!.find { it.subId == 3 }!!
+ assertCarrierMergedConnection(connection, carrierMergedEvent)
+ }
+
+ @Test
+ fun mobileSubUpdatesToCarrierMergedThenBack_hasOldMobileData() = runTest {
+ val latestSubsList by underTest.subscriptions.collectLastValue()
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ val mobileEvent = validMobileEvent(subId = 3, level = 2)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(mobileEvent)
+ assertThat(latestSubsList).hasSize(1)
+
+ val carrierMergedEvent = validCarrierMergedEvent(subId = 3, level = 1)
+ fakeWifiEventFlow.value = carrierMergedEvent
+ assertThat(latestSubsList).hasSize(1)
+ var connection = connections!!.find { it.subId == 3 }!!
+ assertCarrierMergedConnection(connection, carrierMergedEvent)
+
+ // WHEN the carrier merged is removed
+ fakeWifiEventFlow.value =
+ FakeWifiEventModel.Wifi(level = 4, activity = 0, ssid = null, validated = true)
+
+ assertThat(latestSubsList).hasSize(1)
+ assertThat(connections).hasSize(1)
+
+ // THEN the subId=3 connection goes back to the mobile information
+ connection = connections!!.find { it.subId == 3 }!!
+ assertConnection(connection, mobileEvent)
+ }
+
+ @Test
+ fun demoConnection_singleSubscription() = runTest {
+ var currentEvent: FakeNetworkEventModel = validMobileEvent(subId = 1)
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent)
+
+ assertThat(connections).hasSize(1)
+ val connection1 = connections!!.first()
+
+ assertConnection(connection1, currentEvent)
+
+ // Exercise the whole api
+
+ currentEvent = validMobileEvent(subId = 1, level = 2)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent)
+ assertConnection(connection1, currentEvent)
+ }
+
+ @Test
+ fun demoConnection_twoConnections_updateSecond_noAffectOnFirst() = runTest {
+ var currentEvent1 = validMobileEvent(subId = 1)
+ var connection1: DemoMobileConnectionRepositoryKairos? = null
+ var currentEvent2 = validMobileEvent(subId = 2)
+ var connection2: DemoMobileConnectionRepositoryKairos? = null
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent2)
+ assertThat(connections).hasSize(2)
+ connections!!.forEach {
+ when (it.subId) {
+ 1 -> connection1 = it
+ 2 -> connection2 = it
+ else -> Assert.fail("Unexpected subscription")
+ }
+ }
+
+ assertConnection(connection1!!, currentEvent1)
+ assertConnection(connection2!!, currentEvent2)
+
+ // WHEN the event changes for connection 2, it updates, and connection 1 stays the same
+ currentEvent2 = validMobileEvent(subId = 2, activity = DATA_ACTIVITY_INOUT)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent2)
+ assertConnection(connection1!!, currentEvent1)
+ assertConnection(connection2!!, currentEvent2)
+
+ // and vice versa
+ currentEvent1 = validMobileEvent(subId = 1, inflateStrength = true)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ assertConnection(connection1!!, currentEvent1)
+ assertConnection(connection2!!, currentEvent2)
+ }
+
+ @Test
+ fun demoConnection_twoConnections_updateCarrierMerged_noAffectOnFirst() = runTest {
+ var currentEvent1 = validMobileEvent(subId = 1)
+ var connection1: DemoMobileConnectionRepositoryKairos? = null
+ var currentEvent2 = validCarrierMergedEvent(subId = 2)
+ var connection2: DemoMobileConnectionRepositoryKairos? = null
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ fakeWifiEventFlow.value = currentEvent2
+ assertThat(connections).hasSize(2)
+ connections!!.forEach {
+ when (it.subId) {
+ 1 -> connection1 = it
+ 2 -> connection2 = it
+ else -> Assert.fail("Unexpected subscription")
+ }
+ }
+
+ assertConnection(connection1!!, currentEvent1)
+ assertCarrierMergedConnection(connection2!!, currentEvent2)
+
+ // WHEN the event changes for connection 2, it updates, and connection 1 stays the same
+ currentEvent2 = validCarrierMergedEvent(subId = 2, level = 4)
+ fakeWifiEventFlow.value = currentEvent2
+ assertConnection(connection1!!, currentEvent1)
+ assertCarrierMergedConnection(connection2!!, currentEvent2)
+
+ // and vice versa
+ currentEvent1 = validMobileEvent(subId = 1, inflateStrength = true)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ assertConnection(connection1!!, currentEvent1)
+ assertCarrierMergedConnection(connection2!!, currentEvent2)
+ }
+
+ @Test
+ fun demoIsNotInEcmState() = runTest {
+ underTest
+ assertThat(kairos.transact { underTest.isInEcmMode.sample() }).isFalse()
+ }
+
+ private suspend fun KairosTestScope.assertConnection(
+ conn: DemoMobileConnectionRepositoryKairos,
+ model: FakeNetworkEventModel,
+ ) {
+ when (model) {
+ is FakeNetworkEventModel.Mobile -> {
+ kairos.transact {
+ assertThat(conn.subId).isEqualTo(model.subId)
+ assertThat(conn.cdmaLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.primaryLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.dataActivityDirection.sample())
+ .isEqualTo(
+ (model.activity ?: DATA_ACTIVITY_NONE).toMobileDataActivityModel()
+ )
+ assertThat(conn.carrierNetworkChangeActive.sample())
+ .isEqualTo(model.carrierNetworkChange)
+ assertThat(conn.isRoaming.sample()).isEqualTo(model.roaming)
+ assertThat(conn.networkName.sample())
+ .isEqualTo(NetworkNameModel.IntentDerived(model.name))
+ assertThat(conn.carrierName.sample())
+ .isEqualTo(
+ NetworkNameModel.SubscriptionDerived("${model.name} ${model.subId}")
+ )
+ assertThat(conn.hasPrioritizedNetworkCapabilities.sample())
+ .isEqualTo(model.slice)
+ assertThat(conn.isNonTerrestrial.sample()).isEqualTo(model.ntn)
+
+ // TODO(b/261029387) check these once we start handling them
+ assertThat(conn.isEmergencyOnly.sample()).isFalse()
+ assertThat(conn.isGsm.sample()).isFalse()
+ assertThat(conn.dataConnectionState.sample())
+ .isEqualTo(DataConnectionState.Connected)
+ }
+ }
+ else -> {}
+ }
+ }
+
+ private suspend fun KairosTestScope.assertCarrierMergedConnection(
+ conn: DemoMobileConnectionRepositoryKairos,
+ model: FakeWifiEventModel.CarrierMerged,
+ ) {
+ kairos.transact {
+ assertThat(conn.subId).isEqualTo(model.subscriptionId)
+ assertThat(conn.cdmaLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.primaryLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.carrierNetworkChangeActive.sample()).isEqualTo(false)
+ assertThat(conn.isRoaming.sample()).isEqualTo(false)
+ assertThat(conn.isEmergencyOnly.sample()).isFalse()
+ assertThat(conn.isGsm.sample()).isFalse()
+ assertThat(conn.dataConnectionState.sample()).isEqualTo(DataConnectionState.Connected)
+ assertThat(conn.hasPrioritizedNetworkCapabilities.sample()).isFalse()
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt
new file mode 100644
index 000000000000..1838d13b793a
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt
@@ -0,0 +1,244 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.telephony.TelephonyManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.fakeWifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class CarrierMergedConnectionRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.underTest by ActivatedKairosFixture {
+ CarrierMergedConnectionRepositoryKairos(
+ subId = SUB_ID,
+ tableLogBuffer = logcatTableLogBuffer(this),
+ telephonyManager = telephonyManager,
+ wifiRepository = wifiRepository,
+ isInEcmMode = stateOf(false),
+ )
+ }
+
+ private val Kosmos.telephonyManager: TelephonyManager by Fixture {
+ mock {
+ on { subscriptionId } doReturn SUB_ID
+ on { simOperatorName } doReturn ""
+ }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ testKosmos().run {
+ useUnconfinedTestDispatcher()
+ runKairosTest { block() }
+ }
+
+ @Test
+ fun inactiveWifi_isDefault() = runTest {
+ val latestConnState by underTest.dataConnectionState.collectLastValue()
+ val latestNetType by underTest.resolvedNetworkType.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.Inactive())
+
+ assertThat(latestConnState).isEqualTo(DataConnectionState.Disconnected)
+ assertThat(latestNetType).isNotEqualTo(ResolvedNetworkType.CarrierMergedNetworkType)
+ }
+
+ @Test
+ fun activeWifi_isDefault() = runTest {
+ val latestConnState by underTest.dataConnectionState.collectLastValue()
+ val latestNetType by underTest.resolvedNetworkType.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.Active.of(level = 1))
+
+ assertThat(latestConnState).isEqualTo(DataConnectionState.Disconnected)
+ assertThat(latestNetType).isNotEqualTo(ResolvedNetworkType.CarrierMergedNetworkType)
+ }
+
+ @Test
+ fun carrierMergedWifi_isValidAndFieldsComeFromWifiNetwork() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+
+ assertThat(latest).isEqualTo(3)
+ }
+
+ @Test
+ fun activity_comesFromWifiActivity() = runTest {
+ val latest by underTest.dataActivityDirection.collectLastValue()
+
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+ fakeWifiRepository.setWifiActivity(
+ DataActivityModel(hasActivityIn = true, hasActivityOut = false)
+ )
+
+ assertThat(latest!!.hasActivityIn).isTrue()
+ assertThat(latest!!.hasActivityOut).isFalse()
+
+ fakeWifiRepository.setWifiActivity(
+ DataActivityModel(hasActivityIn = false, hasActivityOut = true)
+ )
+
+ assertThat(latest!!.hasActivityIn).isFalse()
+ assertThat(latest!!.hasActivityOut).isTrue()
+ }
+
+ @Test
+ fun carrierMergedWifi_wrongSubId_isDefault() = runTest {
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+ val latestType by underTest.resolvedNetworkType.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID + 10, level = 3)
+ )
+
+ assertThat(latestLevel).isNotEqualTo(3)
+ assertThat(latestType).isNotEqualTo(ResolvedNetworkType.CarrierMergedNetworkType)
+ }
+
+ // This scenario likely isn't possible, but write a test for it anyway
+ @Test
+ fun carrierMergedButNotEnabled_isDefault() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+ fakeWifiRepository.setIsWifiEnabled(false)
+
+ assertThat(latest).isNotEqualTo(3)
+ }
+
+ // This scenario likely isn't possible, but write a test for it anyway
+ @Test
+ fun carrierMergedButWifiNotDefault_isDefault() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+ fakeWifiRepository.setIsWifiDefault(false)
+
+ assertThat(latest).isNotEqualTo(3)
+ }
+
+ @Test
+ fun numberOfLevels_comesFromCarrierMerged() = runTest {
+ val latest by underTest.numberOfLevels.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(
+ subscriptionId = SUB_ID,
+ level = 1,
+ numberOfLevels = 6,
+ )
+ )
+
+ assertThat(latest).isEqualTo(6)
+ }
+
+ @Test
+ fun dataEnabled_matchesWifiEnabled() = runTest {
+ val latest by underTest.dataEnabled.collectLastValue()
+
+ fakeWifiRepository.setIsWifiEnabled(true)
+ assertThat(latest).isTrue()
+
+ fakeWifiRepository.setIsWifiEnabled(false)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun cdmaRoaming_alwaysFalse() = runTest {
+ val latest by underTest.cdmaRoaming.collectLastValue()
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun networkName_usesSimOperatorNameAsInitial() = runTest {
+ telephonyManager.stub { on { simOperatorName } doReturn "Test SIM name" }
+
+ val latest by underTest.networkName.collectLastValue()
+
+ assertThat(latest).isEqualTo(NetworkNameModel.SimDerived("Test SIM name"))
+ }
+
+ @Test
+ fun networkName_updatesOnNetworkUpdate() = runTest {
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+
+ telephonyManager.stub { on { simOperatorName } doReturn "Test SIM name" }
+
+ val latest by underTest.networkName.collectLastValue()
+
+ assertThat(latest).isEqualTo(NetworkNameModel.SimDerived("Test SIM name"))
+
+ telephonyManager.stub { on { simOperatorName } doReturn "New SIM name" }
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+
+ assertThat(latest).isEqualTo(NetworkNameModel.SimDerived("New SIM name"))
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_alwaysTrue() = runTest {
+ val latest by underTest.isAllowedDuringAirplaneMode.collectLastValue()
+
+ assertThat(latest).isTrue()
+ }
+
+ private companion object {
+ const val SUB_ID = 123
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt
new file mode 100644
index 000000000000..858bb095df93
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt
@@ -0,0 +1,544 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.os.PersistableBundle
+import android.telephony.ServiceState
+import android.telephony.SignalStrength
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.activated
+import com.android.systemui.flags.Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.flags.featureFlagsClassic
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.BuildSpec
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepository.Companion.COL_EMERGENCY
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepository.Companion.COL_OPERATOR
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_PRIMARY_LEVEL
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.getTelephonyCallbackForType
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.fakeWifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import java.io.PrintWriter
+import java.io.StringWriter
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+/**
+ * This repo acts as a dispatcher to either the `typical` or `carrier merged` versions of the
+ * repository interface it's switching on. These tests just need to verify that the entire interface
+ * properly switches over when the value of `isCarrierMerged` changes.
+ */
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class FullMobileConnectionRepositoryKairosTest : SysuiTestCase() {
+ private val Kosmos.fakeMobileRepo by Fixture {
+ FakeMobileConnectionRepositoryKairos(SUB_ID, kairos, mobileLogger)
+ }
+
+ private val Kosmos.fakeCarrierMergedRepo by Fixture {
+ FakeMobileConnectionRepositoryKairos(SUB_ID, kairos, mobileLogger).apply {
+ // Mimicks the real carrier merged repository
+ isAllowedDuringAirplaneMode.setValue(true)
+ }
+ }
+
+ private var Kosmos.mobileRepo: MobileConnectionRepositoryKairos by Fixture { fakeMobileRepo }
+ private var Kosmos.carrierMergedRepoSpec:
+ BuildSpec<MobileConnectionRepositoryKairos> by Fixture {
+ buildSpec { fakeCarrierMergedRepo }
+ }
+
+ private val Kosmos.mobileLogger by Fixture { logcatTableLogBuffer(this, "TestName") }
+
+ private val Kosmos.underTest by ActivatedKairosFixture {
+ FullMobileConnectionRepositoryKairos(
+ SUB_ID,
+ mobileLogger,
+ mobileRepo,
+ carrierMergedRepoSpec,
+ isCarrierMerged,
+ )
+ }
+
+ private val Kosmos.subscriptionModel by Fixture {
+ MutableState(
+ kairos,
+ SubscriptionModel(
+ subscriptionId = SUB_ID,
+ carrierName = DEFAULT_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ ),
+ )
+ }
+
+ private val Kosmos.isCarrierMerged by Fixture { MutableState(kairos, false) }
+
+ // Use a real config, with no overrides
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_ID, PersistableBundle())
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ fakeFeatureFlagsClassic.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun startingIsCarrierMerged_usesCarrierMergedInitially() = runTest {
+ val carrierMergedOperatorName = "Carrier Merged Operator"
+ val nonCarrierMergedName = "Non-carrier-merged"
+
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(carrierMergedOperatorName)
+ fakeMobileRepo.operatorAlphaShort.setValue(nonCarrierMergedName)
+
+ isCarrierMerged.setValue(true)
+
+ val activeRepo by underTest.activeRepo.collectLastValue()
+ val operatorAlphaShort by underTest.operatorAlphaShort.collectLastValue()
+
+ assertThat(activeRepo).isEqualTo(fakeCarrierMergedRepo)
+ assertThat(operatorAlphaShort).isEqualTo(carrierMergedOperatorName)
+ }
+
+ @Test
+ fun startingNotCarrierMerged_usesTypicalInitially() = runTest {
+ val carrierMergedOperatorName = "Carrier Merged Operator"
+ val nonCarrierMergedName = "Typical Operator"
+
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(carrierMergedOperatorName)
+ fakeMobileRepo.operatorAlphaShort.setValue(nonCarrierMergedName)
+ isCarrierMerged.setValue(false)
+
+ assertThat(underTest.activeRepo.collectLastValue().value).isEqualTo(fakeMobileRepo)
+ assertThat(underTest.operatorAlphaShort.collectLastValue().value)
+ .isEqualTo(nonCarrierMergedName)
+ }
+
+ @Test
+ fun activeRepo_matchesIsCarrierMerged() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latest by underTest.activeRepo.collectLastValue()
+
+ isCarrierMerged.setValue(true)
+
+ assertThat(latest).isEqualTo(fakeCarrierMergedRepo)
+
+ isCarrierMerged.setValue(false)
+
+ assertThat(latest).isEqualTo(fakeMobileRepo)
+
+ isCarrierMerged.setValue(true)
+
+ assertThat(latest).isEqualTo(fakeCarrierMergedRepo)
+ }
+
+ @Test
+ fun connectionInfo_getsUpdatesFromRepo_carrierMerged() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latestName by underTest.operatorAlphaShort.collectLastValue()
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+
+ isCarrierMerged.setValue(true)
+
+ val operator1 = "Carrier Merged Operator"
+ val level1 = 1
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(operator1)
+ fakeCarrierMergedRepo.primaryLevel.setValue(level1)
+
+ assertThat(latestName).isEqualTo(operator1)
+ assertThat(latestLevel).isEqualTo(level1)
+
+ val operator2 = "Carrier Merged Operator #2"
+ val level2 = 2
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(operator2)
+ fakeCarrierMergedRepo.primaryLevel.setValue(level2)
+
+ assertThat(latestName).isEqualTo(operator2)
+ assertThat(latestLevel).isEqualTo(level2)
+
+ val operator3 = "Carrier Merged Operator #3"
+ val level3 = 3
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(operator3)
+ fakeCarrierMergedRepo.primaryLevel.setValue(level3)
+
+ assertThat(latestName).isEqualTo(operator3)
+ assertThat(latestLevel).isEqualTo(level3)
+ }
+
+ @Test
+ fun connectionInfo_getsUpdatesFromRepo_mobile() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latestName by underTest.operatorAlphaShort.collectLastValue()
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+
+ isCarrierMerged.setValue(false)
+
+ val operator1 = "Typical Merged Operator"
+ val level1 = 1
+ fakeMobileRepo.operatorAlphaShort.setValue(operator1)
+ fakeMobileRepo.primaryLevel.setValue(level1)
+
+ assertThat(latestName).isEqualTo(operator1)
+ assertThat(latestLevel).isEqualTo(level1)
+
+ val operator2 = "Typical Merged Operator #2"
+ val level2 = 2
+ fakeMobileRepo.operatorAlphaShort.setValue(operator2)
+ fakeMobileRepo.primaryLevel.setValue(level2)
+
+ assertThat(latestName).isEqualTo(operator2)
+ assertThat(latestLevel).isEqualTo(level2)
+
+ val operator3 = "Typical Merged Operator #3"
+ val level3 = 3
+ fakeMobileRepo.operatorAlphaShort.setValue(operator3)
+ fakeMobileRepo.primaryLevel.setValue(level3)
+
+ assertThat(latestName).isEqualTo(operator3)
+ assertThat(latestLevel).isEqualTo(level3)
+ }
+
+ @Test
+ fun connectionInfo_updatesWhenCarrierMergedUpdates() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latestName by underTest.operatorAlphaShort.collectLastValue()
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+
+ val carrierMergedOperator = "Carrier Merged Operator"
+ val carrierMergedLevel = 4
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(carrierMergedOperator)
+ fakeCarrierMergedRepo.primaryLevel.setValue(carrierMergedLevel)
+
+ val mobileName = "Typical Operator"
+ val mobileLevel = 2
+ fakeMobileRepo.operatorAlphaShort.setValue(mobileName)
+ fakeMobileRepo.primaryLevel.setValue(mobileLevel)
+
+ // Start with the mobile info
+ assertThat(latestName).isEqualTo(mobileName)
+ assertThat(latestLevel).isEqualTo(mobileLevel)
+
+ // WHEN isCarrierMerged is set to true
+ isCarrierMerged.setValue(true)
+
+ // THEN the carrier merged info is used
+ assertThat(latestName).isEqualTo(carrierMergedOperator)
+ assertThat(latestLevel).isEqualTo(carrierMergedLevel)
+
+ val newCarrierMergedName = "New CM Operator"
+ val newCarrierMergedLevel = 0
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(newCarrierMergedName)
+ fakeCarrierMergedRepo.primaryLevel.setValue(newCarrierMergedLevel)
+
+ assertThat(latestName).isEqualTo(newCarrierMergedName)
+ assertThat(latestLevel).isEqualTo(newCarrierMergedLevel)
+
+ // WHEN isCarrierMerged is set to false
+ isCarrierMerged.setValue(false)
+
+ // THEN the typical info is used
+ assertThat(latestName).isEqualTo(mobileName)
+ assertThat(latestLevel).isEqualTo(mobileLevel)
+
+ val newMobileName = "New MobileOperator"
+ val newMobileLevel = 3
+ fakeMobileRepo.operatorAlphaShort.setValue(newMobileName)
+ fakeMobileRepo.primaryLevel.setValue(newMobileLevel)
+
+ assertThat(latestName).isEqualTo(newMobileName)
+ assertThat(latestLevel).isEqualTo(newMobileLevel)
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_updatesWhenCarrierMergedUpdates() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latest by underTest.isAllowedDuringAirplaneMode.collectLastValue()
+
+ assertThat(latest).isFalse()
+
+ isCarrierMerged.setValue(true)
+
+ assertThat(latest).isTrue()
+
+ isCarrierMerged.setValue(false)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun connectionInfo_logging_notCarrierMerged_getsUpdates() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ isCarrierMerged.setValue(false)
+
+ // Stand-up activated repository
+ underTest
+
+ // WHEN we set up some mobile connection info
+ val serviceState = ServiceState()
+ serviceState.setOperatorName("longName", "OpTypical", "1")
+ serviceState.isEmergencyOnly = true
+ getTelephonyCallbackForType<TelephonyCallback.ServiceStateListener>(telephonyManager)
+ .onServiceStateChanged(serviceState)
+
+ // THEN it's logged to the buffer
+ assertThat(dumpBuffer()).contains("$COL_OPERATOR${BUFFER_SEPARATOR}OpTypical")
+ assertThat(dumpBuffer()).contains("$COL_EMERGENCY${BUFFER_SEPARATOR}true")
+
+ // WHEN we update mobile connection info
+ val serviceState2 = ServiceState()
+ serviceState2.setOperatorName("longName", "OpDiff", "1")
+ serviceState2.isEmergencyOnly = false
+ getTelephonyCallbackForType<TelephonyCallback.ServiceStateListener>(telephonyManager)
+ .onServiceStateChanged(serviceState2)
+
+ // THEN the updates are logged
+ assertThat(dumpBuffer()).contains("$COL_OPERATOR${BUFFER_SEPARATOR}OpDiff")
+ assertThat(dumpBuffer()).contains("$COL_EMERGENCY${BUFFER_SEPARATOR}false")
+ }
+
+ @Test
+ fun connectionInfo_logging_carrierMerged_getsUpdates() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ isCarrierMerged.setValue(true)
+
+ // Stand-up activated repository
+ underTest
+
+ // WHEN we set up carrier merged info
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 3))
+
+ // THEN the carrier merged info is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}3")
+
+ // WHEN we update the info
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 1))
+
+ // THEN the updates are logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}1")
+ }
+
+ @Test
+ fun connectionInfo_logging_updatesWhenCarrierMergedUpdates() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ isCarrierMerged.setValue(false)
+
+ // Stand-up activated repository
+ underTest
+
+ // WHEN we set up some mobile connection info
+ val cb =
+ getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>(telephonyManager)
+ cb.onSignalStrengthsChanged(mock(stubOnly = true) { on { level } doReturn 1 })
+
+ // THEN it's logged to the buffer
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}1")
+
+ // WHEN isCarrierMerged is set to true
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 3))
+ isCarrierMerged.setValue(true)
+
+ // THEN the carrier merged info is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}3")
+
+ // WHEN the carrier merge network is updated
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 4))
+
+ // THEN the new level is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}4")
+
+ // WHEN isCarrierMerged is set to false
+ isCarrierMerged.setValue(false)
+
+ // THEN the typical info is logged
+ // Note: Since our first logs also had the typical info, we need to search the log
+ // contents for after our carrier merged level log.
+ val fullBuffer = dumpBuffer()
+ val carrierMergedContentIndex = fullBuffer.indexOf("${BUFFER_SEPARATOR}4")
+ val bufferAfterCarrierMerged = fullBuffer.substring(carrierMergedContentIndex)
+ assertThat(bufferAfterCarrierMerged).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}1")
+
+ // WHEN the normal network is updated
+ cb.onSignalStrengthsChanged(mock(stubOnly = true) { on { level } doReturn 0 })
+
+ // THEN the new level is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}0")
+ }
+
+ @Test
+ fun connectionInfo_logging_doesNotLogUpdatesForNotActiveRepo() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ // WHEN isCarrierMerged = false
+ isCarrierMerged.setValue(false)
+
+ // Stand-up activated repository
+ underTest
+
+ fun setSignalLevel(newLevel: Int) {
+ val signalStrength =
+ mock<SignalStrength>(stubOnly = true) { on { level } doReturn newLevel }
+ argumentCaptor<TelephonyCallback>()
+ .apply { verify(telephonyManager).registerTelephonyCallback(any(), capture()) }
+ .allValues
+ .asSequence()
+ .filterIsInstance<TelephonyCallback.SignalStrengthsListener>()
+ .forEach { it.onSignalStrengthsChanged(signalStrength) }
+ }
+
+ // WHEN we set up some mobile connection info
+ setSignalLevel(1)
+
+ // THEN updates to the carrier merged level aren't logged
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 4))
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}4")
+
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 3))
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}3")
+
+ // WHEN isCarrierMerged is set to true
+ isCarrierMerged.setValue(true)
+
+ // THEN updates to the normal level aren't logged
+ setSignalLevel(5)
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}5")
+
+ setSignalLevel(6)
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}6")
+ }
+
+ private fun KairosTestScope.createRealMobileRepo(
+ telephonyManager: TelephonyManager
+ ): MobileConnectionRepositoryKairosImpl =
+ MobileConnectionRepositoryKairosImpl(
+ subId = SUB_ID,
+ context = context,
+ subscriptionModel = subscriptionModel,
+ defaultNetworkName = DEFAULT_NAME_MODEL,
+ networkNameSeparator = SEP,
+ connectivityManager = mock(stubOnly = true),
+ telephonyManager = telephonyManager,
+ systemUiCarrierConfig = systemUiCarrierConfig,
+ broadcastDispatcher = fakeBroadcastDispatcher,
+ mobileMappingsProxy = mock(stubOnly = true),
+ bgDispatcher = testDispatcher,
+ logger = mock(stubOnly = true),
+ tableLogBuffer = mobileLogger,
+ flags = featureFlagsClassic,
+ )
+ .activated()
+
+ private fun Kosmos.realCarrierMergedRepo(
+ telephonyManager: TelephonyManager
+ ): BuildSpec<CarrierMergedConnectionRepositoryKairos> = buildSpec {
+ activated {
+ CarrierMergedConnectionRepositoryKairos(
+ subId = SUB_ID,
+ tableLogBuffer = mobileLogger,
+ telephonyManager = telephonyManager,
+ wifiRepository = wifiRepository,
+ isInEcmMode = stateOf(false),
+ )
+ }
+ }
+
+ private fun Kosmos.dumpBuffer(): String {
+ val outputWriter = StringWriter()
+ mobileLogger.dump(PrintWriter(outputWriter), arrayOf())
+ return outputWriter.toString()
+ }
+
+ private companion object {
+ const val SUB_ID = 42
+ private const val DEFAULT_NAME = "default name"
+ private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+ private const val SEP = "-"
+ private const val BUFFER_SEPARATOR = "|"
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt
new file mode 100644
index 000000000000..32fc35934bd6
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt
@@ -0,0 +1,1228 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.connectivityManager
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.telephony.AccessNetworkConstants.TRANSPORT_TYPE_WLAN
+import android.telephony.AccessNetworkConstants.TRANSPORT_TYPE_WWAN
+import android.telephony.CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL
+import android.telephony.CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL
+import android.telephony.NetworkRegistrationInfo
+import android.telephony.NetworkRegistrationInfo.DOMAIN_PS
+import android.telephony.NetworkRegistrationInfo.REGISTRATION_STATE_DENIED
+import android.telephony.NetworkRegistrationInfo.REGISTRATION_STATE_HOME
+import android.telephony.ServiceState
+import android.telephony.ServiceState.STATE_IN_SERVICE
+import android.telephony.ServiceState.STATE_OUT_OF_SERVICE
+import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyCallback.CarrierRoamingNtnListener
+import android.telephony.TelephonyCallback.DataActivityListener
+import android.telephony.TelephonyCallback.DisplayInfoListener
+import android.telephony.TelephonyCallback.ServiceStateListener
+import android.telephony.TelephonyDisplayInfo
+import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA
+import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.DATA_ACTIVITY_DORMANT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_IN
+import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import android.telephony.TelephonyManager.DATA_ACTIVITY_OUT
+import android.telephony.TelephonyManager.DATA_CONNECTED
+import android.telephony.TelephonyManager.DATA_CONNECTING
+import android.telephony.TelephonyManager.DATA_DISCONNECTED
+import android.telephony.TelephonyManager.DATA_DISCONNECTING
+import android.telephony.TelephonyManager.DATA_HANDOVER_IN_PROGRESS
+import android.telephony.TelephonyManager.DATA_SUSPENDED
+import android.telephony.TelephonyManager.DATA_UNKNOWN
+import android.telephony.TelephonyManager.ERI_OFF
+import android.telephony.TelephonyManager.ERI_ON
+import android.telephony.TelephonyManager.EXTRA_CARRIER_ID
+import android.telephony.TelephonyManager.EXTRA_DATA_SPN
+import android.telephony.TelephonyManager.EXTRA_PLMN
+import android.telephony.TelephonyManager.EXTRA_SHOW_PLMN
+import android.telephony.TelephonyManager.EXTRA_SHOW_SPN
+import android.telephony.TelephonyManager.EXTRA_SPN
+import android.telephony.TelephonyManager.EXTRA_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager.NETWORK_TYPE_LTE
+import android.telephony.TelephonyManager.NETWORK_TYPE_UNKNOWN
+import android.telephony.telephonyManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.Flags
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.flags.Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO
+import com.android.systemui.flags.fake
+import com.android.systemui.flags.featureFlagsClassic
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.LogcatEchoTrackerAlways
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.tableLogBufferFactory
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfigWithOverride
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.signalStrength
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.telephonyDisplayInfo
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.whenever
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileConnectionRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.underTest by ActivatedKairosFixture {
+ MobileConnectionRepositoryKairosImpl(
+ SUB_1_ID,
+ context,
+ subscriptionModel,
+ DEFAULT_NAME_MODEL,
+ SEP,
+ connectivityManager,
+ telephonyManager,
+ systemUiCarrierConfig,
+ fakeBroadcastDispatcher,
+ mobileMappingsProxy,
+ testDispatcher,
+ logger,
+ tableLogger,
+ featureFlagsClassic,
+ )
+ }
+
+ private val Kosmos.logger: MobileInputLogger by Fixture {
+ MobileInputLogger(LogBuffer("test_buffer", 1, LogcatEchoTrackerAlways()))
+ }
+
+ private val Kosmos.tableLogger: TableLogBuffer by Fixture {
+ tableLogBufferFactory.getOrCreate("test_buffer", 1)
+ }
+
+ private val Kosmos.context: Context by Fixture { mock() }
+
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
+
+ private val Kosmos.subscriptionModel: MutableState<SubscriptionModel?> by Fixture {
+ MutableState(
+ kairos,
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = DEFAULT_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ ),
+ )
+ }
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ featureFlagsClassic.fake.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+ telephonyManager.stub { on { subscriptionId } doReturn SUB_1_ID }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun emergencyOnly() = runTest {
+ val latest by underTest.isEmergencyOnly.collectLastValue()
+
+ val serviceState = ServiceState().apply { isEmergencyOnly = true }
+
+ getTelephonyCallbackForType<ServiceStateListener>().onServiceStateChanged(serviceState)
+
+ assertThat(latest).isEqualTo(true)
+ }
+
+ @Test
+ fun emergencyOnly_toggles() = runTest {
+ val latest by underTest.isEmergencyOnly.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<ServiceStateListener>()
+ callback.onServiceStateChanged(ServiceState().apply { isEmergencyOnly = true })
+
+ assertThat(latest).isTrue()
+
+ callback.onServiceStateChanged(ServiceState().apply { isEmergencyOnly = false })
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun cdmaLevelUpdates() = runTest {
+ val latest by underTest.cdmaLevel.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>()
+ var strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(2)
+
+ // gsmLevel updates, no change to cdmaLevel
+ strength = signalStrength(gsmLevel = 3, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(2)
+ }
+
+ @Test
+ fun gsmLevelUpdates() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>()
+ var strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(1)
+
+ strength = signalStrength(gsmLevel = 3, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(3)
+ }
+
+ @Test
+ fun isGsm() = runTest {
+ val latest by underTest.isGsm.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>()
+ var strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isTrue()
+
+ strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = false)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun dataConnectionState_connected() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_CONNECTED, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Connected)
+ }
+
+ @Test
+ fun dataConnectionState_connecting() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_CONNECTING, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Connecting)
+ }
+
+ @Test
+ fun dataConnectionState_disconnected() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_DISCONNECTED, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Disconnected)
+ }
+
+ @Test
+ fun dataConnectionState_disconnecting() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_DISCONNECTING, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Disconnecting)
+ }
+
+ @Test
+ fun dataConnectionState_suspended() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_SUSPENDED, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Suspended)
+ }
+
+ @Test
+ fun dataConnectionState_handoverInProgress() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_HANDOVER_IN_PROGRESS, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.HandoverInProgress)
+ }
+
+ @Test
+ fun dataConnectionState_unknown() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_UNKNOWN, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Unknown)
+ }
+
+ @Test
+ fun dataConnectionState_invalid() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(45, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Invalid)
+ }
+
+ @Test
+ fun dataActivity() = runTest {
+ val latest by underTest.dataActivityDirection.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<DataActivityListener>()
+ callback.onDataActivity(DATA_ACTIVITY_INOUT)
+
+ assertThat(latest).isEqualTo(DATA_ACTIVITY_INOUT.toMobileDataActivityModel())
+ }
+
+ @Test
+ fun carrierId_initialValueCaptured() = runTest {
+ whenever(telephonyManager.simCarrierId).thenReturn(1234)
+
+ val latest by underTest.carrierId.collectLastValue()
+
+ assertThat(latest).isEqualTo(1234)
+ }
+
+ @Test
+ fun carrierId_updatesOnBroadcast() = runTest {
+ whenever(telephonyManager.simCarrierId).thenReturn(1234)
+
+ val latest by underTest.carrierId.collectLastValue()
+
+ fakeBroadcastDispatcher.sendIntentToMatchingReceiversOnly(
+ context,
+ carrierIdIntent(carrierId = 4321),
+ )
+
+ assertThat(latest).isEqualTo(4321)
+ }
+
+ @Test
+ fun carrierNetworkChange() = runTest {
+ val latest by underTest.carrierNetworkChangeActive.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.CarrierNetworkListener>()
+ callback.onCarrierNetworkChange(true)
+
+ assertThat(latest).isEqualTo(true)
+ }
+
+ @Test
+ fun networkType_default() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val expected = UnknownNetworkType
+
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun networkType_unknown_hasCorrectKey() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val ti =
+ telephonyDisplayInfo(
+ networkType = NETWORK_TYPE_UNKNOWN,
+ overrideNetworkType = NETWORK_TYPE_UNKNOWN,
+ )
+
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = UnknownNetworkType
+ assertThat(latest).isEqualTo(expected)
+ assertThat(latest!!.lookupKey).isEqualTo(MobileMappings.toIconKey(NETWORK_TYPE_UNKNOWN))
+ }
+
+ @Test
+ fun networkType_updatesUsingDefault() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val overrideType = OVERRIDE_NETWORK_TYPE_NONE
+ val type = NETWORK_TYPE_LTE
+ val ti = telephonyDisplayInfo(networkType = type, overrideNetworkType = overrideType)
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = DefaultNetworkType(mobileMappingsProxy.toIconKey(type))
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun networkType_updatesUsingOverride() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val type = OVERRIDE_NETWORK_TYPE_LTE_CA
+ val ti = telephonyDisplayInfo(networkType = type, overrideNetworkType = type)
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(type))
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun networkType_unknownNetworkWithOverride_usesOverrideKey() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val unknown = NETWORK_TYPE_UNKNOWN
+ val type = OVERRIDE_NETWORK_TYPE_LTE_CA
+ val ti = telephonyDisplayInfo(unknown, type)
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(type))
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun dataEnabled_initial_false() = runTest {
+ whenever(telephonyManager.isDataConnectionAllowed).thenReturn(false)
+
+ val latest by underTest.dataEnabled.collectLastValue()
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isDataEnabled_tracksTelephonyCallback() = runTest {
+ val latest by underTest.dataEnabled.collectLastValue()
+
+ whenever(telephonyManager.isDataConnectionAllowed).thenReturn(false)
+ assertThat(latest).isFalse()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataEnabledListener>()
+
+ callback.onDataEnabledChanged(true, 1)
+ assertThat(latest).isTrue()
+
+ callback.onDataEnabledChanged(false, 1)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun numberOfLevels_isDefault() = runTest {
+ val latest by underTest.numberOfLevels.collectLastValue()
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
+ }
+
+ @Test
+ fun roaming_cdma_queriesTelephonyManager() = runTest {
+ val latest by underTest.cdmaRoaming.collectLastValue()
+
+ val cb = getTelephonyCallbackForType<ServiceStateListener>()
+
+ // CDMA roaming is off, GSM roaming is on
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_OFF)
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = true })
+
+ assertThat(latest).isFalse()
+
+ // CDMA roaming is on, GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_ON)
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = false })
+
+ assertThat(latest).isTrue()
+ }
+
+ /**
+ * [TelephonyManager.getCdmaEnhancedRoamingIndicatorDisplayNumber] returns -1 if the service is
+ * not running or if there is an error while retrieving the cdma ERI
+ */
+ @Test
+ fun cdmaRoaming_ignoresNegativeOne() = runTest {
+ val latest by underTest.cdmaRoaming.collectLastValue()
+
+ val serviceState = ServiceState()
+ serviceState.roaming = false
+
+ val cb = getTelephonyCallbackForType<ServiceStateListener>()
+
+ // CDMA roaming is unavailable (-1), GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(-1)
+ cb.onServiceStateChanged(serviceState)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun roaming_gsm_queriesDisplayInfo_viaDisplayInfo() = runTest {
+ // GIVEN flag is true
+ featureFlagsClassic.fake.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+
+ val latest by underTest.isRoaming.collectLastValue()
+
+ val cb = getTelephonyCallbackForType<DisplayInfoListener>()
+
+ // CDMA roaming is off, GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_OFF)
+ cb.onDisplayInfoChanged(TelephonyDisplayInfo(NETWORK_TYPE_LTE, NETWORK_TYPE_UNKNOWN, false))
+
+ assertThat(latest).isFalse()
+
+ // CDMA roaming is off, GSM roaming is on
+ cb.onDisplayInfoChanged(TelephonyDisplayInfo(NETWORK_TYPE_LTE, NETWORK_TYPE_UNKNOWN, true))
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun roaming_gsm_queriesDisplayInfo_viaServiceState() = runTest {
+ // GIVEN flag is false
+ featureFlagsClassic.fake.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, false)
+
+ val latest by underTest.isRoaming.collectLastValue()
+
+ val cb = getTelephonyCallbackForType<ServiceStateListener>()
+
+ // CDMA roaming is off, GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_OFF)
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = false })
+
+ assertThat(latest).isFalse()
+
+ // CDMA roaming is off, GSM roaming is on
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = true })
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun activity_updatesFromCallback() = runTest {
+ val latest by underTest.dataActivityDirection.collectLastValue()
+
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+
+ val cb = getTelephonyCallbackForType<DataActivityListener>()
+ cb.onDataActivity(DATA_ACTIVITY_IN)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = true, hasActivityOut = false))
+
+ cb.onDataActivity(DATA_ACTIVITY_OUT)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = true))
+
+ cb.onDataActivity(DATA_ACTIVITY_INOUT)
+ assertThat(latest).isEqualTo(DataActivityModel(hasActivityIn = true, hasActivityOut = true))
+
+ cb.onDataActivity(DATA_ACTIVITY_NONE)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+
+ cb.onDataActivity(DATA_ACTIVITY_DORMANT)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+
+ cb.onDataActivity(1234)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+ }
+
+ @Test
+ fun networkNameForSubId_updates() = runTest {
+ val latest by underTest.carrierName.collectLastValue()
+
+ subscriptionModel.setValue(
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = DEFAULT_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ )
+
+ assertThat(latest?.name).isEqualTo(DEFAULT_NAME)
+
+ val updatedName = "Derived Carrier"
+ subscriptionModel.setValue(
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = updatedName,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ )
+
+ assertThat(latest?.name).isEqualTo(updatedName)
+ }
+
+ @Test
+ fun networkNameForSubId_defaultWhenSubscriptionModelNull() = runTest {
+ val latest by underTest.carrierName.collectLastValue()
+
+ subscriptionModel.setValue(null)
+
+ assertThat(latest?.name).isEqualTo(DEFAULT_NAME)
+ }
+
+ @Test
+ fun networkName_default() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usesBroadcastInfo_returnsDerived() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // spnIntent() sets all values to true and test strings
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usesBroadcastInfo_returnsDerived_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // spnIntent() sets all values to true and test strings
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastNotForThisSubId_keepsOldValue() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ // WHEN an intent with a different subId is sent
+ val wrongSubIntent = spnIntent(subId = 101)
+
+ captor.lastValue.onReceive(context, wrongSubIntent)
+
+ // THEN the previous intent's name is still used
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastNotForThisSubId_keepsOldValue_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ // WHEN an intent with a different subId is sent
+ val wrongSubIntent = spnIntent(subId = 101)
+
+ captor.lastValue.onReceive(context, wrongSubIntent)
+
+ // THEN the previous intent's name is still used
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastHasNoData_updatesToDefault() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ val intentWithoutInfo = spnIntent(showSpn = false, showPlmn = false)
+
+ captor.lastValue.onReceive(context, intentWithoutInfo)
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastHasNoData_updatesToDefault_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ val intentWithoutInfo = spnIntent(showSpn = false, showPlmn = false)
+
+ captor.lastValue.onReceive(context, intentWithoutInfo)
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usingEagerStrategy_retainsNameBetweenSubscribers() = runTest {
+ // Use the [StateFlow.value] getter so we can prove that the collection happens
+ // even when there is no [Job]
+
+ // Starts out default
+ val latest by underTest.networkName.collectLastValue()
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // The value is still there despite no active subscribers
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usingEagerStrategy_retainsNameBetweenSubscribers_flagOff() = runTest {
+ // Use the [StateFlow.value] getter so we can prove that the collection happens
+ // even when there is no [Job]
+
+ // Starts out default
+ val latest by underTest.networkName.collectLastValue()
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // The value is still there despite no active subscribers
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_allFieldsSet_prioritizesDataSpnOverSpn() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_spnAndPlmn_fallbackToSpnWhenNullDataSpn() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_allFieldsSet_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNotNull_showSpn_spnNull_dataSpnNotNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = null,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNotNull_showSpn_spnNotNull_dataSpnNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNotNull_showSpn_spnNull_dataSpnNotNull_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = null,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ fun networkName_showPlmn_noShowSPN() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = false,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn_dataSpnNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn_bothSpnNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = null,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$DATA_SPN"))
+ }
+
+ @Test
+ fun operatorAlphaShort_tracked() = runTest {
+ val latest by underTest.operatorAlphaShort.collectLastValue()
+
+ val shortName = "short name"
+ val serviceState = ServiceState()
+ serviceState.setOperatorName(
+ /* longName */ "long name",
+ /* shortName */ shortName,
+ /* numeric */ "12345",
+ )
+
+ getTelephonyCallbackForType<ServiceStateListener>().onServiceStateChanged(serviceState)
+
+ assertThat(latest).isEqualTo(shortName)
+ }
+
+ @Test
+ fun isInService_notIwlan() = runTest {
+ val latest by underTest.isInService.collectLastValue()
+
+ val nriInService =
+ NetworkRegistrationInfo.Builder()
+ .setDomain(DOMAIN_PS)
+ .setTransportType(TRANSPORT_TYPE_WWAN)
+ .setRegistrationState(REGISTRATION_STATE_HOME)
+ .build()
+
+ getTelephonyCallbackForType<ServiceStateListener>()
+ .onServiceStateChanged(
+ ServiceState().also {
+ it.voiceRegState = STATE_IN_SERVICE
+ it.addNetworkRegistrationInfo(nriInService)
+ }
+ )
+
+ assertThat(latest).isTrue()
+
+ getTelephonyCallbackForType<ServiceStateListener>()
+ .onServiceStateChanged(
+ ServiceState().also {
+ it.voiceRegState = STATE_OUT_OF_SERVICE
+ it.addNetworkRegistrationInfo(nriInService)
+ }
+ )
+ assertThat(latest).isTrue()
+
+ val nriNotInService =
+ NetworkRegistrationInfo.Builder()
+ .setDomain(DOMAIN_PS)
+ .setTransportType(TRANSPORT_TYPE_WWAN)
+ .setRegistrationState(REGISTRATION_STATE_DENIED)
+ .build()
+ getTelephonyCallbackForType<ServiceStateListener>()
+ .onServiceStateChanged(
+ ServiceState().also {
+ it.voiceRegState = STATE_OUT_OF_SERVICE
+ it.addNetworkRegistrationInfo(nriNotInService)
+ }
+ )
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isInService_isIwlan_voiceOutOfService_dataInService() = runTest {
+ val latest by underTest.isInService.collectLastValue()
+
+ val iwlanData =
+ NetworkRegistrationInfo.Builder()
+ .setDomain(DOMAIN_PS)
+ .setTransportType(TRANSPORT_TYPE_WLAN)
+ .setRegistrationState(REGISTRATION_STATE_HOME)
+ .build()
+ val serviceState =
+ ServiceState().also {
+ it.voiceRegState = STATE_OUT_OF_SERVICE
+ it.addNetworkRegistrationInfo(iwlanData)
+ }
+
+ getTelephonyCallbackForType<ServiceStateListener>().onServiceStateChanged(serviceState)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isNonTerrestrial_updatesFromCallback0() = runTest {
+ val latest by underTest.isNonTerrestrial.collectLastValue()
+
+ // Starts out false
+ assertThat(latest).isFalse()
+
+ val callback = getTelephonyCallbackForType<CarrierRoamingNtnListener>()
+
+ callback.onCarrierRoamingNtnModeChanged(true)
+ assertThat(latest).isTrue()
+
+ callback.onCarrierRoamingNtnModeChanged(false)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun numberOfLevels_usesCarrierConfig() = runTest {
+ val latest by underTest.numberOfLevels.collectLastValue()
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ )
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS + 1)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ )
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
+ }
+
+ @Test
+ fun inflateSignalStrength_usesCarrierConfig() = runTest {
+ val latest by underTest.inflateSignalStrength.collectLastValue()
+
+ assertThat(latest).isEqualTo(false)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ )
+
+ assertThat(latest).isEqualTo(true)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ )
+
+ assertThat(latest).isEqualTo(false)
+ }
+
+ @Test
+ fun allowNetworkSliceIndicator_exposesCarrierConfigValue() = runTest {
+ val latest by underTest.allowNetworkSliceIndicator.collectLastValue()
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, true)
+ )
+
+ assertThat(latest).isTrue()
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, false)
+ )
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_alwaysFalse() = runTest {
+ val latest by underTest.isAllowedDuringAirplaneMode.collectLastValue()
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun hasPrioritizedCaps_defaultFalse() = runTest {
+ // stand up under-test to kick-off activation
+ underTest
+
+ assertThat(kairos.transact { underTest.hasPrioritizedNetworkCapabilities.sample() })
+ .isFalse()
+ }
+
+ @Test
+ fun hasPrioritizedCaps_trueWhenAvailable() = runTest {
+ val latest by underTest.hasPrioritizedNetworkCapabilities.collectLastValue()
+
+ val callback: NetworkCallback =
+ argumentCaptor<NetworkCallback>()
+ .apply { verify(connectivityManager).registerNetworkCallback(any(), capture()) }
+ .lastValue
+
+ callback.onAvailable(mock())
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasPrioritizedCaps_becomesFalseWhenNetworkLost() = runTest {
+ val latest by underTest.hasPrioritizedNetworkCapabilities.collectLastValue()
+
+ val callback: NetworkCallback =
+ argumentCaptor<NetworkCallback>()
+ .apply { verify(connectivityManager).registerNetworkCallback(any(), capture()) }
+ .lastValue
+
+ callback.onAvailable(mock())
+
+ assertThat(latest).isTrue()
+
+ callback.onLost(mock())
+
+ assertThat(latest).isFalse()
+ }
+
+ private inline fun <reified T> Kosmos.getTelephonyCallbackForType(): T {
+ return MobileTelephonyHelpers.getTelephonyCallbackForType(telephonyManager)
+ }
+
+ private fun carrierIdIntent(subId: Int = SUB_1_ID, carrierId: Int): Intent =
+ Intent(TelephonyManager.ACTION_SUBSCRIPTION_CARRIER_IDENTITY_CHANGED).apply {
+ putExtra(EXTRA_SUBSCRIPTION_ID, subId)
+ putExtra(EXTRA_CARRIER_ID, carrierId)
+ }
+
+ private fun spnIntent(
+ subId: Int = SUB_1_ID,
+ showSpn: Boolean = true,
+ spn: String? = SPN,
+ dataSpn: String? = DATA_SPN,
+ showPlmn: Boolean = true,
+ plmn: String? = PLMN,
+ ): Intent =
+ Intent(TelephonyManager.ACTION_SERVICE_PROVIDERS_UPDATED).apply {
+ putExtra(EXTRA_SUBSCRIPTION_INDEX, subId)
+ putExtra(EXTRA_SHOW_SPN, showSpn)
+ putExtra(EXTRA_SPN, spn)
+ putExtra(EXTRA_DATA_SPN, dataSpn)
+ putExtra(EXTRA_SHOW_PLMN, showPlmn)
+ putExtra(EXTRA_PLMN, plmn)
+ }
+
+ companion object {
+ private const val SUB_1_ID = 1
+
+ private const val DEFAULT_NAME = "Fake Mobile Network"
+ private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+ private const val SEP = "-"
+
+ private const val SPN = "testSpn"
+ private const val DATA_SPN = "testDataSpn"
+ private const val PLMN = "testPlmn"
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt
new file mode 100644
index 000000000000..e04a96eb3032
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt
@@ -0,0 +1,1350 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.content.applicationContext
+import android.content.testableContext
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.connectivityManager
+import android.net.vcn.VcnTransportInfo
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.ParcelUuid
+import android.telephony.CarrierConfigManager
+import android.telephony.ServiceState
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.SubscriptionManager.OnSubscriptionsChangedListener
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.telephony.TelephonyCallback.ActiveDataSubscriptionIdListener
+import android.telephony.TelephonyCallback.EmergencyCallbackModeListener
+import android.telephony.TelephonyManager
+import android.telephony.telephonyManager
+import android.testing.TestableLooper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.telephony.PhoneConstants
+import com.android.keyguard.KeyguardUpdateMonitorCallback
+import com.android.keyguard.keyguardUpdateMonitor
+import com.android.settingslib.R
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.broadcast.broadcastDispatcher
+import com.android.systemui.broadcast.broadcastDispatcherContext
+import com.android.systemui.flags.Flags
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.connectivity.WifiPickerTrackerFactory
+import com.android.systemui.statusbar.pipeline.airplane.data.repository.airplaneModeRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileConnectionsRepositoryKairosImpl
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.getTelephonyCallbackForType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.subscriptionManager
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.subscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.fake
+import com.android.systemui.statusbar.pipeline.shared.data.model.ConnectivitySlots
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepositoryImpl
+import com.android.systemui.statusbar.pipeline.shared.data.repository.connectivityRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.prod.WifiRepositoryImpl
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.userRepository
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+import com.android.wifitrackerlib.MergedCarrierEntry
+import com.android.wifitrackerlib.WifiEntry
+import com.android.wifitrackerlib.WifiPickerTracker
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.UUID
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.whenever
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+// This is required because our [SubscriptionManager.OnSubscriptionsChangedListener] uses a looper
+// to run the callback and this makes the looper place nicely with TestScope etc.
+@TestableLooper.RunWithLooper
+@RunWith(AndroidJUnit4::class)
+class MobileConnectionsRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.wifiManager: WifiManager by Fixture { mock {} }
+ private val Kosmos.wifiPickerTrackerFactory: WifiPickerTrackerFactory by Fixture {
+ mock {
+ on { create(any(), any(), wifiPickerTrackerCallback.capture(), any()) } doReturn
+ wifiPickerTracker
+ }
+ }
+ private val Kosmos.wifiPickerTracker: WifiPickerTracker by Fixture { mock {} }
+ private val Kosmos.wifiTableLogBuffer by Fixture { logcatTableLogBuffer(this, "wifiTableLog") }
+
+ private val mainExecutor = FakeExecutor(FakeSystemClock())
+ private val wifiLogBuffer = LogBuffer("wifi", maxSize = 100, logcatEchoTracker = mock())
+ private val wifiPickerTrackerCallback =
+ argumentCaptor<WifiPickerTracker.WifiPickerTrackerCallback>()
+ private val vcnTransportInfo = VcnTransportInfo.Builder().build()
+
+ private val Kosmos.underTest
+ get() = mobileConnectionsRepositoryKairosImpl
+
+ private val kosmos =
+ testKosmos().apply {
+ fakeFeatureFlagsClassic.set(Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+ broadcastDispatcherContext = testableContext
+ connectivityRepository =
+ ConnectivityRepositoryImpl(
+ connectivityManager,
+ ConnectivitySlots(applicationContext),
+ applicationContext,
+ mock(),
+ mock(),
+ applicationCoroutineScope,
+ mock(),
+ )
+ wifiRepository =
+ WifiRepositoryImpl(
+ applicationContext,
+ userRepository,
+ applicationCoroutineScope,
+ mainExecutor,
+ testDispatcher,
+ wifiPickerTrackerFactory,
+ wifiManager,
+ wifiLogBuffer,
+ wifiTableLogBuffer,
+ )
+ subscriptionManager.stub {
+ // For convenience, set up the subscription info callbacks
+ on { getActiveSubscriptionInfo(anyInt()) } doAnswer
+ { invocation ->
+ when (invocation.getArgument(0) as Int) {
+ 1 -> SUB_1
+ 2 -> SUB_2
+ 3 -> SUB_3
+ 4 -> SUB_4
+ else -> null
+ }
+ }
+ }
+ telephonyManager.stub {
+ on { simOperatorName } doReturn ""
+ // Set up so the individual connection repositories
+ on { createForSubscriptionId(anyInt()) } doAnswer
+ { invocation ->
+ telephonyManager.stub {
+ on { subscriptionId } doReturn invocation.getArgument(0)
+ }
+ }
+ }
+ testScope.runCurrent()
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun testSubscriptions_initiallyEmpty() = runTest {
+ assertThat(underTest.subscriptions.collectLastValue().value)
+ .isEqualTo(listOf<SubscriptionModel>())
+ }
+
+ @Test
+ fun testSubscriptions_listUpdates() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2))
+ }
+
+ @Test
+ fun testSubscriptions_removingSub_updatesList() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ // WHEN 2 networks show up
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ // WHEN one network is removed
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ // THEN the subscriptions list represents the newest change
+ assertThat(latest).isEqualTo(listOf(MODEL_2))
+ }
+
+ @Test
+ fun subscriptions_subIsOnlyNtn_modelHasExclusivelyNtnTrue() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ val onlyNtnSub =
+ mock<SubscriptionInfo> {
+ on { isOnlyNonTerrestrialNetwork } doReturn true
+ on { subscriptionId } doReturn 45
+ on { groupUuid } doReturn GROUP_1
+ on { carrierName } doReturn "NTN only"
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(onlyNtnSub)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!![0].isExclusivelyNonTerrestrial).isTrue()
+ }
+
+ @Test
+ fun subscriptions_subIsNotOnlyNtn_modelHasExclusivelyNtnFalse() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ val notOnlyNtnSub =
+ mock<SubscriptionInfo> {
+ on { isOnlyNonTerrestrialNetwork } doReturn false
+ on { subscriptionId } doReturn 45
+ on { groupUuid } doReturn GROUP_1
+ on { carrierName } doReturn "NTN only"
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(notOnlyNtnSub)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!![0].isExclusivelyNonTerrestrial).isFalse()
+ }
+
+ @Test
+ fun testSubscriptions_carrierMergedOnly_listHasCarrierMerged() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_CM))
+ }
+
+ @Test
+ fun testSubscriptions_carrierMergedAndOther_listHasBothWithCarrierMergedLast() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2, SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2, MODEL_CM))
+ }
+
+ @Test
+ fun testActiveDataSubscriptionId_initialValueIsNull() = runTest {
+ assertThat(underTest.activeMobileDataSubscriptionId.collectLastValue().value)
+ .isEqualTo(null)
+ }
+
+ @Test
+ fun testActiveDataSubscriptionId_updates() = runTest {
+ val active by underTest.activeMobileDataSubscriptionId.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+
+ assertThat(active).isEqualTo(SUB_2_ID)
+ }
+
+ @Test
+ fun activeSubId_nullIfInvalidSubIdIsReceived() = runTest {
+ val latest by underTest.activeMobileDataSubscriptionId.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+
+ assertThat(latest).isNotNull()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(INVALID_SUBSCRIPTION_ID)
+
+ assertThat(latest).isNull()
+ }
+
+ @Test
+ fun activeRepo_initiallyNull() = runTest {
+ assertThat(underTest.activeMobileDataRepository.collectLastValue().value).isNull()
+ }
+
+ @Test
+ fun activeRepo_updatesWithActiveDataId() = runTest {
+ val latest by underTest.activeMobileDataRepository.collectLastValue()
+ testScope.runCurrent()
+
+ // GIVEN the subscription list is then updated which includes the active data sub id
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+
+ assertThat(latest?.subId).isEqualTo(SUB_2_ID)
+ }
+
+ @Test
+ fun activeRepo_nullIfActiveDataSubIdBecomesInvalid() = runTest {
+ val latest by underTest.activeMobileDataRepository.collectLastValue()
+ testScope.runCurrent()
+
+ // GIVEN the subscription list is then updated which includes the active data sub id
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ assertThat(latest).isNotNull()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(INVALID_SUBSCRIPTION_ID)
+ testScope.runCurrent()
+
+ assertThat(latest).isNull()
+ }
+
+ @Test
+ /** Regression test for b/268146648. */
+ fun activeSubIdIsSetBeforeSubscriptionsAreUpdated_doesNotThrow() = runTest {
+ val activeRepo by underTest.activeMobileDataRepository.collectLastValue()
+ val subscriptions by underTest.subscriptions.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ assertThat(subscriptions).isEmpty()
+ assertThat(activeRepo).isNull()
+ }
+
+ @Test
+ fun getRepoForSubId_activeDataSubIdIsRequestedBeforeSubscriptionsUpdate() = runTest {
+ underTest
+
+ var latestActiveRepo: MobileConnectionRepositoryKairos? = null
+ testScope.backgroundScope.launch {
+ kairos.activateSpec {
+ underTest.activeMobileDataSubscriptionId
+ .combine(underTest.mobileConnectionsBySubId) { id, conns ->
+ id?.let { conns[id] }
+ }
+ .observe {
+ if (it != null) {
+ latestActiveRepo = it
+ }
+ }
+ }
+ }
+
+ val latestSubscriptions by underTest.subscriptions.collectLastValue()
+ testScope.runCurrent()
+
+ // Active data subscription id is sent, but no subscription change has been posted yet
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ // Subscriptions list is empty
+ assertThat(latestSubscriptions).isEmpty()
+
+ // getRepoForSubId does not throw
+ assertThat(latestActiveRepo).isNull()
+ }
+
+ @Test
+ fun activeDataSentBeforeSubscriptionList_subscriptionReusesActiveDataRepo() = runTest {
+ val activeRepo by underTest.activeMobileDataRepository.collectLastValue()
+ testScope.runCurrent()
+
+ // GIVEN active repo is updated before the subscription list updates
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ assertThat(activeRepo).isNull()
+
+ // GIVEN the subscription list is then updated which includes the active data sub id
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+ testScope.runCurrent()
+
+ // WHEN requesting a connection repository for the subscription
+ val newRepo =
+ kairos.transact { underTest.mobileConnectionsBySubId.map { it[SUB_2_ID] }.sample() }
+
+ // THEN the newly request repo has been cached and reused
+ assertThat(activeRepo).isSameInstanceAs(newRepo)
+ }
+
+ @Test
+ fun testConnectionRepository_validSubId_isCached() = runTest {
+ underTest
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repo1 by underTest.mobileConnectionsBySubId.map { it[SUB_1_ID] }.collectLastValue()
+ val repo2 by underTest.mobileConnectionsBySubId.map { it[SUB_1_ID] }.collectLastValue()
+
+ assertThat(repo1).isNotNull()
+ assertThat(repo1).isSameInstanceAs(repo2)
+ }
+
+ @Test
+ fun testConnectionRepository_carrierMergedSubId_isCached() = runTest {
+ underTest
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, WIFI_NETWORK_CAPS_CM)
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repo1 by underTest.mobileConnectionsBySubId.map { it[SUB_CM_ID] }.collectLastValue()
+ val repo2 by underTest.mobileConnectionsBySubId.map { it[SUB_CM_ID] }.collectLastValue()
+
+ assertThat(repo1).isNotNull()
+ assertThat(repo1).isSameInstanceAs(repo2)
+ }
+
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ @Test
+ fun testDeviceEmergencyCallState_eagerlyChecksState() = runTest {
+ val latest by underTest.isDeviceEmergencyCallCapable.collectLastValue()
+
+ // Value starts out false
+ assertThat(latest).isFalse()
+ telephonyManager.stub { on { activeModemCount } doReturn 1 }
+ whenever(telephonyManager.getServiceStateForSlot(any())).thenAnswer { _ ->
+ ServiceState().apply { isEmergencyOnly = true }
+ }
+
+ // WHEN an appropriate intent gets sent out
+ val intent = serviceStateIntent(subId = -1)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+ testScope.runCurrent()
+
+ // THEN the repo's state is updated despite no listeners
+ assertThat(latest).isEqualTo(true)
+ }
+
+ @Test
+ fun testDeviceEmergencyCallState_aggregatesAcrossSlots_oneTrue() = runTest {
+ val latest by underTest.isDeviceEmergencyCallCapable.collectLastValue()
+
+ // GIVEN there are multiple slots
+ telephonyManager.stub { on { activeModemCount } doReturn 4 }
+ // GIVEN only one of them reports ECM
+ whenever(telephonyManager.getServiceStateForSlot(any())).thenAnswer { invocation ->
+ when (invocation.getArgument(0) as Int) {
+ 0 -> ServiceState().apply { isEmergencyOnly = false }
+ 1 -> ServiceState().apply { isEmergencyOnly = false }
+ 2 -> ServiceState().apply { isEmergencyOnly = true }
+ 3 -> ServiceState().apply { isEmergencyOnly = false }
+ else -> null
+ }
+ }
+
+ // GIVEN a broadcast goes out for the appropriate subID
+ val intent = serviceStateIntent(subId = -1)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+ testScope.runCurrent()
+
+ // THEN the device is in ECM, because one of the service states is
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun testDeviceEmergencyCallState_aggregatesAcrossSlots_allFalse() = runTest {
+ val latest by underTest.isDeviceEmergencyCallCapable.collectLastValue()
+
+ // GIVEN there are multiple slots
+ telephonyManager.stub { on { activeModemCount } doReturn 4 }
+ // GIVEN only one of them reports ECM
+ whenever(telephonyManager.getServiceStateForSlot(any())).thenAnswer { invocation ->
+ when (invocation.getArgument(0) as Int) {
+ 0 -> ServiceState().apply { isEmergencyOnly = false }
+ 1 -> ServiceState().apply { isEmergencyOnly = false }
+ 2 -> ServiceState().apply { isEmergencyOnly = false }
+ 3 -> ServiceState().apply { isEmergencyOnly = false }
+ else -> null
+ }
+ }
+
+ // GIVEN a broadcast goes out for the appropriate subID
+ val intent = serviceStateIntent(subId = -1)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+ testScope.runCurrent()
+
+ // THEN the device is in ECM, because one of the service states is
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun testConnectionCache_clearsInvalidSubscriptions() = runTest {
+ underTest
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repoCache by underTest.mobileConnectionsBySubId.collectLastValue()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID, SUB_2_ID)
+
+ // SUB_2 disappears
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID)
+ }
+
+ @Test
+ fun testConnectionCache_clearsInvalidSubscriptions_includingCarrierMerged() = runTest {
+ underTest
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, WIFI_NETWORK_CAPS_CM)
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2, SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repoCache by underTest.mobileConnectionsBySubId.collectLastValue()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID, SUB_2_ID, SUB_CM_ID)
+
+ // SUB_2 and SUB_CM disappear
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID)
+ }
+
+ /** Regression test for b/261706421 */
+ @Test
+ fun testConnectionsCache_clearMultipleSubscriptionsAtOnce_doesNotThrow() = runTest {
+ underTest
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repoCache by underTest.mobileConnectionsBySubId.collectLastValue()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID, SUB_2_ID)
+
+ // All subscriptions disappear
+ subscriptionManager.stub { on { completeActiveSubscriptionInfoList } doReturn listOf() }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(repoCache).isEmpty()
+ }
+
+ @Test
+ fun testDefaultDataSubId_updatesOnBroadcast() = runTest {
+ val latest by underTest.defaultDataSubId.collectLastValue()
+
+ assertThat(latest).isEqualTo(null)
+
+ val intent2 =
+ Intent(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ .putExtra(PhoneConstants.SUBSCRIPTION_KEY, SUB_2_ID)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent2)
+
+ assertThat(latest).isEqualTo(SUB_2_ID)
+
+ val intent1 =
+ Intent(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ .putExtra(PhoneConstants.SUBSCRIPTION_KEY, SUB_1_ID)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent1)
+
+ assertThat(latest).isEqualTo(SUB_1_ID)
+ }
+
+ @Test
+ fun defaultDataSubId_fetchesInitialValueOnStart() = runTest {
+ subscriptionManagerProxy.fake.defaultDataSubId = 2
+ val latest by underTest.defaultDataSubId.collectLastValue()
+
+ assertThat(latest).isEqualTo(2)
+ }
+
+ @Test
+ fun mobileIsDefault_startsAsFalse() = runTest {
+ assertThat(underTest.mobileIsDefault.collectLastValue().value).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_capsHaveCellular_isDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun mobileIsDefault_capsDoNotHaveCellular_isNotDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn false
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_carrierMergedViaMobile_isDefault() = runTest {
+ val carrierMergedInfo = mock<WifiInfo> { on { isCarrierMerged } doReturn true }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun mobileIsDefault_wifiDefault_mobileNotDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_ethernetDefault_mobileNotDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_ETHERNET) } doReturn true
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaWifi_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaMobile_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ private fun KairosTestScope.newWifiNetwork(wifiInfo: WifiInfo): Network {
+ val network = mock<Network>()
+ val capabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn wifiInfo
+ }
+ connectivityManager.stub { on { getNetworkCapabilities(network) } doReturn capabilities }
+ return network
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaWifiWithVcnTransport_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val underlyingWifi = newWifiNetwork(carrierMergedInfo)
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn vcnTransportInfo
+ on { underlyingNetworks } doReturn listOf(underlyingWifi)
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaMobileWithVcnTransport_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val underlyingWifi = newWifiNetwork(carrierMergedInfo)
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn vcnTransportInfo
+ on { underlyingNetworks } doReturn listOf(underlyingWifi)
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_isCarrierMergedViaUnderlyingWifi_isTrue() = runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ val underlyingNetwork = mock<Network>()
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val underlyingWifiCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+ connectivityManager.stub {
+ on { getNetworkCapabilities(underlyingNetwork) } doReturn underlyingWifiCapabilities
+ }
+
+ // WHEN the main capabilities have an underlying carrier merged network via WIFI
+ // transport and WifiInfo
+ val mainCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ on { underlyingNetworks } doReturn listOf(underlyingNetwork)
+ }
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, mainCapabilities)
+ setWifiState(isCarrierMerged = true)
+
+ // THEN there's a carrier merged connection
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_isCarrierMergedViaUnderlyingCellular_isTrue() = runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ val underlyingCarrierMergedNetwork = mock<Network>()
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+
+ // The Wifi network that is under the VCN network
+ val physicalWifiNetwork = newWifiNetwork(carrierMergedInfo)
+
+ val underlyingCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn vcnTransportInfo
+ on { underlyingNetworks } doReturn listOf(physicalWifiNetwork)
+ }
+ connectivityManager.stub {
+ on { getNetworkCapabilities(underlyingCarrierMergedNetwork) } doReturn
+ underlyingCapabilities
+ }
+
+ // WHEN the main capabilities have an underlying carrier merged network via CELLULAR
+ // transport and VcnTransportInfo
+ val mainCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ on { underlyingNetworks } doReturn listOf(underlyingCarrierMergedNetwork)
+ }
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, mainCapabilities)
+ setWifiState(isCarrierMerged = true)
+
+ // THEN there's a carrier merged connection
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun hasCarrierMergedConnection_defaultIsWifiNotCarrierMerged_wifiRepoIsCarrierMerged_isTrue() =
+ runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ // WHEN the default callback is TRANSPORT_WIFI but not carrier merged
+ val carrierMergedInfo = mock<WifiInfo> { on { isCarrierMerged } doReturn false }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ // BUT the wifi repo has gotten updates that it *is* carrier merged
+ setWifiState(isCarrierMerged = true)
+
+ // THEN hasCarrierMergedConnection is true
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/278618530. */
+ @Test
+ fun hasCarrierMergedConnection_defaultIsCellular_wifiRepoIsCarrierMerged_isFalse() = runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ // WHEN the default callback is TRANSPORT_CELLULAR and not carrier merged
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ }
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ // BUT the wifi repo has gotten updates that it *is* carrier merged
+ setWifiState(isCarrierMerged = true)
+
+ // THEN hasCarrierMergedConnection is **false** (The default network being CELLULAR
+ // takes precedence over the wifi network being carrier merged.)
+ assertThat(latest).isFalse()
+ }
+
+ /** Regression test for b/278618530. */
+ @Test
+ fun hasCarrierMergedConnection_defaultCellular_wifiIsCarrierMerged_airplaneMode_isTrue() =
+ runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ // WHEN the default callback is TRANSPORT_CELLULAR and not carrier merged
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ }
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ // BUT the wifi repo has gotten updates that it *is* carrier merged
+ setWifiState(isCarrierMerged = true)
+ // AND we're in airplane mode
+ airplaneModeRepository.setIsAirplaneMode(true)
+
+ // THEN hasCarrierMergedConnection is true.
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun defaultConnectionIsValidated_startsAsFalse() = runTest {
+ assertThat(underTest.defaultConnectionIsValidated.collectLastValue().value).isFalse()
+ }
+
+ @Test
+ fun defaultConnectionIsValidated_capsHaveValidated_isValidated() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn true
+ }
+
+ val latest by underTest.defaultConnectionIsValidated.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun defaultConnectionIsValidated_capsHaveNotValidated_isNotValidated() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn false
+ }
+
+ val latest by underTest.defaultConnectionIsValidated.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun config_initiallyFromContext() = runTest {
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ assertTrue(latest!!.areEqual(configFromContext))
+ assertTrue(latest!!.showAtLeast3G)
+ }
+
+ @Test
+ fun config_subIdChangeEvent_updated() = runTest {
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ assertThat(latest!!.showAtLeast3G).isFalse()
+
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ // WHEN the change event is fired
+ val intent =
+ Intent(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ .putExtra(PhoneConstants.SUBSCRIPTION_KEY, SUB_1_ID)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+
+ // THEN the config is updated
+ assertThat(latest?.areEqual(configFromContext)).isEqualTo(true)
+ assertThat(latest?.showAtLeast3G).isEqualTo(true)
+ }
+
+ @Test
+ fun config_carrierConfigChangeEvent_updated() = runTest {
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ assertThat(latest!!.showAtLeast3G).isFalse()
+
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ // WHEN the change event is fired
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(
+ applicationContext,
+ Intent(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED),
+ )
+
+ // THEN the config is updated
+ assertThat(latest?.areEqual(configFromContext)).isEqualTo(true)
+ assertThat(latest?.showAtLeast3G).isEqualTo(true)
+ }
+
+ @Test
+ fun carrierConfig_initialValueIsFetched() = runTest {
+ underTest
+ testScope.runCurrent()
+
+ // Value starts out false
+ assertThat(underTest.defaultDataSubRatConfig.sample().showAtLeast3G).isFalse()
+
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ assertThat(broadcastDispatcher.numReceiversRegistered).isAtLeast(1)
+
+ // WHEN the change event is fired
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(
+ applicationContext,
+ Intent(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED),
+ )
+ testScope.runCurrent()
+
+ // WHEN collection starts AFTER the broadcast is sent out
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ // THEN the config has the updated value
+ assertWithMessage("showAtLeast3G is false").that(latest!!.showAtLeast3G).isTrue()
+ assertWithMessage("not equal").that(latest!!.areEqual(configFromContext)).isTrue()
+ }
+
+ @Test
+ fun activeDataChange_inSameGroup_emitsUnit() = runTest {
+ var eventCount = 0
+ underTest
+ testScope.backgroundScope.launch {
+ kairos.activateSpec { underTest.activeSubChangedInGroupEvent.observe { eventCount++ } }
+ }
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_3_ID_GROUPED)
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_4_ID_GROUPED)
+ testScope.runCurrent()
+
+ assertThat(eventCount).isEqualTo(1)
+ }
+
+ @Test
+ fun activeDataChange_notInSameGroup_doesNotEmit() = runTest {
+ var eventCount = 0
+ underTest
+ testScope.backgroundScope.launch {
+ kairos.activateSpec { underTest.activeSubChangedInGroupEvent.observe { eventCount++ } }
+ }
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_3_ID_GROUPED)
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_1_ID)
+ testScope.runCurrent()
+
+ assertThat(eventCount).isEqualTo(0)
+ }
+
+ @Test
+ fun anySimSecure_propagatesStateFromKeyguardUpdateMonitor() = runTest {
+ val latest by underTest.isAnySimSecure.collectLastValue()
+ assertThat(latest).isFalse()
+
+ val updateMonitorCallback = argumentCaptor<KeyguardUpdateMonitorCallback>()
+ verify(keyguardUpdateMonitor).registerCallback(updateMonitorCallback.capture())
+
+ keyguardUpdateMonitor.stub { on { isSimPinSecure } doReturn true }
+ updateMonitorCallback.lastValue.onSimStateChanged(0, 0, 0)
+
+ assertThat(latest).isTrue()
+
+ keyguardUpdateMonitor.stub { on { isSimPinSecure } doReturn false }
+ updateMonitorCallback.lastValue.onSimStateChanged(0, 0, 0)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun getIsAnySimSecure_delegatesCallToKeyguardUpdateMonitor() = runTest {
+ val anySimSecure by underTest.isAnySimSecure.collectLastValue()
+
+ assertThat(anySimSecure).isFalse()
+
+ keyguardUpdateMonitor.stub { on { isSimPinSecure } doReturn true }
+ argumentCaptor<KeyguardUpdateMonitorCallback>()
+ .apply { verify(keyguardUpdateMonitor).registerCallback(capture()) }
+ .lastValue
+ .onSimStateChanged(0, 0, 0)
+
+ assertThat(anySimSecure).isTrue()
+ }
+
+ @Test
+ fun noSubscriptionsInEcmMode_notInEcmMode() = runTest {
+ val latest by underTest.isInEcmMode.collectLastValue()
+ testScope.runCurrent()
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun someSubscriptionsInEcmMode_inEcmMode() = runTest {
+ val latest by underTest.isInEcmMode.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<EmergencyCallbackModeListener>(telephonyManager)
+ .onCallbackModeStarted(0, mock(), 0)
+
+ assertThat(latest).isTrue()
+ }
+
+ private fun KairosTestScope.getDefaultNetworkCallback(): ConnectivityManager.NetworkCallback {
+ testScope.runCurrent()
+ val callbackCaptor = argumentCaptor<ConnectivityManager.NetworkCallback>()
+ verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture())
+ return callbackCaptor.lastValue
+ }
+
+ private fun KairosTestScope.setWifiState(isCarrierMerged: Boolean) {
+ if (isCarrierMerged) {
+ val mergedEntry =
+ mock<MergedCarrierEntry> {
+ on { isPrimaryNetwork } doReturn true
+ on { isDefaultNetwork } doReturn true
+ on { subscriptionId } doReturn SUB_CM_ID
+ }
+ wifiPickerTracker.stub {
+ on { mergedCarrierEntry } doReturn mergedEntry
+ on { connectedWifiEntry } doReturn null
+ }
+ } else {
+ val wifiEntry =
+ mock<WifiEntry> {
+ on { isPrimaryNetwork } doReturn true
+ on { isDefaultNetwork } doReturn true
+ }
+ wifiPickerTracker.stub {
+ on { connectedWifiEntry } doReturn wifiEntry
+ on { mergedCarrierEntry } doReturn null
+ }
+ }
+ wifiPickerTrackerCallback.allValues.forEach { it.onWifiEntriesChanged() }
+ }
+
+ private fun KairosTestScope.getSubscriptionCallback(): OnSubscriptionsChangedListener {
+ testScope.runCurrent()
+ return argumentCaptor<OnSubscriptionsChangedListener>()
+ .apply {
+ verify(subscriptionManager).addOnSubscriptionsChangedListener(any(), capture())
+ }
+ .lastValue
+ }
+
+ companion object {
+ // Subscription 1
+ private const val SUB_1_ID = 1
+ private const val SUB_1_NAME = "Carrier $SUB_1_ID"
+ private val GROUP_1 = ParcelUuid(UUID.randomUUID())
+ private val SUB_1 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_1_ID
+ on { groupUuid } doReturn GROUP_1
+ on { carrierName } doReturn SUB_1_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ groupUuid = GROUP_1,
+ carrierName = SUB_1_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // Subscription 2
+ private const val SUB_2_ID = 2
+ private const val SUB_2_NAME = "Carrier $SUB_2_ID"
+ private val GROUP_2 = ParcelUuid(UUID.randomUUID())
+ private val SUB_2 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_2_ID
+ on { groupUuid } doReturn GROUP_2
+ on { carrierName } doReturn SUB_2_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ groupUuid = GROUP_2,
+ carrierName = SUB_2_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // Subs 3 and 4 are considered to be in the same group ------------------------------------
+ private val GROUP_ID_3_4 = ParcelUuid(UUID.randomUUID())
+
+ // Subscription 3
+ private const val SUB_3_ID_GROUPED = 3
+ private val SUB_3 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_3_ID_GROUPED
+ on { groupUuid } doReturn GROUP_ID_3_4
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ // Subscription 4
+ private const val SUB_4_ID_GROUPED = 4
+ private val SUB_4 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_4_ID_GROUPED
+ on { groupUuid } doReturn GROUP_ID_3_4
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ // Subs 3 and 4 are considered to be in the same group ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+ private const val NET_ID = 123
+ private val NETWORK = mock<Network> { on { getNetId() } doReturn NET_ID }
+
+ // Carrier merged subscription
+ private const val SUB_CM_ID = 5
+ private const val SUB_CM_NAME = "Carrier $SUB_CM_ID"
+ private val SUB_CM =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_CM_ID
+ on { carrierName } doReturn SUB_CM_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_CM =
+ SubscriptionModel(
+ subscriptionId = SUB_CM_ID,
+ carrierName = SUB_CM_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private val WIFI_INFO_CM =
+ mock<WifiInfo> {
+ on { isPrimary } doReturn true
+ on { isCarrierMerged } doReturn true
+ on { subscriptionId } doReturn SUB_CM_ID
+ }
+ private val WIFI_NETWORK_CAPS_CM =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn WIFI_INFO_CM
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn true
+ }
+
+ private val WIFI_INFO_ACTIVE =
+ mock<WifiInfo> {
+ on { isPrimary } doReturn true
+ on { isCarrierMerged } doReturn false
+ }
+
+ private val WIFI_NETWORK_CAPS_ACTIVE =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn WIFI_INFO_ACTIVE
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn true
+ }
+
+ /**
+ * To properly mimic telephony manager, create a service state, and then turn it into an
+ * intent
+ */
+ private fun serviceStateIntent(subId: Int): Intent {
+ return Intent(Intent.ACTION_SERVICE_STATE).apply {
+ putExtra(SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX, subId)
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt
new file mode 100644
index 000000000000..c89dc5722c7a
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt
@@ -0,0 +1,868 @@
+/*
+ * Copyright (C) 2022 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.systemui.statusbar.pipeline.mobile.domain.interactor
+
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.telephony.CellSignalStrength
+import android.telephony.TelephonyManager.NETWORK_TYPE_UNKNOWN
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.mobile.MobileIconCarrierIdOverrides
+import com.android.settingslib.mobile.MobileIconCarrierIdOverridesImpl
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.CarrierMergedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.FIVE_G_OVERRIDE
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.FOUR_G
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.THREE_G
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
+import com.android.systemui.statusbar.pipeline.mobile.util.FakeMobileMappingsProxy
+import com.android.systemui.testKosmos
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyString
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileIconInteractorKairosTest : SysuiTestCase() {
+ private val kosmos = testKosmos()
+
+ private lateinit var underTest: MobileIconInteractorKairos
+ private val mobileMappingsProxy = FakeMobileMappingsProxy()
+ private val mobileIconsInteractor = FakeMobileIconsInteractor(mobileMappingsProxy, mock())
+
+ private val connectionRepository =
+ FakeMobileConnectionRepository(
+ SUB_1_ID,
+ logcatTableLogBuffer(kosmos, "MobileIconInteractorTest"),
+ )
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ @Before
+ fun setUp() {
+ underTest = createInteractor()
+
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled.value = true
+ connectionRepository.isInService.value = true
+ }
+
+ @Test
+ fun gsm_usesGsmLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = true
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(GSM_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun gsm_alwaysShowCdmaTrue_stillUsesGsmLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = true
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+ mobileIconsInteractor.alwaysUseCdmaLevel.value = true
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(GSM_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun notGsm_level_default_unknown() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = false
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN)
+ job.cancel()
+ }
+
+ @Test
+ fun notGsm_alwaysShowCdmaTrue_usesCdmaLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = false
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+ mobileIconsInteractor.alwaysUseCdmaLevel.value = true
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(CDMA_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun notGsm_alwaysShowCdmaFalse_usesPrimaryLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = false
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+ mobileIconsInteractor.alwaysUseCdmaLevel.value = false
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(GSM_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun numberOfLevels_comesFromRepo_whenApplicable() =
+ testScope.runTest {
+ var latest: Int? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = (it as? SignalIconModel.Cellular)?.numberOfLevels }
+ .launchIn(this)
+
+ connectionRepository.numberOfLevels.value = 5
+ assertThat(latest).isEqualTo(5)
+
+ connectionRepository.numberOfLevels.value = 4
+ assertThat(latest).isEqualTo(4)
+
+ job.cancel()
+ }
+
+ @Test
+ fun inflateSignalStrength_arbitrarilyAddsOneToTheReportedLevel() =
+ testScope.runTest {
+ connectionRepository.inflateSignalStrength.value = false
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ connectionRepository.primaryLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.inflateSignalStrength.value = true
+ connectionRepository.primaryLevel.value = 4
+
+ // when INFLATE_SIGNAL_STRENGTH is true, we add 1 to the reported signal level
+ assertThat(latest!!.level).isEqualTo(5)
+ }
+
+ @Test
+ fun networkSlice_configOn_hasPrioritizedCaps_showsSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = true
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = true
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun networkSlice_configOn_noPrioritizedCaps_noSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = true
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun networkSlice_configOff_hasPrioritizedCaps_noSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = false
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = true
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun networkSlice_configOff_noPrioritizedCaps_noSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = false
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun iconGroup_three_g() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(THREE_G))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest).isEqualTo(NetworkTypeIconModel.DefaultIcon(TelephonyIcons.THREE_G))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_updates_on_change() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(THREE_G))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(FOUR_G))
+
+ assertThat(latest).isEqualTo(NetworkTypeIconModel.DefaultIcon(TelephonyIcons.FOUR_G))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_5g_override_type() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(FIVE_G_OVERRIDE))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest).isEqualTo(NetworkTypeIconModel.DefaultIcon(TelephonyIcons.NR_5G))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_default_if_no_lookup() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(NETWORK_TYPE_UNKNOWN))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest)
+ .isEqualTo(NetworkTypeIconModel.DefaultIcon(FakeMobileIconsInteractor.DEFAULT_ICON))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_carrierMerged_usesOverride() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value = CarrierMergedNetworkType
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest)
+ .isEqualTo(
+ NetworkTypeIconModel.DefaultIcon(CarrierMergedNetworkType.iconGroupOverride)
+ )
+
+ job.cancel()
+ }
+
+ @Test
+ fun overrideIcon_usesCarrierIdOverride() =
+ testScope.runTest {
+ val overrides =
+ mock<MobileIconCarrierIdOverrides>().also {
+ whenever(it.carrierIdEntryExists(anyInt())).thenReturn(true)
+ whenever(it.getOverrideFor(anyInt(), anyString(), any())).thenReturn(1234)
+ }
+
+ underTest = createInteractor(overrides)
+
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(THREE_G))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest)
+ .isEqualTo(NetworkTypeIconModel.OverriddenIcon(TelephonyIcons.THREE_G, 1234))
+
+ job.cancel()
+ }
+
+ @Test
+ fun alwaysShowDataRatIcon_matchesParent() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.alwaysShowDataRatIcon.onEach { latest = it }.launchIn(this)
+
+ mobileIconsInteractor.alwaysShowDataRatIcon.value = true
+ assertThat(latest).isTrue()
+
+ mobileIconsInteractor.alwaysShowDataRatIcon.value = false
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun dataState_connected() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isDataConnected.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.dataConnectionState.value = DataConnectionState.Connected
+
+ assertThat(latest).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun dataState_notConnected() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isDataConnected.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.dataConnectionState.value = DataConnectionState.Disconnected
+
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun isInService_usesRepositoryValue() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isInService.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.isInService.value = true
+
+ assertThat(latest).isTrue()
+
+ connectionRepository.isInService.value = false
+
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun roaming_isGsm_usesConnectionModel() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isRoaming.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = true
+ connectionRepository.isRoaming.value = false
+
+ assertThat(latest).isFalse()
+
+ connectionRepository.isRoaming.value = true
+
+ assertThat(latest).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun roaming_isCdma_usesCdmaRoamingBit() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isRoaming.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.cdmaRoaming.value = false
+ connectionRepository.isGsm.value = false
+ connectionRepository.isRoaming.value = true
+
+ assertThat(latest).isFalse()
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = false
+ connectionRepository.isRoaming.value = false
+
+ assertThat(latest).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun roaming_falseWhileCarrierNetworkChangeActive() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isRoaming.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = false
+ connectionRepository.isRoaming.value = true
+ connectionRepository.carrierNetworkChangeActive.value = true
+
+ assertThat(latest).isFalse()
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = true
+
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun networkName_usesOperatorAlphaShortWhenNonNullAndRepoIsDefault() =
+ testScope.runTest {
+ var latest: NetworkNameModel? = null
+ val job = underTest.networkName.onEach { latest = it }.launchIn(this)
+
+ val testOperatorName = "operatorAlphaShort"
+
+ // Default network name, operator name is non-null, uses the operator name
+ connectionRepository.networkName.value = DEFAULT_NAME_MODEL
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived(testOperatorName))
+
+ // Default network name, operator name is null, uses the default
+ connectionRepository.operatorAlphaShort.value = null
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+
+ // Derived network name, operator name non-null, uses the derived name
+ connectionRepository.networkName.value = DERIVED_NAME_MODEL
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(DERIVED_NAME_MODEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun networkNameForSubId_usesOperatorAlphaShortWhenNonNullAndRepoIsDefault() =
+ testScope.runTest {
+ var latest: String? = null
+ val job = underTest.carrierName.onEach { latest = it }.launchIn(this)
+
+ val testOperatorName = "operatorAlphaShort"
+
+ // Default network name, operator name is non-null, uses the operator name
+ connectionRepository.carrierName.value = DEFAULT_NAME_MODEL
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(testOperatorName)
+
+ // Default network name, operator name is null, uses the default
+ connectionRepository.operatorAlphaShort.value = null
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME)
+
+ // Derived network name, operator name non-null, uses the derived name
+ connectionRepository.carrierName.value =
+ NetworkNameModel.SubscriptionDerived(DERIVED_NAME)
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(DERIVED_NAME)
+
+ job.cancel()
+ }
+
+ @Test
+ fun isSingleCarrier_matchesParent() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this)
+
+ mobileIconsInteractor.isSingleCarrier.value = true
+ assertThat(latest).isTrue()
+
+ mobileIconsInteractor.isSingleCarrier.value = false
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun isForceHidden_matchesParent() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isForceHidden.onEach { latest = it }.launchIn(this)
+
+ mobileIconsInteractor.isForceHidden.value = true
+ assertThat(latest).isTrue()
+
+ mobileIconsInteractor.isForceHidden.value = false
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_matchesRepo() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.isAllowedDuringAirplaneMode)
+
+ connectionRepository.isAllowedDuringAirplaneMode.value = true
+ assertThat(latest).isTrue()
+
+ connectionRepository.isAllowedDuringAirplaneMode.value = false
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun cellBasedIconId_correctLevel_notCutout() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+ connectionRepository.primaryLevel.value = 1
+ connectionRepository.setDataEnabled(false)
+ connectionRepository.isNonTerrestrial.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest?.level).isEqualTo(1)
+ assertThat(latest?.showExclamationMark).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun icon_usesLevelFromInteractor() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+
+ var latest: SignalIconModel? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.primaryLevel.value = 3
+ assertThat(latest!!.level).isEqualTo(3)
+
+ connectionRepository.primaryLevel.value = 1
+ assertThat(latest!!.level).isEqualTo(1)
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_usesNumberOfLevelsFromInteractor() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ connectionRepository.numberOfLevels.value = 5
+ assertThat(latest!!.numberOfLevels).isEqualTo(5)
+
+ connectionRepository.numberOfLevels.value = 2
+ assertThat(latest!!.numberOfLevels).isEqualTo(2)
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_defaultDataDisabled_showExclamationTrue() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest!!.showExclamationMark).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_defaultConnectionFailed_showExclamationTrue() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ mobileIconsInteractor.isDefaultConnectionFailed.value = true
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest!!.showExclamationMark).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_enabledAndNotFailed_showExclamationFalse() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled.value = true
+ mobileIconsInteractor.isDefaultConnectionFailed.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest!!.showExclamationMark).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_usesEmptyState_whenNotInService() =
+ testScope.runTest {
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = false
+
+ assertThat(latest?.level).isEqualTo(0)
+ assertThat(latest?.showExclamationMark).isTrue()
+
+ // Changing the level doesn't overwrite the disabled state
+ connectionRepository.primaryLevel.value = 2
+ assertThat(latest?.level).isEqualTo(0)
+ assertThat(latest?.showExclamationMark).isTrue()
+
+ // Once back in service, the regular icon appears
+ connectionRepository.isInService.value = true
+ assertThat(latest?.level).isEqualTo(2)
+ assertThat(latest?.showExclamationMark).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_usesCarrierNetworkState_whenInCarrierNetworkChangeMode() =
+ testScope.runTest {
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular? }
+ .launchIn(this)
+
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+ connectionRepository.carrierNetworkChangeActive.value = true
+ connectionRepository.primaryLevel.value = 1
+ connectionRepository.cdmaLevel.value = 1
+
+ assertThat(latest!!.level).isEqualTo(1)
+ assertThat(latest!!.carrierNetworkChange).isTrue()
+
+ // SignalIconModel respects the current level
+ connectionRepository.primaryLevel.value = 2
+
+ assertThat(latest!!.level).isEqualTo(2)
+ assertThat(latest!!.carrierNetworkChange).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun satBasedIcon_isUsedWhenNonTerrestrial() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // Start off using cellular
+ assertThat(latest).isInstanceOf(SignalIconModel.Cellular::class.java)
+
+ connectionRepository.isNonTerrestrial.value = true
+
+ assertThat(latest).isInstanceOf(SignalIconModel.Satellite::class.java)
+ }
+
+ @DisableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ // See b/346904529 for more context
+ fun satBasedIcon_doesNotInflateSignalStrength_flagOff() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+ // GIVEN this carrier has set INFLATE_SIGNAL_STRENGTH
+ connectionRepository.inflateSignalStrength.value = true
+
+ connectionRepository.primaryLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.inflateSignalStrength.value = true
+ connectionRepository.primaryLevel.value = 4
+
+ // Icon level is unaffected
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ @EnableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ // See b/346904529 for more context
+ fun satBasedIcon_doesNotInflateSignalStrength_flagOn() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+ // GIVEN this carrier has set INFLATE_SIGNAL_STRENGTH
+ connectionRepository.inflateSignalStrength.value = true
+
+ connectionRepository.satelliteLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.inflateSignalStrength.value = true
+ connectionRepository.primaryLevel.value = 4
+
+ // Icon level is unaffected
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ @DisableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ fun satBasedIcon_usesPrimaryLevel_flagOff() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+
+ // GIVEN primary level is set
+ connectionRepository.primaryLevel.value = 4
+ connectionRepository.satelliteLevel.value = 0
+
+ // THEN icon uses the primary level because the flag is off
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ @EnableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ fun satBasedIcon_usesSatelliteLevel_flagOn() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+
+ // GIVEN satellite level is set
+ connectionRepository.satelliteLevel.value = 4
+ connectionRepository.primaryLevel.value = 0
+
+ // THEN icon uses the satellite level because the flag is on
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ /**
+ * Context (b/377518113), this test will not be needed after FLAG_CARRIER_ROAMING_NB_IOT_NTN is
+ * rolled out. The new API should report 0 automatically if not in service.
+ */
+ @DisableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ fun satBasedIcon_reportsLevelZeroWhenOutOfService() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+ // GIVEN this carrier has set INFLATE_SIGNAL_STRENGTH
+ connectionRepository.inflateSignalStrength.value = true
+
+ connectionRepository.primaryLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.isInService.value = false
+ connectionRepository.primaryLevel.value = 4
+
+ // THEN level reports 0, by policy
+ assertThat(latest!!.level).isEqualTo(0)
+ }
+
+ private fun createInteractor(
+ overrides: MobileIconCarrierIdOverrides = MobileIconCarrierIdOverridesImpl()
+ ) =
+ MobileIconInteractorKairosImpl(
+ testScope.backgroundScope,
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled,
+ mobileIconsInteractor.alwaysShowDataRatIcon,
+ mobileIconsInteractor.alwaysUseCdmaLevel,
+ mobileIconsInteractor.isSingleCarrier,
+ mobileIconsInteractor.mobileIsDefault,
+ mobileIconsInteractor.defaultMobileIconMapping,
+ mobileIconsInteractor.defaultMobileIconGroup,
+ mobileIconsInteractor.isDefaultConnectionFailed,
+ mobileIconsInteractor.isForceHidden,
+ connectionRepository,
+ context,
+ overrides,
+ )
+
+ companion object {
+ private const val GSM_LEVEL = 1
+ private const val CDMA_LEVEL = 2
+
+ private const val SUB_1_ID = 1
+
+ private const val DEFAULT_NAME = "test default name"
+ private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+ private const val DERIVED_NAME = "test derived name"
+ private val DERIVED_NAME_MODEL = NetworkNameModel.IntentDerived(DERIVED_NAME)
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt
new file mode 100644
index 000000000000..a9360d139a3d
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt
@@ -0,0 +1,1046 @@
+/*
+ * Copyright (C) 2022 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.systemui.statusbar.pipeline.mobile.domain.interactor
+
+import android.os.ParcelUuid
+import android.platform.test.annotations.EnableFlags
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.SubscriptionManager.PROFILE_CLASS_PROVISIONING
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.flags.Flags
+import com.android.systemui.flags.fake
+import com.android.systemui.flags.featureFlagsClassic
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.collectLastValue
+import com.android.systemui.kosmos.runCurrent
+import com.android.systemui.kosmos.runTest
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.statusbar.core.NewStatusBarIcons
+import com.android.systemui.statusbar.core.StatusBarRootModernization
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fake
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fakeMobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileConnectionsRepositoryLogbufferName
+import com.android.systemui.statusbar.pipeline.shared.data.model.ConnectivitySlot
+import com.android.systemui.statusbar.pipeline.shared.data.repository.connectivityRepository
+import com.android.systemui.statusbar.pipeline.shared.data.repository.fake
+import com.android.systemui.statusbar.policy.data.repository.FakeUserSetupRepository
+import com.android.systemui.testKosmos
+import com.android.systemui.util.CarrierConfigTracker
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import kotlinx.coroutines.test.advanceTimeBy
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileIconsInteractorKairosTest : SysuiTestCase() {
+ private val kosmos by lazy {
+ testKosmos().apply {
+ mobileConnectionsRepositoryLogbufferName = "MobileIconsInteractorTest"
+ mobileConnectionsRepository.fake.run {
+ setMobileConnectionRepositoryMap(
+ mapOf(
+ SUB_1_ID to FakeMobileConnectionRepository(SUB_1_ID, mock()),
+ SUB_2_ID to FakeMobileConnectionRepository(SUB_2_ID, mock()),
+ SUB_3_ID to FakeMobileConnectionRepository(SUB_3_ID, mock()),
+ SUB_4_ID to FakeMobileConnectionRepository(SUB_4_ID, mock()),
+ )
+ )
+ setActiveMobileDataSubscriptionId(SUB_1_ID)
+ }
+ featureFlagsClassic.fake.set(Flags.FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS, true)
+ }
+ }
+
+ // shortcut rename
+ private val Kosmos.connectionsRepository by Fixture { mobileConnectionsRepository.fake }
+
+ private val Kosmos.carrierConfigTracker by Fixture { mock<CarrierConfigTracker>() }
+
+ private val Kosmos.underTest by Fixture {
+ MobileIconsInteractorKairosImpl(
+ mobileConnectionsRepository,
+ carrierConfigTracker,
+ tableLogger = mock(),
+ connectivityRepository,
+ FakeUserSetupRepository(),
+ testScope.backgroundScope,
+ context,
+ featureFlagsClassic,
+ )
+ }
+
+ @Test
+ fun filteredSubscriptions_default() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf<SubscriptionModel>())
+ }
+
+ // Based on the logic from the old pipeline, we'll never filter subs when there are more than 2
+ @Test
+ fun filteredSubscriptions_moreThanTwo_doesNotFilter() =
+ kosmos.runTest {
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_3_OPP, SUB_4_OPP))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_4_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(SUB_1, SUB_3_OPP, SUB_4_OPP))
+ }
+
+ @Test
+ fun filteredSubscriptions_nonOpportunistic_updatesWithMultipleSubs() =
+ kosmos.runTest {
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(SUB_1, SUB_2))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_differentGroups_doesNotFilter() =
+ kosmos.runTest {
+ connectionsRepository.setSubscriptions(listOf(SUB_3_OPP, SUB_4_OPP))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(SUB_3_OPP, SUB_4_OPP))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_nonGrouped_doesNotFilter() =
+ kosmos.runTest {
+ val (sub1, sub2) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_2_ID),
+ opportunistic = Pair(true, true),
+ grouped = false,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_1_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1, sub2))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_grouped_configFalse_showsActive_3() =
+ kosmos.runTest {
+ val (sub3, sub4) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_3_ID, SUB_4_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub3, sub4))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the active one when the config is false
+ assertThat(latest).isEqualTo(listOf(sub3))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_grouped_configFalse_showsActive_4() =
+ kosmos.runTest {
+ val (sub3, sub4) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_3_ID, SUB_4_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub3, sub4))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_4_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the active one when the config is false
+ assertThat(latest).isEqualTo(listOf(sub4))
+ }
+
+ @Test
+ fun filteredSubscriptions_oneOpportunistic_grouped_configTrue_showsPrimary_active_1() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(false, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_1_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(true)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the primary (non-opportunistic) if the config is
+ // true
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_oneOpportunistic_grouped_configTrue_showsPrimary_nonActive_1() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(false, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(true)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the primary (non-opportunistic) if the config is
+ // true
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_vcnSubId_agreesWithActiveSubId_usesActiveAkaVcnSub() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ kosmos.connectivityRepository.fake.vcnSubId.value = SUB_3_ID
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub3))
+ }
+
+ @Test
+ fun filteredSubscriptions_vcnSubId_disagreesWithActiveSubId_usesVcnSub() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ kosmos.connectivityRepository.fake.vcnSubId.value = SUB_1_ID
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_doesNotFilterProvisioningWhenFlagIsFalse() =
+ kosmos.runTest {
+ // GIVEN the flag is false
+ featureFlagsClassic.fake.set(Flags.FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS, false)
+
+ // GIVEN 1 sub that is in PROFILE_CLASS_PROVISIONING
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ isOpportunistic = false,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(sub1))
+
+ // WHEN filtering is applied
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // THEN the provisioning sub is still present (unfiltered)
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_filtersOutProvisioningSubs() =
+ kosmos.runTest {
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ isOpportunistic = false,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ isOpportunistic = false,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ /** Note: I'm not sure if this will ever be the case, but we can test it at least */
+ @Test
+ fun filteredSubscriptions_filtersOutProvisioningSubsBeforeOpportunistic() =
+ kosmos.runTest {
+ // This is a contrived test case, where the active subId is the one that would
+ // also be filtered by opportunistic filtering.
+
+ // GIVEN grouped, opportunistic subscriptions
+ val groupUuid = ParcelUuid(UUID.randomUUID())
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = 1,
+ isOpportunistic = true,
+ groupUuid = groupUuid,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = 2,
+ isOpportunistic = true,
+ groupUuid = groupUuid,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // GIVEN active subId is 1
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2))
+ connectionsRepository.setActiveMobileDataSubscriptionId(1)
+
+ // THEN filtering of provisioning subs takes place first, and we result in sub2
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub2))
+ }
+
+ @Test
+ fun filteredSubscriptions_groupedPairAndNonProvisioned_groupedFilteringStillHappens() =
+ kosmos.runTest {
+ // Grouped filtering only happens when the list of subs is length 2. In this case
+ // we'll show that filtering of provisioning subs happens before, and thus grouped
+ // filtering happens even though the unfiltered list is length 3
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = 2,
+ isOpportunistic = true,
+ groupUuid = null,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(1)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_subNotExclusivelyNonTerrestrial_hasSub() =
+ kosmos.runTest {
+ val notExclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = false,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(notExclusivelyNonTerrestrialSub))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(notExclusivelyNonTerrestrialSub))
+ }
+
+ @Test
+ fun filteredSubscriptions_subExclusivelyNonTerrestrial_doesNotHaveSub() =
+ kosmos.runTest {
+ val exclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = true,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(exclusivelyNonTerrestrialSub))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEmpty()
+ }
+
+ @Test
+ fun filteredSubscription_mixOfExclusivelyNonTerrestrialAndOther_hasOtherSubsOnly() =
+ kosmos.runTest {
+ val exclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = true,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ val otherSub1 =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = false,
+ subscriptionId = 1,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ val otherSub2 =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = false,
+ subscriptionId = 2,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ connectionsRepository.setSubscriptions(
+ listOf(otherSub1, exclusivelyNonTerrestrialSub, otherSub2)
+ )
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(otherSub1, otherSub2))
+ }
+
+ @Test
+ fun filteredSubscriptions_exclusivelyNonTerrestrialSub_andOpportunistic_bothFiltersHappen() =
+ kosmos.runTest {
+ // Exclusively non-terrestrial sub
+ val exclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = true,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // Opportunistic subs
+ val (sub3, sub4) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_3_ID, SUB_4_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+
+ // WHEN both an exclusively non-terrestrial sub and opportunistic sub pair is included
+ connectionsRepository.setSubscriptions(listOf(sub3, sub4, exclusivelyNonTerrestrialSub))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // THEN both the only-non-terrestrial sub and the non-active sub are filtered out,
+ // leaving only sub3.
+ assertThat(latest).isEqualTo(listOf(sub3))
+ }
+
+ @Test
+ fun activeDataConnection_turnedOn() =
+ kosmos.runTest {
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .dataEnabled
+ .value = true
+
+ val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun activeDataConnection_turnedOff() =
+ kosmos.runTest {
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .dataEnabled
+ .value = true
+
+ val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled)
+
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .dataEnabled
+ .value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun activeDataConnection_invalidSubId() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled)
+
+ connectionsRepository.setActiveMobileDataSubscriptionId(INVALID_SUBSCRIPTION_ID)
+
+ // An invalid active subId should tell us that data is off
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_default_validated_notFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_notDefault_notValidated_notFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = false
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_default_notValidated_failed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun failedConnection_carrierMergedDefault_notValidated_failed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.hasCarrierMergedConnection.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/275076959. */
+ @Test
+ fun failedConnection_dataSwitchInSameGroup_notFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // WHEN there's a data change in the same subscription group
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // THEN the default connection is *not* marked as failed because of forced validation
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_dataSwitchNotInSameGroup_isFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // WHEN the connection is invalidated without a activeSubChangedInGroupEvent
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ // THEN the connection is immediately marked as failed
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun alwaysShowDataRatIcon_configHasTrue() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysShowDataRatIcon)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowDataRatIcon = true
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun alwaysShowDataRatIcon_configHasFalse() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysShowDataRatIcon)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowDataRatIcon = false
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun alwaysUseCdmaLevel_configHasTrue() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysUseCdmaLevel)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowCdmaRssi = true
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun alwaysUseCdmaLevel_configHasFalse() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysUseCdmaLevel)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowCdmaRssi = false
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isSingleCarrier_zeroSubscriptions_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(emptyList())
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isSingleCarrier_oneSubscription_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1))
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun isSingleCarrier_twoSubscriptions_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isSingleCarrier_updates() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1))
+ assertThat(latest).isTrue()
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_mobileFalseAndCarrierMergedFalse_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = false
+ connectionsRepository.hasCarrierMergedConnection.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_mobileTrueAndCarrierMergedFalse_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.hasCarrierMergedConnection.value = false
+
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun mobileIsDefault_mobileFalseAndCarrierMergedTrue_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = false
+ connectionsRepository.hasCarrierMergedConnection.value = true
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun mobileIsDefault_updatesWhenRepoUpdates() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = true
+ assertThat(latest).isTrue()
+
+ connectionsRepository.mobileIsDefault.value = false
+ assertThat(latest).isFalse()
+
+ connectionsRepository.hasCarrierMergedConnection.value = true
+ assertThat(latest).isTrue()
+ }
+
+ // The data switch tests are mostly testing the [forcingCellularValidation] flow, but that flow
+ // is private and can only be tested by looking at [isDefaultConnectionFailed].
+
+ @Test
+ fun dataSwitch_inSameGroup_validatedMatchesPreviousValue_expiresAfter2s() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // Trigger a data change in the same subscription group that's not yet validated
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // After 1s, the force validation bit is still present, so the connection is not marked
+ // as failed
+ testScope.advanceTimeBy(1000)
+ assertThat(latest).isFalse()
+
+ // After 2s, the force validation expires so the connection updates to failed
+ testScope.advanceTimeBy(1001)
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun dataSwitch_inSameGroup_notValidated_immediatelyMarkedAsFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun dataSwitch_loseValidation_thenSwitchHappens_clearsForcedBit() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ // GIVEN the network starts validated
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // WHEN a data change happens in the same group
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ // WHEN the validation bit is lost
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // WHEN another data change happens in the same group
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ // THEN the forced validation bit is still used...
+ assertThat(latest).isFalse()
+
+ testScope.advanceTimeBy(1000)
+ assertThat(latest).isFalse()
+
+ // ... but expires after 2s
+ testScope.advanceTimeBy(1001)
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun dataSwitch_whileAlreadyForcingValidation_resetsClock() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ testScope.advanceTimeBy(1000)
+
+ // WHEN another change in same group event happens
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // THEN the forced validation remains for exactly 2 more seconds from now
+
+ // 1.500s from second event
+ testScope.advanceTimeBy(1500)
+ assertThat(latest).isFalse()
+
+ // 2.001s from the second event
+ testScope.advanceTimeBy(501)
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun isForceHidden_repoHasMobileHidden_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isForceHidden)
+
+ kosmos.connectivityRepository.fake.setForceHiddenIcons(setOf(ConnectivitySlot.MOBILE))
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun isForceHidden_repoDoesNotHaveMobileHidden_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isForceHidden)
+
+ kosmos.connectivityRepository.fake.setForceHiddenIcons(setOf(ConnectivitySlot.WIFI))
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun iconInteractor_cachedPerSubId() =
+ kosmos.runTest {
+ val interactor1 = underTest.getMobileConnectionInteractorForSubId(SUB_1_ID)
+ val interactor2 = underTest.getMobileConnectionInteractorForSubId(SUB_1_ID)
+
+ assertThat(interactor1).isNotNull()
+ assertThat(interactor1).isSameInstanceAs(interactor2)
+ }
+
+ @Test
+ fun deviceBasedEmergencyMode_emergencyCallsOnly_followsDeviceServiceStateFromRepo() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDeviceInEmergencyCallsOnlyMode)
+
+ connectionsRepository.isDeviceEmergencyCallCapable.value = true
+
+ assertThat(latest).isTrue()
+
+ connectionsRepository.isDeviceEmergencyCallCapable.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun defaultDataSubId_tracksRepo() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.defaultDataSubId)
+
+ connectionsRepository.defaultDataSubId.value = 1
+
+ assertThat(latest).isEqualTo(1)
+
+ connectionsRepository.defaultDataSubId.value = 2
+
+ assertThat(latest).isEqualTo(2)
+ }
+
+ @Test
+ @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
+ fun isStackable_tracksNumberOfSubscriptions() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isStackable)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1))
+ assertThat(latest).isFalse()
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ assertThat(latest).isTrue()
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2, SUB_3_OPP))
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
+ fun isStackable_checksForTerrestrialConnections() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isStackable)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ setNumberOfLevelsForSubId(SUB_1_ID, 5)
+ setNumberOfLevelsForSubId(SUB_2_ID, 5)
+ assertThat(latest).isTrue()
+
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .isNonTerrestrial
+ .value = true
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
+ fun isStackable_checksForNumberOfBars() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isStackable)
+
+ // Number of levels is the same for both
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ setNumberOfLevelsForSubId(SUB_1_ID, 5)
+ setNumberOfLevelsForSubId(SUB_2_ID, 5)
+
+ assertThat(latest).isTrue()
+
+ // Change the number of levels to be different than SUB_2
+ setNumberOfLevelsForSubId(SUB_1_ID, 6)
+
+ assertThat(latest).isFalse()
+ }
+
+ private fun setNumberOfLevelsForSubId(subId: Int, numberOfLevels: Int) {
+ with(kosmos) {
+ (fakeMobileConnectionsRepository.getRepoForSubId(subId)
+ as FakeMobileConnectionRepository)
+ .numberOfLevels
+ .value = numberOfLevels
+ }
+ }
+
+ /**
+ * Convenience method for creating a pair of subscriptions to test the filteredSubscriptions
+ * flow.
+ */
+ private fun createSubscriptionPair(
+ subscriptionIds: Pair<Int, Int>,
+ opportunistic: Pair<Boolean, Boolean> = Pair(false, false),
+ grouped: Boolean = false,
+ ): Pair<SubscriptionModel, SubscriptionModel> {
+ val groupUuid = if (grouped) ParcelUuid(UUID.randomUUID()) else null
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = subscriptionIds.first,
+ isOpportunistic = opportunistic.first,
+ groupUuid = groupUuid,
+ carrierName = "Carrier ${subscriptionIds.first}",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = subscriptionIds.second,
+ isOpportunistic = opportunistic.second,
+ groupUuid = groupUuid,
+ carrierName = "Carrier ${opportunistic.second}",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ return Pair(sub1, sub2)
+ }
+
+ companion object {
+
+ private const val SUB_1_ID = 1
+ private val SUB_1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = "Carrier $SUB_1_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_2_ID = 2
+ private val SUB_2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ carrierName = "Carrier $SUB_2_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_3_ID = 3
+ private val SUB_3_OPP =
+ SubscriptionModel(
+ subscriptionId = SUB_3_ID,
+ isOpportunistic = true,
+ groupUuid = ParcelUuid(UUID.randomUUID()),
+ carrierName = "Carrier $SUB_3_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_4_ID = 4
+ private val SUB_4_OPP =
+ SubscriptionModel(
+ subscriptionId = SUB_4_ID,
+ isOpportunistic = true,
+ groupUuid = ParcelUuid(UUID.randomUUID()),
+ carrierName = "Carrier $SUB_4_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ }
+}
diff --git a/packages/SystemUI/res/drawable/media_output_dialog_seekbar_background.xml b/packages/SystemUI/res/drawable/media_output_dialog_seekbar_background.xml
index adeb81fcf820..592217bb0e8f 100644
--- a/packages/SystemUI/res/drawable/media_output_dialog_seekbar_background.xml
+++ b/packages/SystemUI/res/drawable/media_output_dialog_seekbar_background.xml
@@ -23,16 +23,24 @@
/>
<solid android:color="@android:color/transparent" />
<size
- android:height="64dp"/>
+ android:height="@dimen/media_output_dialog_item_height"/>
+ </shape>
+ </item>
+ <item android:id="@+id/contrast_dot" android:right="8dp" android:gravity="center_vertical|end">
+ <shape android:shape="oval">
+ <solid android:color="@color/media_dialog_seekbar_progress" />
+ <size
+ android:width="4dp"
+ android:height="4dp" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners
- android:radius="16dp"/>
+ android:radius="@dimen/media_output_dialog_background_radius"/>
<size
- android:height="64dp"/>
+ android:height="@dimen/media_output_dialog_item_height"/>
<solid android:color="@color/material_dynamic_primary80" />
</shape>
</clip>
diff --git a/packages/SystemUI/res/drawable/settingslib_track_off_background.xml b/packages/SystemUI/res/drawable/settingslib_track_off_background.xml
index 3a09284d10a0..284953a2818c 100644
--- a/packages/SystemUI/res/drawable/settingslib_track_off_background.xml
+++ b/packages/SystemUI/res/drawable/settingslib_track_off_background.xml
@@ -23,4 +23,5 @@
android:right="@dimen/settingslib_switch_thumb_margin"/>
<solid android:color="@color/settingslib_track_off_color"/>
<corners android:radius="@dimen/settingslib_switch_track_radius"/>
+ <stroke android:width="2dp" android:color="@color/settingslib_track_online_color"/>
</shape>
diff --git a/packages/SystemUI/res/layout/media_output_list_item_advanced.xml b/packages/SystemUI/res/layout/media_output_list_item_advanced.xml
index 6b868b3c7379..ec80cf0d2f54 100644
--- a/packages/SystemUI/res/layout/media_output_list_item_advanced.xml
+++ b/packages/SystemUI/res/layout/media_output_list_item_advanced.xml
@@ -151,6 +151,8 @@
<ImageButton
android:id="@+id/end_area_image_button"
android:background="@android:color/transparent"
+ android:padding="20dp"
+ android:scaleType="fitCenter"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt
index 94fca218c74f..f8f692dd9033 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt
@@ -124,10 +124,18 @@ constructor(
when (deviceItem.type) {
DeviceItemType.AUDIO_SHARING_MEDIA_BLUETOOTH_DEVICE -> {
uiEventLogger.log(BluetoothTileDialogUiEvent.CHECK_MARK_ACTION_BUTTON_CLICKED)
+ logger.logAudioSharingButtonClick(
+ AudioSharingButtonClick.CHECK_MARK,
+ deviceItem,
+ )
audioSharingInteractor.stopAudioSharing()
}
DeviceItemType.AVAILABLE_AUDIO_SHARING_MEDIA_BLUETOOTH_DEVICE -> {
uiEventLogger.log(BluetoothTileDialogUiEvent.PLUS_ACTION_BUTTON_CLICKED)
+ logger.logAudioSharingButtonClick(
+ AudioSharingButtonClick.PLUS_BUTTON,
+ deviceItem,
+ )
audioSharingInteractor.startAudioSharing()
}
else -> {
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractor.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractor.kt
index 832afb1799b1..7a76eed57095 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractor.kt
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.withContext
@@ -73,6 +74,7 @@ constructor(
private val context: Context,
private val localBluetoothManager: LocalBluetoothManager?,
private val audioSharingRepository: AudioSharingRepository,
+ private val logger: BluetoothTileDialogLogger,
@Background private val backgroundDispatcher: CoroutineDispatcher,
) : AudioSharingInteractor {
@@ -92,9 +94,12 @@ constructor(
override val audioSourceStateUpdate =
isAudioSharingOn
+ .onEach { logger.logAudioSharingStateChanged(it) }
.flatMapLatest {
if (it) {
- audioSharingRepository.audioSourceStateUpdate
+ audioSharingRepository.audioSourceStateUpdate.onEach {
+ logger.logAudioSourceStateUpdate()
+ }
} else {
emptyFlow()
}
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepository.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepository.kt
index 44f9769f5930..d84b34a5a933 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingRepository.kt
@@ -53,6 +53,7 @@ interface AudioSharingRepository {
class AudioSharingRepositoryImpl(
private val localBluetoothManager: LocalBluetoothManager,
private val settingsLibAudioSharingRepository: SettingsLibAudioSharingRepository,
+ private val logger: BluetoothTileDialogLogger,
@Background private val backgroundDispatcher: CoroutineDispatcher,
) : AudioSharingRepository {
@@ -79,7 +80,11 @@ class AudioSharingRepositoryImpl(
}
leAudioBroadcastProfile?.latestBluetoothLeBroadcastMetadata?.let { metadata ->
leAudioBroadcastAssistantProfile?.let {
- it.allConnectedDevices.forEach { sink -> it.addSource(sink, metadata, false) }
+ it.allConnectedDevices.forEach { sink ->
+ it.addSource(sink, metadata, false).also {
+ logger.logAudioSharingRequest(AudioSharingRequest.ADD_SOURCE)
+ }
+ }
}
}
}
@@ -99,7 +104,9 @@ class AudioSharingRepositoryImpl(
if (!settingsLibAudioSharingRepository.audioSharingAvailable()) {
return@withContext
}
- leAudioBroadcastProfile?.startPrivateBroadcast()
+ leAudioBroadcastProfile?.startPrivateBroadcast().also {
+ logger.logAudioSharingRequest(AudioSharingRequest.START_BROADCAST)
+ }
}
}
@@ -108,7 +115,9 @@ class AudioSharingRepositoryImpl(
if (!settingsLibAudioSharingRepository.audioSharingAvailable()) {
return@withContext
}
- leAudioBroadcastProfile?.stopLatestBroadcast()
+ leAudioBroadcastProfile?.stopLatestBroadcast().also {
+ logger.logAudioSharingRequest(AudioSharingRequest.STOP_BROADCAST)
+ }
}
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt
index 576acd2e5304..5a5a51e53d63 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt
@@ -218,7 +218,7 @@ constructor(
scrollViewContent.layoutParams.height = WRAP_CONTENT
lastUiUpdateMs = systemClock.elapsedRealtime()
lastItemRow = itemRow
- logger.logDeviceUiUpdate(lastUiUpdateMs - start)
+ logger.logDeviceUiUpdate(lastUiUpdateMs - start, deviceItem)
}
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogLogger.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogLogger.kt
index 06116f0a21c3..5f866c5522f6 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogLogger.kt
@@ -27,18 +27,29 @@ private const val TAG = "BluetoothTileDialogLog"
enum class BluetoothStateStage {
USER_TOGGLED,
BLUETOOTH_STATE_VALUE_SET,
- BLUETOOTH_STATE_CHANGE_RECEIVED
+ BLUETOOTH_STATE_CHANGE_RECEIVED,
}
enum class DeviceFetchTrigger {
FIRST_LOAD,
BLUETOOTH_STATE_CHANGE_RECEIVED,
- BLUETOOTH_CALLBACK_RECEIVED
+ BLUETOOTH_CALLBACK_RECEIVED,
+}
+
+enum class AudioSharingButtonClick {
+ PLUS_BUTTON,
+ CHECK_MARK,
+}
+
+enum class AudioSharingRequest {
+ START_BROADCAST,
+ STOP_BROADCAST,
+ ADD_SOURCE,
}
enum class JobStatus {
FINISHED,
- CANCELLED
+ CANCELLED,
}
class BluetoothTileDialogLogger
@@ -53,7 +64,7 @@ constructor(@BluetoothTileDialogLog private val logBuffer: LogBuffer) {
str1 = stage.toString()
str2 = state
},
- { "BluetoothState. stage=$str1 state=$str2" }
+ { "BluetoothState. stage=$str1 state=$str2" },
)
fun logDeviceClick(address: String, type: DeviceItemType) =
@@ -64,7 +75,7 @@ constructor(@BluetoothTileDialogLog private val logBuffer: LogBuffer) {
str1 = address
str2 = type.toString()
},
- { "DeviceClick. address=$str1 type=$str2" }
+ { "DeviceClick. address=$str1 type=$str2" },
)
fun logActiveDeviceChanged(address: String?, profileId: Int) =
@@ -75,7 +86,7 @@ constructor(@BluetoothTileDialogLog private val logBuffer: LogBuffer) {
str1 = address
int1 = profileId
},
- { "ActiveDeviceChanged. address=$str1 profileId=$int1" }
+ { "ActiveDeviceChanged. address=$str1 profileId=$int1" },
)
fun logProfileConnectionStateChanged(address: String, state: String, profileId: Int) =
@@ -87,7 +98,7 @@ constructor(@BluetoothTileDialogLog private val logBuffer: LogBuffer) {
str2 = state
int1 = profileId
},
- { "ProfileConnectionStateChanged. address=$str1 state=$str2 profileId=$int1" }
+ { "ProfileConnectionStateChanged. address=$str1 state=$str2 profileId=$int1" },
)
fun logBatteryChanged(address: String, key: Int, value: ByteArray?) =
@@ -99,7 +110,7 @@ constructor(@BluetoothTileDialogLog private val logBuffer: LogBuffer) {
int1 = key
str2 = value?.toString() ?: ""
},
- { "BatteryChanged. address=$str1 key=$int1 value=$str2" }
+ { "BatteryChanged. address=$str1 key=$int1 value=$str2" },
)
fun logDeviceFetch(status: JobStatus, trigger: DeviceFetchTrigger, duration: Long) =
@@ -111,18 +122,26 @@ constructor(@BluetoothTileDialogLog private val logBuffer: LogBuffer) {
str2 = trigger.toString()
long1 = duration
},
- { "DeviceFetch. status=$str1 trigger=$str2 duration=$long1" }
+ { "DeviceFetch. status=$str1 trigger=$str2 duration=$long1" },
)
- fun logDeviceUiUpdate(duration: Long) =
- logBuffer.log(TAG, DEBUG, { long1 = duration }, { "DeviceUiUpdate. duration=$long1" })
+ fun logDeviceUiUpdate(duration: Long, deviceItem: List<DeviceItem>) =
+ logBuffer.log(
+ TAG,
+ DEBUG,
+ {
+ long1 = duration
+ str1 = deviceItem.toString()
+ },
+ { "DeviceUiUpdate. duration=$long1 deviceItem=$str1" },
+ )
fun logDeviceClickInAudioSharingWhenEnabled(inAudioSharing: Boolean) {
logBuffer.log(
TAG,
DEBUG,
{ str1 = inAudioSharing.toString() },
- { "DeviceClick. in audio sharing=$str1" }
+ { "DeviceClick. in audio sharing=$str1" },
)
}
@@ -138,7 +157,36 @@ constructor(@BluetoothTileDialogLog private val logBuffer: LogBuffer) {
str1 = criteria
str2 = deviceItem.toString()
},
- { "$str1. deviceItem=$str2" }
+ { "$str1. deviceItem=$str2" },
)
}
+
+ fun logAudioSharingStateChanged(stateOn: Boolean) =
+ logBuffer.log(
+ TAG,
+ DEBUG,
+ { str1 = stateOn.toString() },
+ { "AudioSharingStateChanged. state=$str1" },
+ )
+
+ fun logAudioSourceStateUpdate() = logBuffer.log(TAG, DEBUG, {}, { "AudioSourceStateUpdate" })
+
+ fun logAudioSharingButtonClick(click: AudioSharingButtonClick, deviceItem: DeviceItem) =
+ logBuffer.log(
+ TAG,
+ DEBUG,
+ {
+ str1 = click.toString()
+ str2 = deviceItem.toString()
+ },
+ { "AudioSharingButtonClick. click=$str1 deviceItem=$str2" },
+ )
+
+ fun logAudioSharingRequest(apiCall: AudioSharingRequest) =
+ logBuffer.log(
+ TAG,
+ DEBUG,
+ { str1 = apiCall.toString() },
+ { "AudioSharingRequest. apiCall=$str1" },
+ )
}
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/dagger/AudioSharingModule.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/dagger/AudioSharingModule.kt
index afe9a1eec0b6..01c4b21bfd9f 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/dagger/AudioSharingModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/dagger/AudioSharingModule.kt
@@ -30,6 +30,7 @@ import com.android.systemui.bluetooth.qsdialog.AudioSharingRepositoryEmptyImpl
import com.android.systemui.bluetooth.qsdialog.AudioSharingRepositoryImpl
import com.android.systemui.bluetooth.qsdialog.AvailableAudioSharingMediaDeviceItemFactory
import com.android.systemui.bluetooth.qsdialog.AvailableMediaDeviceItemFactory
+import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogLogger
import com.android.systemui.bluetooth.qsdialog.ConnectedDeviceItemFactory
import com.android.systemui.bluetooth.qsdialog.DeviceItemActionInteractor
import com.android.systemui.bluetooth.qsdialog.DeviceItemActionInteractorImpl
@@ -53,6 +54,7 @@ interface AudioSharingModule {
fun provideAudioSharingRepository(
localBluetoothManager: LocalBluetoothManager?,
settingsLibAudioSharingRepository: SettingsLibAudioSharingRepository,
+ logger: BluetoothTileDialogLogger,
@Background backgroundDispatcher: CoroutineDispatcher,
): AudioSharingRepository =
if (
@@ -62,6 +64,7 @@ interface AudioSharingModule {
AudioSharingRepositoryImpl(
localBluetoothManager,
settingsLibAudioSharingRepository,
+ logger,
backgroundDispatcher,
)
} else {
diff --git a/packages/SystemUI/src/com/android/systemui/brightness/ui/compose/BrightnessSlider.kt b/packages/SystemUI/src/com/android/systemui/brightness/ui/compose/BrightnessSlider.kt
index b13f6df3f4f5..79748a255ed0 100644
--- a/packages/SystemUI/src/com/android/systemui/brightness/ui/compose/BrightnessSlider.kt
+++ b/packages/SystemUI/src/com/android/systemui/brightness/ui/compose/BrightnessSlider.kt
@@ -70,6 +70,7 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.app.tracing.coroutines.launchTraced as launch
+import com.android.compose.modifiers.padding
import com.android.compose.ui.graphics.drawInOverlay
import com.android.systemui.Flags
import com.android.systemui.biometrics.Utils.toBitmap
@@ -348,7 +349,13 @@ fun BrightnessSliderContainer(
DisposableEffect(Unit) { onDispose { viewModel.setIsDragging(false) } }
- Box(modifier = modifier.fillMaxWidth().sysuiResTag("brightness_slider")) {
+ Box(
+ modifier =
+ modifier
+ .padding(vertical = { SliderBackgroundFrameSize.height.roundToPx() })
+ .fillMaxWidth()
+ .sysuiResTag("brightness_slider")
+ ) {
BrightnessSlider(
gammaValue = gamma,
valueRange = viewModel.minBrightness.value..viewModel.maxBrightness.value,
diff --git a/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt b/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt
index 9fddbfb16d4d..a1546949835c 100644
--- a/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt
+++ b/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt
@@ -16,6 +16,11 @@
package com.android.systemui.log.table
+import com.android.systemui.kairos.BuildScope
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.changes
+import com.android.systemui.kairos.effect
import com.android.systemui.util.kotlin.pairwiseBy
import kotlinx.coroutines.flow.Flow
@@ -184,3 +189,94 @@ fun <T> Flow<List<T>>.logDiffsForTable(
newVal
}
}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logIntDiffsForTable")
+fun BuildScope.logDiffsForTable(
+ intState: State<Int?>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ intState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new, isInitial = isInitial)
+ isInitial = false
+ }
+}
+
+/**
+ * Each time the flow is updated with a new value, logs the differences between the previous value
+ * and the new value to the given [tableLogBuffer].
+ *
+ * The new value's [Diffable.logDiffs] method will be used to log the differences to the table.
+ *
+ * @param columnPrefix a prefix that will be applied to every column name that gets logged.
+ */
+@ExperimentalKairosApi
+fun <T : Diffable<T>> BuildScope.logDiffsForTable(
+ diffableState: State<T>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+) {
+ val initialValue = diffableState.sampleDeferred()
+ effect {
+ // Fully log the initial value to the table.
+ tableLogBuffer.logChange(columnPrefix, isInitial = true) { row ->
+ initialValue.value.logFull(row)
+ }
+ }
+ diffableState.changes.observe { newState ->
+ val prevState = diffableState.sample()
+ tableLogBuffer.logDiffs(columnPrefix, prevVal = prevState, newVal = newState)
+ }
+}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logBooleanDiffsForTable")
+fun BuildScope.logDiffsForTable(
+ booleanState: State<Boolean>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ booleanState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new, isInitial = isInitial)
+ isInitial = false
+ }
+}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logStringDiffsForTable")
+fun BuildScope.logDiffsForTable(
+ stringState: State<String?>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ stringState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new, isInitial = isInitial)
+ isInitial = false
+ }
+}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logListDiffsForTable")
+fun <T> BuildScope.logDiffsForTable(
+ listState: State<List<T>>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ listState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new.toString(), isInitial = isInitial)
+ isInitial = false
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapterLegacy.java b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapterLegacy.java
index f8e57ef489aa..300a3578bb8f 100644
--- a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapterLegacy.java
+++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapterLegacy.java
@@ -186,8 +186,7 @@ public class MediaOutputAdapterLegacy extends MediaOutputAdapterBase {
mVolumeValueText.setTextColor(mController.getColorItemContent());
mIconAreaLayout.setBackground(null);
updateIconAreaClickListener(null);
- mSeekBar.setProgressTintList(
- ColorStateList.valueOf(mController.getColorSeekbarProgress()));
+ updateSeekBarProgressColor();
updateContainerContentA11yImportance(true /* isImportant */);
renderItem(mediaItem, position);
}
@@ -332,6 +331,16 @@ public class MediaOutputAdapterLegacy extends MediaOutputAdapterBase {
}
}
+ private void updateSeekBarProgressColor() {
+ mSeekBar.setProgressTintList(
+ ColorStateList.valueOf(mController.getColorSeekbarProgress()));
+ final Drawable contrastDotDrawable =
+ ((LayerDrawable) mSeekBar.getProgressDrawable()).findDrawableByLayerId(
+ R.id.contrast_dot);
+ contrastDotDrawable.setTintList(
+ ColorStateList.valueOf(mController.getColorItemContent()));
+ }
+
void updateSeekbarProgressBackground() {
final ClipDrawable clipDrawable =
(ClipDrawable) ((LayerDrawable) mSeekBar.getProgressDrawable())
diff --git a/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
index 44c8dc38ff8c..5e7e0c97a147 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
@@ -42,7 +42,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
@@ -128,6 +127,7 @@ import com.android.systemui.qs.panels.ui.compose.QuickQuickSettings
import com.android.systemui.qs.panels.ui.compose.TileGrid
import com.android.systemui.qs.shared.ui.ElementKeys
import com.android.systemui.qs.ui.composable.QuickSettingsShade
+import com.android.systemui.qs.ui.composable.QuickSettingsShade.systemGestureExclusionInShade
import com.android.systemui.qs.ui.composable.QuickSettingsTheme
import com.android.systemui.res.R
import com.android.systemui.util.LifecycleFragment
@@ -716,10 +716,7 @@ constructor(
BrightnessSliderContainer(
viewModel = containerViewModel.brightnessSliderViewModel,
modifier =
- Modifier.fillMaxWidth()
- .height(
- QuickSettingsShade.Dimensions.BrightnessSliderHeight
- ),
+ Modifier.systemGestureExclusionInShade().fillMaxWidth(),
)
}
val TileGrid =
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/scroll/LongScreenshotActivity.java b/packages/SystemUI/src/com/android/systemui/screenshot/scroll/LongScreenshotActivity.java
index 48e08a0496c1..ecea30f1b1c3 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/scroll/LongScreenshotActivity.java
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/scroll/LongScreenshotActivity.java
@@ -16,6 +16,8 @@
package com.android.systemui.screenshot.scroll;
+import static com.android.systemui.shared.Flags.usePreferredImageEditor;
+
import android.app.Activity;
import android.app.ActivityOptions;
import android.content.ComponentName;
@@ -355,26 +357,47 @@ public class LongScreenshotActivity extends Activity {
mScreenshotUserHandle, false,
/* activityOptions */ null, /* transitionCoordinator */ null);
} else {
- String editorPackage = getString(R.string.config_screenshotEditor);
- Intent intent = new Intent(Intent.ACTION_EDIT);
- intent.setDataAndType(uri, "image/png");
- intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
- | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
- Bundle options = null;
-
- // Skip shared element transition for implicit edit intents
- if (!TextUtils.isEmpty(editorPackage)) {
- intent.setComponent(ComponentName.unflattenFromString(editorPackage));
- mTransitionView.setImageBitmap(mOutputBitmap);
- mTransitionView.setVisibility(View.VISIBLE);
- mTransitionView.setTransitionName(
- ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME);
- options = ActivityOptions.makeSceneTransitionAnimation(this, mTransitionView,
- ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME).toBundle();
- // TODO: listen for transition completing instead of finishing onStop
- mTransitionStarted = true;
+ if (usePreferredImageEditor()) {
+ Intent intent = mActionIntentCreator.createEdit(uri);
+ Bundle options = null;
+
+ if (intent.getComponent() != null) {
+ // Modify intent for shared transition if we're opening a specific editor.
+ intent.removeFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.removeFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ mTransitionView.setImageBitmap(mOutputBitmap);
+ mTransitionView.setVisibility(View.VISIBLE);
+ mTransitionView.setTransitionName(
+ ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME);
+ options = ActivityOptions.makeSceneTransitionAnimation(this, mTransitionView,
+ ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME).toBundle();
+ // TODO: listen for transition completing instead of finishing onStop
+ mTransitionStarted = true;
+ }
+
+ startActivity(intent, options);
+ } else {
+ String editorPackage = getString(R.string.config_screenshotEditor);
+ Intent intent = new Intent(Intent.ACTION_EDIT);
+ intent.setDataAndType(uri, "image/png");
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ Bundle options = null;
+
+ // Skip shared element transition for implicit edit intents
+ if (!TextUtils.isEmpty(editorPackage)) {
+ intent.setComponent(ComponentName.unflattenFromString(editorPackage));
+ mTransitionView.setImageBitmap(mOutputBitmap);
+ mTransitionView.setVisibility(View.VISIBLE);
+ mTransitionView.setTransitionName(
+ ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME);
+ options = ActivityOptions.makeSceneTransitionAnimation(this, mTransitionView,
+ ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME).toBundle();
+ // TODO: listen for transition completing instead of finishing onStop
+ mTransitionStarted = true;
+ }
+ startActivity(intent, options);
}
- startActivity(intent, options);
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/settings/brightness/ComposeDialogComposableProvider.kt b/packages/SystemUI/src/com/android/systemui/settings/brightness/ComposeDialogComposableProvider.kt
index dde2ebc4a237..9e20055de856 100644
--- a/packages/SystemUI/src/com/android/systemui/settings/brightness/ComposeDialogComposableProvider.kt
+++ b/packages/SystemUI/src/com/android/systemui/settings/brightness/ComposeDialogComposableProvider.kt
@@ -17,7 +17,6 @@
package com.android.systemui.settings.brightness
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
@@ -26,7 +25,6 @@ import com.android.compose.theme.PlatformTheme
import com.android.systemui.brightness.ui.compose.BrightnessSliderContainer
import com.android.systemui.brightness.ui.viewmodel.BrightnessSliderViewModel
import com.android.systemui.lifecycle.rememberViewModel
-import com.android.systemui.qs.ui.composable.QuickSettingsShade
object ComposeDialogComposableProvider {
@@ -46,10 +44,7 @@ private fun BrightnessSliderForDialog(
rememberViewModel(traceName = "BrightnessDialog.viewModel") {
brightnessSliderViewModelFactory.create(false)
}
- BrightnessSliderContainer(
- viewModel = viewModel,
- Modifier.fillMaxWidth().height(QuickSettingsShade.Dimensions.BrightnessSliderHeight),
- )
+ BrightnessSliderContainer(viewModel = viewModel, Modifier.fillMaxWidth())
}
class ComposableProvider(
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt
new file mode 100644
index 000000000000..d02ae43a98cf
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.systemui.statusbar.notification.row.ui.viewmodel
+
+import android.graphics.drawable.Drawable
+import android.view.View
+import androidx.compose.animation.core.tween
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MotionScheme
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.dp
+import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
+import com.android.compose.animation.scene.SceneTransitionLayoutState
+import com.android.compose.animation.scene.transitions
+import com.android.systemui.notifications.ui.composable.row.BundleHeader
+import kotlinx.coroutines.CoroutineScope
+
+interface BundleHeaderViewModel {
+ val titleText: String
+ val numberOfChildren: Int?
+ val bundleIcon: Drawable?
+ val previewIcons: List<Drawable>
+
+ val state: SceneTransitionLayoutState
+
+ val hasUnreadMessages: Boolean
+ val backgroundDrawable: Drawable?
+
+ fun onHeaderClicked(scope: CoroutineScope)
+}
+
+class BundleHeaderViewModelImpl : BundleHeaderViewModel {
+ override var titleText by mutableStateOf("")
+ override var numberOfChildren by mutableStateOf<Int?>(1)
+ override var hasUnreadMessages by mutableStateOf(true)
+ override var bundleIcon by mutableStateOf<Drawable?>(null)
+ override var previewIcons by mutableStateOf(listOf<Drawable>())
+ override var backgroundDrawable by mutableStateOf<Drawable?>(null)
+
+ var onExpandClickListener: View.OnClickListener? = null
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ override var state: MutableSceneTransitionLayoutState =
+ MutableSceneTransitionLayoutState(
+ BundleHeader.Scenes.Collapsed,
+ MotionScheme.standard(),
+ transitions {
+ from(BundleHeader.Scenes.Collapsed, to = BundleHeader.Scenes.Expanded) {
+ spec = tween(500)
+ translate(BundleHeader.Elements.PreviewIcon3, x = 32.dp)
+ translate(BundleHeader.Elements.PreviewIcon2, x = 16.dp)
+ fade(BundleHeader.Elements.PreviewIcon1)
+ fade(BundleHeader.Elements.PreviewIcon2)
+ fade(BundleHeader.Elements.PreviewIcon3)
+ }
+ },
+ )
+
+ override fun onHeaderClicked(scope: CoroutineScope) {
+ val targetScene =
+ when (state.currentScene) {
+ BundleHeader.Scenes.Collapsed -> BundleHeader.Scenes.Expanded
+ BundleHeader.Scenes.Expanded -> BundleHeader.Scenes.Collapsed
+ else -> error("Unknown Scene")
+ }
+ state.setTargetScene(targetScene, scope)
+
+ onExpandClickListener?.onClick(null)
+ hasUnreadMessages = false
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
index c71162a22d2f..29528502aa03 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
@@ -18,7 +18,9 @@ package com.android.systemui.statusbar.pipeline.dagger
import android.net.wifi.WifiManager
import com.android.systemui.CoreStartable
+import com.android.systemui.Flags
import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.kairos.ExperimentalKairosApi
import com.android.systemui.log.LogBuffer
import com.android.systemui.log.LogBufferFactory
import com.android.systemui.log.table.TableLogBuffer
@@ -35,9 +37,15 @@ import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierCon
import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierConfigRepository
import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierConfigRepositoryImpl
import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepositoryKairosAdapter
import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileRepositorySwitcher
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileRepositorySwitcherKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoModeMobileConnectionDataSourceKairosImpl
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionRepositoryKairosFactoryImpl
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryKairosImpl
import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor
import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractorImpl
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractorKairosImpl
import com.android.systemui.statusbar.pipeline.mobile.ui.MobileUiAdapter
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconsViewModel
import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
@@ -74,9 +82,20 @@ import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import java.util.function.Supplier
import javax.inject.Named
+import javax.inject.Provider
import kotlinx.coroutines.flow.Flow
-@Module
+@OptIn(ExperimentalKairosApi::class)
+@Module(
+ includes =
+ [
+ DemoModeMobileConnectionDataSourceKairosImpl.Module::class,
+ MobileRepositorySwitcherKairos.Module::class,
+ MobileConnectionsRepositoryKairosImpl.Module::class,
+ MobileConnectionRepositoryKairosFactoryImpl.Module::class,
+ MobileConnectionsRepositoryKairosAdapter.Module::class,
+ ]
+)
abstract class StatusBarPipelineModule {
@Binds
abstract fun airplaneModeRepository(impl: AirplaneModeRepositoryImpl): AirplaneModeRepository
@@ -117,11 +136,6 @@ abstract class StatusBarPipelineModule {
@Binds abstract fun wifiInteractor(impl: WifiInteractorImpl): WifiInteractor
- @Binds
- abstract fun mobileConnectionsRepository(
- impl: MobileRepositorySwitcher
- ): MobileConnectionsRepository
-
@Binds abstract fun userSetupRepository(impl: UserSetupRepositoryImpl): UserSetupRepository
@Binds abstract fun mobileMappingsProxy(impl: MobileMappingsProxyImpl): MobileMappingsProxy
@@ -135,9 +149,6 @@ abstract class StatusBarPipelineModule {
): SubscriptionManagerProxy
@Binds
- abstract fun mobileIconsInteractor(impl: MobileIconsInteractorImpl): MobileIconsInteractor
-
- @Binds
@IntoMap
@ClassKey(MobileUiAdapter::class)
abstract fun bindFeature(impl: MobileUiAdapter): CoreStartable
@@ -158,6 +169,30 @@ abstract class StatusBarPipelineModule {
companion object {
@Provides
+ fun mobileIconsInteractor(
+ impl: Provider<MobileIconsInteractorImpl>,
+ kairosImpl: Provider<MobileIconsInteractorKairosImpl>,
+ ): MobileIconsInteractor {
+ return if (Flags.statusBarMobileIconKairos()) {
+ kairosImpl.get()
+ } else {
+ impl.get()
+ }
+ }
+
+ @Provides
+ fun mobileConnectionsRepository(
+ impl: Provider<MobileRepositorySwitcher>,
+ kairosImpl: Provider<MobileConnectionsRepositoryKairosAdapter>,
+ ): MobileConnectionsRepository {
+ return if (Flags.statusBarMobileIconKairos()) {
+ kairosImpl.get()
+ } else {
+ impl.get()
+ }
+ }
+
+ @Provides
@SysUISingleton
fun provideRealWifiRepository(
wifiManager: WifiManager?,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..2e796263afa9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import android.telephony.CellSignalStrength
+import android.telephony.TelephonyManager
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+
+/**
+ * Every mobile line of service can be identified via a [SubscriptionInfo] object. We set up a
+ * repository for each individual, tracked subscription via [MobileConnectionsRepository], and this
+ * repository is responsible for setting up a [TelephonyManager] object tied to its subscriptionId
+ *
+ * There should only ever be one [MobileConnectionRepository] per subscription, since
+ * [TelephonyManager] limits the number of callbacks that can be registered per process.
+ *
+ * This repository should have all of the relevant information for a single line of service, which
+ * eventually becomes a single icon in the status bar.
+ */
+@ExperimentalKairosApi
+interface MobileConnectionRepositoryKairos {
+ /** The subscriptionId that this connection represents */
+ val subId: Int
+
+ /** The carrierId for this connection. See [TelephonyManager.getSimCarrierId] */
+ val carrierId: State<Int>
+
+ /** Reflects the value from the carrier config INFLATE_SIGNAL_STRENGTH for this connection */
+ val inflateSignalStrength: State<Boolean>
+
+ /** Carrier config KEY_SHOW_5G_SLICE_ICON_BOOL for this connection */
+ val allowNetworkSliceIndicator: State<Boolean>
+
+ /**
+ * The table log buffer created for this connection. Will have the name "MobileConnectionLog
+ * [subId]"
+ */
+ val tableLogBuffer: TableLogBuffer
+
+ /** True if the [android.telephony.ServiceState] says this connection is emergency calls only */
+ val isEmergencyOnly: State<Boolean>
+
+ /** True if [android.telephony.ServiceState] says we are roaming */
+ val isRoaming: State<Boolean>
+
+ /**
+ * See [android.telephony.ServiceState.getOperatorAlphaShort], this value is defined as the
+ * current registered operator name in short alphanumeric format. In some cases this name might
+ * be preferred over other methods of calculating the network name
+ */
+ val operatorAlphaShort: State<String?>
+
+ /**
+ * TODO (b/263167683): Clarify this field
+ *
+ * This check comes from [com.android.settingslib.Utils.isInService]. It is intended to be a
+ * mapping from a ServiceState to a notion of connectivity. Notably, it will consider a
+ * connection to be in-service if either the voice registration state is IN_SERVICE or the data
+ * registration state is IN_SERVICE and NOT IWLAN.
+ */
+ val isInService: State<Boolean>
+
+ /**
+ * True if this subscription is actively connected to a non-terrestrial network and false
+ * otherwise. Reflects [android.telephony.ServiceState.isUsingNonTerrestrialNetwork].
+ *
+ * Notably: This value reflects that this subscription is **currently** using a non-terrestrial
+ * network, because some subscriptions can switch between terrestrial and non-terrestrial
+ * networks. [SubscriptionModel.isExclusivelyNonTerrestrial] reflects whether a subscription is
+ * configured to exclusively connect to non-terrestrial networks. [isNonTerrestrial] can change
+ * during the lifetime of a subscription but [SubscriptionModel.isExclusivelyNonTerrestrial]
+ * will stay constant.
+ */
+ val isNonTerrestrial: State<Boolean>
+
+ /** True if [android.telephony.SignalStrength] told us that this connection is using GSM */
+ val isGsm: State<Boolean>
+
+ /**
+ * There is still specific logic in the pipeline that calls out CDMA level explicitly. This
+ * field is not completely orthogonal to [primaryLevel], because CDMA could be primary.
+ */
+ // @IntRange(from = 0, to = 4)
+ val cdmaLevel: State<Int>
+
+ /** [android.telephony.SignalStrength]'s concept of the overall signal level */
+ // @IntRange(from = 0, to = 4)
+ val primaryLevel: State<Int>
+
+ /**
+ * This level can be used to reflect the signal strength when in carrier roaming NTN mode
+ * (carrier-based satellite)
+ */
+ val satelliteLevel: State<Int>
+
+ /** The current data connection state. See [DataConnectionState] */
+ val dataConnectionState: State<DataConnectionState>
+
+ /** The current data activity direction. See [DataActivityModel] */
+ val dataActivityDirection: State<DataActivityModel>
+
+ /** True if there is currently a carrier network change in process */
+ val carrierNetworkChangeActive: State<Boolean>
+
+ /**
+ * [resolvedNetworkType] is the [TelephonyDisplayInfo.getOverrideNetworkType] if it exists or
+ * [TelephonyDisplayInfo.getNetworkType]. This is used to look up the proper network type icon
+ */
+ val resolvedNetworkType: State<ResolvedNetworkType>
+
+ /** The total number of levels. Used with [SignalDrawable]. */
+ val numberOfLevels: State<Int>
+
+ /** Observable tracking [TelephonyManager.isDataConnectionAllowed] */
+ val dataEnabled: State<Boolean>
+
+ /**
+ * See [TelephonyManager.getCdmaEnhancedRoamingIndicatorDisplayNumber]. This bit only matters if
+ * the connection type is CDMA.
+ *
+ * True if the Enhanced Roaming Indicator (ERI) display number is not [TelephonyManager.ERI_OFF]
+ */
+ val cdmaRoaming: State<Boolean>
+
+ /** The service provider name for this network connection, or the default name. */
+ val networkName: State<NetworkNameModel>
+
+ /**
+ * The service provider name for this network connection, or the default name.
+ *
+ * TODO(b/296600321): De-duplicate this field with [networkName] after determining the data
+ * provided is identical
+ */
+ val carrierName: State<NetworkNameModel>
+
+ /**
+ * True if this type of connection is allowed while airplane mode is on, and false otherwise.
+ */
+ val isAllowedDuringAirplaneMode: State<Boolean>
+
+ /**
+ * True if this network has NET_CAPABILITIY_PRIORITIZE_LATENCY, and can be considered to be a
+ * network slice
+ */
+ val hasPrioritizedNetworkCapabilities: State<Boolean>
+
+ /**
+ * True if this connection is in emergency callback mode.
+ *
+ * @see [TelephonyManager.getEmergencyCallbackMode]
+ */
+ val isInEcmMode: State<Boolean>
+
+ companion object {
+ /** The default number of levels to use for [numberOfLevels]. */
+ val DEFAULT_NUM_LEVELS = CellSignalStrength.getNumSignalStrengthLevels()
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt
new file mode 100644
index 000000000000..79bfb6e48171
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import android.telephony.CarrierConfigManager
+import android.telephony.SubscriptionManager
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.mobile.MobileMappings
+import com.android.settingslib.mobile.MobileMappings.Config
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+
+/**
+ * Repo for monitoring the complete active subscription info list, to be consumed and filtered based
+ * on various policy
+ */
+@ExperimentalKairosApi
+interface MobileConnectionsRepositoryKairos {
+
+ /** All active mobile connections. */
+ val mobileConnectionsBySubId: Incremental<Int, MobileConnectionRepositoryKairos>
+
+ /** Observable list of current mobile subscriptions */
+ val subscriptions: State<Collection<SubscriptionModel>>
+
+ /**
+ * Observable for the subscriptionId of the current mobile data connection. Null if we don't
+ * have a valid subscription id
+ */
+ val activeMobileDataSubscriptionId: State<Int?>
+
+ /** Repo that tracks the current [activeMobileDataSubscriptionId] */
+ val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?>
+
+ /**
+ * Observable event for when the active data sim switches but the group stays the same. E.g.,
+ * CBRS switching would trigger this
+ */
+ val activeSubChangedInGroupEvent: Events<Unit>
+
+ /** Tracks [SubscriptionManager.getDefaultDataSubscriptionId]. `null` if there is no default. */
+ val defaultDataSubId: State<Int?>
+
+ /**
+ * True if the default network connection is a mobile-like connection and false otherwise.
+ *
+ * This is typically shown by having [android.net.NetworkCapabilities.TRANSPORT_CELLULAR], but
+ * there are edge cases (like carrier merged wifi) that could also result in the default
+ * connection being mobile-like.
+ */
+ val mobileIsDefault: State<Boolean>
+
+ /**
+ * True if the device currently has a carrier merged connection.
+ *
+ * See [CarrierMergedConnectionRepository] for more info.
+ */
+ val hasCarrierMergedConnection: State<Boolean>
+
+ /** True if the default network connection is validated and false otherwise. */
+ val defaultConnectionIsValidated: State<Boolean>
+
+ /**
+ * [Config] is an object that tracks relevant configuration flags for a given subscription ID.
+ * In the case of [MobileMappings], it's hard-coded to check the default data subscription's
+ * config, so this will apply to every icon that we care about.
+ *
+ * Relevant bits in the config are things like
+ * [CarrierConfigManager.KEY_SHOW_4G_FOR_LTE_DATA_ICON_BOOL]
+ *
+ * This flow will produce whenever the default data subscription or the carrier config changes.
+ */
+ val defaultDataSubRatConfig: State<Config>
+
+ /** The icon mapping from network type to [MobileIconGroup] for the default subscription */
+ val defaultMobileIconMapping: State<Map<String, MobileIconGroup>>
+
+ /** Fallback [MobileIconGroup] in the case where there is no icon in the mapping */
+ val defaultMobileIconGroup: State<MobileIconGroup>
+
+ /**
+ * Can the device make emergency calls using the device-based service state? This field is only
+ * useful when all known active subscriptions are OOS and not emergency call capable.
+ *
+ * Specifically, this checks every [ServiceState] of the device, and looks for any that report
+ * [ServiceState.isEmergencyOnly].
+ *
+ * This is an eager flow, and re-evaluates whenever ACTION_SERVICE_STATE is sent for subId = -1.
+ */
+ val isDeviceEmergencyCallCapable: State<Boolean>
+
+ /**
+ * If any active SIM on the device is in
+ * [android.telephony.TelephonyManager.SIM_STATE_PIN_REQUIRED] or
+ * [android.telephony.TelephonyManager.SIM_STATE_PUK_REQUIRED] or
+ * [android.telephony.TelephonyManager.SIM_STATE_PERM_DISABLED]
+ */
+ val isAnySimSecure: State<Boolean>
+
+ /**
+ * Checks if any subscription has [android.telephony.TelephonyManager.getEmergencyCallbackMode]
+ * == true
+ */
+ val isInEcmMode: State<Boolean>
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt
new file mode 100644
index 000000000000..64144d9a0ab5
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import android.content.Context
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairos.toColdConflatedFlow
+import com.android.systemui.kairosBuilder
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionRepositoryKairosAdapter
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+
+@ExperimentalKairosApi
+@SysUISingleton
+class MobileConnectionsRepositoryKairosAdapter
+@Inject
+constructor(
+ private val kairosRepo: MobileConnectionsRepositoryKairos,
+ private val kairosNetwork: KairosNetwork,
+ @Application scope: CoroutineScope,
+ connectivityRepository: ConnectivityRepository,
+ context: Context,
+ carrierConfigRepo: CarrierConfigRepository,
+) : MobileConnectionsRepository, KairosBuilder by kairosBuilder() {
+ override val subscriptions: StateFlow<List<SubscriptionModel>> =
+ kairosRepo.subscriptions
+ .map { it.toList() }
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
+
+ override val activeMobileDataSubscriptionId: StateFlow<Int?> =
+ kairosRepo.activeMobileDataSubscriptionId
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ private val reposBySubIdK = buildIncremental {
+ kairosRepo.mobileConnectionsBySubId
+ .mapValues { (subId, repo) ->
+ buildSpec {
+ MobileConnectionRepositoryKairosAdapter(
+ kairosRepo = repo,
+ carrierConfig = carrierConfigRepo.getOrCreateConfigForSubId(subId),
+ )
+ }
+ }
+ .applyLatestSpecForKey()
+ }
+
+ private val reposBySubId =
+ reposBySubIdK
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.Eagerly, emptyMap())
+
+ override val activeMobileDataRepository: StateFlow<MobileConnectionRepository?> =
+ combine(kairosRepo.activeMobileDataSubscriptionId, reposBySubIdK) { id, repos -> repos[id] }
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ override val activeSubChangedInGroupEvent: Flow<Unit> =
+ kairosRepo.activeSubChangedInGroupEvent.toColdConflatedFlow(kairosNetwork)
+
+ override val defaultDataSubId: StateFlow<Int?> =
+ kairosRepo.defaultDataSubId
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ override val mobileIsDefault: StateFlow<Boolean> =
+ kairosRepo.mobileIsDefault
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectivityRepository.defaultConnections.value.mobile.isDefault,
+ )
+
+ override val hasCarrierMergedConnection: Flow<Boolean> =
+ kairosRepo.hasCarrierMergedConnection.toColdConflatedFlow(kairosNetwork)
+
+ override val defaultConnectionIsValidated: StateFlow<Boolean> =
+ kairosRepo.defaultConnectionIsValidated
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectivityRepository.defaultConnections.value.isValidated,
+ )
+
+ override fun getRepoForSubId(subId: Int): MobileConnectionRepository =
+ reposBySubId.value[subId] ?: error("Unknown subscription id: $subId")
+
+ override val defaultDataSubRatConfig: StateFlow<MobileMappings.Config> =
+ kairosRepo.defaultDataSubRatConfig
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ MobileMappings.Config.readConfig(context),
+ )
+
+ override val defaultMobileIconMapping: Flow<Map<String, SignalIcon.MobileIconGroup>> =
+ kairosRepo.defaultMobileIconMapping.toColdConflatedFlow(kairosNetwork)
+
+ override val defaultMobileIconGroup: Flow<SignalIcon.MobileIconGroup> =
+ kairosRepo.defaultMobileIconGroup.toColdConflatedFlow(kairosNetwork)
+
+ override val isDeviceEmergencyCallCapable: StateFlow<Boolean> =
+ kairosRepo.isDeviceEmergencyCallCapable
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override val isAnySimSecure: StateFlow<Boolean> =
+ kairosRepo.isAnySimSecure
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun getIsAnySimSecure(): Boolean = isAnySimSecure.value
+
+ override suspend fun isInEcmMode(): Boolean =
+ kairosNetwork.transact { kairosRepo.isInEcmMode.sample() }
+
+ @dagger.Module
+ object Module {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<MobileConnectionsRepositoryKairosAdapter>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt
index 66587c779fbe..caf4bf502c06 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt
@@ -48,9 +48,9 @@ import kotlinx.coroutines.flow.stateIn
* something like this:
* ```
* RealRepository
- * │
- * ├──►RepositorySwitcher──►RealInteractor──►RealViewModel
- * │
+ * │
+ * ├──►RepositorySwitcher──►RealInteractor──►RealViewModel
+ * │
* DemoRepository
* ```
*
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt
new file mode 100644
index 000000000000..1f5b849c56cc
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import android.os.Bundle
+import androidx.annotation.VisibleForTesting
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.activated
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.demomode.DemoMode
+import com.android.systemui.demomode.DemoModeController
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.flatMap
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.switchEvents
+import com.android.systemui.kairos.switchIncremental
+import com.android.systemui.kairosBuilder
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryKairosImpl
+import dagger.Binds
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.channels.awaitClose
+
+/**
+ * A provider for the [MobileConnectionsRepository] interface that can choose between the Demo and
+ * Prod concrete implementations at runtime. It works by defining a base flow, [activeRepo], which
+ * switches based on the latest information from [DemoModeController], and switches every flow in
+ * the interface to point to the currently-active provider. This allows us to put the demo mode
+ * interface in its own repository, completely separate from the real version, while still using all
+ * of the prod implementations for the rest of the pipeline (interactors and onward). Looks
+ * something like this:
+ * ```
+ * RealRepository
+ * │
+ * ├──►RepositorySwitcher──►RealInteractor──►RealViewModel
+ * │
+ * DemoRepository
+ * ```
+ *
+ * NOTE: because the UI layer for mobile icons relies on a nested-repository structure, it is likely
+ * that we will have to drain the subscription list whenever demo mode changes. Otherwise if a real
+ * subscription list [1] is replaced with a demo subscription list [1], the view models will not see
+ * a change (due to `distinctUntilChanged`) and will not refresh their data providers to the demo
+ * implementation.
+ */
+@ExperimentalKairosApi
+@SysUISingleton
+class MobileRepositorySwitcherKairos
+@Inject
+constructor(
+ private val realRepository: MobileConnectionsRepositoryKairosImpl,
+ private val demoRepositoryFactory: DemoMobileConnectionsRepositoryKairos.Factory,
+ demoModeController: DemoModeController,
+) : MobileConnectionsRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ private val isDemoMode: State<Boolean> = buildState {
+ conflatedCallbackFlow {
+ val callback =
+ object : DemoMode {
+ override fun dispatchDemoCommand(command: String?, args: Bundle?) {
+ // Nothing, we just care about on/off
+ }
+
+ override fun onDemoModeStarted() {
+ trySend(true)
+ }
+
+ override fun onDemoModeFinished() {
+ trySend(false)
+ }
+ }
+
+ demoModeController.addCallback(callback)
+ awaitClose { demoModeController.removeCallback(callback) }
+ }
+ .toState(demoModeController.isInDemoMode)
+ }
+
+ // Convenient definition flow for the currently active repo (based on demo mode or not)
+ @VisibleForTesting
+ val activeRepo: State<MobileConnectionsRepositoryKairos> = buildState {
+ isDemoMode.mapLatestBuild { demoMode ->
+ if (demoMode) {
+ activated { demoRepositoryFactory.create() }
+ } else {
+ realRepository
+ }
+ }
+ }
+
+ override val mobileConnectionsBySubId: Incremental<Int, MobileConnectionRepositoryKairos> =
+ activeRepo.map { it.mobileConnectionsBySubId }.switchIncremental()
+
+ override val subscriptions: State<Collection<SubscriptionModel>> =
+ activeRepo.flatMap { it.subscriptions }
+
+ override val activeMobileDataSubscriptionId: State<Int?> =
+ activeRepo.flatMap { it.activeMobileDataSubscriptionId }
+
+ override val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?> =
+ activeRepo.flatMap { it.activeMobileDataRepository }
+
+ override val activeSubChangedInGroupEvent: Events<Unit> =
+ activeRepo.map { it.activeSubChangedInGroupEvent }.switchEvents()
+
+ override val defaultDataSubRatConfig: State<MobileMappings.Config> =
+ activeRepo.flatMap { it.defaultDataSubRatConfig }
+
+ override val defaultMobileIconMapping: State<Map<String, SignalIcon.MobileIconGroup>> =
+ activeRepo.flatMap { it.defaultMobileIconMapping }
+
+ override val defaultMobileIconGroup: State<SignalIcon.MobileIconGroup> =
+ activeRepo.flatMap { it.defaultMobileIconGroup }
+
+ override val isDeviceEmergencyCallCapable: State<Boolean> =
+ activeRepo.flatMap { it.isDeviceEmergencyCallCapable }
+
+ override val isAnySimSecure: State<Boolean> = activeRepo.flatMap { it.isAnySimSecure }
+
+ override val defaultDataSubId: State<Int?> = activeRepo.flatMap { it.defaultDataSubId }
+
+ override val mobileIsDefault: State<Boolean> = activeRepo.flatMap { it.mobileIsDefault }
+
+ override val hasCarrierMergedConnection: State<Boolean> =
+ activeRepo.flatMap { it.hasCarrierMergedConnection }
+
+ override val defaultConnectionIsValidated: State<Boolean> =
+ activeRepo.flatMap { it.defaultConnectionIsValidated }
+
+ override val isInEcmMode: State<Boolean> = activeRepo.flatMap { it.isInEcmMode }
+
+ @dagger.Module
+ interface Module {
+ @Binds fun bindImpl(impl: MobileRepositorySwitcherKairos): MobileConnectionsRepositoryKairos
+
+ companion object {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<MobileRepositorySwitcherKairos>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..a244feb1739a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt
@@ -0,0 +1,270 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.demo
+
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager
+import com.android.settingslib.SignalIcon
+import com.android.systemui.KairosBuilder
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.TransactionScope
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapCheap
+import com.android.systemui.kairos.mergeLeft
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairos.util.Either
+import com.android.systemui.kairos.util.Either.First
+import com.android.systemui.kairos.util.Either.Second
+import com.android.systemui.kairos.util.firstOrNull
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile as FakeMobileEvent
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_CARRIER_ID
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_CARRIER_NETWORK_CHANGE
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_CDMA_LEVEL
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_IS_IN_SERVICE
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_IS_NTN
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_OPERATOR
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_PRIMARY_LEVEL
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_ROAMING
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_SATELLITE_LEVEL
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel.CarrierMerged as FakeCarrierMergedEvent
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/**
+ * Demo version of [MobileConnectionRepository]. Note that this class shares all of its flows using
+ * [SharingStarted.WhileSubscribed()] to give the same semantics as using a regular
+ * [MutableStateFlow] while still logging all of the inputs in the same manor as the production
+ * repos.
+ */
+@ExperimentalKairosApi
+class DemoMobileConnectionRepositoryKairos(
+ override val subId: Int,
+ override val tableLogBuffer: TableLogBuffer,
+ mobileEvents: Events<FakeMobileEvent>,
+ carrierMergedResetEvents: Events<Any?>,
+ wifiEvents: Events<FakeCarrierMergedEvent>,
+ private val mobileMappingsReverseLookup: State<Map<SignalIcon.MobileIconGroup, String>>,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ private val initialState =
+ FakeMobileEvent(
+ level = null,
+ dataType = null,
+ subId = subId,
+ carrierId = null,
+ activity = null,
+ carrierNetworkChange = false,
+ roaming = false,
+ name = DEMO_CARRIER_NAME,
+ )
+
+ private val lastMobileEvent: State<FakeMobileEvent> = buildState {
+ mobileEvents.holdState(initialState)
+ }
+
+ private val lastEvent: State<Either<FakeMobileEvent, FakeCarrierMergedEvent>> = buildState {
+ mergeLeft(
+ mobileEvents.mapCheap { First(it) },
+ wifiEvents.mapCheap { Second(it) },
+ carrierMergedResetEvents.mapCheap { First(lastMobileEvent.sample()) },
+ )
+ .holdState(First(initialState))
+ }
+
+ override val carrierId: State<Int> =
+ lastEvent
+ .map { it.firstOrNull()?.carrierId ?: INVALID_SUBSCRIPTION_ID }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ intState = it,
+ tableLogBuffer = tableLogBuffer,
+ columnName = COL_CARRIER_ID,
+ )
+ }
+ }
+
+ override val inflateSignalStrength: State<Boolean> = buildState {
+ mobileEvents
+ .map { ev -> ev.inflateStrength }
+ .holdState(false)
+ .also { logDiffsForTable(it, tableLogBuffer, "", columnName = "inflate") }
+ }
+
+ // I don't see a reason why we would turn the config off for demo mode.
+ override val allowNetworkSliceIndicator: State<Boolean> = stateOf(true)
+
+ // TODO(b/261029387): not yet supported
+ override val isEmergencyOnly: State<Boolean> = stateOf(false)
+
+ override val isRoaming: State<Boolean> =
+ lastEvent
+ .map { it.firstOrNull()?.roaming ?: false }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_ROAMING) } }
+
+ override val operatorAlphaShort: State<String?> =
+ lastEvent
+ .map { it.firstOrNull()?.name }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_OPERATOR) }
+ }
+
+ override val isInService: State<Boolean> =
+ lastEvent
+ .map {
+ when (it) {
+ is First -> it.value.level?.let { level -> level > 0 } ?: false
+ is Second -> true
+ }
+ }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_IN_SERVICE) }
+ }
+
+ override val isNonTerrestrial: State<Boolean> = buildState {
+ mobileEvents
+ .map { it.ntn }
+ .holdState(false)
+ .also { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_NTN) }
+ }
+
+ // TODO(b/261029387): not yet supported
+ override val isGsm: State<Boolean> = stateOf(false)
+
+ override val cdmaLevel: State<Int> =
+ lastEvent
+ .map {
+ when (it) {
+ is First -> it.value.level ?: 0
+ is Second -> it.value.level
+ }
+ }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_CDMA_LEVEL) }
+ }
+
+ override val primaryLevel: State<Int> =
+ lastEvent
+ .map {
+ when (it) {
+ is First -> it.value.level ?: 0
+ is Second -> it.value.level
+ }
+ }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_PRIMARY_LEVEL) }
+ }
+
+ override val satelliteLevel: State<Int> =
+ stateOf(0).also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_SATELLITE_LEVEL) }
+ }
+
+ // TODO(b/261029387): not yet supported
+ override val dataConnectionState: State<DataConnectionState> =
+ buildState {
+ mergeLeft(mobileEvents, wifiEvents)
+ .map { DataConnectionState.Connected }
+ .holdState(DataConnectionState.Disconnected)
+ }
+ .also {
+ onActivated {
+ logDiffsForTable(diffableState = it, tableLogBuffer = tableLogBuffer)
+ }
+ }
+
+ override val dataActivityDirection: State<DataActivityModel> =
+ lastEvent
+ .map {
+ val activity =
+ when (it) {
+ is First -> it.value.activity ?: TelephonyManager.DATA_ACTIVITY_NONE
+ is Second -> it.value.activity
+ }
+ activity.toMobileDataActivityModel()
+ }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val carrierNetworkChangeActive: State<Boolean> =
+ lastEvent
+ .map { it.firstOrNull()?.carrierNetworkChange ?: false }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogBuffer, columnName = COL_CARRIER_NETWORK_CHANGE)
+ }
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> = buildState {
+ lastEvent
+ .mapTransactionally {
+ it.firstOrNull()?.dataType?.let { resolvedNetworkTypeForIconGroup(it) }
+ ?: ResolvedNetworkType.CarrierMergedNetworkType
+ }
+ .also { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") }
+ }
+
+ override val numberOfLevels: State<Int> =
+ inflateSignalStrength.map { shouldInflate ->
+ if (shouldInflate) DEFAULT_NUM_LEVELS + 1 else DEFAULT_NUM_LEVELS
+ }
+
+ override val dataEnabled: State<Boolean> = stateOf(true)
+
+ override val cdmaRoaming: State<Boolean> = lastEvent.map { it.firstOrNull()?.roaming ?: false }
+
+ override val networkName: State<NetworkNameModel.IntentDerived> =
+ lastEvent.map {
+ NetworkNameModel.IntentDerived(it.firstOrNull()?.name ?: CARRIER_MERGED_NAME)
+ }
+
+ override val carrierName: State<NetworkNameModel.SubscriptionDerived> =
+ lastEvent.map {
+ NetworkNameModel.SubscriptionDerived(
+ it.firstOrNull()?.let { event -> "${event.name} ${event.subId}" }
+ ?: CARRIER_MERGED_NAME
+ )
+ }
+
+ override val isAllowedDuringAirplaneMode: State<Boolean> = lastEvent.map { it is Second }
+
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> =
+ lastEvent.map { it.firstOrNull()?.slice ?: false }
+
+ override val isInEcmMode: State<Boolean> = stateOf(false)
+
+ private fun TransactionScope.resolvedNetworkTypeForIconGroup(
+ iconGroup: SignalIcon.MobileIconGroup?
+ ) = DefaultNetworkType(mobileMappingsReverseLookup.sample()[iconGroup] ?: "dis")
+
+ companion object {
+ private const val DEMO_CARRIER_NAME = "Demo Carrier"
+ private const val CARRIER_MERGED_NAME = "Carrier Merged Network"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt
new file mode 100644
index 000000000000..925ee541bf73
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.demo
+
+import android.content.Context
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.util.Log
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.KairosBuilder
+import com.android.systemui.activated
+import com.android.systemui.kairos.BuildScope
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.GroupedEvents
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.TransactionScope
+import com.android.systemui.kairos.asIncremental
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.emptyEvents
+import com.android.systemui.kairos.filter
+import com.android.systemui.kairos.filterIsInstance
+import com.android.systemui.kairos.groupBy
+import com.android.systemui.kairos.groupByKey
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapCheap
+import com.android.systemui.kairos.mapNotNull
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairos.mergeLeft
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBufferFactory
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepository.Factory.Companion.MOBILE_CONNECTION_BUFFER_SIZE
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.DemoModeWifiDataSource
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+
+/** This repository vends out data based on demo mode commands */
+@ExperimentalKairosApi
+class DemoMobileConnectionsRepositoryKairos
+@AssistedInject
+constructor(
+ mobileDataSource: DemoModeMobileConnectionDataSourceKairos,
+ private val wifiDataSource: DemoModeWifiDataSource,
+ context: Context,
+ private val logFactory: TableLogBufferFactory,
+) : MobileConnectionsRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ @AssistedFactory
+ fun interface Factory {
+ fun create(): DemoMobileConnectionsRepositoryKairos
+ }
+
+ private val wifiEvents: Events<FakeWifiEventModel?> = buildEvents {
+ wifiDataSource.wifiEvents.toEvents()
+ }
+
+ private val mobileEventsWithSubId: Events<Pair<Int, FakeNetworkEventModel>> =
+ mobileDataSource.mobileEvents.mapNotNull { event ->
+ event?.let { (event.subId ?: lastSeenSubId.sample())?.let { it to event } }
+ }
+
+ private val mobileEventsBySubId: GroupedEvents<Int, FakeNetworkEventModel> =
+ mobileEventsWithSubId.map { mapOf(it) }.groupByKey()
+
+ private val carrierMergedEvents: Events<FakeWifiEventModel.CarrierMerged> =
+ wifiEvents.filterIsInstance<FakeWifiEventModel.CarrierMerged>()
+
+ private val wifiEventsBySubId: GroupedEvents<Int, FakeWifiEventModel.CarrierMerged> =
+ carrierMergedEvents.groupBy { it.subscriptionId }
+
+ private val lastSeenSubId: State<Int?> = buildState {
+ mergeLeft(
+ mobileEventsWithSubId.mapCheap { it.first },
+ carrierMergedEvents.mapCheap { it.subscriptionId },
+ )
+ .holdState(null)
+ }
+
+ private val activeCarrierMergedSubscription: State<Int?> = buildState {
+ mergeLeft(
+ carrierMergedEvents.mapCheap { it.subscriptionId },
+ wifiEvents
+ .filter {
+ it is FakeWifiEventModel.Wifi || it is FakeWifiEventModel.WifiDisabled
+ }
+ .map { null },
+ )
+ .holdState(null)
+ }
+
+ private val activeMobileSubscriptions: State<Set<Int>> = buildState {
+ mobileDataSource.mobileEvents
+ .mapNotNull { event ->
+ when (event) {
+ null -> null
+ is Mobile -> event.subId?.let { subId -> { subs: Set<Int> -> subs + subId } }
+ is MobileDisabled ->
+ (event.subId ?: maybeGetOnlySubIdForRemoval())?.let { subId ->
+ { subs: Set<Int> -> subs - subId }
+ }
+ }
+ }
+ .foldState(emptySet()) { f, s -> f(s) }
+ }
+
+ private val subscriptionIds: State<Set<Int>> =
+ combine(activeMobileSubscriptions, activeCarrierMergedSubscription) { mobile, carrierMerged
+ ->
+ carrierMerged?.let { mobile + carrierMerged } ?: mobile
+ }
+
+ private val subscriptionsById: State<Map<Int, SubscriptionModel>> =
+ subscriptionIds.map { subs ->
+ subs.associateWith { subId ->
+ SubscriptionModel(
+ subscriptionId = subId,
+ isOpportunistic = false,
+ carrierName = DEFAULT_CARRIER_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ }
+ }
+
+ override val subscriptions: State<Collection<SubscriptionModel>> =
+ subscriptionsById.map { it.values }
+
+ private fun TransactionScope.maybeGetOnlySubIdForRemoval(): Int? {
+ val subIds = activeMobileSubscriptions.sample()
+ return if (subIds.size == 1) {
+ subIds.first()
+ } else {
+ Log.d(
+ TAG,
+ "processDisabledMobileState: Unable to infer subscription to " +
+ "disable. Specify subId using '-e slot <subId>'. " +
+ "Known subIds: [${subIds.joinToString(",")}]",
+ )
+ null
+ }
+ }
+
+ private val reposBySubId: Incremental<Int, DemoMobileConnectionRepositoryKairos> =
+ buildIncremental {
+ subscriptionsById
+ .asIncremental()
+ .mapValues { (id, _) -> buildSpec { newRepo(id) } }
+ .applyLatestSpecForKey()
+ }
+
+ // TODO(b/261029387): add a command for this value
+ override val activeMobileDataSubscriptionId: State<Int> =
+ // For now, active is just the first in the list
+ subscriptions.map { infos ->
+ infos.firstOrNull()?.subscriptionId ?: INVALID_SUBSCRIPTION_ID
+ }
+
+ override val activeMobileDataRepository: State<DemoMobileConnectionRepositoryKairos?> =
+ combine(activeMobileDataSubscriptionId, reposBySubId) { subId, repoMap -> repoMap[subId] }
+
+ // TODO(b/261029387): consider adding a demo command for this
+ override val activeSubChangedInGroupEvent: Events<Unit> = emptyEvents
+
+ /** Demo mode doesn't currently support modifications to the mobile mappings */
+ override val defaultDataSubRatConfig: State<MobileMappings.Config> =
+ stateOf(MobileMappings.Config.readConfig(context))
+
+ override val defaultMobileIconGroup: State<SignalIcon.MobileIconGroup> =
+ stateOf(TelephonyIcons.THREE_G)
+
+ // TODO(b/339023069): demo command for device-based emergency calls state
+ override val isDeviceEmergencyCallCapable: State<Boolean> = stateOf(false)
+
+ override val isAnySimSecure: State<Boolean> = stateOf(false)
+
+ override val defaultMobileIconMapping: State<Map<String, SignalIcon.MobileIconGroup>> =
+ stateOf(TelephonyIcons.ICON_NAME_TO_ICON)
+
+ /**
+ * In order to maintain compatibility with the old demo mode shell command API, reverse the
+ * [MobileMappings] lookup from (NetworkType: String -> Icon: MobileIconGroup), so that we can
+ * parse the string from the command line into a preferred icon group, and send _a_ valid
+ * network type for that icon through the pipeline.
+ *
+ * Note: collisions don't matter here, because the data source (the command line) only cares
+ * about the resulting icon, not the underlying network type.
+ */
+ private val mobileMappingsReverseLookup: State<Map<SignalIcon.MobileIconGroup, String>> =
+ defaultMobileIconMapping.map { networkToIconMap -> networkToIconMap.reverse() }
+
+ private fun <K, V> Map<K, V>.reverse() = entries.associate { (k, v) -> v to k }
+
+ // TODO(b/261029387): add a command for this value
+ override val defaultDataSubId: State<Int?> = stateOf(null)
+
+ // TODO(b/261029387): not yet supported
+ override val mobileIsDefault: State<Boolean> = stateOf(true)
+
+ // TODO(b/261029387): not yet supported
+ override val hasCarrierMergedConnection: State<Boolean> = stateOf(false)
+
+ // TODO(b/261029387): not yet supported
+ override val defaultConnectionIsValidated: State<Boolean> = stateOf(true)
+
+ override val isInEcmMode: State<Boolean> = stateOf(false)
+
+ override val mobileConnectionsBySubId: Incremental<Int, DemoMobileConnectionRepositoryKairos>
+ get() = reposBySubId
+
+ private fun BuildScope.newRepo(subId: Int) = activated {
+ DemoMobileConnectionRepositoryKairos(
+ subId = subId,
+ tableLogBuffer =
+ logFactory.getOrCreate(
+ "DemoMobileConnectionLog[$subId]",
+ MOBILE_CONNECTION_BUFFER_SIZE,
+ ),
+ mobileEvents = mobileEventsBySubId[subId].filterIsInstance(),
+ carrierMergedResetEvents =
+ wifiEvents.mapNotNull { it?.takeIf { it !is FakeWifiEventModel.CarrierMerged } },
+ wifiEvents = wifiEventsBySubId[subId],
+ mobileMappingsReverseLookup = mobileMappingsReverseLookup,
+ )
+ }
+
+ companion object {
+ private const val TAG = "DemoMobileConnectionsRepo"
+
+ private const val DEFAULT_CARRIER_NAME = "demo carrier"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt
new file mode 100644
index 000000000000..f32938335e6d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.demo
+
+import android.os.Bundle
+import android.telephony.Annotation.DataActivityType
+import android.telephony.TelephonyManager.DATA_ACTIVITY_IN
+import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import android.telephony.TelephonyManager.DATA_ACTIVITY_OUT
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.demomode.DemoMode.COMMAND_NETWORK
+import com.android.systemui.demomode.DemoModeController
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairosBuilder
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled
+import dagger.Binds
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * Data source that can map from demo mode commands to inputs into the
+ * [DemoMobileConnectionsRepositoryKairos]
+ */
+@ExperimentalKairosApi
+interface DemoModeMobileConnectionDataSourceKairos {
+ val mobileEvents: Events<FakeNetworkEventModel?>
+}
+
+@ExperimentalKairosApi
+@SysUISingleton
+class DemoModeMobileConnectionDataSourceKairosImpl
+@Inject
+constructor(demoModeController: DemoModeController) :
+ KairosBuilder by kairosBuilder(), DemoModeMobileConnectionDataSourceKairos {
+ private val demoCommandStream: Flow<Bundle> =
+ demoModeController.demoFlowForCommand(COMMAND_NETWORK)
+
+ // If the args contains "mobile", then all of the args are relevant. It's just the way demo mode
+ // commands work and it's a little silly
+ private val _mobileCommands: Flow<FakeNetworkEventModel?> =
+ demoCommandStream.map { args -> args.toMobileEvent() }
+ override val mobileEvents: Events<FakeNetworkEventModel?> = buildEvents {
+ _mobileCommands.toEvents()
+ }
+
+ private fun Bundle.toMobileEvent(): FakeNetworkEventModel? {
+ val mobile = getString("mobile") ?: return null
+ return if (mobile == "show") {
+ activeMobileEvent()
+ } else {
+ MobileDisabled(subId = getString("slot")?.toInt())
+ }
+ }
+
+ /** Parse a valid mobile command string into a network event */
+ private fun Bundle.activeMobileEvent(): Mobile {
+ // There are many key/value pairs supported by mobile demo mode. Bear with me here
+ val level = getString("level")?.toInt()
+ val dataType = getString("datatype")?.toDataType()
+ val slot = getString("slot")?.toInt()
+ val carrierId = getString("carrierid")?.toInt()
+ val inflateStrength = getString("inflate").toBoolean()
+ val activity = getString("activity")?.toActivity()
+ val carrierNetworkChange = getString("carriernetworkchange") == "show"
+ val roaming = getString("roam") == "show"
+ val name = getString("networkname") ?: "demo mode"
+ val slice = getString("slice").toBoolean()
+ val ntn = getString("ntn").toBoolean()
+
+ return Mobile(
+ level = level,
+ dataType = dataType,
+ subId = slot,
+ carrierId = carrierId,
+ inflateStrength = inflateStrength,
+ activity = activity,
+ carrierNetworkChange = carrierNetworkChange,
+ roaming = roaming,
+ name = name,
+ slice = slice,
+ ntn = ntn,
+ )
+ }
+
+ @dagger.Module
+ interface Module {
+ @Binds
+ fun bindImpl(
+ impl: DemoModeMobileConnectionDataSourceKairosImpl
+ ): DemoModeMobileConnectionDataSourceKairos
+
+ companion object {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<DemoModeMobileConnectionDataSourceKairosImpl>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+ }
+}
+
+private fun String.toDataType(): MobileIconGroup =
+ when (this) {
+ "1x" -> TelephonyIcons.ONE_X
+ "3g" -> TelephonyIcons.THREE_G
+ "4g" -> TelephonyIcons.FOUR_G
+ "4g+" -> TelephonyIcons.FOUR_G_PLUS
+ "5g" -> TelephonyIcons.NR_5G
+ "5ge" -> TelephonyIcons.LTE_CA_5G_E
+ "5g+" -> TelephonyIcons.NR_5G_PLUS
+ "e" -> TelephonyIcons.E
+ "g" -> TelephonyIcons.G
+ "h" -> TelephonyIcons.H
+ "h+" -> TelephonyIcons.H_PLUS
+ "lte" -> TelephonyIcons.LTE
+ "lte+" -> TelephonyIcons.LTE_PLUS
+ "dis" -> TelephonyIcons.DATA_DISABLED
+ "not" -> TelephonyIcons.NOT_DEFAULT_DATA
+ else -> TelephonyIcons.UNKNOWN
+ }
+
+@DataActivityType
+private fun String.toActivity(): Int =
+ when (this) {
+ "inout" -> DATA_ACTIVITY_INOUT
+ "in" -> DATA_ACTIVITY_IN
+ "out" -> DATA_ACTIVITY_OUT
+ else -> DATA_ACTIVITY_NONE
+ }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt
index 42171d0dc2b5..54162bb75f3e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt
@@ -25,11 +25,13 @@ import com.android.settingslib.SignalIcon
* Nullable fields represent optional command line arguments
*/
sealed interface FakeNetworkEventModel {
+ // Null means the default (chosen by the repository)
+ val subId: Int?
+
data class Mobile(
val level: Int?,
val dataType: SignalIcon.MobileIconGroup?,
- // Null means the default (chosen by the repository)
- val subId: Int?,
+ override val subId: Int?,
val carrierId: Int?,
val inflateStrength: Boolean = false,
@DataActivityType val activity: Int?,
@@ -40,8 +42,5 @@ sealed interface FakeNetworkEventModel {
val ntn: Boolean = false,
) : FakeNetworkEventModel
- data class MobileDisabled(
- // Null means the default (chosen by the repository)
- val subId: Int?
- ) : FakeNetworkEventModel
+ data class MobileDisabled(override val subId: Int?) : FakeNetworkEventModel
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..d61d11bcf6b7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.telephony.CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager
+import android.util.Log
+import com.android.systemui.KairosBuilder
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.WifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import javax.inject.Inject
+
+/**
+ * A repository implementation for a carrier merged (aka VCN) network. A carrier merged network is
+ * delivered to SysUI as a wifi network (see [WifiNetworkModel.CarrierMerged], but is visually
+ * displayed as a mobile network triangle.
+ *
+ * See [android.net.wifi.WifiInfo.isCarrierMerged] for more information.
+ *
+ * See [MobileConnectionRepositoryImpl] for a repository implementation of a typical mobile
+ * connection.
+ */
+@ExperimentalKairosApi
+class CarrierMergedConnectionRepositoryKairos(
+ override val subId: Int,
+ override val tableLogBuffer: TableLogBuffer,
+ private val telephonyManager: TelephonyManager,
+ val wifiRepository: WifiRepository,
+ override val isInEcmMode: State<Boolean>,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+ init {
+ if (telephonyManager.subscriptionId != subId) {
+ error(
+ """CarrierMergedRepo: TelephonyManager should be created with subId($subId).
+ | Found ${telephonyManager.subscriptionId} instead."""
+ .trimMargin()
+ )
+ }
+ }
+
+ private val isWifiEnabled: State<Boolean> = buildState {
+ wifiRepository.isWifiEnabled.toState()
+ }
+ private val isWifiDefault: State<Boolean> = buildState {
+ wifiRepository.isWifiDefault.toState()
+ }
+ private val wifiNetwork: State<WifiNetworkModel> = buildState {
+ wifiRepository.wifiNetwork.toState()
+ }
+
+ /**
+ * Outputs the carrier merged network to use, or null if we don't have a valid carrier merged
+ * network.
+ */
+ private val network: State<WifiNetworkModel.CarrierMerged?> =
+ combine(isWifiEnabled, isWifiDefault, wifiNetwork) { isEnabled, isDefault, network ->
+ when {
+ !isEnabled -> null
+ !isDefault -> null
+ network !is WifiNetworkModel.CarrierMerged -> null
+ network.subscriptionId != subId -> {
+ Log.w(
+ TAG,
+ """Connection repo subId=$subId does not equal wifi repo
+ | subId=${network.subscriptionId}; not showing carrier merged"""
+ .trimMargin(),
+ )
+ null
+ }
+ else -> network
+ }
+ }
+
+ override val cdmaRoaming: State<Boolean> = stateOf(ROAMING)
+
+ override val networkName: State<NetworkNameModel> =
+ // The SIM operator name should be the same throughout the lifetime of a subId, **but**
+ // it may not be available when this repo is created because it takes time to load. To
+ // be safe, we re-fetch it each time the network has changed.
+ network.map { NetworkNameModel.SimDerived(telephonyManager.simOperatorName) }
+
+ override val carrierName: State<NetworkNameModel>
+ get() = networkName
+
+ override val numberOfLevels: State<Int> =
+ wifiNetwork.map {
+ if (it is WifiNetworkModel.CarrierMerged) {
+ it.numberOfLevels
+ } else {
+ DEFAULT_NUM_LEVELS
+ }
+ }
+
+ override val primaryLevel: State<Int> =
+ network.map { it?.level ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN }
+
+ override val cdmaLevel: State<Int> =
+ network.map { it?.level ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN }
+
+ override val dataActivityDirection: State<DataActivityModel> = buildState {
+ wifiRepository.wifiActivity.toState()
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> =
+ network.map {
+ if (it != null) {
+ ResolvedNetworkType.CarrierMergedNetworkType
+ } else {
+ ResolvedNetworkType.UnknownNetworkType
+ }
+ }
+
+ override val dataConnectionState: State<DataConnectionState> =
+ network.map {
+ if (it != null) {
+ DataConnectionState.Connected
+ } else {
+ DataConnectionState.Disconnected
+ }
+ }
+
+ override val isRoaming: State<Boolean> = stateOf(false)
+ override val carrierId: State<Int> = stateOf(INVALID_SUBSCRIPTION_ID)
+ override val inflateSignalStrength: State<Boolean> = stateOf(false)
+ override val allowNetworkSliceIndicator: State<Boolean> = stateOf(false)
+ override val isEmergencyOnly: State<Boolean> = stateOf(false)
+ override val operatorAlphaShort: State<String?> = stateOf(null)
+ override val isInService: State<Boolean> = stateOf(true)
+ override val isNonTerrestrial: State<Boolean> = stateOf(false)
+ override val isGsm: State<Boolean> = stateOf(false)
+ override val carrierNetworkChangeActive: State<Boolean> = stateOf(false)
+ override val satelliteLevel: State<Int> = stateOf(0)
+
+ /**
+ * Carrier merged connections happen over wifi but are displayed as a mobile triangle. Because
+ * they occur over wifi, it's possible to have a valid carrier merged connection even during
+ * airplane mode. See b/291993542.
+ */
+ override val isAllowedDuringAirplaneMode: State<Boolean> = stateOf(true)
+
+ /**
+ * It's not currently considered possible that a carrier merged network can have these
+ * prioritized capabilities. If we need to track them, we can add the same check as is in
+ * [MobileConnectionRepositoryImpl].
+ */
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> = stateOf(false)
+
+ override val dataEnabled: State<Boolean>
+ get() = isWifiEnabled
+
+ companion object {
+ // Carrier merged is never roaming
+ private const val ROAMING = false
+ }
+
+ @SysUISingleton
+ class Factory
+ @Inject
+ constructor(
+ private val telephonyManager: TelephonyManager,
+ private val wifiRepository: WifiRepository,
+ ) {
+ fun build(
+ subId: Int,
+ mobileLogger: TableLogBuffer,
+ mobileRepo: MobileConnectionRepositoryKairos,
+ ): CarrierMergedConnectionRepositoryKairos {
+ return CarrierMergedConnectionRepositoryKairos(
+ subId,
+ mobileLogger,
+ telephonyManager.createForSubscriptionId(subId),
+ wifiRepository,
+ mobileRepo.isInEcmMode,
+ )
+ }
+ }
+}
+
+private const val TAG = "CarrierMergedConnectionRepository"
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..1a8ca9577bd7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.util.IndentingPrintWriter
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.KairosBuilder
+import com.android.systemui.kairos.BuildSpec
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.flatMap
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import java.io.PrintWriter
+
+/**
+ * A repository that fully implements a mobile connection.
+ *
+ * This connection could either be a typical mobile connection (see [MobileConnectionRepositoryImpl]
+ * or a carrier merged connection (see [CarrierMergedConnectionRepository]). This repository
+ * switches between the two types of connections based on whether the connection is currently
+ * carrier merged.
+ */
+@ExperimentalKairosApi
+class FullMobileConnectionRepositoryKairos
+@AssistedInject
+constructor(
+ @Assisted override val subId: Int,
+ @Assisted override val tableLogBuffer: TableLogBuffer,
+ @Assisted private val mobileRepo: MobileConnectionRepositoryKairos,
+ @Assisted private val carrierMergedRepoSpec: BuildSpec<MobileConnectionRepositoryKairos>,
+ @Assisted private val isCarrierMerged: State<Boolean>,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ init {
+ onActivated {
+ logDiffsForTable(isCarrierMerged, tableLogBuffer, columnName = "isCarrierMerged")
+ }
+ }
+
+ @VisibleForTesting
+ val activeRepo: State<MobileConnectionRepositoryKairos> = buildState {
+ isCarrierMerged.mapLatestBuild { merged ->
+ if (merged) {
+ carrierMergedRepoSpec.applySpec()
+ } else {
+ mobileRepo
+ }
+ }
+ }
+
+ override val carrierId: State<Int> = activeRepo.flatMap { it.carrierId }
+
+ override val cdmaRoaming: State<Boolean> = activeRepo.flatMap { it.cdmaRoaming }
+
+ override val isEmergencyOnly: State<Boolean> =
+ activeRepo
+ .flatMap { it.isEmergencyOnly }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_EMERGENCY) }
+ }
+
+ override val isRoaming: State<Boolean> =
+ activeRepo
+ .flatMap { it.isRoaming }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_ROAMING) } }
+
+ override val operatorAlphaShort: State<String?> =
+ activeRepo
+ .flatMap { it.operatorAlphaShort }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_OPERATOR) }
+ }
+
+ override val isInService: State<Boolean> =
+ activeRepo
+ .flatMap { it.isInService }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_IN_SERVICE) }
+ }
+
+ override val isNonTerrestrial: State<Boolean> =
+ activeRepo
+ .flatMap { it.isNonTerrestrial }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_NTN) } }
+
+ override val isGsm: State<Boolean> =
+ activeRepo
+ .flatMap { it.isGsm }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_GSM) } }
+
+ override val cdmaLevel: State<Int> =
+ activeRepo
+ .flatMap { it.cdmaLevel }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_CDMA_LEVEL) }
+ }
+
+ override val primaryLevel: State<Int> =
+ activeRepo
+ .flatMap { it.primaryLevel }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_PRIMARY_LEVEL) }
+ }
+
+ override val satelliteLevel: State<Int> =
+ activeRepo
+ .flatMap { it.satelliteLevel }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogBuffer, columnName = COL_SATELLITE_LEVEL)
+ }
+ }
+
+ override val dataConnectionState: State<DataConnectionState> =
+ activeRepo
+ .flatMap { it.dataConnectionState }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val dataActivityDirection: State<DataActivityModel> =
+ activeRepo
+ .flatMap { it.dataActivityDirection }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val carrierNetworkChangeActive: State<Boolean> =
+ activeRepo
+ .flatMap { it.carrierNetworkChangeActive }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogBuffer, columnName = COL_CARRIER_NETWORK_CHANGE)
+ }
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> =
+ activeRepo
+ .flatMap { it.resolvedNetworkType }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val dataEnabled: State<Boolean> =
+ activeRepo
+ .flatMap { it.dataEnabled }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = "dataEnabled") }
+ }
+
+ override val inflateSignalStrength: State<Boolean> =
+ activeRepo
+ .flatMap { it.inflateSignalStrength }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = "inflate") } }
+
+ override val allowNetworkSliceIndicator: State<Boolean> =
+ activeRepo
+ .flatMap { it.allowNetworkSliceIndicator }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = "allowSlice") }
+ }
+
+ override val numberOfLevels: State<Int> = activeRepo.flatMap { it.numberOfLevels }
+
+ override val networkName: State<NetworkNameModel> =
+ activeRepo
+ .flatMap { it.networkName }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "intent") } }
+
+ override val carrierName: State<NetworkNameModel> =
+ activeRepo
+ .flatMap { it.carrierName }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "sub") } }
+
+ override val isAllowedDuringAirplaneMode: State<Boolean> =
+ activeRepo.flatMap { it.isAllowedDuringAirplaneMode }
+
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> =
+ activeRepo.flatMap { it.hasPrioritizedNetworkCapabilities }
+
+ override val isInEcmMode: State<Boolean> = activeRepo.flatMap { it.isInEcmMode }
+
+ private var dumpCache: DumpCache? = null
+
+ private data class DumpCache(
+ val isCarrierMerged: Boolean,
+ val activeRepo: MobileConnectionRepositoryKairos,
+ )
+
+ fun dump(pw: PrintWriter) {
+ val cache = dumpCache ?: return
+ val ipw = IndentingPrintWriter(pw, " ")
+
+ ipw.println("MobileConnectionRepository[$subId]")
+ ipw.increaseIndent()
+
+ ipw.println("carrierMerged=${cache.isCarrierMerged}")
+
+ ipw.print("Type (cellular or carrier merged): ")
+ when (cache.activeRepo) {
+ is CarrierMergedConnectionRepositoryKairos -> ipw.println("Carrier merged")
+ is MobileConnectionRepositoryKairosImpl -> ipw.println("Cellular")
+ }
+
+ ipw.increaseIndent()
+ ipw.println("Provider: ${cache.activeRepo}")
+ ipw.decreaseIndent()
+
+ ipw.decreaseIndent()
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ subId: Int,
+ mobileLogger: TableLogBuffer,
+ isCarrierMerged: State<Boolean>,
+ mobileRepo: MobileConnectionRepositoryKairos,
+ mergedRepoSpec: BuildSpec<MobileConnectionRepositoryKairos>,
+ ): FullMobileConnectionRepositoryKairos
+ }
+
+ companion object {
+ const val COL_CARRIER_ID = "carrierId"
+ const val COL_CARRIER_NETWORK_CHANGE = "carrierNetworkChangeActive"
+ const val COL_CDMA_LEVEL = "cdmaLevel"
+ const val COL_EMERGENCY = "emergencyOnly"
+ const val COL_IS_NTN = "isNtn"
+ const val COL_IS_GSM = "isGsm"
+ const val COL_IS_IN_SERVICE = "isInService"
+ const val COL_OPERATOR = "operatorName"
+ const val COL_PRIMARY_LEVEL = "primaryLevel"
+ const val COL_SATELLITE_LEVEL = "satelliteLevel"
+ const val COL_ROAMING = "roaming"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
index b4a45e24a0cb..bf7c2990b5ab 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
@@ -542,6 +542,10 @@ sealed interface CallbackEvent {
data class OnCarrierRoamingNtnSignalStrengthChanged(val signalStrength: NtnSignalStrength) :
CallbackEvent
+
+ data class OnCallBackModeStarted(val type: Int) : CallbackEvent
+
+ data class OnCallBackModeStopped(val type: Int) : CallbackEvent
}
/**
@@ -560,6 +564,8 @@ data class TelephonyCallbackState(
val onCarrierRoamingNtnSignalStrengthChanged:
CallbackEvent.OnCarrierRoamingNtnSignalStrengthChanged? =
null,
+ val addedCallbackModes: Set<Int> = emptySet(),
+ val removedCallbackModes: Set<Int> = emptySet(),
) {
fun applyEvent(event: CallbackEvent): TelephonyCallbackState {
return when (event) {
@@ -578,6 +584,37 @@ data class TelephonyCallbackState(
is CallbackEvent.OnSignalStrengthChanged -> copy(onSignalStrengthChanged = event)
is CallbackEvent.OnCarrierRoamingNtnSignalStrengthChanged ->
copy(onCarrierRoamingNtnSignalStrengthChanged = event)
+ is CallbackEvent.OnCallBackModeStarted -> {
+ copy(
+ addedCallbackModes =
+ if (event.type !in removedCallbackModes) {
+ addedCallbackModes + event.type
+ } else {
+ addedCallbackModes
+ },
+ removedCallbackModes =
+ if (event.type !in addedCallbackModes) {
+ removedCallbackModes - event.type
+ } else {
+ removedCallbackModes
+ },
+ )
+ }
+ is CallbackEvent.OnCallBackModeStopped ->
+ copy(
+ addedCallbackModes =
+ if (event.type !in removedCallbackModes) {
+ addedCallbackModes - event.type
+ } else {
+ addedCallbackModes
+ },
+ removedCallbackModes =
+ if (event.type !in addedCallbackModes) {
+ removedCallbackModes + event.type
+ } else {
+ removedCallbackModes
+ },
+ )
}
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt
new file mode 100644
index 000000000000..9b37f4896878
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import com.android.systemui.kairos.BuildScope
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.util.kotlin.Producer
+import kotlinx.coroutines.flow.StateFlow
+
+@ExperimentalKairosApi
+fun BuildScope.MobileConnectionRepositoryKairosAdapter(
+ kairosRepo: MobileConnectionRepositoryKairos,
+ carrierConfig: SystemUiCarrierConfig,
+) =
+ MobileConnectionRepositoryKairosAdapter(
+ subId = kairosRepo.subId,
+ carrierId = kairosRepo.carrierId.toStateFlow(),
+ inflateSignalStrength = carrierConfig.shouldInflateSignalStrength,
+ allowNetworkSliceIndicator = carrierConfig.allowNetworkSliceIndicator,
+ tableLogBuffer = kairosRepo.tableLogBuffer,
+ isEmergencyOnly = kairosRepo.isEmergencyOnly.toStateFlow(),
+ isRoaming = kairosRepo.isRoaming.toStateFlow(),
+ operatorAlphaShort = kairosRepo.operatorAlphaShort.toStateFlow(),
+ isInService = kairosRepo.isInService.toStateFlow(),
+ isNonTerrestrial = kairosRepo.isNonTerrestrial.toStateFlow(),
+ isGsm = kairosRepo.isGsm.toStateFlow(),
+ cdmaLevel = kairosRepo.cdmaLevel.toStateFlow(),
+ primaryLevel = kairosRepo.primaryLevel.toStateFlow(),
+ satelliteLevel = kairosRepo.satelliteLevel.toStateFlow(),
+ dataConnectionState = kairosRepo.dataConnectionState.toStateFlow(),
+ dataActivityDirection = kairosRepo.dataActivityDirection.toStateFlow(),
+ carrierNetworkChangeActive = kairosRepo.carrierNetworkChangeActive.toStateFlow(),
+ resolvedNetworkType = kairosRepo.resolvedNetworkType.toStateFlow(),
+ numberOfLevels = kairosRepo.numberOfLevels.toStateFlow(),
+ dataEnabled = kairosRepo.dataEnabled.toStateFlow(),
+ cdmaRoaming = kairosRepo.cdmaRoaming.toStateFlow(),
+ networkName = kairosRepo.networkName.toStateFlow(),
+ carrierName = kairosRepo.carrierName.toStateFlow(),
+ isAllowedDuringAirplaneMode = kairosRepo.isAllowedDuringAirplaneMode.toStateFlow(),
+ hasPrioritizedNetworkCapabilities =
+ kairosRepo.hasPrioritizedNetworkCapabilities.toStateFlow(),
+ isInEcmMode = { kairosNetwork.transact { kairosRepo.isInEcmMode.sample() } },
+ )
+
+@ExperimentalKairosApi
+class MobileConnectionRepositoryKairosAdapter(
+ override val subId: Int,
+ override val carrierId: StateFlow<Int>,
+ override val inflateSignalStrength: StateFlow<Boolean>,
+ override val allowNetworkSliceIndicator: StateFlow<Boolean>,
+ override val tableLogBuffer: TableLogBuffer,
+ override val isEmergencyOnly: StateFlow<Boolean>,
+ override val isRoaming: StateFlow<Boolean>,
+ override val operatorAlphaShort: StateFlow<String?>,
+ override val isInService: StateFlow<Boolean>,
+ override val isNonTerrestrial: StateFlow<Boolean>,
+ override val isGsm: StateFlow<Boolean>,
+ override val cdmaLevel: StateFlow<Int>,
+ override val primaryLevel: StateFlow<Int>,
+ override val satelliteLevel: StateFlow<Int>,
+ override val dataConnectionState: StateFlow<DataConnectionState>,
+ override val dataActivityDirection: StateFlow<DataActivityModel>,
+ override val carrierNetworkChangeActive: StateFlow<Boolean>,
+ override val resolvedNetworkType: StateFlow<ResolvedNetworkType>,
+ override val numberOfLevels: StateFlow<Int>,
+ override val dataEnabled: StateFlow<Boolean>,
+ override val cdmaRoaming: StateFlow<Boolean>,
+ override val networkName: StateFlow<NetworkNameModel>,
+ override val carrierName: StateFlow<NetworkNameModel>,
+ override val isAllowedDuringAirplaneMode: StateFlow<Boolean>,
+ override val hasPrioritizedNetworkCapabilities: StateFlow<Boolean>,
+ private val isInEcmMode: Producer<Boolean>,
+) : MobileConnectionRepository {
+ override suspend fun isInEcmMode(): Boolean = isInEcmMode.get()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt
new file mode 100644
index 000000000000..abe72e17163b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt
@@ -0,0 +1,485 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.telephony.CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN
+import android.telephony.CellSignalStrengthCdma
+import android.telephony.ServiceState
+import android.telephony.SignalStrength
+import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyDisplayInfo
+import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.ERI_FLASH
+import android.telephony.TelephonyManager.ERI_ON
+import android.telephony.TelephonyManager.EXTRA_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager.NETWORK_TYPE_UNKNOWN
+import android.telephony.TelephonyManager.UNKNOWN_CARRIER_ID
+import com.android.settingslib.Utils
+import com.android.systemui.KairosBuilder
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.flags.FeatureFlagsClassic
+import com.android.systemui.flags.Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.Transactional
+import com.android.systemui.kairos.awaitClose
+import com.android.systemui.kairos.coalescingEvents
+import com.android.systemui.kairos.conflatedEvents
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapNotNull
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairos.transactionally
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState.Disconnected
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.toDataConnectionType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.toNetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import java.time.Duration
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.withContext
+
+/**
+ * A repository implementation for a typical mobile connection (as opposed to a carrier merged
+ * connection -- see [CarrierMergedConnectionRepository]).
+ */
+@ExperimentalKairosApi
+class MobileConnectionRepositoryKairosImpl
+@AssistedInject
+constructor(
+ @Assisted override val subId: Int,
+ private val context: Context,
+ @Assisted subscriptionModel: State<SubscriptionModel?>,
+ @Assisted defaultNetworkName: NetworkNameModel,
+ @Assisted networkNameSeparator: String,
+ connectivityManager: ConnectivityManager,
+ @Assisted private val telephonyManager: TelephonyManager,
+ @Assisted systemUiCarrierConfig: SystemUiCarrierConfig,
+ broadcastDispatcher: BroadcastDispatcher,
+ private val mobileMappingsProxy: MobileMappingsProxy,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ logger: MobileInputLogger,
+ @Assisted override val tableLogBuffer: TableLogBuffer,
+ flags: FeatureFlagsClassic,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ init {
+ if (telephonyManager.subscriptionId != subId) {
+ throw IllegalStateException(
+ "MobileRepo: TelephonyManager should be created with subId($subId). " +
+ "Found ${telephonyManager.subscriptionId} instead."
+ )
+ }
+ }
+
+ /**
+ * This flow defines the single shared connection to system_server via TelephonyCallback. Any
+ * new callback should be added to this listener and funneled through callbackEvents via a data
+ * class. See [CallbackEvent] for defining new callbacks.
+ *
+ * The reason we need to do this is because TelephonyManager limits the number of registered
+ * listeners per-process, so we don't want to create a new listener for every callback.
+ *
+ * A note on the design for back pressure here: We don't control _which_ telephony callback
+ * comes in first, since we register every relevant bit of information as a batch. E.g., if a
+ * downstream starts collecting on a field which is backed by
+ * [TelephonyCallback.ServiceStateListener], it's not possible for us to guarantee that _that_
+ * callback comes in -- the first callback could very well be
+ * [TelephonyCallback.DataActivityListener], which would promptly be dropped if we didn't keep
+ * it tracked. We use the [scan] operator here to track the most recent callback of _each type_
+ * here. See [TelephonyCallbackState] to see how the callbacks are stored.
+ */
+ private val callbackEvents: Events<TelephonyCallbackState> = buildEvents {
+ coalescingEvents(
+ initialValue = TelephonyCallbackState(),
+ coalesce = TelephonyCallbackState::applyEvent,
+ ) {
+ val callback =
+ object :
+ TelephonyCallback(),
+ TelephonyCallback.CarrierNetworkListener,
+ TelephonyCallback.CarrierRoamingNtnListener,
+ TelephonyCallback.DataActivityListener,
+ TelephonyCallback.DataConnectionStateListener,
+ TelephonyCallback.DataEnabledListener,
+ TelephonyCallback.DisplayInfoListener,
+ TelephonyCallback.ServiceStateListener,
+ TelephonyCallback.SignalStrengthsListener,
+ TelephonyCallback.EmergencyCallbackModeListener {
+
+ override fun onCarrierNetworkChange(active: Boolean) {
+ logger.logOnCarrierNetworkChange(active, subId)
+ emit(CallbackEvent.OnCarrierNetworkChange(active))
+ }
+
+ override fun onCarrierRoamingNtnModeChanged(active: Boolean) {
+ logger.logOnCarrierRoamingNtnModeChanged(active)
+ emit(CallbackEvent.OnCarrierRoamingNtnModeChanged(active))
+ }
+
+ override fun onDataActivity(direction: Int) {
+ logger.logOnDataActivity(direction, subId)
+ emit(CallbackEvent.OnDataActivity(direction))
+ }
+
+ override fun onDataEnabledChanged(enabled: Boolean, reason: Int) {
+ logger.logOnDataEnabledChanged(enabled, subId)
+ emit(CallbackEvent.OnDataEnabledChanged(enabled))
+ }
+
+ override fun onDataConnectionStateChanged(dataState: Int, networkType: Int) {
+ logger.logOnDataConnectionStateChanged(dataState, networkType, subId)
+ emit(CallbackEvent.OnDataConnectionStateChanged(dataState))
+ }
+
+ override fun onDisplayInfoChanged(telephonyDisplayInfo: TelephonyDisplayInfo) {
+ logger.logOnDisplayInfoChanged(telephonyDisplayInfo, subId)
+ emit(CallbackEvent.OnDisplayInfoChanged(telephonyDisplayInfo))
+ }
+
+ override fun onServiceStateChanged(serviceState: ServiceState) {
+ logger.logOnServiceStateChanged(serviceState, subId)
+ emit(CallbackEvent.OnServiceStateChanged(serviceState))
+ }
+
+ override fun onSignalStrengthsChanged(signalStrength: SignalStrength) {
+ logger.logOnSignalStrengthsChanged(signalStrength, subId)
+ emit(CallbackEvent.OnSignalStrengthChanged(signalStrength))
+ }
+
+ override fun onCallbackModeStarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ // logger.logOnCallBackModeStarted(type, subId)
+ emit(CallbackEvent.OnCallBackModeStarted(type))
+ }
+
+ override fun onCallbackModeRestarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ // no-op
+ }
+
+ override fun onCallbackModeStopped(type: Int, reason: Int, subId: Int) {
+ // logger.logOnCallBackModeStopped(type, reason, subId)
+ emit(CallbackEvent.OnCallBackModeStopped(type))
+ }
+ }
+ withContext(bgDispatcher) {
+ telephonyManager.registerTelephonyCallback(bgDispatcher.asExecutor(), callback)
+ }
+ awaitClose { telephonyManager.unregisterTelephonyCallback(callback) }
+ }
+ }
+
+ private val serviceState: State<ServiceState?> = buildState {
+ callbackEvents.mapNotNull { it.onServiceStateChanged?.serviceState }.holdState(null)
+ }
+
+ override val isEmergencyOnly: State<Boolean> = serviceState.map { it?.isEmergencyOnly == true }
+
+ private val displayInfo: State<TelephonyDisplayInfo?> = buildState {
+ callbackEvents.mapNotNull { it.onDisplayInfoChanged?.telephonyDisplayInfo }.holdState(null)
+ }
+
+ override val isRoaming: State<Boolean> =
+ if (flags.isEnabled(ROAMING_INDICATOR_VIA_DISPLAY_INFO)) {
+ displayInfo.map { it?.isRoaming == true }
+ } else {
+ serviceState.map { it?.roaming == true }
+ }
+
+ override val operatorAlphaShort: State<String?> = serviceState.map { it?.operatorAlphaShort }
+
+ override val isInService: State<Boolean> =
+ serviceState.map { it?.let(Utils::isInService) == true }
+
+ private val carrierRoamingNtnActive: State<Boolean> = buildState {
+ callbackEvents.mapNotNull { it.onCarrierRoamingNtnModeChanged?.active }.holdState(false)
+ }
+
+ override val isNonTerrestrial: State<Boolean>
+ get() = carrierRoamingNtnActive
+
+ private val signalStrength: State<SignalStrength?> = buildState {
+ callbackEvents.mapNotNull { it.onSignalStrengthChanged?.signalStrength }.holdState(null)
+ }
+
+ override val isGsm: State<Boolean> = signalStrength.map { it?.isGsm == true }
+
+ override val cdmaLevel: State<Int> =
+ signalStrength.map {
+ it?.getCellSignalStrengths(CellSignalStrengthCdma::class.java)?.firstOrNull()?.level
+ ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN
+ }
+
+ override val primaryLevel: State<Int> =
+ signalStrength.map { it?.level ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN }
+
+ override val satelliteLevel: State<Int> = buildState {
+ callbackEvents
+ .mapNotNull { it.onCarrierRoamingNtnSignalStrengthChanged?.signalStrength?.level }
+ .holdState(0)
+ }
+
+ override val dataConnectionState: State<DataConnectionState> = buildState {
+ callbackEvents
+ .mapNotNull { it.onDataConnectionStateChanged?.dataState?.toDataConnectionType() }
+ .holdState(Disconnected)
+ }
+
+ override val dataActivityDirection: State<DataActivityModel> = buildState {
+ callbackEvents
+ .mapNotNull { it.onDataActivity?.direction?.toMobileDataActivityModel() }
+ .holdState(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+ }
+
+ override val carrierNetworkChangeActive: State<Boolean> = buildState {
+ callbackEvents.mapNotNull { it.onCarrierNetworkChange?.active }.holdState(false)
+ }
+
+ private val telephonyDisplayInfo: State<TelephonyDisplayInfo?> = buildState {
+ callbackEvents.mapNotNull { it.onDisplayInfoChanged?.telephonyDisplayInfo }.holdState(null)
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> =
+ telephonyDisplayInfo.map { displayInfo ->
+ displayInfo
+ ?.overrideNetworkType
+ ?.takeIf { it != OVERRIDE_NETWORK_TYPE_NONE }
+ ?.let { OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(it)) }
+ ?: displayInfo
+ ?.networkType
+ ?.takeIf { it != NETWORK_TYPE_UNKNOWN }
+ ?.let { DefaultNetworkType(mobileMappingsProxy.toIconKey(it)) }
+ ?: UnknownNetworkType
+ }
+
+ override val inflateSignalStrength: State<Boolean> = buildState {
+ systemUiCarrierConfig.shouldInflateSignalStrength.toState()
+ }
+
+ override val allowNetworkSliceIndicator: State<Boolean> = buildState {
+ systemUiCarrierConfig.allowNetworkSliceIndicator.toState()
+ }
+
+ override val numberOfLevels: State<Int> =
+ inflateSignalStrength.map { shouldInflate ->
+ if (shouldInflate) {
+ DEFAULT_NUM_LEVELS + 1
+ } else {
+ DEFAULT_NUM_LEVELS
+ }
+ }
+
+ override val carrierName: State<NetworkNameModel> =
+ subscriptionModel.map {
+ it?.let { model -> NetworkNameModel.SubscriptionDerived(model.carrierName) }
+ ?: defaultNetworkName
+ }
+
+ /**
+ * There are a few cases where we will need to poll [TelephonyManager] so we can update some
+ * internal state where callbacks aren't provided. Any of those events should be merged into
+ * this flow, which can be used to trigger the polling.
+ */
+ private val telephonyPollingEvent: Events<Unit> = callbackEvents.map {}
+
+ private val cdmaEnhancedRoamingIndicatorDisplayNumber: Transactional<Int?> = transactionally {
+ try {
+ telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber
+ } catch (e: UnsupportedOperationException) {
+ // Handles the same as a function call failure
+ null
+ }
+ }
+
+ override val cdmaRoaming: State<Boolean> = buildState {
+ telephonyPollingEvent
+ .map {
+ val cdmaEri = cdmaEnhancedRoamingIndicatorDisplayNumber.sample()
+ cdmaEri == ERI_ON || cdmaEri == ERI_FLASH
+ }
+ .holdState(false)
+ }
+
+ override val carrierId: State<Int> = buildState {
+ broadcastDispatcher
+ .broadcastFlow(
+ filter =
+ IntentFilter(TelephonyManager.ACTION_SUBSCRIPTION_CARRIER_IDENTITY_CHANGED),
+ map = { intent, _ -> intent },
+ )
+ .filter { intent ->
+ intent.getIntExtra(EXTRA_SUBSCRIPTION_ID, INVALID_SUBSCRIPTION_ID) == subId
+ }
+ .map { it.carrierId() }
+ .toState(telephonyManager.simCarrierId)
+ }
+
+ /**
+ * BroadcastDispatcher does not handle sticky broadcasts, so we can't use it here. Note that we
+ * now use the [SharingStarted.Eagerly] strategy, because there have been cases where the sticky
+ * broadcast does not represent the correct state.
+ *
+ * See b/322432056 for context.
+ */
+ @SuppressLint("RegisterReceiverViaContext")
+ override val networkName: State<NetworkNameModel> = buildState {
+ conflatedEvents {
+ val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (
+ intent.getIntExtra(
+ EXTRA_SUBSCRIPTION_INDEX,
+ INVALID_SUBSCRIPTION_ID,
+ ) == subId
+ ) {
+ logger.logServiceProvidersUpdatedBroadcast(intent)
+ emit(
+ intent.toNetworkNameModel(networkNameSeparator)
+ ?: defaultNetworkName
+ )
+ }
+ }
+ }
+
+ context.registerReceiver(
+ receiver,
+ IntentFilter(TelephonyManager.ACTION_SERVICE_PROVIDERS_UPDATED),
+ )
+
+ awaitClose { context.unregisterReceiver(receiver) }
+ }
+ .holdState(defaultNetworkName)
+ }
+
+ override val dataEnabled: State<Boolean> = buildState {
+ callbackEvents
+ .mapNotNull { it.onDataEnabledChanged?.enabled }
+ .holdState(telephonyManager.isDataConnectionAllowed)
+ }
+
+ override val isInEcmMode: State<Boolean> = buildState {
+ callbackEvents
+ .mapNotNull {
+ (it.addedCallbackModes to it.removedCallbackModes).takeIf { (added, removed) ->
+ added.isNotEmpty() || removed.isNotEmpty()
+ }
+ }
+ .foldState(emptySet<Int>()) { (added, removed), acc -> acc - removed + added }
+ .mapTransactionally { it.isNotEmpty() }
+ }
+
+ /** Typical mobile connections aren't available during airplane mode. */
+ override val isAllowedDuringAirplaneMode: State<Boolean> = stateOf(false)
+
+ /**
+ * Currently, a network with NET_CAPABILITY_PRIORITIZE_LATENCY is the only type of network that
+ * we consider to be a "network slice". _PRIORITIZE_BANDWIDTH may be added in the future. Any of
+ * these capabilities that are used here must also be represented in the
+ * self_certified_network_capabilities.xml config file
+ */
+ @SuppressLint("WrongConstant")
+ private val networkSliceRequest: NetworkRequest =
+ NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY)
+ .setSubscriptionIds(setOf(subId))
+ .build()
+
+ @SuppressLint("MissingPermission")
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> = buildState {
+ conflatedEvents {
+ // Our network callback listens only for this.subId && net_cap_prioritize_latency
+ // therefore our state is a simple mapping of whether or not that network exists
+ val callback =
+ object : NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ logger.logPrioritizedNetworkAvailable(network.netId)
+ emit(true)
+ }
+
+ override fun onLost(network: Network) {
+ logger.logPrioritizedNetworkLost(network.netId)
+ emit(false)
+ }
+ }
+
+ connectivityManager.registerNetworkCallback(networkSliceRequest, callback)
+
+ awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
+ }
+ .holdState(false)
+ }
+
+ @AssistedFactory
+ fun interface Factory {
+ fun create(
+ subId: Int,
+ mobileLogger: TableLogBuffer,
+ subscriptionModel: State<SubscriptionModel?>,
+ defaultNetworkName: NetworkNameModel,
+ networkNameSeparator: String,
+ systemUiCarrierConfig: SystemUiCarrierConfig,
+ telephonyManager: TelephonyManager,
+ ): MobileConnectionRepositoryKairosImpl
+ }
+}
+
+private fun Intent.carrierId(): Int =
+ getIntExtra(TelephonyManager.EXTRA_CARRIER_ID, UNKNOWN_CARRIER_ID)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt
new file mode 100644
index 000000000000..e46815954e64
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt
@@ -0,0 +1,584 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository.prod
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.telephony.CarrierConfigManager
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyCallback.ActiveDataSubscriptionIdListener
+import android.telephony.TelephonyCallback.EmergencyCallbackModeListener
+import android.telephony.TelephonyManager
+import android.util.IndentingPrintWriter
+import com.android.internal.telephony.PhoneConstants
+import com.android.keyguard.KeyguardUpdateMonitor
+import com.android.keyguard.KeyguardUpdateMonitorCallback
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.mobile.MobileMappings.Config
+import com.android.systemui.Dumpable
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.activated
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.kairos.BuildSpec
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.StateSelector
+import com.android.systemui.kairos.asIncremental
+import com.android.systemui.kairos.asyncEvent
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.changes
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.effect
+import com.android.systemui.kairos.filterNotNull
+import com.android.systemui.kairos.flatMap
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapNotNull
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairos.mergeLeft
+import com.android.systemui.kairos.onEach
+import com.android.systemui.kairos.rebuildOn
+import com.android.systemui.kairos.selector
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairos.switchEvents
+import com.android.systemui.kairos.transitions
+import com.android.systemui.kairos.util.WithPrev
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.TableLogBufferFactory
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.pipeline.airplane.data.repository.AirplaneModeRepository
+import com.android.systemui.statusbar.pipeline.dagger.MobileSummaryLog
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierConfigRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.SubscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.shared.data.model.DefaultConnectionModel
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.WifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
+import dagger.Binds
+import dagger.Lazy
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import java.io.PrintWriter
+import java.time.Duration
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.withContext
+
+@ExperimentalKairosApi
+@SysUISingleton
+class MobileConnectionsRepositoryKairosImpl
+@Inject
+constructor(
+ connectivityRepository: ConnectivityRepository,
+ private val subscriptionManager: SubscriptionManager,
+ private val subscriptionManagerProxy: SubscriptionManagerProxy,
+ private val telephonyManager: TelephonyManager,
+ private val logger: MobileInputLogger,
+ @MobileSummaryLog private val tableLogger: TableLogBuffer,
+ mobileMappingsProxy: MobileMappingsProxy,
+ broadcastDispatcher: BroadcastDispatcher,
+ private val context: Context,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ @Main private val mainDispatcher: CoroutineDispatcher,
+ airplaneModeRepository: AirplaneModeRepository,
+ // Some "wifi networks" should be rendered as a mobile connection, which is why the wifi
+ // repository is an input to the mobile repository.
+ // See [CarrierMergedConnectionRepositoryKairos] for details.
+ wifiRepository: WifiRepository,
+ private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
+ dumpManager: DumpManager,
+ private val mobileRepoFactory: Lazy<ConnectionRepoFactory>,
+) : MobileConnectionsRepositoryKairos, Dumpable, KairosBuilder by kairosBuilder() {
+
+ init {
+ dumpManager.registerNormalDumpable("MobileConnectionsRepositoryKairos", this)
+ }
+
+ private val carrierMergedSubId: State<Int?> = buildState {
+ combine(
+ wifiRepository.wifiNetwork.toState(),
+ connectivityRepository.defaultConnections.toState(),
+ airplaneModeRepository.isAirplaneMode.toState(),
+ ) { wifiNetwork, defaultConnections, isAirplaneMode ->
+ // The carrier merged connection should only be used if it's also the default
+ // connection or mobile connections aren't available because of airplane mode.
+ val defaultConnectionIsNonMobile =
+ defaultConnections.carrierMerged.isDefault ||
+ defaultConnections.wifi.isDefault ||
+ isAirplaneMode
+
+ if (wifiNetwork is WifiNetworkModel.CarrierMerged && defaultConnectionIsNonMobile) {
+ wifiNetwork.subscriptionId
+ } else {
+ null
+ }
+ }
+ .also {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "carrierMergedSubId")
+ }
+ }
+
+ private val mobileSubscriptionsChangeEvent: Events<Unit> = buildEvents {
+ conflatedCallbackFlow {
+ val callback =
+ object : SubscriptionManager.OnSubscriptionsChangedListener() {
+ override fun onSubscriptionsChanged() {
+ logger.logOnSubscriptionsChanged()
+ trySend(Unit)
+ }
+ }
+ subscriptionManager.addOnSubscriptionsChangedListener(Runnable::run, callback)
+ awaitClose { subscriptionManager.removeOnSubscriptionsChangedListener(callback) }
+ }
+ .flowOn(bgDispatcher)
+ .toEvents()
+ }
+
+ /** Turn ACTION_SERVICE_STATE (for subId = -1) into an event */
+ private val serviceStateChangedEvent: Events<Unit> = buildEvents {
+ broadcastDispatcher
+ .broadcastFlow(IntentFilter(Intent.ACTION_SERVICE_STATE)) { intent, _ ->
+ val subId =
+ intent.getIntExtra(
+ SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX,
+ INVALID_SUBSCRIPTION_ID,
+ )
+
+ // Only emit if the subId is not associated with an active subscription
+ if (subId == INVALID_SUBSCRIPTION_ID) {
+ Unit
+ }
+ }
+ .toEvents()
+ }
+
+ /** Eager flow to determine the device-based emergency calls only state */
+ override val isDeviceEmergencyCallCapable: State<Boolean> = buildState {
+ rebuildOn(serviceStateChangedEvent) { asyncEvent { doAnyModemsSupportEmergencyCalls() } }
+ .switchEvents()
+ .holdState(false)
+ .also {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "deviceEmergencyOnly",
+ )
+ }
+ }
+
+ private suspend fun doAnyModemsSupportEmergencyCalls(): Boolean =
+ withContext(bgDispatcher) {
+ val modems = telephonyManager.activeModemCount
+
+ // Assume false for automotive devices which don't have the calling feature.
+ // TODO: b/398045526 to revisit the below.
+ val isAutomotive: Boolean =
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
+ val hasFeatureCalling: Boolean =
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY_CALLING)
+ if (isAutomotive && !hasFeatureCalling) {
+ return@withContext false
+ }
+
+ // Check the service state for every modem. If any state reports emergency calling
+ // capable, then consider the device to have emergency call capabilities
+ (0..<modems)
+ .map { telephonyManager.getServiceStateForSlot(it) }
+ .any { it?.isEmergencyOnly == true }
+ }
+
+ /**
+ * State flow that emits the set of mobile data subscriptions, each represented by its own
+ * [SubscriptionModel].
+ */
+ override val subscriptions: State<List<SubscriptionModel>> = buildState {
+ rebuildOn(mergeLeft(mobileSubscriptionsChangeEvent, carrierMergedSubId.changes)) {
+ asyncEvent { fetchSubscriptionModels() }
+ }
+ .switchEvents()
+ .holdState(emptyList())
+ .also {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "subscriptions")
+ }
+ }
+
+ val subscriptionsById: State<Map<Int, SubscriptionModel>> =
+ subscriptions.map { subs -> subs.associateBy { it.subscriptionId } }
+
+ override val mobileConnectionsBySubId: Incremental<Int, MobileConnectionRepositoryKairos> =
+ buildIncremental {
+ subscriptionsById
+ .asIncremental()
+ .mapValues { (subId, sub) -> mobileRepoFactory.get().create(subId) }
+ .applyLatestSpecForKey()
+ }
+
+ private val telephonyManagerState: State<Pair<Int?, Set<Int>>> = buildState {
+ callbackFlow {
+ val callback =
+ object :
+ TelephonyCallback(),
+ ActiveDataSubscriptionIdListener,
+ EmergencyCallbackModeListener {
+ override fun onActiveDataSubscriptionIdChanged(subId: Int) {
+ if (subId != INVALID_SUBSCRIPTION_ID) {
+ trySend { (_, set): Pair<Int?, Set<Int>> -> subId to set }
+ } else {
+ trySend { (_, set): Pair<Int?, Set<Int>> -> null to set }
+ }
+ }
+
+ override fun onCallbackModeStarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ trySend { (id, set): Pair<Int?, Set<Int>> -> id to (set + type) }
+ }
+
+ override fun onCallbackModeRestarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ // no-op
+ }
+
+ override fun onCallbackModeStopped(type: Int, reason: Int, subId: Int) {
+ trySend { (id, set): Pair<Int?, Set<Int>> -> id to (set - type) }
+ }
+ }
+ telephonyManager.registerTelephonyCallback(Runnable::run, callback)
+ awaitClose { telephonyManager.unregisterTelephonyCallback(callback) }
+ }
+ .flowOn(bgDispatcher)
+ .scanToState(null to emptySet())
+ }
+
+ override val activeMobileDataSubscriptionId: State<Int?> =
+ telephonyManagerState
+ .map { it.first }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "activeSubId")
+ }
+ }
+
+ override val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?> =
+ combine(activeMobileDataSubscriptionId, mobileConnectionsBySubId) { id, cache -> cache[id] }
+
+ override val defaultDataSubId: State<Int?> = buildState {
+ broadcastDispatcher
+ .broadcastFlow(
+ IntentFilter(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ ) { intent, _ ->
+ intent
+ .getIntExtra(PhoneConstants.SUBSCRIPTION_KEY, INVALID_SUBSCRIPTION_ID)
+ .takeIf { it != INVALID_SUBSCRIPTION_ID }
+ }
+ .onStart {
+ emit(
+ subscriptionManagerProxy.getDefaultDataSubscriptionId().takeIf {
+ it != INVALID_SUBSCRIPTION_ID
+ }
+ )
+ }
+ .toState(initialValue = null)
+ .also { logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "defaultSubId") }
+ }
+
+ private val carrierConfigChangedEvent: Events<Unit> =
+ buildEvents {
+ broadcastDispatcher
+ .broadcastFlow(IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED))
+ .toEvents()
+ }
+ .onEach { logger.logActionCarrierConfigChanged() }
+
+ override val defaultDataSubRatConfig: State<Config> = buildState {
+ rebuildOn(mergeLeft(defaultDataSubId.changes, carrierConfigChangedEvent)) {
+ Config.readConfig(context).also { effect { logger.logDefaultDataSubRatConfig(it) } }
+ }
+ }
+
+ override val defaultMobileIconMapping: State<Map<String, MobileIconGroup>> = buildState {
+ defaultDataSubRatConfig
+ .map { mobileMappingsProxy.mapIconSets(it) }
+ .apply { observe { logger.logDefaultMobileIconMapping(it) } }
+ }
+
+ override val defaultMobileIconGroup: State<MobileIconGroup> = buildState {
+ defaultDataSubRatConfig
+ .map { mobileMappingsProxy.getDefaultIcons(it) }
+ .apply { observe { logger.logDefaultMobileIconGroup(it) } }
+ }
+
+ override val isAnySimSecure: State<Boolean> = buildState {
+ conflatedCallbackFlow {
+ val callback =
+ object : KeyguardUpdateMonitorCallback() {
+ override fun onSimStateChanged(subId: Int, slotId: Int, simState: Int) {
+ logger.logOnSimStateChanged()
+ trySend(keyguardUpdateMonitor.isSimPinSecure)
+ }
+ }
+ keyguardUpdateMonitor.registerCallback(callback)
+ awaitClose { keyguardUpdateMonitor.removeCallback(callback) }
+ }
+ .flowOn(mainDispatcher)
+ .toState(false)
+ .also {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "isAnySimSecure")
+ }
+ }
+
+ private val defaultConnections: State<DefaultConnectionModel> = buildState {
+ connectivityRepository.defaultConnections.toState()
+ }
+
+ override val mobileIsDefault: State<Boolean> =
+ defaultConnections
+ .map { it.mobile.isDefault }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "mobileIsDefault",
+ )
+ }
+ }
+
+ override val hasCarrierMergedConnection: State<Boolean> =
+ carrierMergedSubId
+ .map { it != null }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "hasCarrierMergedConnection",
+ )
+ }
+ }
+
+ override val defaultConnectionIsValidated: State<Boolean> =
+ defaultConnections
+ .map { it.isValidated }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "defaultConnectionIsValidated",
+ )
+ }
+ }
+
+ /**
+ * Flow that tracks the active mobile data subscriptions. Emits `true` whenever the active data
+ * subscription Id changes but the subscription group remains the same. In these cases, we want
+ * to retain the previous subscription's validation status for up to 2s to avoid flickering the
+ * icon.
+ *
+ * TODO(b/265164432): we should probably expose all change events, not just same group
+ */
+ @SuppressLint("MissingPermission")
+ override val activeSubChangedInGroupEvent: Events<Unit> = buildEvents {
+ activeMobileDataSubscriptionId.transitions
+ .mapNotNull { (prevVal, newVal) ->
+ prevVal?.let { newVal?.let { WithPrev(prevVal, newVal) } }
+ }
+ .mapAsyncLatest { (prevVal, newVal) ->
+ if (isActiveSubChangeInGroup(prevVal, newVal)) Unit else null
+ }
+ .filterNotNull()
+ }
+
+ private suspend fun isActiveSubChangeInGroup(prevId: Int, newId: Int): Boolean =
+ withContext(bgDispatcher) {
+ val prevSub = subscriptionManager.getActiveSubscriptionInfo(prevId)?.groupUuid
+ val nextSub = subscriptionManager.getActiveSubscriptionInfo(newId)?.groupUuid
+ prevSub != null && prevSub == nextSub
+ }
+
+ private val isInEcmModeTopLevel: State<Boolean> =
+ telephonyManagerState.map { it.second.isNotEmpty() }
+
+ override val isInEcmMode: State<Boolean> =
+ isInEcmModeTopLevel.flatMap { isInEcm ->
+ if (isInEcm) {
+ stateOf(true)
+ } else {
+ mobileConnectionsBySubId.flatMap {
+ it.mapValues { it.value.isInEcmMode }.combine().map { it.values.any { it } }
+ }
+ }
+ }
+
+ /** Determines which subId is currently carrier-merged. */
+ val carrierMergedSelector: StateSelector<Int?> = carrierMergedSubId.selector()
+
+ private suspend fun fetchSubscriptionModels(): List<SubscriptionModel> =
+ withContext(bgDispatcher) {
+ subscriptionManager.completeActiveSubscriptionInfoList.map { it.toSubscriptionModel() }
+ }
+
+ private fun SubscriptionInfo.toSubscriptionModel(): SubscriptionModel =
+ SubscriptionModel(
+ subscriptionId = subscriptionId,
+ isOpportunistic = isOpportunistic,
+ isExclusivelyNonTerrestrial = isOnlyNonTerrestrialNetwork,
+ groupUuid = groupUuid,
+ carrierName = carrierName.toString(),
+ profileClass = profileClass,
+ )
+
+ private var dumpCache: DumpCache? = null
+
+ private data class DumpCache(val repos: Map<Int, FullMobileConnectionRepositoryKairos>)
+
+ override fun dump(pw: PrintWriter, args: Array<String>) {
+ val cache = dumpCache ?: return
+ val ipw = IndentingPrintWriter(pw, " ")
+ ipw.println("Connection cache:")
+
+ ipw.increaseIndent()
+ cache.repos.forEach { (subId, repo) -> ipw.println("$subId: $repo") }
+ ipw.decreaseIndent()
+
+ ipw.println("Connections (${cache.repos.size} total):")
+ ipw.increaseIndent()
+ cache.repos.values.forEach { it.dump(ipw) }
+ ipw.decreaseIndent()
+ }
+
+ fun interface ConnectionRepoFactory {
+ fun create(subId: Int): BuildSpec<MobileConnectionRepositoryKairos>
+ }
+
+ @dagger.Module
+ object Module {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<MobileConnectionsRepositoryKairosImpl>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+
+ companion object {
+ private const val LOGGING_PREFIX = "Repo"
+ }
+}
+
+@ExperimentalKairosApi
+class MobileConnectionRepositoryKairosFactoryImpl
+@Inject
+constructor(
+ context: Context,
+ private val connectionsRepo: MobileConnectionsRepositoryKairosImpl,
+ private val logFactory: TableLogBufferFactory,
+ private val carrierConfigRepo: CarrierConfigRepository,
+ private val telephonyManager: TelephonyManager,
+ private val mobileRepoFactory: MobileConnectionRepositoryKairosImpl.Factory,
+ private val mergedRepoFactory: CarrierMergedConnectionRepositoryKairos.Factory,
+) : MobileConnectionsRepositoryKairosImpl.ConnectionRepoFactory {
+
+ private val networkNameSeparator: String =
+ context.getString(R.string.status_bar_network_name_separator)
+
+ private val defaultNetworkName =
+ NetworkNameModel.Default(
+ context.getString(com.android.internal.R.string.lockscreen_carrier_default)
+ )
+
+ override fun create(subId: Int): BuildSpec<MobileConnectionRepositoryKairos> = buildSpec {
+ activated {
+ val mobileLogger =
+ logFactory.getOrCreate(tableBufferLogName(subId), MOBILE_CONNECTION_BUFFER_SIZE)
+ val mobileRepo = activated {
+ mobileRepoFactory.create(
+ subId,
+ mobileLogger,
+ connectionsRepo.subscriptionsById.map { subs -> subs[subId] },
+ defaultNetworkName,
+ networkNameSeparator,
+ carrierConfigRepo.getOrCreateConfigForSubId(subId),
+ telephonyManager.createForSubscriptionId(subId),
+ )
+ }
+ FullMobileConnectionRepositoryKairos(
+ subId = subId,
+ tableLogBuffer = mobileLogger,
+ mobileRepo = mobileRepo,
+ carrierMergedRepoSpec =
+ buildSpec {
+ activated { mergedRepoFactory.build(subId, mobileLogger, mobileRepo) }
+ },
+ isCarrierMerged = connectionsRepo.carrierMergedSelector[subId],
+ )
+ }
+ }
+
+ companion object {
+ /** The buffer size to use for logging. */
+ private const val MOBILE_CONNECTION_BUFFER_SIZE = 100
+
+ /** Returns a log buffer name for a mobile connection with the given [subId]. */
+ fun tableBufferLogName(subId: Int): String = "MobileConnectionLog[$subId]"
+ }
+
+ @dagger.Module
+ interface Module {
+ @Binds
+ fun bindImpl(
+ impl: MobileConnectionRepositoryKairosFactoryImpl
+ ): MobileConnectionsRepositoryKairosImpl.ConnectionRepoFactory
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt
new file mode 100644
index 000000000000..4580ad974b29
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt
@@ -0,0 +1,380 @@
+/*
+ * Copyright (C) 2022 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.systemui.statusbar.pipeline.mobile.domain.interactor
+
+import android.content.Context
+import com.android.internal.telephony.flags.Flags
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.graph.SignalDrawable
+import com.android.settingslib.mobile.MobileIconCarrierIdOverrides
+import com.android.settingslib.mobile.MobileIconCarrierIdOverridesImpl
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState.Connected
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel.DefaultIcon
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel.OverriddenIcon
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
+import com.android.systemui.statusbar.pipeline.satellite.ui.model.SatelliteIconModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+interface MobileIconInteractorKairos {
+ /** The table log created for this connection */
+ val tableLogBuffer: TableLogBuffer
+
+ /** The current mobile data activity */
+ val activity: Flow<DataActivityModel>
+
+ /** See [MobileConnectionsRepository.mobileIsDefault]. */
+ val mobileIsDefault: Flow<Boolean>
+
+ /**
+ * True when telephony tells us that the data state is CONNECTED. See
+ * [android.telephony.TelephonyCallback.DataConnectionStateListener] for more details. We
+ * consider this connection to be serving data, and thus want to show a network type icon, when
+ * data is connected. Other data connection states would typically cause us not to show the icon
+ */
+ val isDataConnected: StateFlow<Boolean>
+
+ /** True if we consider this connection to be in service, i.e. can make calls */
+ val isInService: StateFlow<Boolean>
+
+ /** True if this connection is emergency only */
+ val isEmergencyOnly: StateFlow<Boolean>
+
+ /** Observable for the data enabled state of this connection */
+ val isDataEnabled: StateFlow<Boolean>
+
+ /** True if the RAT icon should always be displayed and false otherwise. */
+ val alwaysShowDataRatIcon: StateFlow<Boolean>
+
+ /** Canonical representation of the current mobile signal strength as a triangle. */
+ val signalLevelIcon: StateFlow<SignalIconModel>
+
+ /** Observable for RAT type (network type) indicator */
+ val networkTypeIconGroup: StateFlow<NetworkTypeIconModel>
+
+ /** Whether or not to show the slice attribution */
+ val showSliceAttribution: StateFlow<Boolean>
+
+ /** True if this connection is satellite-based */
+ val isNonTerrestrial: StateFlow<Boolean>
+
+ /**
+ * Provider name for this network connection. The name can be one of 3 values:
+ * 1. The default network name, if one is configured
+ * 2. A derived name based off of the intent [ACTION_SERVICE_PROVIDERS_UPDATED]
+ * 3. Or, in the case where the repository sends us the default network name, we check for an
+ * override in [connectionInfo.operatorAlphaShort], a value that is derived from
+ * [ServiceState]
+ */
+ val networkName: StateFlow<NetworkNameModel>
+
+ /**
+ * Provider name for this network connection. The name can be one of 3 values:
+ * 1. The default network name, if one is configured
+ * 2. A name provided by the [SubscriptionModel] of this network connection
+ * 3. Or, in the case where the repository sends us the default network name, we check for an
+ * override in [connectionInfo.operatorAlphaShort], a value that is derived from
+ * [ServiceState]
+ *
+ * TODO(b/296600321): De-duplicate this field with [networkName] after determining the data
+ * provided is identical
+ */
+ val carrierName: StateFlow<String>
+
+ /** True if there is only one active subscription. */
+ val isSingleCarrier: StateFlow<Boolean>
+
+ /**
+ * True if this connection is considered roaming. The roaming bit can come from [ServiceState],
+ * or directly from the telephony manager's CDMA ERI number value. Note that we don't consider a
+ * connection to be roaming while carrier network change is active
+ */
+ val isRoaming: StateFlow<Boolean>
+
+ /** See [MobileIconsInteractor.isForceHidden]. */
+ val isForceHidden: Flow<Boolean>
+
+ /** See [MobileConnectionRepository.isAllowedDuringAirplaneMode]. */
+ val isAllowedDuringAirplaneMode: StateFlow<Boolean>
+
+ /** True when in carrier network change mode */
+ val carrierNetworkChangeActive: StateFlow<Boolean>
+}
+
+/** Interactor for a single mobile connection. This connection _should_ have one subscription ID */
+@Suppress("EXPERIMENTAL_IS_NOT_ENABLED")
+class MobileIconInteractorKairosImpl(
+ @Background scope: CoroutineScope,
+ defaultSubscriptionHasDataEnabled: StateFlow<Boolean>,
+ override val alwaysShowDataRatIcon: StateFlow<Boolean>,
+ alwaysUseCdmaLevel: StateFlow<Boolean>,
+ override val isSingleCarrier: StateFlow<Boolean>,
+ override val mobileIsDefault: StateFlow<Boolean>,
+ defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>>,
+ defaultMobileIconGroup: StateFlow<MobileIconGroup>,
+ isDefaultConnectionFailed: StateFlow<Boolean>,
+ override val isForceHidden: Flow<Boolean>,
+ connectionRepository: MobileConnectionRepository,
+ private val context: Context,
+ val carrierIdOverrides: MobileIconCarrierIdOverrides = MobileIconCarrierIdOverridesImpl(),
+) : MobileIconInteractor, MobileIconInteractorKairos {
+ override val tableLogBuffer: TableLogBuffer = connectionRepository.tableLogBuffer
+
+ override val activity = connectionRepository.dataActivityDirection
+
+ override val isDataEnabled: StateFlow<Boolean> = connectionRepository.dataEnabled
+
+ override val carrierNetworkChangeActive: StateFlow<Boolean> =
+ connectionRepository.carrierNetworkChangeActive
+
+ // True if there exists _any_ icon override for this carrierId. Note that overrides can include
+ // any or none of the icon groups defined in MobileMappings, so we still need to check on a
+ // per-network-type basis whether or not the given icon group is overridden
+ private val carrierIdIconOverrideExists =
+ connectionRepository.carrierId
+ .map { carrierIdOverrides.carrierIdEntryExists(it) }
+ .distinctUntilChanged()
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val networkName =
+ combine(connectionRepository.operatorAlphaShort, connectionRepository.networkName) {
+ operatorAlphaShort,
+ networkName ->
+ if (networkName is NetworkNameModel.Default && operatorAlphaShort != null) {
+ NetworkNameModel.IntentDerived(operatorAlphaShort)
+ } else {
+ networkName
+ }
+ }
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectionRepository.networkName.value,
+ )
+
+ override val carrierName =
+ combine(connectionRepository.operatorAlphaShort, connectionRepository.carrierName) {
+ operatorAlphaShort,
+ networkName ->
+ if (networkName is NetworkNameModel.Default && operatorAlphaShort != null) {
+ operatorAlphaShort
+ } else {
+ networkName.name
+ }
+ }
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectionRepository.carrierName.value.name,
+ )
+
+ /** What the mobile icon would be before carrierId overrides */
+ private val defaultNetworkType: StateFlow<MobileIconGroup> =
+ combine(
+ connectionRepository.resolvedNetworkType,
+ defaultMobileIconMapping,
+ defaultMobileIconGroup,
+ ) { resolvedNetworkType, mapping, defaultGroup ->
+ when (resolvedNetworkType) {
+ is ResolvedNetworkType.CarrierMergedNetworkType ->
+ resolvedNetworkType.iconGroupOverride
+ else -> {
+ mapping[resolvedNetworkType.lookupKey] ?: defaultGroup
+ }
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), defaultMobileIconGroup.value)
+
+ override val networkTypeIconGroup =
+ combine(defaultNetworkType, carrierIdIconOverrideExists) { networkType, overrideExists ->
+ // DefaultIcon comes out of the icongroup lookup, we check for overrides here
+ if (overrideExists) {
+ val iconOverride =
+ carrierIdOverrides.getOverrideFor(
+ connectionRepository.carrierId.value,
+ networkType.name,
+ context.resources,
+ )
+ if (iconOverride > 0) {
+ OverriddenIcon(networkType, iconOverride)
+ } else {
+ DefaultIcon(networkType)
+ }
+ } else {
+ DefaultIcon(networkType)
+ }
+ }
+ .distinctUntilChanged()
+ .logDiffsForTable(
+ tableLogBuffer = tableLogBuffer,
+ initialValue = DefaultIcon(defaultMobileIconGroup.value),
+ )
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ DefaultIcon(defaultMobileIconGroup.value),
+ )
+
+ override val showSliceAttribution: StateFlow<Boolean> =
+ combine(
+ connectionRepository.allowNetworkSliceIndicator,
+ connectionRepository.hasPrioritizedNetworkCapabilities,
+ ) { allowed, hasPrioritizedNetworkCapabilities ->
+ allowed && hasPrioritizedNetworkCapabilities
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isNonTerrestrial: StateFlow<Boolean> = connectionRepository.isNonTerrestrial
+
+ override val isRoaming: StateFlow<Boolean> =
+ combine(
+ connectionRepository.carrierNetworkChangeActive,
+ connectionRepository.isGsm,
+ connectionRepository.isRoaming,
+ connectionRepository.cdmaRoaming,
+ ) { carrierNetworkChangeActive, isGsm, isRoaming, cdmaRoaming ->
+ if (carrierNetworkChangeActive) {
+ false
+ } else if (isGsm) {
+ isRoaming
+ } else {
+ cdmaRoaming
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ private val level: StateFlow<Int> =
+ combine(
+ connectionRepository.isGsm,
+ connectionRepository.primaryLevel,
+ connectionRepository.cdmaLevel,
+ alwaysUseCdmaLevel,
+ ) { isGsm, primaryLevel, cdmaLevel, alwaysUseCdmaLevel ->
+ when {
+ // GSM connections should never use the CDMA level
+ isGsm -> primaryLevel
+ alwaysUseCdmaLevel -> cdmaLevel
+ else -> primaryLevel
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), 0)
+
+ private val numberOfLevels: StateFlow<Int> = connectionRepository.numberOfLevels
+
+ override val isDataConnected: StateFlow<Boolean> =
+ connectionRepository.dataConnectionState
+ .map { it == Connected }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isInService = connectionRepository.isInService
+
+ override val isEmergencyOnly: StateFlow<Boolean> = connectionRepository.isEmergencyOnly
+
+ override val isAllowedDuringAirplaneMode = connectionRepository.isAllowedDuringAirplaneMode
+
+ /** Whether or not to show the error state of [SignalDrawable] */
+ private val showExclamationMark: StateFlow<Boolean> =
+ combine(defaultSubscriptionHasDataEnabled, isDefaultConnectionFailed, isInService) {
+ isDefaultDataEnabled,
+ isDefaultConnectionFailed,
+ isInService ->
+ !isDefaultDataEnabled || isDefaultConnectionFailed || !isInService
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), true)
+
+ private val cellularShownLevel: StateFlow<Int> =
+ combine(level, isInService, connectionRepository.inflateSignalStrength) {
+ level,
+ isInService,
+ inflate ->
+ if (isInService) {
+ if (inflate) level + 1 else level
+ } else 0
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), 0)
+
+ // Satellite level is unaffected by the inflateSignalStrength property
+ // See b/346904529 for details
+ private val satelliteShownLevel: StateFlow<Int> =
+ if (Flags.carrierRoamingNbIotNtn()) {
+ connectionRepository.satelliteLevel
+ } else {
+ combine(level, isInService) { level, isInService -> if (isInService) level else 0 }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), 0)
+
+ private val cellularIcon: Flow<SignalIconModel.Cellular> =
+ combine(
+ cellularShownLevel,
+ numberOfLevels,
+ showExclamationMark,
+ carrierNetworkChangeActive,
+ ) { cellularShownLevel, numberOfLevels, showExclamationMark, carrierNetworkChange ->
+ SignalIconModel.Cellular(
+ cellularShownLevel,
+ numberOfLevels,
+ showExclamationMark,
+ carrierNetworkChange,
+ )
+ }
+
+ private val satelliteIcon: Flow<SignalIconModel.Satellite> =
+ satelliteShownLevel.map {
+ SignalIconModel.Satellite(
+ level = it,
+ icon =
+ SatelliteIconModel.fromSignalStrength(it)
+ ?: SatelliteIconModel.fromSignalStrength(0)!!,
+ )
+ }
+
+ override val signalLevelIcon: StateFlow<SignalIconModel> = run {
+ val initial =
+ SignalIconModel.Cellular(
+ cellularShownLevel.value,
+ numberOfLevels.value,
+ showExclamationMark.value,
+ carrierNetworkChangeActive.value,
+ )
+ isNonTerrestrial
+ .flatMapLatest { ntn ->
+ if (ntn) {
+ satelliteIcon
+ } else {
+ cellularIcon
+ }
+ }
+ .distinctUntilChanged()
+ .logDiffsForTable(tableLogBuffer, columnPrefix = "icon", initialValue = initial)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), initial)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt
new file mode 100644
index 000000000000..e8e0a833af2a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt
@@ -0,0 +1,452 @@
+/*
+ * Copyright (C) 2022 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.systemui.statusbar.pipeline.mobile.domain.interactor
+
+import android.content.Context
+import android.telephony.CarrierConfigManager
+import android.telephony.SubscriptionManager
+import android.telephony.SubscriptionManager.PROFILE_CLASS_PROVISIONING
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.flags.FeatureFlagsClassic
+import com.android.systemui.flags.Flags.FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.core.NewStatusBarIcons
+import com.android.systemui.statusbar.core.StatusBarRootModernization
+import com.android.systemui.statusbar.pipeline.dagger.MobileSummaryLog
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.ConnectivitySlot
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
+import com.android.systemui.statusbar.policy.data.repository.UserSetupRepository
+import com.android.systemui.util.CarrierConfigTracker
+import java.lang.ref.WeakReference
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transformLatest
+
+/**
+ * Business layer logic for the set of mobile subscription icons.
+ *
+ * This interactor represents known set of mobile subscriptions (represented by [SubscriptionInfo]).
+ * The list of subscriptions is filtered based on the opportunistic flags on the infos.
+ *
+ * It provides the default mapping between the telephony display info and the icon group that
+ * represents each RAT (LTE, 3G, etc.), as well as can produce an interactor for each individual
+ * icon
+ */
+interface MobileIconsInteractorKairos {
+ /** See [MobileConnectionsRepository.mobileIsDefault]. */
+ val mobileIsDefault: StateFlow<Boolean>
+
+ /** List of subscriptions, potentially filtered for CBRS */
+ val filteredSubscriptions: Flow<List<SubscriptionModel>>
+
+ /** Subscription ID of the current default data subscription */
+ val defaultDataSubId: Flow<Int?>
+
+ /**
+ * The current list of [MobileIconInteractor]s associated with the current list of
+ * [filteredSubscriptions]
+ */
+ val icons: StateFlow<List<MobileIconInteractor>>
+
+ /** Whether the mobile icons can be stacked vertically. */
+ val isStackable: StateFlow<Boolean>
+
+ /**
+ * Observable for the subscriptionId of the current mobile data connection. Null if we don't
+ * have a valid subscription id
+ */
+ val activeMobileDataSubscriptionId: StateFlow<Int?>
+
+ /** True if the active mobile data subscription has data enabled */
+ val activeDataConnectionHasDataEnabled: StateFlow<Boolean>
+
+ /**
+ * Flow providing a reference to the Interactor for the active data subId. This represents the
+ * [MobileIconInteractor] responsible for the active data connection, if any.
+ */
+ val activeDataIconInteractor: StateFlow<MobileIconInteractor?>
+
+ /** True if the RAT icon should always be displayed and false otherwise. */
+ val alwaysShowDataRatIcon: StateFlow<Boolean>
+
+ /** True if the CDMA level should be preferred over the primary level. */
+ val alwaysUseCdmaLevel: StateFlow<Boolean>
+
+ /** True if there is only one active subscription. */
+ val isSingleCarrier: StateFlow<Boolean>
+
+ /** The icon mapping from network type to [MobileIconGroup] for the default subscription */
+ val defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>>
+
+ /** Fallback [MobileIconGroup] in the case where there is no icon in the mapping */
+ val defaultMobileIconGroup: StateFlow<MobileIconGroup>
+
+ /** True only if the default network is mobile, and validation also failed */
+ val isDefaultConnectionFailed: StateFlow<Boolean>
+
+ /** True once the user has been set up */
+ val isUserSetUp: StateFlow<Boolean>
+
+ /** True if we're configured to force-hide the mobile icons and false otherwise. */
+ val isForceHidden: Flow<Boolean>
+
+ /**
+ * True if the device-level service state (with -1 subscription id) reports emergency calls
+ * only. This value is only useful when there are no other subscriptions OR all existing
+ * subscriptions report that they are not in service.
+ */
+ val isDeviceInEmergencyCallsOnlyMode: Flow<Boolean>
+
+ /**
+ * Vends out a [MobileIconInteractor] tracking the [MobileConnectionRepository] for the given
+ * subId.
+ */
+ fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@Suppress("EXPERIMENTAL_IS_NOT_ENABLED")
+@SysUISingleton
+class MobileIconsInteractorKairosImpl
+@Inject
+constructor(
+ private val mobileConnectionsRepo: MobileConnectionsRepository,
+ private val carrierConfigTracker: CarrierConfigTracker,
+ @MobileSummaryLog private val tableLogger: TableLogBuffer,
+ connectivityRepository: ConnectivityRepository,
+ userSetupRepo: UserSetupRepository,
+ @Background private val scope: CoroutineScope,
+ private val context: Context,
+ private val featureFlagsClassic: FeatureFlagsClassic,
+) : MobileIconsInteractor, MobileIconsInteractorKairos {
+
+ // Weak reference lookup for created interactors
+ private val reuseCache = mutableMapOf<Int, WeakReference<MobileIconInteractor>>()
+
+ override val mobileIsDefault =
+ combine(
+ mobileConnectionsRepo.mobileIsDefault,
+ mobileConnectionsRepo.hasCarrierMergedConnection,
+ ) { mobileIsDefault, hasCarrierMergedConnection ->
+ // Because carrier merged networks are displayed as mobile networks, they're part of
+ // the `isDefault` calculation. See b/272586234.
+ mobileIsDefault || hasCarrierMergedConnection
+ }
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "mobileIsDefault",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val activeMobileDataSubscriptionId: StateFlow<Int?> =
+ mobileConnectionsRepo.activeMobileDataSubscriptionId
+
+ override val activeDataConnectionHasDataEnabled: StateFlow<Boolean> =
+ mobileConnectionsRepo.activeMobileDataRepository
+ .flatMapLatest { it?.dataEnabled ?: flowOf(false) }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val activeDataIconInteractor: StateFlow<MobileIconInteractor?> =
+ mobileConnectionsRepo.activeMobileDataSubscriptionId
+ .mapLatest {
+ if (it != null) {
+ getMobileConnectionInteractorForSubId(it)
+ } else {
+ null
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ private val unfilteredSubscriptions: Flow<List<SubscriptionModel>> =
+ mobileConnectionsRepo.subscriptions
+
+ /** Any filtering that we can do based purely on the info of each subscription individually. */
+ private val subscriptionsBasedFilteredSubs =
+ unfilteredSubscriptions
+ .map { it.filterBasedOnProvisioning().filterBasedOnNtn() }
+ .distinctUntilChanged()
+
+ private fun List<SubscriptionModel>.filterBasedOnProvisioning(): List<SubscriptionModel> =
+ if (!featureFlagsClassic.isEnabled(FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS)) {
+ this
+ } else {
+ this.filter { it.profileClass != PROFILE_CLASS_PROVISIONING }
+ }
+
+ /**
+ * Subscriptions that exclusively support non-terrestrial networks should **never** directly
+ * show any iconography in the status bar. These subscriptions only exist to provide a backing
+ * for the device-based satellite connections, and the iconography for those connections are
+ * already being handled in
+ * [com.android.systemui.statusbar.pipeline.satellite.data.DeviceBasedSatelliteRepository]. We
+ * need to filter out those subscriptions here so we guarantee the subscription never turns into
+ * an icon. See b/336881301.
+ */
+ private fun List<SubscriptionModel>.filterBasedOnNtn(): List<SubscriptionModel> {
+ return this.filter { !it.isExclusivelyNonTerrestrial }
+ }
+
+ /**
+ * Generally, SystemUI wants to show iconography for each subscription that is listed by
+ * [SubscriptionManager]. However, in the case of opportunistic subscriptions, we want to only
+ * show a single representation of the pair of subscriptions. The docs define opportunistic as:
+ *
+ * "A subscription is opportunistic (if) the network it connects to has limited coverage"
+ * https://developer.android.com/reference/android/telephony/SubscriptionManager#setOpportunistic(boolean,%20int)
+ *
+ * In the case of opportunistic networks (typically CBRS), we will filter out one of the
+ * subscriptions based on
+ * [CarrierConfigManager.KEY_ALWAYS_SHOW_PRIMARY_SIGNAL_BAR_IN_OPPORTUNISTIC_NETWORK_BOOLEAN],
+ * and by checking which subscription is opportunistic, or which one is active.
+ */
+ override val filteredSubscriptions: Flow<List<SubscriptionModel>> =
+ combine(
+ subscriptionsBasedFilteredSubs,
+ mobileConnectionsRepo.activeMobileDataSubscriptionId,
+ connectivityRepository.vcnSubId,
+ ) { preFilteredSubs, activeId, vcnSubId ->
+ filterSubsBasedOnOpportunistic(preFilteredSubs, activeId, vcnSubId)
+ }
+ .distinctUntilChanged()
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "filteredSubscriptions",
+ initialValue = listOf(),
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), listOf())
+
+ private fun filterSubsBasedOnOpportunistic(
+ subList: List<SubscriptionModel>,
+ activeId: Int?,
+ vcnSubId: Int?,
+ ): List<SubscriptionModel> {
+ // Based on the old logic,
+ if (subList.size != 2) {
+ return subList
+ }
+
+ val info1 = subList[0]
+ val info2 = subList[1]
+
+ // Filtering only applies to subscriptions in the same group
+ if (info1.groupUuid == null || info1.groupUuid != info2.groupUuid) {
+ return subList
+ }
+
+ // If both subscriptions are primary, show both
+ if (!info1.isOpportunistic && !info2.isOpportunistic) {
+ return subList
+ }
+
+ // NOTE: at this point, we are now returning a single SubscriptionInfo
+
+ // If carrier required, always show the icon of the primary subscription.
+ // Otherwise, show whichever subscription is currently active for internet.
+ if (carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) {
+ // return the non-opportunistic info
+ return if (info1.isOpportunistic) listOf(info2) else listOf(info1)
+ } else {
+ // It's possible for the subId of the VCN to disagree with the active subId in
+ // cases where the system has tried to switch but found no connection. In these
+ // scenarios, VCN will always have the subId that we want to use, so use that
+ // value instead of the activeId reported by telephony
+ val subIdToKeep = vcnSubId ?: activeId
+
+ return if (info1.subscriptionId == subIdToKeep) {
+ listOf(info1)
+ } else {
+ listOf(info2)
+ }
+ }
+ }
+
+ override val defaultDataSubId = mobileConnectionsRepo.defaultDataSubId
+
+ override val icons =
+ filteredSubscriptions
+ .mapLatest { subs ->
+ subs.map { getMobileConnectionInteractorForSubId(it.subscriptionId) }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
+
+ override val isStackable =
+ if (NewStatusBarIcons.isEnabled && StatusBarRootModernization.isEnabled) {
+ icons.flatMapLatest { icons ->
+ combine(icons.map { it.signalLevelIcon }) { signalLevelIcons ->
+ // These are only stackable if:
+ // - They are cellular
+ // - There's exactly two
+ // - They have the same number of levels
+ signalLevelIcons.filterIsInstance<SignalIconModel.Cellular>().let {
+ it.size == 2 && it[0].numberOfLevels == it[1].numberOfLevels
+ }
+ }
+ }
+ } else {
+ flowOf(false)
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ /**
+ * Copied from the old pipeline. We maintain a 2s period of time where we will keep the
+ * validated bit from the old active network (A) while data is changing to the new one (B).
+ *
+ * This condition only applies if
+ * 1. A and B are in the same subscription group (e.g. for CBRS data switching) and
+ * 2. A was validated before the switch
+ *
+ * The goal of this is to minimize the flickering in the UI of the cellular indicator
+ */
+ private val forcingCellularValidation =
+ mobileConnectionsRepo.activeSubChangedInGroupEvent
+ .filter { mobileConnectionsRepo.defaultConnectionIsValidated.value }
+ .transformLatest {
+ emit(true)
+ delay(2000)
+ emit(false)
+ }
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "forcingValidation",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ /**
+ * Mapping from network type to [MobileIconGroup] using the config generated for the default
+ * subscription Id. This mapping is the same for every subscription.
+ */
+ override val defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>> =
+ mobileConnectionsRepo.defaultMobileIconMapping.stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ initialValue = mapOf(),
+ )
+
+ override val alwaysShowDataRatIcon: StateFlow<Boolean> =
+ mobileConnectionsRepo.defaultDataSubRatConfig
+ .mapLatest { it.alwaysShowDataRatIcon }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val alwaysUseCdmaLevel: StateFlow<Boolean> =
+ mobileConnectionsRepo.defaultDataSubRatConfig
+ .mapLatest { it.alwaysShowCdmaRssi }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isSingleCarrier: StateFlow<Boolean> =
+ mobileConnectionsRepo.subscriptions
+ .map { it.size == 1 }
+ .logDiffsForTable(
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "isSingleCarrier",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ /** If there is no mapping in [defaultMobileIconMapping], then use this default icon group */
+ override val defaultMobileIconGroup: StateFlow<MobileIconGroup> =
+ mobileConnectionsRepo.defaultMobileIconGroup.stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ initialValue = TelephonyIcons.G,
+ )
+
+ /**
+ * We want to show an error state when cellular has actually failed to validate, but not if some
+ * other transport type is active, because then we expect there not to be validation.
+ */
+ override val isDefaultConnectionFailed: StateFlow<Boolean> =
+ combine(
+ mobileIsDefault,
+ mobileConnectionsRepo.defaultConnectionIsValidated,
+ forcingCellularValidation,
+ ) { mobileIsDefault, defaultConnectionIsValidated, forcingCellularValidation ->
+ when {
+ !mobileIsDefault -> false
+ forcingCellularValidation -> false
+ else -> !defaultConnectionIsValidated
+ }
+ }
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "isDefaultConnectionFailed",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isUserSetUp: StateFlow<Boolean> = userSetupRepo.isUserSetUp
+
+ override val isForceHidden: Flow<Boolean> =
+ connectivityRepository.forceHiddenSlots
+ .map { it.contains(ConnectivitySlot.MOBILE) }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isDeviceInEmergencyCallsOnlyMode: Flow<Boolean> =
+ mobileConnectionsRepo.isDeviceEmergencyCallCapable
+
+ /** Vends out new [MobileIconInteractor] for a particular subId */
+ override fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
+ reuseCache[subId]?.get() ?: createMobileConnectionInteractorForSubId(subId)
+
+ private fun createMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
+ MobileIconInteractorImpl(
+ scope,
+ activeDataConnectionHasDataEnabled,
+ alwaysShowDataRatIcon,
+ alwaysUseCdmaLevel,
+ isSingleCarrier,
+ mobileIsDefault,
+ defaultMobileIconMapping,
+ defaultMobileIconGroup,
+ isDefaultConnectionFailed,
+ isForceHidden,
+ mobileConnectionsRepo.getRepoForSubId(subId),
+ context,
+ )
+ .also { reuseCache[subId] = WeakReference(it) }
+
+ companion object {
+ private const val LOGGING_PREFIX = "Intr"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt
new file mode 100644
index 000000000000..a6209fa72b53
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.systemui.util.kotlin
+
+/** Like a [javax.inject.Provider], but [get] is a `suspend fun`. */
+fun interface Producer<out T> {
+ suspend fun get(): T
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorTest.kt
index 9ae57153f3ef..2fc81eb9d66a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorTest.kt
@@ -231,6 +231,11 @@ class AudioSharingDeviceItemActionInteractorTest : SysuiTestCase() {
actionInteractorImpl.onActionIconClick(inAudioSharingMediaDeviceItem) {}
assertThat(bluetoothTileDialogAudioSharingRepository.audioSharingStarted)
.isEqualTo(false)
+ verify(bluetoothTileDialogLogger)
+ .logAudioSharingButtonClick(
+ AudioSharingButtonClick.CHECK_MARK,
+ inAudioSharingMediaDeviceItem,
+ )
}
}
}
@@ -243,6 +248,11 @@ class AudioSharingDeviceItemActionInteractorTest : SysuiTestCase() {
actionInteractorImpl.onActionIconClick(connectedAudioSharingMediaDeviceItem) {}
assertThat(bluetoothTileDialogAudioSharingRepository.audioSharingStarted)
.isEqualTo(true)
+ verify(bluetoothTileDialogLogger)
+ .logAudioSharingButtonClick(
+ AudioSharingButtonClick.PLUS_BUTTON,
+ connectedAudioSharingMediaDeviceItem,
+ )
}
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt
index df74404aaaf8..db948e947a26 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt
@@ -17,7 +17,6 @@
package com.android.systemui.statusbar.pipeline.mobile.data.model
import android.os.PersistableBundle
-import android.telephony.CarrierConfigManager
import android.telephony.CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL
import android.telephony.CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL
import android.telephony.CarrierConfigManager.KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL
@@ -37,7 +36,7 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
@Before
fun setUp() {
- underTest = SystemUiCarrierConfig(SUB_1_ID, createTestConfig())
+ underTest = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
}
@Test
@@ -46,7 +45,7 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
assertThat(underTest.isUsingDefault).isTrue()
// ANY new config means we're no longer tracking defaults
- underTest.processNewCarrierConfig(createTestConfig())
+ underTest.processNewCarrierConfig(testCarrierConfig())
assertThat(underTest.isUsingDefault).isFalse()
}
@@ -58,7 +57,7 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
assertThat(underTest.allowNetworkSliceIndicator.value).isTrue()
underTest.processNewCarrierConfig(
- configWithOverrides(
+ testCarrierConfigWithOverrides(
KEY_INFLATE_SIGNAL_STRENGTH_BOOL to true,
KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL to true,
KEY_SHOW_5G_SLICE_ICON_BOOL to false,
@@ -81,11 +80,11 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
underTest =
SystemUiCarrierConfig(
SUB_1_ID,
- configWithOverrides(
+ testCarrierConfigWithOverrides(
KEY_INFLATE_SIGNAL_STRENGTH_BOOL to true,
KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL to true,
KEY_SHOW_5G_SLICE_ICON_BOOL to true,
- )
+ ),
)
assertThat(underTest.isUsingDefault).isTrue()
@@ -104,26 +103,5 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
companion object {
private const val SUB_1_ID = 1
-
- /**
- * In order to keep us from having to update every place that might want to create a config,
- * make sure to add new keys here
- */
- fun createTestConfig() =
- PersistableBundle().also {
- it.putBoolean(CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
- it.putBoolean(CarrierConfigManager.KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL, false)
- it.putBoolean(CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL, true)
- }
-
- /** Override the default config with the given (key, value) pair */
- fun configWithOverride(key: String, override: Boolean): PersistableBundle =
- createTestConfig().also { it.putBoolean(key, override) }
-
- /** Override any number of configs from the default */
- fun configWithOverrides(vararg overrides: Pair<String, Boolean>) =
- createTestConfig().also { config ->
- overrides.forEach { (key, value) -> config.putBoolean(key, value) }
- }
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt
index d074fc256133..e1cc25972105 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt
@@ -26,7 +26,7 @@ import com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession
import com.android.systemui.SysuiTestCase
import com.android.systemui.dump.DumpManager
import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.createTestConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.launchIn
@@ -163,8 +163,8 @@ class CarrierConfigRepositoryImplTest : SysuiTestCase() {
private const val SUB_ID_1 = 1
private const val SUB_ID_2 = 2
- private val DEFAULT_CONFIG = createTestConfig()
- private val CONFIG_1 = createTestConfig()
- private val CONFIG_2 = createTestConfig()
+ private val DEFAULT_CONFIG = testCarrierConfig()
+ private val CONFIG_1 = testCarrierConfig()
+ private val CONFIG_2 = testCarrierConfig()
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
index a51e919636ee..ed8be9b253ab 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
@@ -86,8 +86,8 @@ import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetwork
import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.configWithOverride
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.createTestConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfigWithOverride
import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.signalStrength
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.telephonyDisplayInfo
@@ -129,7 +129,7 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
@Mock private lateinit var context: Context
private val mobileMappings = FakeMobileMappingsProxy()
- private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, createTestConfig())
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -1314,13 +1314,13 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
)
assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS + 1)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
)
assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
@@ -1336,13 +1336,13 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
assertThat(latest).isEqualTo(false)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
)
assertThat(latest).isEqualTo(true)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
)
assertThat(latest).isEqualTo(false)
@@ -1354,13 +1354,13 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
val latest by collectLastValue(underTest.allowNetworkSliceIndicator)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, true)
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, true)
)
assertThat(latest).isTrue()
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, false)
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, false)
)
assertThat(latest).isFalse()
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
index ec260fcc7a65..6f21e795532b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
@@ -41,7 +41,7 @@ import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameMode
import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.getTelephonyCallbackForType
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.signalStrength
import com.android.systemui.statusbar.pipeline.mobile.util.FakeMobileMappingsProxy
@@ -105,11 +105,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
@Mock private lateinit var subscriptionModel: StateFlow<SubscriptionModel?>
private val mobileMappings = FakeMobileMappingsProxy()
- private val systemUiCarrierConfig =
- SystemUiCarrierConfig(
- SUB_1_ID,
- SystemUiCarrierConfigTest.createTestConfig(),
- )
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -185,12 +181,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
val job = underTest.dataActivityDirection.onEach { latest = it }.launchIn(this)
assertThat(latest)
- .isEqualTo(
- DataActivityModel(
- hasActivityIn = true,
- hasActivityOut = true,
- )
- )
+ .isEqualTo(DataActivityModel(hasActivityIn = true, hasActivityOut = true))
displayInfoJob.cancel()
job.cancel()
@@ -209,7 +200,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
connectionCallback.onDataConnectionStateChanged(
TelephonyManager.DATA_CONNECTED,
- 200 /* unused */
+ 200, /* unused */
)
flipActivity(100, activityCallback)
@@ -320,10 +311,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
job.cancel()
}
- private fun flipActivity(
- times: Int,
- callback: DataActivityListener,
- ) {
+ private fun flipActivity(times: Int, callback: DataActivityListener) {
repeat(times) { index -> callback.onDataActivity(index % 4) }
}
diff --git a/packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt b/packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt
new file mode 100644
index 000000000000..516053d00ee2
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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 android.net
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.util.mockito.mockFixture
+
+var Kosmos.connectivityManager: ConnectivityManager by mockFixture()
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorKosmos.kt
index e0c0fbd7f033..bc8e62c40f92 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/AudioSharingInteractorKosmos.kt
@@ -26,6 +26,7 @@ val Kosmos.audioSharingInteractor: AudioSharingInteractor by
applicationContext,
localBluetoothManager,
bluetoothTileDialogAudioSharingRepository,
+ bluetoothTileDialogLogger,
testDispatcher,
)
}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/FakeAudioSharingRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/FakeAudioSharingRepository.kt
index c744eacfa3f4..0f6f19129dd3 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/FakeAudioSharingRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/bluetooth/qsdialog/FakeAudioSharingRepository.kt
@@ -28,7 +28,7 @@ class FakeAudioSharingRepository : AudioSharingRepository {
private val mutableInAudioSharing: MutableStateFlow<Boolean> = MutableStateFlow(false)
- private val mutableAudioSourceStateUpdate = MutableSharedFlow<Unit>()
+ private val mutableAudioSourceStateUpdate = MutableSharedFlow<Unit>(replay = 1)
var sourceAdded: Boolean = false
private set
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt
new file mode 100644
index 000000000000..927209f84f1d
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.kairos
+
+import com.android.systemui.coroutines.collectLastValue
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+
+/**
+ * Collect [state] in a new [Job] and return a getter for the collection of values collected.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val values by collectValues(underTest.flow)
+ * assertThat(values).isEqualTo(listOf(expected1, expected2, ...))
+ * }
+ * ```
+ */
+@ExperimentalKairosApi
+fun <T> TestScope.collectLastValue(state: State<T>, kairosNetwork: KairosNetwork): KairosValue<T?> {
+ var value: T? = null
+ backgroundScope.launch { kairosNetwork.activateSpec { state.observe { value = it } } }
+ return KairosValueImpl {
+ runCurrent()
+ value
+ }
+}
+
+/**
+ * Collect [flow] in a new [Job] and return a getter for the collection of values collected.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val values by collectValues(underTest.flow)
+ * assertThat(values).isEqualTo(listOf(expected1, expected2, ...))
+ * }
+ * ```
+ */
+@ExperimentalKairosApi
+fun <T> TestScope.collectLastValue(flow: Events<T>, kairosNetwork: KairosNetwork): KairosValue<T?> {
+ var value: T? = null
+ backgroundScope.launch { kairosNetwork.activateSpec { flow.observe { value = it } } }
+ return KairosValueImpl {
+ runCurrent()
+ value
+ }
+}
+
+/**
+ * Collect [flow] in a new [Job] and return a getter for the collection of values collected.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val values by collectValues(underTest.flow)
+ * assertThat(values).isEqualTo(listOf(expected1, expected2, ...))
+ * }
+ * ```
+ */
+@ExperimentalKairosApi
+fun <T> TestScope.collectValues(
+ flow: Events<T>,
+ kairosNetwork: KairosNetwork,
+): KairosValue<List<T>> {
+ val values = mutableListOf<T>()
+ backgroundScope.launch { kairosNetwork.activateSpec { flow.observe { values.add(it) } } }
+ return KairosValueImpl {
+ runCurrent()
+ values.toList()
+ }
+}
+
+/** @see collectLastValue */
+interface KairosValue<T> : ReadOnlyProperty<Any?, T> {
+ val value: T
+}
+
+private class KairosValueImpl<T>(private val block: () -> T) : KairosValue<T> {
+ override val value: T
+ get() = block()
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T = value
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt
new file mode 100644
index 000000000000..d7f204183467
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 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.systemui.kairos
+
+import com.android.systemui.KairosActivatable
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testScope
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+
+@ExperimentalKairosApi
+val Kosmos.kairos: KairosNetwork by Fixture { applicationCoroutineScope.launchKairosNetwork() }
+
+@ExperimentalKairosApi
+fun Kosmos.activateKairosActivatable(activatable: KairosActivatable) {
+ applicationCoroutineScope.launch { kairos.activateSpec { activatable.run { activate() } } }
+}
+
+@ExperimentalKairosApi
+fun <T : KairosActivatable> ActivatedKairosFixture(block: Kosmos.() -> T) = Fixture {
+ block().also { activateKairosActivatable(it) }
+}
+
+@ExperimentalKairosApi
+fun Kosmos.runKairosTest(timeout: Duration = 5.seconds, block: suspend KairosTestScope.() -> Unit) =
+ testScope.runTest(timeout) { KairosTestScopeImpl(this@runKairosTest, this, kairos).block() }
+
+@ExperimentalKairosApi
+interface KairosTestScope : Kosmos {
+ fun <T> State<T>.collectLastValue(): KairosValue<T?>
+
+ suspend fun <T> State<T>.sample(): T
+
+ fun <T : KairosActivatable> T.activated(): T
+}
+
+@ExperimentalKairosApi
+private class KairosTestScopeImpl(
+ kosmos: Kosmos,
+ val testScope: TestScope,
+ val kairos: KairosNetwork,
+) : KairosTestScope, Kosmos by kosmos {
+ override fun <T> State<T>.collectLastValue(): KairosValue<T?> =
+ testScope.collectLastValue(this@collectLastValue, kairos)
+
+ override suspend fun <T> State<T>.sample(): T = kairos.transact { sample() }
+
+ override fun <T : KairosActivatable> T.activated(): T =
+ this.also { activateKairosActivatable(it) }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt
new file mode 100644
index 000000000000..13b016342c15
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.systemui.statusbar.pipeline.mobile.data.model
+
+import android.os.PersistableBundle
+import android.telephony.CarrierConfigManager
+
+/**
+ * In order to keep us from having to update every place that might want to create a config, make
+ * sure to add new keys here
+ */
+fun testCarrierConfig() =
+ PersistableBundle().also {
+ it.putBoolean(CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ it.putBoolean(CarrierConfigManager.KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL, false)
+ it.putBoolean(CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL, true)
+ }
+
+/** Override the default config with the given (key, value) pair */
+fun testCarrierConfigWithOverride(key: String, override: Boolean): PersistableBundle =
+ testCarrierConfig().also { it.putBoolean(key, override) }
+
+/** Override any number of configs from the default */
+fun testCarrierConfigWithOverrides(vararg overrides: Pair<String, Boolean>) =
+ testCarrierConfig().also { config ->
+ overrides.forEach { (key, value) -> config.putBoolean(key, value) }
+ }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..8cf3ee8d3c1c
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.State
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository.Companion.DEFAULT_NETWORK_NAME
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+
+@ExperimentalKairosApi
+class FakeMobileConnectionRepositoryKairos(
+ override val subId: Int,
+ kairos: KairosNetwork,
+ override val tableLogBuffer: TableLogBuffer,
+) : MobileConnectionRepositoryKairos {
+ override val carrierId = MutableState(kairos, 0)
+ override val inflateSignalStrength = MutableState(kairos, false)
+ override val allowNetworkSliceIndicator = MutableState(kairos, true)
+ override val isEmergencyOnly = MutableState(kairos, false)
+ override val isRoaming = MutableState(kairos, false)
+ override val operatorAlphaShort = MutableState<String?>(kairos, null)
+ override val isInService = MutableState(kairos, false)
+ override val isNonTerrestrial = MutableState(kairos, false)
+ override val isGsm = MutableState(kairos, false)
+ override val cdmaLevel = MutableState(kairos, 0)
+ override val primaryLevel = MutableState(kairos, 0)
+ override val satelliteLevel = MutableState(kairos, 0)
+ override val dataConnectionState = MutableState(kairos, DataConnectionState.Disconnected)
+ override val dataActivityDirection =
+ MutableState(kairos, DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+ override val carrierNetworkChangeActive = MutableState(kairos, false)
+ override val resolvedNetworkType =
+ MutableState<ResolvedNetworkType>(kairos, ResolvedNetworkType.UnknownNetworkType)
+ override val numberOfLevels = MutableState(kairos, DEFAULT_NUM_LEVELS)
+ override val dataEnabled = MutableState(kairos, true)
+ override val cdmaRoaming = MutableState(kairos, false)
+ override val networkName =
+ MutableState<NetworkNameModel>(kairos, NetworkNameModel.Default(DEFAULT_NETWORK_NAME))
+ override val carrierName =
+ MutableState<NetworkNameModel>(kairos, NetworkNameModel.Default(DEFAULT_NETWORK_NAME))
+ override val isAllowedDuringAirplaneMode = MutableState(kairos, false)
+ override val hasPrioritizedNetworkCapabilities = MutableState(kairos, false)
+ override val isInEcmMode: State<Boolean> = MutableState(kairos, false)
+
+ /**
+ * Set [primaryLevel] and [cdmaLevel]. Convenient when you don't care about the connection type
+ */
+ fun setAllLevels(level: Int) {
+ cdmaLevel.setValue(level)
+ primaryLevel.setValue(level)
+ }
+
+ /** Set the correct [resolvedNetworkType] for the given group via its lookup key */
+ fun setNetworkTypeKey(key: String) {
+ resolvedNetworkType.setValue(ResolvedNetworkType.DefaultNetworkType(key))
+ }
+
+ /**
+ * Set both [isRoaming] and [cdmaRoaming] properties, in the event that you don't care about the
+ * connection type
+ */
+ fun setAllRoaming(roaming: Boolean) {
+ isRoaming.setValue(roaming)
+ cdmaRoaming.setValue(roaming)
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt
new file mode 100644
index 000000000000..624b2cc12c89
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyDisplayInfo
+import android.telephony.TelephonyManager
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.KairosBuilder
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.MutableEvents
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.asIncremental
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.util.FakeMobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
+
+// TODO(b/261632894): remove this in favor of the real impl or DemoMobileConnectionsRepositoryKairos
+@ExperimentalKairosApi
+class FakeMobileConnectionsRepositoryKairos(
+ kairos: KairosNetwork,
+ val tableLogBuffer: TableLogBuffer,
+ mobileMappings: MobileMappingsProxy = FakeMobileMappingsProxy(),
+) : MobileConnectionsRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ val GSM_KEY = mobileMappings.toIconKey(GSM)
+ val LTE_KEY = mobileMappings.toIconKey(LTE)
+ val UMTS_KEY = mobileMappings.toIconKey(UMTS)
+ val LTE_ADVANCED_KEY = mobileMappings.toIconKeyOverride(LTE_ADVANCED_PRO)
+
+ /**
+ * To avoid a reliance on [MobileMappings], we'll build a simpler map from network type to
+ * mobile icon. See TelephonyManager.NETWORK_TYPES for a list of types and [TelephonyIcons] for
+ * the exhaustive set of icons
+ */
+ val TEST_MAPPING: Map<String, SignalIcon.MobileIconGroup> =
+ mapOf(
+ GSM_KEY to TelephonyIcons.THREE_G,
+ LTE_KEY to TelephonyIcons.LTE,
+ UMTS_KEY to TelephonyIcons.FOUR_G,
+ LTE_ADVANCED_KEY to TelephonyIcons.NR_5G,
+ )
+
+ override val subscriptions = MutableState(kairos, emptyList<SubscriptionModel>())
+
+ override val mobileConnectionsBySubId = buildIncremental {
+ subscriptions
+ .map { it.associate { sub -> sub.subscriptionId to Unit } }
+ .asIncremental()
+ .mapValues { (subId, _) ->
+ buildSpec {
+ FakeMobileConnectionRepositoryKairos(subId, kairosNetwork, tableLogBuffer)
+ }
+ }
+ .applyLatestSpecForKey()
+ }
+
+ private val _activeMobileDataSubscriptionId = MutableState<Int?>(kairos, null)
+ override val activeMobileDataSubscriptionId: State<Int?> = _activeMobileDataSubscriptionId
+
+ override val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?> =
+ combine(mobileConnectionsBySubId, activeMobileDataSubscriptionId) { conns, activeSub ->
+ conns[activeSub]
+ }
+
+ override val activeSubChangedInGroupEvent = MutableEvents<Unit>(kairos)
+
+ override val defaultDataSubId = MutableState(kairos, INVALID_SUBSCRIPTION_ID)
+
+ override val mobileIsDefault = MutableState(kairos, false)
+
+ override val hasCarrierMergedConnection = MutableState(kairos, false)
+
+ override val defaultConnectionIsValidated = MutableState(kairos, false)
+
+ override val defaultDataSubRatConfig = MutableState(kairos, MobileMappings.Config())
+
+ override val defaultMobileIconMapping = MutableState(kairos, TEST_MAPPING)
+
+ override val defaultMobileIconGroup = MutableState(kairos, DEFAULT_ICON)
+
+ override val isDeviceEmergencyCallCapable = MutableState(kairos, false)
+
+ override val isAnySimSecure = MutableState(kairos, false)
+
+ override val isInEcmMode: State<Boolean> = MutableState(kairos, false)
+
+ fun setActiveMobileDataSubscriptionId(subId: Int) {
+ // Simulate the filtering that the repo does
+ if (subId == INVALID_SUBSCRIPTION_ID) {
+ _activeMobileDataSubscriptionId.setValue(null)
+ } else {
+ _activeMobileDataSubscriptionId.setValue(subId)
+ }
+ }
+
+ companion object {
+ val DEFAULT_ICON = TelephonyIcons.G
+
+ // Use [MobileMappings] to define some simple definitions
+ const val GSM = TelephonyManager.NETWORK_TYPE_GSM
+ const val LTE = TelephonyManager.NETWORK_TYPE_LTE
+ const val UMTS = TelephonyManager.NETWORK_TYPE_UMTS
+ const val LTE_ADVANCED_PRO = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO
+ }
+}
+
+@ExperimentalKairosApi
+val MobileConnectionsRepositoryKairos.fake
+ get() = this as FakeMobileConnectionsRepositoryKairos
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt
new file mode 100644
index 000000000000..f57cf99c1309
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2024 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.systemui.statusbar.pipeline.mobile.data.repository
+
+import android.content.applicationContext
+import android.telephony.SubscriptionManager
+import android.telephony.telephonyManager
+import com.android.keyguard.keyguardUpdateMonitor
+import com.android.systemui.broadcast.broadcastDispatcher
+import com.android.systemui.demoModeController
+import com.android.systemui.dump.dumpManager
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.MutableEvents
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.log.table.tableLogBufferFactory
+import com.android.systemui.statusbar.pipeline.airplane.data.repository.airplaneModeRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoModeMobileConnectionDataSourceKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryKairosImpl
+import com.android.systemui.statusbar.pipeline.mobile.util.FakeSubscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.SubscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.shared.data.repository.connectivityRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.DemoModeWifiDataSource
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.util.mockito.mockFixture
+import org.mockito.kotlin.mock
+
+@ExperimentalKairosApi
+var Kosmos.mobileConnectionsRepositoryKairos: MobileConnectionsRepositoryKairos by Fixture {
+ mobileRepositorySwitcherKairos
+}
+
+@ExperimentalKairosApi
+val Kosmos.fakeMobileConnectionsRepositoryKairos by ActivatedKairosFixture {
+ FakeMobileConnectionsRepositoryKairos(kairos, logcatTableLogBuffer(this), mobileMappingsProxy)
+}
+
+@ExperimentalKairosApi
+val Kosmos.demoMobileConnectionsRepositoryKairos by ActivatedKairosFixture {
+ DemoMobileConnectionsRepositoryKairos(
+ mobileDataSource = demoModeMobileConnectionDataSourceKairos,
+ wifiDataSource = wifiDataSource,
+ context = applicationContext,
+ logFactory = tableLogBufferFactory,
+ )
+}
+
+@ExperimentalKairosApi
+val Kosmos.demoModeMobileConnectionDataSourceKairos:
+ DemoModeMobileConnectionDataSourceKairos by Fixture {
+ FakeDemoModeMobileConnectionDataSourceKairos(kairos)
+}
+
+val Kosmos.wifiDataSource: DemoModeWifiDataSource by mockFixture()
+
+@ExperimentalKairosApi
+class FakeDemoModeMobileConnectionDataSourceKairos(kairos: KairosNetwork) :
+ DemoModeMobileConnectionDataSourceKairos {
+ override val mobileEvents = MutableEvents<FakeNetworkEventModel?>(kairos)
+}
+
+@ExperimentalKairosApi
+val DemoModeMobileConnectionDataSourceKairos.fake
+ get() = this as FakeDemoModeMobileConnectionDataSourceKairos
+
+@ExperimentalKairosApi
+val Kosmos.mobileRepositorySwitcherKairos:
+ MobileRepositorySwitcherKairos by ActivatedKairosFixture {
+ MobileRepositorySwitcherKairos(
+ realRepository = mobileConnectionsRepositoryKairosImpl,
+ demoRepositoryFactory = demoMobileConnectionsRepositoryKairosFactory,
+ demoModeController = demoModeController,
+ )
+}
+
+@ExperimentalKairosApi
+val Kosmos.demoMobileConnectionsRepositoryKairosFactory:
+ DemoMobileConnectionsRepositoryKairos.Factory by Fixture {
+ DemoMobileConnectionsRepositoryKairos.Factory {
+ DemoMobileConnectionsRepositoryKairos(
+ mobileDataSource = demoModeMobileConnectionDataSourceKairos,
+ wifiDataSource = wifiDataSource,
+ context = applicationContext,
+ logFactory = tableLogBufferFactory,
+ )
+ }
+}
+
+@ExperimentalKairosApi
+val Kosmos.mobileConnectionsRepositoryKairosImpl:
+ MobileConnectionsRepositoryKairosImpl by ActivatedKairosFixture {
+ MobileConnectionsRepositoryKairosImpl(
+ connectivityRepository = connectivityRepository,
+ subscriptionManager = subscriptionManager,
+ subscriptionManagerProxy = subscriptionManagerProxy,
+ telephonyManager = telephonyManager,
+ logger = mobileInputLogger,
+ tableLogger = summaryLogger,
+ mobileMappingsProxy = mobileMappingsProxy,
+ broadcastDispatcher = broadcastDispatcher,
+ context = applicationContext,
+ bgDispatcher = testDispatcher,
+ mainDispatcher = testDispatcher,
+ airplaneModeRepository = airplaneModeRepository,
+ wifiRepository = wifiRepository,
+ keyguardUpdateMonitor = keyguardUpdateMonitor,
+ dumpManager = dumpManager,
+ mobileRepoFactory = { mobileConnectionRepositoryKairosFactory },
+ )
+}
+
+val Kosmos.subscriptionManager: SubscriptionManager by mockFixture()
+val Kosmos.mobileInputLogger: MobileInputLogger by mockFixture()
+val Kosmos.summaryLogger: TableLogBuffer by Fixture { logcatTableLogBuffer(this, "summaryLogger") }
+
+@ExperimentalKairosApi
+val Kosmos.mobileConnectionRepositoryKairosFactory by Fixture {
+ MobileConnectionsRepositoryKairosImpl.ConnectionRepoFactory { subId ->
+ buildSpec { FakeMobileConnectionRepositoryKairos(subId, kairos, mock()) }
+ }
+}
+
+val Kosmos.subscriptionManagerProxy: SubscriptionManagerProxy by Fixture {
+ FakeSubscriptionManagerProxy()
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt
index a80238167b85..a391c44018f5 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * 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.
@@ -23,7 +23,7 @@ import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
class FakeSubscriptionManagerProxy(
/** Set the default data subId to be returned in [getDefaultDataSubscriptionId] */
var defaultDataSubId: Int = INVALID_SUBSCRIPTION_ID,
- var activeSubscriptionInfo: SubscriptionInfo? = null
+ var activeSubscriptionInfo: SubscriptionInfo? = null,
) : SubscriptionManagerProxy {
override fun getDefaultDataSubscriptionId(): Int = defaultDataSubId
@@ -41,3 +41,6 @@ class FakeSubscriptionManagerProxy(
SubscriptionInfo.Builder().setId(subId).setEmbedded(isEmbedded).build()
}
}
+
+val SubscriptionManagerProxy.fake
+ get() = this as FakeSubscriptionManagerProxy
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt
index 00bfa994aabd..bb254a18efb7 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt
@@ -18,5 +18,5 @@ package com.android.systemui.statusbar.pipeline.shared.data.repository
import com.android.systemui.kosmos.Kosmos
-val Kosmos.connectivityRepository: ConnectivityRepository by
+var Kosmos.connectivityRepository: ConnectivityRepository by
Kosmos.Fixture { FakeConnectivityRepository() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt
index e44061a718d5..f560c502d606 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt
@@ -19,4 +19,4 @@ package com.android.systemui.statusbar.pipeline.wifi.data.repository
import com.android.systemui.kosmos.Kosmos
val Kosmos.fakeWifiRepository: FakeWifiRepository by Kosmos.Fixture { FakeWifiRepository() }
-val Kosmos.wifiRepository: WifiRepository by Kosmos.Fixture { fakeWifiRepository }
+var Kosmos.wifiRepository: WifiRepository by Kosmos.Fixture { fakeWifiRepository }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt
new file mode 100644
index 000000000000..1638cb7772ac
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.systemui.util.mockito
+
+import com.android.systemui.kosmos.Kosmos.Fixture
+import org.mockito.kotlin.mock
+
+inline fun <reified T> mockFixture(): Fixture<T> = Fixture { mock() }
diff --git a/services/accessibility/java/com/android/server/accessibility/gestures/TouchExplorer.java b/services/accessibility/java/com/android/server/accessibility/gestures/TouchExplorer.java
index fd230f69aec0..fb329430acb2 100644
--- a/services/accessibility/java/com/android/server/accessibility/gestures/TouchExplorer.java
+++ b/services/accessibility/java/com/android/server/accessibility/gestures/TouchExplorer.java
@@ -887,6 +887,9 @@ public class TouchExplorer extends BaseEventStreamTransformation
mSendHoverEnterAndMoveDelayed.cancel();
mSendHoverExitDelayed.cancel();
}
+ if (pointerIndex < 0) {
+ return;
+ }
// If the user is touch exploring the second pointer may be
// performing a double tap to activate an item without need
// for the user to lift their exploring finger.
diff --git a/services/core/java/com/android/server/am/SettingsToPropertiesMapper.java b/services/core/java/com/android/server/am/SettingsToPropertiesMapper.java
index 0954c49f94d6..3a041fd3b38a 100644
--- a/services/core/java/com/android/server/am/SettingsToPropertiesMapper.java
+++ b/services/core/java/com/android/server/am/SettingsToPropertiesMapper.java
@@ -228,6 +228,7 @@ public class SettingsToPropertiesMapper {
"media_reliability",
"media_solutions",
"media_tv",
+ "microxr",
"nearby",
"nfc",
"pdf_viewer",
diff --git a/services/core/java/com/android/server/appop/AppOpsUidStateTrackerImpl.java b/services/core/java/com/android/server/appop/AppOpsUidStateTrackerImpl.java
index ca9a25b86aed..6f8c241a86ae 100644
--- a/services/core/java/com/android/server/appop/AppOpsUidStateTrackerImpl.java
+++ b/services/core/java/com/android/server/appop/AppOpsUidStateTrackerImpl.java
@@ -341,17 +341,15 @@ class AppOpsUidStateTrackerImpl implements AppOpsUidStateTracker {
}
private void commitUidPendingState(int uid) {
- int pendingUidState = mPendingUidStates.get(uid,
- mUidStates.get(uid, MIN_PRIORITY_UID_STATE));
- int pendingCapability = mPendingCapability.get(uid,
- mCapability.get(uid, PROCESS_CAPABILITY_NONE));
- boolean pendingAppWidgetVisible = mPendingAppWidgetVisible.get(uid,
- mAppWidgetVisible.get(uid, false));
int uidState = mUidStates.get(uid, MIN_PRIORITY_UID_STATE);
int capability = mCapability.get(uid, PROCESS_CAPABILITY_NONE);
boolean appWidgetVisible = mAppWidgetVisible.get(uid, false);
+ int pendingUidState = mPendingUidStates.get(uid, uidState);
+ int pendingCapability = mPendingCapability.get(uid, capability);
+ boolean pendingAppWidgetVisible = mPendingAppWidgetVisible.get(uid, appWidgetVisible);
+
boolean foregroundChange = uidState <= UID_STATE_MAX_LAST_NON_RESTRICTED
!= pendingUidState <= UID_STATE_MAX_LAST_NON_RESTRICTED
|| capability != pendingCapability
diff --git a/services/core/java/com/android/server/clipboard/OWNERS b/services/core/java/com/android/server/clipboard/OWNERS
index 0d5dbf9acac3..4ca4b80ab2f0 100644
--- a/services/core/java/com/android/server/clipboard/OWNERS
+++ b/services/core/java/com/android/server/clipboard/OWNERS
@@ -1,3 +1,3 @@
-per-file EmulatorClipboardMonitor.java = bohu@google.com,lfy@google.com,rkir@google.com
+per-file EmulatorClipboardMonitor.java = bohu@google.com,rkir@google.com
olilan@google.com
diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/OWNERS b/services/core/java/com/android/server/locksettings/recoverablekeystore/OWNERS
index ebf7e6bed064..e54061670346 100644
--- a/services/core/java/com/android/server/locksettings/recoverablekeystore/OWNERS
+++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/OWNERS
@@ -1,3 +1,2 @@
aseemk@google.com
-bozhu@google.com
dementyev@google.com
diff --git a/services/core/java/com/android/server/security/advancedprotection/OWNERS b/services/core/java/com/android/server/security/advancedprotection/OWNERS
index 9bf5e58c01a9..8336ee8702b1 100644
--- a/services/core/java/com/android/server/security/advancedprotection/OWNERS
+++ b/services/core/java/com/android/server/security/advancedprotection/OWNERS
@@ -1 +1,2 @@
file:platform/frameworks/base:main:/core/java/android/security/advancedprotection/OWNERS
+per-file features/UsbDataAdvancedProtectionHook.java = georgechan@google.com, maunik@google.com
diff --git a/services/core/java/com/android/server/wm/SurfaceAnimationRunner.java b/services/core/java/com/android/server/wm/SurfaceAnimationRunner.java
index bbc35a35e93f..fba29d47d4d4 100644
--- a/services/core/java/com/android/server/wm/SurfaceAnimationRunner.java
+++ b/services/core/java/com/android/server/wm/SurfaceAnimationRunner.java
@@ -16,7 +16,6 @@
package com.android.server.wm;
-import static android.os.Trace.TRACE_TAG_WINDOW_MANAGER;
import static android.util.TimeUtils.NANOS_PER_MS;
import static android.view.Choreographer.CALLBACK_TRAVERSAL;
import static android.view.Choreographer.getSfInstance;
@@ -27,25 +26,13 @@ import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.annotation.Nullable;
-import android.graphics.BitmapShader;
-import android.graphics.Canvas;
-import android.graphics.Insets;
-import android.graphics.Paint;
-import android.graphics.PixelFormat;
-import android.graphics.Rect;
import android.hardware.power.Boost;
import android.os.Handler;
import android.os.PowerManagerInternal;
-import android.os.Trace;
import android.util.ArrayMap;
-import android.util.Log;
import android.view.Choreographer;
-import android.view.Surface;
import android.view.SurfaceControl;
import android.view.SurfaceControl.Transaction;
-import android.view.animation.Animation;
-import android.view.animation.Transformation;
-import android.window.ScreenCapture;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
@@ -53,9 +40,6 @@ import com.android.internal.graphics.SfVsyncFrameCallbackProvider;
import com.android.server.AnimationThread;
import com.android.server.wm.LocalAnimationAdapter.AnimationSpec;
-import java.util.ArrayList;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
import java.util.function.Supplier;
/**
@@ -73,12 +57,6 @@ class SurfaceAnimationRunner {
*/
private final Object mCancelLock = new Object();
- /**
- * Lock for synchronizing {@link #mEdgeExtensions} to prevent race conditions when managing
- * created edge extension surfaces.
- */
- private final Object mEdgeExtensionLock = new Object();
-
@VisibleForTesting
Choreographer mChoreographer;
@@ -91,12 +69,6 @@ class SurfaceAnimationRunner {
private final PowerManagerInternal mPowerManagerInternal;
private boolean mApplyScheduled;
- // Executor to perform the edge extension.
- // With two threads because in practice we will want to extend two surfaces in one animation,
- // in which case we want to be able to parallelize those two extensions to cut down latency in
- // starting the animation.
- private final ExecutorService mEdgeExtensionExecutor = Executors.newFixedThreadPool(2);
-
@GuardedBy("mLock")
@VisibleForTesting
final ArrayMap<SurfaceControl, RunningAnimation> mPendingAnimations = new ArrayMap<>();
@@ -112,11 +84,6 @@ class SurfaceAnimationRunner {
@GuardedBy("mLock")
private boolean mAnimationStartDeferred;
- // Mapping animation leashes to a list of edge extension surfaces associated with them
- @GuardedBy("mEdgeExtensionLock")
- private final ArrayMap<SurfaceControl, ArrayList<SurfaceControl>> mEdgeExtensions =
- new ArrayMap<>();
-
/**
* There should only ever be one instance of this class. Usual spot for it is with
* {@link WindowManagerService}
@@ -175,64 +142,9 @@ class SurfaceAnimationRunner {
synchronized (mLock) {
final RunningAnimation runningAnim = new RunningAnimation(a, animationLeash,
finishCallback);
- boolean requiresEdgeExtension = requiresEdgeExtension(a);
-
- if (requiresEdgeExtension) {
- final ArrayList<SurfaceControl> extensionSurfaces = new ArrayList<>();
- synchronized (mEdgeExtensionLock) {
- mEdgeExtensions.put(animationLeash, extensionSurfaces);
- }
-
- mPreProcessingAnimations.put(animationLeash, runningAnim);
-
- // We must wait for t to be committed since otherwise the leash doesn't have the
- // windows we want to screenshot and extend as children.
- t.addTransactionCommittedListener(mEdgeExtensionExecutor, () -> {
- if (!animationLeash.isValid()) {
- Log.e(TAG, "Animation leash is not valid");
- synchronized (mEdgeExtensionLock) {
- mEdgeExtensions.remove(animationLeash);
- }
- synchronized (mLock) {
- mPreProcessingAnimations.remove(animationLeash);
- }
- return;
- }
- final WindowAnimationSpec animationSpec = a.asWindowAnimationSpec();
-
- final Transaction edgeExtensionCreationTransaction = new Transaction();
- edgeExtendWindow(animationLeash,
- animationSpec.getRootTaskBounds(), animationSpec.getAnimation(),
- edgeExtensionCreationTransaction);
-
- synchronized (mLock) {
- // only run if animation is not yet canceled by this point
- if (mPreProcessingAnimations.get(animationLeash) == runningAnim) {
- // In the case the animation is cancelled, edge extensions are removed
- // onAnimationLeashLost which is called before onAnimationCancelled.
- // So we need to check if the edge extensions have already been removed
- // or not, and if so we don't want to apply the transaction.
- synchronized (mEdgeExtensionLock) {
- if (!mEdgeExtensions.isEmpty()) {
- edgeExtensionCreationTransaction.apply();
- }
- }
-
- mPreProcessingAnimations.remove(animationLeash);
- mPendingAnimations.put(animationLeash, runningAnim);
- if (!mAnimationStartDeferred && mPreProcessingAnimations.isEmpty()) {
- mChoreographer.postFrameCallback(this::startAnimations);
- }
- }
- }
- });
- }
-
- if (!requiresEdgeExtension) {
- mPendingAnimations.put(animationLeash, runningAnim);
- if (!mAnimationStartDeferred && mPreProcessingAnimations.isEmpty()) {
- mChoreographer.postFrameCallback(this::startAnimations);
- }
+ mPendingAnimations.put(animationLeash, runningAnim);
+ if (!mAnimationStartDeferred && mPreProcessingAnimations.isEmpty()) {
+ mChoreographer.postFrameCallback(this::startAnimations);
}
// Some animations (e.g. move animations) require the initial transform to be
@@ -241,10 +153,6 @@ class SurfaceAnimationRunner {
}
}
- private boolean requiresEdgeExtension(AnimationSpec a) {
- return a.asWindowAnimationSpec() != null && a.asWindowAnimationSpec().hasExtension();
- }
-
void onAnimationCancelled(SurfaceControl leash) {
synchronized (mLock) {
if (mPendingAnimations.containsKey(leash)) {
@@ -374,161 +282,6 @@ class SurfaceAnimationRunner {
mApplyScheduled = false;
}
- private void edgeExtendWindow(SurfaceControl leash, Rect bounds, Animation a,
- Transaction transaction) {
- final Transformation transformationAtStart = new Transformation();
- a.getTransformationAt(0, transformationAtStart);
- final Transformation transformationAtEnd = new Transformation();
- a.getTransformationAt(1, transformationAtEnd);
-
- // We want to create an extension surface that is the maximal size and the animation will
- // take care of cropping any part that overflows.
- final Insets maxExtensionInsets = Insets.min(
- transformationAtStart.getInsets(), transformationAtEnd.getInsets());
-
- final int targetSurfaceHeight = bounds.height();
- final int targetSurfaceWidth = bounds.width();
-
- if (maxExtensionInsets.left < 0) {
- final Rect edgeBounds = new Rect(bounds.left, bounds.top, bounds.left + 1,
- bounds.bottom);
- final Rect extensionRect = new Rect(0, 0,
- -maxExtensionInsets.left, targetSurfaceHeight);
- final int xPos = bounds.left + maxExtensionInsets.left;
- final int yPos = bounds.top;
- createExtensionSurface(leash, edgeBounds,
- extensionRect, xPos, yPos, "Left Edge Extension", transaction);
- }
-
- if (maxExtensionInsets.top < 0) {
- final Rect edgeBounds = new Rect(bounds.left, bounds.top, targetSurfaceWidth,
- bounds.top + 1);
- final Rect extensionRect = new Rect(0, 0,
- targetSurfaceWidth, -maxExtensionInsets.top);
- final int xPos = bounds.left;
- final int yPos = bounds.top + maxExtensionInsets.top;
- createExtensionSurface(leash, edgeBounds,
- extensionRect, xPos, yPos, "Top Edge Extension", transaction);
- }
-
- if (maxExtensionInsets.right < 0) {
- final Rect edgeBounds = new Rect(bounds.right - 1, bounds.top, bounds.right,
- bounds.bottom);
- final Rect extensionRect = new Rect(0, 0,
- -maxExtensionInsets.right, targetSurfaceHeight);
- final int xPos = bounds.right;
- final int yPos = bounds.top;
- createExtensionSurface(leash, edgeBounds,
- extensionRect, xPos, yPos, "Right Edge Extension", transaction);
- }
-
- if (maxExtensionInsets.bottom < 0) {
- final Rect edgeBounds = new Rect(bounds.left, bounds.bottom - 1,
- bounds.right, bounds.bottom);
- final Rect extensionRect = new Rect(0, 0,
- targetSurfaceWidth, -maxExtensionInsets.bottom);
- final int xPos = bounds.left;
- final int yPos = bounds.bottom;
- createExtensionSurface(leash, edgeBounds,
- extensionRect, xPos, yPos, "Bottom Edge Extension", transaction);
- }
- }
-
- private void createExtensionSurface(SurfaceControl leash, Rect edgeBounds,
- Rect extensionRect, int xPos, int yPos, String layerName,
- Transaction startTransaction) {
- Trace.traceBegin(TRACE_TAG_WINDOW_MANAGER, "createExtensionSurface");
- doCreateExtensionSurface(leash, edgeBounds, extensionRect, xPos, yPos, layerName,
- startTransaction);
- Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);
- }
-
- private void doCreateExtensionSurface(SurfaceControl leash, Rect edgeBounds,
- Rect extensionRect, int xPos, int yPos, String layerName,
- Transaction startTransaction) {
- ScreenCapture.LayerCaptureArgs captureArgs =
- new ScreenCapture.LayerCaptureArgs.Builder(leash /* surfaceToExtend */)
- .setSourceCrop(edgeBounds)
- .setFrameScale(1)
- .setPixelFormat(PixelFormat.RGBA_8888)
- .setChildrenOnly(true)
- .setAllowProtected(true)
- .setCaptureSecureLayers(true)
- .build();
- final ScreenCapture.ScreenshotHardwareBuffer edgeBuffer =
- ScreenCapture.captureLayers(captureArgs);
-
- if (edgeBuffer == null) {
- // The leash we are trying to screenshot may have been removed by this point, which is
- // likely the reason for ending up with a null edgeBuffer, in which case we just want to
- // return and do nothing.
- Log.e(TAG, "Failed to create edge extension - edge buffer is null");
- return;
- }
-
- final SurfaceControl edgeExtensionLayer = new SurfaceControl.Builder()
- .setName(layerName)
- .setHidden(true)
- .setCallsite("DefaultTransitionHandler#startAnimation")
- .setOpaque(true)
- .setBufferSize(extensionRect.width(), extensionRect.height())
- .build();
-
- BitmapShader shader = new BitmapShader(edgeBuffer.asBitmap(),
- android.graphics.Shader.TileMode.CLAMP,
- android.graphics.Shader.TileMode.CLAMP);
- final Paint paint = new Paint();
- paint.setShader(shader);
-
- final Surface surface = new Surface(edgeExtensionLayer);
- Canvas c = surface.lockHardwareCanvas();
- c.drawRect(extensionRect, paint);
- surface.unlockCanvasAndPost(c);
- surface.release();
-
- synchronized (mEdgeExtensionLock) {
- if (!mEdgeExtensions.containsKey(leash)) {
- // The animation leash has already been removed, so we don't want to attach the
- // edgeExtension layer and should immediately remove it instead.
- startTransaction.remove(edgeExtensionLayer);
- return;
- }
-
- startTransaction.reparent(edgeExtensionLayer, leash);
- startTransaction.setLayer(edgeExtensionLayer, Integer.MIN_VALUE);
- startTransaction.setPosition(edgeExtensionLayer, xPos, yPos);
- startTransaction.setVisibility(edgeExtensionLayer, true);
-
- mEdgeExtensions.get(leash).add(edgeExtensionLayer);
- }
- }
-
- private float getScaleXForExtensionSurface(Rect edgeBounds, Rect extensionRect) {
- if (edgeBounds.width() == extensionRect.width()) {
- // Top or bottom edge extension, no need to scale the X axis of the extension surface.
- return 1;
- }
- if (edgeBounds.width() == 1) {
- // Left or right edge extension, scale the surface to be the extensionRect's width.
- return extensionRect.width();
- }
-
- throw new RuntimeException("Unexpected edgeBounds and extensionRect widths");
- }
-
- private float getScaleYForExtensionSurface(Rect edgeBounds, Rect extensionRect) {
- if (edgeBounds.height() == extensionRect.height()) {
- // Left or right edge extension, no need to scale the Y axis of the extension surface.
- return 1;
- }
- if (edgeBounds.height() == 1) {
- // Top or bottom edge extension, scale the surface to be the extensionRect's height.
- return extensionRect.height();
- }
-
- throw new RuntimeException("Unexpected edgeBounds and extensionRect heights");
- }
-
private static final class RunningAnimation {
final AnimationSpec mAnimSpec;
final SurfaceControl mLeash;
@@ -545,22 +298,6 @@ class SurfaceAnimationRunner {
}
}
- protected void onAnimationLeashLost(SurfaceControl animationLeash,
- Transaction t) {
- synchronized (mEdgeExtensionLock) {
- if (!mEdgeExtensions.containsKey(animationLeash)) {
- return;
- }
-
- final ArrayList<SurfaceControl> edgeExtensions = mEdgeExtensions.get(animationLeash);
- for (int i = 0; i < edgeExtensions.size(); i++) {
- final SurfaceControl extension = edgeExtensions.get(i);
- t.remove(extension);
- }
- mEdgeExtensions.remove(animationLeash);
- }
- }
-
@VisibleForTesting
interface AnimatorFactory {
ValueAnimator makeAnimator();
diff --git a/services/core/java/com/android/server/wm/WindowContainer.java b/services/core/java/com/android/server/wm/WindowContainer.java
index 1c3510d1b43f..e3746f18dca0 100644
--- a/services/core/java/com/android/server/wm/WindowContainer.java
+++ b/services/core/java/com/android/server/wm/WindowContainer.java
@@ -3067,7 +3067,6 @@ class WindowContainer<E extends WindowContainer> extends ConfigurationContainer<
@Override
public void onAnimationLeashLost(Transaction t) {
mLastLayer = -1;
- mWmService.mSurfaceAnimationRunner.onAnimationLeashLost(mAnimationLeash, t);
mAnimationLeash = null;
mNeedsZBoost = false;
reassignLayer(t);
diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java
index 28f2825150c2..71f69048f2cb 100644
--- a/services/core/java/com/android/server/wm/WindowManagerService.java
+++ b/services/core/java/com/android/server/wm/WindowManagerService.java
@@ -6863,7 +6863,7 @@ public class WindowManagerService extends IWindowManager.Stub
pw.print(' '); pw.println(imeControlTarget);
}
pw.print(" Minimum task size of display#"); pw.print(displayId);
- pw.print(' '); pw.print(dc.mMinSizeOfResizeableTaskDp);
+ pw.print(' '); pw.println(dc.mMinSizeOfResizeableTaskDp);
});
pw.print(" mBlurEnabled="); pw.println(mBlurController.getBlurEnabled());
pw.print(" mDisableSecureWindows="); pw.println(mDisableSecureWindows);
diff --git a/services/core/jni/stats/OWNERS b/services/core/jni/stats/OWNERS
index 8d87925fbe45..03086b3e44fc 100644
--- a/services/core/jni/stats/OWNERS
+++ b/services/core/jni/stats/OWNERS
@@ -1,6 +1,5 @@
jeffreyhuang@google.com
muhammadq@google.com
-sharaieko@google.com
singhtejinder@google.com
tsaichristine@google.com
yaochen@google.com
diff --git a/services/credentials/java/com/android/server/credentials/CredentialManagerService.java b/services/credentials/java/com/android/server/credentials/CredentialManagerService.java
index 014f0a2229c1..42e457c97fd4 100644
--- a/services/credentials/java/com/android/server/credentials/CredentialManagerService.java
+++ b/services/credentials/java/com/android/server/credentials/CredentialManagerService.java
@@ -31,6 +31,7 @@ import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
+import android.content.res.Resources;
import android.credentials.ClearCredentialStateRequest;
import android.credentials.CreateCredentialException;
import android.credentials.CreateCredentialRequest;
@@ -50,6 +51,7 @@ import android.credentials.ISetEnabledProvidersCallback;
import android.credentials.PrepareGetCredentialResponseInternal;
import android.credentials.RegisterCredentialDescriptionRequest;
import android.credentials.UnregisterCredentialDescriptionRequest;
+import android.credentials.flags.Flags;
import android.os.Binder;
import android.os.CancellationSignal;
import android.os.IBinder;
@@ -79,6 +81,7 @@ import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@@ -535,6 +538,33 @@ public final class CredentialManagerService
final int userId = UserHandle.getCallingUserId();
final int callingUid = Binder.getCallingUid();
+ if (Flags.safeguardCandidateCredentialsApiCaller()) {
+ try {
+ String credentialManagerAutofillCompName = mContext.getResources().getString(
+ R.string.config_defaultCredentialManagerAutofillService);
+ ComponentName componentName = ComponentName.unflattenFromString(
+ credentialManagerAutofillCompName);
+ if (componentName == null) {
+ throw new SecurityException(
+ "Credential Autofill service does not exist on this device.");
+ }
+ PackageManager pm = mContext.createContextAsUser(
+ UserHandle.getUserHandleForUid(callingUid), 0).getPackageManager();
+ String callingProcessPackage = pm.getNameForUid(callingUid);
+ if (callingProcessPackage == null) {
+ throw new SecurityException(
+ "Couldn't determine the identity of the caller.");
+ }
+ if (!Objects.equals(componentName.getPackageName(), callingProcessPackage)) {
+ throw new SecurityException(callingProcessPackage
+ + " is not the device's credential autofill package.");
+ }
+ } catch (Resources.NotFoundException e) {
+ throw new SecurityException(
+ "Credential Autofill service does not exist on this device.");
+ }
+ }
+
// New request session, scoped for this request only.
final GetCandidateRequestSession session =
diff --git a/tests/Input/Android.bp b/tests/Input/Android.bp
index 1f0bd61b5c3f..544f94b49ee7 100644
--- a/tests/Input/Android.bp
+++ b/tests/Input/Android.bp
@@ -15,6 +15,7 @@ android_test {
"modules-utils-testable-device-config-defaults",
],
srcs: [
+ "src/**/*.aidl",
"src/**/*.java",
"src/**/*.kt",
],
diff --git a/tests/Input/src/com/android/server/input/InputManagerServiceTests.kt b/tests/Input/src/com/android/server/input/InputManagerServiceTests.kt
index eac426700ec1..7ec8f9ce9864 100644
--- a/tests/Input/src/com/android/server/input/InputManagerServiceTests.kt
+++ b/tests/Input/src/com/android/server/input/InputManagerServiceTests.kt
@@ -76,7 +76,7 @@ import org.mockito.Mockito.spy
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
-import org.mockito.Mockito.verifyZeroInteractions
+import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.mockito.stubbing.OngoingStubbing
@@ -209,7 +209,7 @@ class InputManagerServiceTests {
@Test
fun testStart() {
- verifyZeroInteractions(native)
+ verifyNoInteractions(native)
service.start()
verify(native).start()
@@ -217,7 +217,7 @@ class InputManagerServiceTests {
@Test
fun testInputSettingsUpdatedOnSystemRunning() {
- verifyZeroInteractions(native)
+ verifyNoInteractions(native)
runWithShellPermissionIdentity {
service.systemRunning()
diff --git a/tests/Input/src/com/android/test/input/AnrTest.kt b/tests/Input/src/com/android/test/input/AnrTest.kt
index 73192eac89c0..f8cb86b7b1fe 100644
--- a/tests/Input/src/com/android/test/input/AnrTest.kt
+++ b/tests/Input/src/com/android/test/input/AnrTest.kt
@@ -15,32 +15,37 @@
*/
package com.android.test.input
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.filters.MediumTest
-
import android.app.ActivityManager
import android.app.ApplicationExitInfo
-import android.content.Context
-import android.graphics.Rect
+import android.app.Instrumentation
+import android.content.Intent
import android.hardware.display.DisplayManager
import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
import android.os.IInputConstants.UNMULTIPLIED_DEFAULT_DISPATCHING_TIMEOUT_MILLIS
import android.os.SystemClock
-import android.server.wm.CtsWindowInfoUtils.waitForStableWindowGeometry
+import android.server.wm.CtsWindowInfoUtils.getWindowCenter
+import android.server.wm.CtsWindowInfoUtils.waitForWindowOnTop
import android.testing.PollingCheck
-
+import android.view.InputEvent
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_DOWN
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
-
+import com.android.cts.input.BlockingQueueEventVerifier
import com.android.cts.input.DebugInputRule
import com.android.cts.input.ShowErrorDialogsRule
import com.android.cts.input.UinputTouchScreen
-
+import com.android.cts.input.inputeventmatchers.withMotionAction
import java.time.Duration
-
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.function.Supplier
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -51,13 +56,34 @@ import org.junit.Test
import org.junit.runner.RunWith
/**
+ * Click on the center of the window identified by the provided window token.
+ * The click is performed using "UinputTouchScreen" device.
+ * If the touchscreen device is closed too soon, it may cause the click to be dropped. Therefore,
+ * the provided runnable can ensure that the click is delivered before the device is closed, thus
+ * avoiding this race.
+ */
+private fun clickOnWindow(
+ token: IBinder,
+ displayId: Int,
+ instrumentation: Instrumentation,
+ waitForEvent: Runnable,
+) {
+ val displayManager = instrumentation.context.getSystemService(DisplayManager::class.java)
+ val display = displayManager.getDisplay(displayId)
+ val point = getWindowCenter({ token }, display.displayId)
+ UinputTouchScreen(instrumentation, display).use { touchScreen ->
+ touchScreen.touchDown(point.x, point.y).lift()
+ // If the device is allowed to close without waiting here, the injected click may be dropped
+ waitForEvent.run()
+ }
+}
+
+/**
* This test makes sure that an unresponsive gesture monitor gets an ANR.
*
* The gesture monitor must be registered from a different process than the instrumented process.
- * Otherwise, when the test runs, you will get:
- * Test failed to run to completion.
- * Reason: 'Instrumentation run failed due to 'keyDispatchingTimedOut''.
- * Check device logcat for details
+ * Otherwise, when the test runs, you will get: Test failed to run to completion. Reason:
+ * 'Instrumentation run failed due to 'keyDispatchingTimedOut''. Check device logcat for details
* RUNNER ERROR: Instrumentation run failed due to 'keyDispatchingTimedOut'
*/
@MediumTest
@@ -65,30 +91,43 @@ import org.junit.runner.RunWith
class AnrTest {
companion object {
private const val TAG = "AnrTest"
- private const val ALL_PIDS = 0
private const val NO_MAX = 0
}
private val instrumentation = InstrumentationRegistry.getInstrumentation()
- private var hideErrorDialogs = 0
private lateinit var PACKAGE_NAME: String
- private val DISPATCHING_TIMEOUT = (UNMULTIPLIED_DEFAULT_DISPATCHING_TIMEOUT_MILLIS *
- Build.HW_TIMEOUT_MULTIPLIER)
+ private val DISPATCHING_TIMEOUT =
+ (UNMULTIPLIED_DEFAULT_DISPATCHING_TIMEOUT_MILLIS * Build.HW_TIMEOUT_MULTIPLIER)
+ private var remoteWindowToken: IBinder? = null
+ private var remoteDisplayId: Int? = null
+ private var remotePid: Int? = null
+ private val remoteInputEvents = LinkedBlockingQueue<InputEvent>()
+ private val verifier = BlockingQueueEventVerifier(remoteInputEvents)
+
+ val binder =
+ object : IAnrTestService.Stub() {
+ override fun provideActivityInfo(token: IBinder, displayId: Int, pid: Int) {
+ remoteWindowToken = token
+ remoteDisplayId = displayId
+ remotePid = pid
+ }
+
+ override fun notifyMotion(event: MotionEvent) {
+ remoteInputEvents.add(event)
+ }
+ }
- @get:Rule
- val debugInputRule = DebugInputRule()
+ @get:Rule val showErrorDialogs = ShowErrorDialogsRule()
- @get:Rule
- val showErrorDialogs = ShowErrorDialogsRule()
+ @get:Rule val debugInputRule = DebugInputRule()
@Before
fun setUp() {
+ startUnresponsiveActivity()
PACKAGE_NAME = UnresponsiveGestureMonitorActivity::class.java.getPackage()!!.getName()
}
- @After
- fun tearDown() {
- }
+ @After fun tearDown() {}
@Test
@DebugInputRule.DebugInput(bug = 339924248)
@@ -112,7 +151,7 @@ class AnrTest {
val timestamp = System.currentTimeMillis()
val uiDevice: UiDevice = UiDevice.getInstance(instrumentation)
val closeAppButton: UiObject2? =
- uiDevice.wait(Until.findObject(By.res("android:id/aerr_close")), 20000)
+ uiDevice.wait(Until.findObject(By.res("android:id/aerr_close")), 20000)
if (closeAppButton == null) {
fail("Could not find anr dialog/close button")
return
@@ -120,10 +159,10 @@ class AnrTest {
closeAppButton.click()
/**
* We must wait for the app to be fully closed before exiting this test. This is because
- * another test may again invoke 'am start' for the same activity.
- * If the 1st process that got ANRd isn't killed by the time second 'am start' runs,
- * the killing logic will apply to the newly launched 'am start' instance, and the second
- * test will fail because the unresponsive activity will never be launched.
+ * another test may again invoke 'am start' for the same activity. If the 1st process that
+ * got ANRd isn't killed by the time second 'am start' runs, the killing logic will apply to
+ * the newly launched 'am start' instance, and the second test will fail because the
+ * unresponsive activity will never be launched.
*/
waitForNewExitReasonAfter(timestamp)
}
@@ -132,7 +171,7 @@ class AnrTest {
// Find anr dialog and tap on wait
val uiDevice: UiDevice = UiDevice.getInstance(instrumentation)
val waitButton: UiObject2? =
- uiDevice.wait(Until.findObject(By.res("android:id/aerr_wait")), 20000)
+ uiDevice.wait(Until.findObject(By.res("android:id/aerr_wait")), 20000)
if (waitButton == null) {
fail("Could not find anr dialog/wait button")
return
@@ -144,7 +183,7 @@ class AnrTest {
lateinit var infos: List<ApplicationExitInfo>
instrumentation.runOnMainSync {
val am = instrumentation.getContext().getSystemService(ActivityManager::class.java)!!
- infos = am.getHistoricalProcessExitReasons(PACKAGE_NAME, ALL_PIDS, NO_MAX)
+ infos = am.getHistoricalProcessExitReasons(PACKAGE_NAME, remotePid!!, NO_MAX)
}
return infos
}
@@ -159,37 +198,32 @@ class AnrTest {
assertEquals(ApplicationExitInfo.REASON_ANR, reasons[0].reason)
}
- private fun clickOnObject(obj: UiObject2) {
- val displayManager =
- instrumentation.context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
- val display = displayManager.getDisplay(obj.getDisplayId())
- val rect: Rect = obj.visibleBounds
- UinputTouchScreen(instrumentation, display).use { touchScreen ->
- touchScreen
- .touchDown(rect.centerX(), rect.centerY())
- .lift()
- }
- }
-
private fun triggerAnr() {
- startUnresponsiveActivity()
- val uiDevice: UiDevice = UiDevice.getInstance(instrumentation)
- val obj: UiObject2? = uiDevice.wait(Until.findObject(By.pkg(PACKAGE_NAME)), 10000)
-
- if (obj == null) {
- fail("Could not find unresponsive activity")
- return
- }
-
- clickOnObject(obj)
+ clickOnWindow(
+ remoteWindowToken!!,
+ remoteDisplayId!!,
+ instrumentation,
+ ) { verifier.assertReceivedMotion(withMotionAction(ACTION_DOWN)) }
SystemClock.sleep(DISPATCHING_TIMEOUT.toLong()) // default ANR timeout for gesture monitors
}
private fun startUnresponsiveActivity() {
- val flags = " -W -n "
- val startCmd = "am start $flags $PACKAGE_NAME/.UnresponsiveGestureMonitorActivity"
- instrumentation.uiAutomation.executeShellCommand(startCmd)
- waitForStableWindowGeometry(Duration.ofSeconds(5))
+ val intent =
+ Intent(instrumentation.targetContext, UnresponsiveGestureMonitorActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
+ val bundle = Bundle()
+ bundle.putBinder("serviceBinder", binder)
+ intent.putExtra("serviceBundle", bundle)
+ instrumentation.targetContext.startActivity(intent)
+ // first, wait for the token to become valid
+ PollingCheck.check(
+ "UnresponsiveGestureMonitorActivity failed to call 'provideActivityInfo'",
+ Duration.ofSeconds(5).toMillis()) { remoteWindowToken != null }
+ // next, wait for the window of the activity to get on top
+ // we could combine the two checks above, but the current setup makes it easier to detect
+ // errors
+ assertTrue("Remote activity window did not become visible",
+ waitForWindowOnTop(Duration.ofSeconds(5), Supplier { remoteWindowToken }))
}
}
diff --git a/tests/Input/src/com/android/test/input/IAnrTestService.aidl b/tests/Input/src/com/android/test/input/IAnrTestService.aidl
new file mode 100644
index 000000000000..e3caf06b742b
--- /dev/null
+++ b/tests/Input/src/com/android/test/input/IAnrTestService.aidl
@@ -0,0 +1,17 @@
+package com.android.test.input;
+
+import android.view.MotionEvent;
+
+interface IAnrTestService {
+ /**
+ * Provide the activity information. This includes:
+ * windowToken: the windowToken of the activity window
+ * displayId: the display id on which the activity is positioned
+ * pid: the pid of the activity
+ */
+ void provideActivityInfo(IBinder windowToken, int displayId, int pid);
+ /**
+ * Provide the MotionEvent received by the remote activity.
+ */
+ void notifyMotion(in MotionEvent event);
+}
diff --git a/tests/Input/src/com/android/test/input/UnresponsiveGestureMonitorActivity.kt b/tests/Input/src/com/android/test/input/UnresponsiveGestureMonitorActivity.kt
index 1842f0a64a83..1e44617af111 100644
--- a/tests/Input/src/com/android/test/input/UnresponsiveGestureMonitorActivity.kt
+++ b/tests/Input/src/com/android/test/input/UnresponsiveGestureMonitorActivity.kt
@@ -23,20 +23,24 @@ import android.app.Activity
import android.hardware.input.InputManager
import android.os.Bundle
import android.os.Looper
+import android.os.Process
import android.util.Log
import android.view.InputChannel
import android.view.InputEvent
import android.view.InputEventReceiver
import android.view.InputMonitor
+import android.view.MotionEvent
-class UnresponsiveReceiver(channel: InputChannel, looper: Looper) :
- InputEventReceiver(channel, looper) {
+class UnresponsiveReceiver(channel: InputChannel, looper: Looper, val service: IAnrTestService) :
+ InputEventReceiver(channel, looper) {
companion object {
const val TAG = "UnresponsiveReceiver"
}
+
override fun onInputEvent(event: InputEvent) {
Log.i(TAG, "Received $event")
// Not calling 'finishInputEvent' in order to trigger the ANR
+ service.notifyMotion(event as MotionEvent)
}
}
@@ -44,14 +48,27 @@ class UnresponsiveGestureMonitorActivity : Activity() {
companion object {
const val MONITOR_NAME = "unresponsive gesture monitor"
}
+
private lateinit var mInputEventReceiver: InputEventReceiver
private lateinit var mInputMonitor: InputMonitor
+ private lateinit var service: IAnrTestService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ val bundle = intent.getBundleExtra("serviceBundle")!!
+ service = IAnrTestService.Stub.asInterface(bundle.getBinder("serviceBinder"))
val inputManager = checkNotNull(getSystemService(InputManager::class.java))
mInputMonitor = inputManager.monitorGestureInput(MONITOR_NAME, displayId)
- mInputEventReceiver = UnresponsiveReceiver(
- mInputMonitor.getInputChannel(), Looper.myLooper()!!)
+ mInputEventReceiver =
+ UnresponsiveReceiver(mInputMonitor.getInputChannel(), Looper.myLooper()!!, service)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ service.provideActivityInfo(
+ window.decorView.windowToken,
+ display.displayId,
+ Process.myPid(),
+ )
}
}