Merge "[SB][Sat] Update carrier text to display device-based satellite info." into main
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 27aa15f..b8e78a4 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -1718,6 +1718,11 @@
     <!-- Accessibility label for available satellite connection [CHAR LIMIT=NONE] -->
     <string name="accessibility_status_bar_satellite_available">Satellite, connection available</string>
 
+    <!-- Text displayed indicating that the user is connected to a satellite signal. -->
+    <string name="satellite_connected_carrier_text">Connected to satellite</string>
+    <!-- Text displayed indicating that the user is not connected to a satellite signal. -->
+    <string name="satellite_not_connected_carrier_text">Not connected to satellite</string>
+
     <!-- Accessibility label for managed profile icon (not shown on screen) [CHAR LIMIT=NONE] -->
     <string name="accessibility_managed_profile">Work profile</string>
 
diff --git a/packages/SystemUI/src/com/android/keyguard/CarrierTextManager.java b/packages/SystemUI/src/com/android/keyguard/CarrierTextManager.java
index b9b8fbe..5647b0b 100644
--- a/packages/SystemUI/src/com/android/keyguard/CarrierTextManager.java
+++ b/packages/SystemUI/src/com/android/keyguard/CarrierTextManager.java
@@ -19,6 +19,7 @@
 import static com.android.keyguard.logging.CarrierTextManagerLogger.REASON_ACTIVE_DATA_SUB_CHANGED;
 import static com.android.keyguard.logging.CarrierTextManagerLogger.REASON_ON_TELEPHONY_CAPABLE;
 import static com.android.keyguard.logging.CarrierTextManagerLogger.REASON_REFRESH_CARRIER_INFO;
+import static com.android.keyguard.logging.CarrierTextManagerLogger.REASON_SATELLITE_CHANGED;
 import static com.android.keyguard.logging.CarrierTextManagerLogger.REASON_SIM_ERROR_STATE_CHANGED;
 
 import android.content.Context;
@@ -43,17 +44,22 @@
 import com.android.systemui.dagger.qualifiers.Main;
 import com.android.systemui.keyguard.WakefulnessLifecycle;
 import com.android.systemui.res.R;
+import com.android.systemui.statusbar.pipeline.satellite.ui.viewmodel.DeviceBasedSatelliteViewModel;
 import com.android.systemui.statusbar.pipeline.wifi.data.repository.WifiRepository;
 import com.android.systemui.telephony.TelephonyListenerManager;
+import com.android.systemui.util.kotlin.JavaAdapter;
 
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
+import java.util.concurrent.CancellationException;
 import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import javax.inject.Inject;
 
+import kotlinx.coroutines.Job;
+
 /**
  * Controller that generates text including the carrier names and/or the status of all the SIM
  * interfaces in the device. Through a callback, the updates can be retrieved either as a list or
@@ -77,10 +83,17 @@
     protected KeyguardUpdateMonitor mKeyguardUpdateMonitor;
     private final CarrierTextManagerLogger mLogger;
     private final WifiRepository mWifiRepository;
+    private final DeviceBasedSatelliteViewModel mDeviceBasedSatelliteViewModel;
+    private final JavaAdapter mJavaAdapter;
     private final boolean[] mSimErrorState;
     private final int mSimSlotsNumber;
     @Nullable // Check for nullability before dispatching
     private CarrierTextCallback mCarrierTextCallback;
+    @Nullable
+    private Job mSatelliteConnectionJob;
+
+    @Nullable private String mSatelliteCarrierText;
+
     private final Context mContext;
     private final TelephonyManager mTelephonyManager;
     private final CharSequence mSeparator;
@@ -178,6 +191,8 @@
             boolean showAirplaneMode,
             boolean showMissingSim,
             WifiRepository wifiRepository,
+            DeviceBasedSatelliteViewModel deviceBasedSatelliteViewModel,
+            JavaAdapter javaAdapter,
             TelephonyManager telephonyManager,
             TelephonyListenerManager telephonyListenerManager,
             WakefulnessLifecycle wakefulnessLifecycle,
@@ -192,6 +207,8 @@
         mShowAirplaneMode = showAirplaneMode;
         mShowMissingSim = showMissingSim;
         mWifiRepository = wifiRepository;
+        mDeviceBasedSatelliteViewModel = deviceBasedSatelliteViewModel;
+        mJavaAdapter = javaAdapter;
         mTelephonyManager = telephonyManager;
         mSeparator = separator;
         mTelephonyListenerManager = telephonyListenerManager;
@@ -282,6 +299,11 @@
                     mWakefulnessLifecycle.addObserver(mWakefulnessObserver);
                 });
                 mTelephonyListenerManager.addActiveDataSubscriptionIdListener(mPhoneStateListener);
+                cancelSatelliteCollectionJob(/* reason= */ "Starting new job");
+                mSatelliteConnectionJob =
+                    mJavaAdapter.alwaysCollectFlow(
+                        mDeviceBasedSatelliteViewModel.getCarrierText(),
+                        this::onSatelliteCarrierTextChanged);
             } else {
                 // Don't listen and clear out the text when the device isn't a phone.
                 mMainExecutor.execute(() -> callback.updateCarrierInfo(
@@ -294,6 +316,7 @@
                 mWakefulnessLifecycle.removeObserver(mWakefulnessObserver);
             });
             mTelephonyListenerManager.removeActiveDataSubscriptionIdListener(mPhoneStateListener);
+            cancelSatelliteCollectionJob(/* reason= */ "Stopping listening");
         }
     }
 
@@ -311,6 +334,12 @@
         return mKeyguardUpdateMonitor.getFilteredSubscriptionInfo();
     }
 
+    private void onSatelliteCarrierTextChanged(@Nullable String text) {
+        mLogger.logUpdateCarrierTextForReason(REASON_SATELLITE_CHANGED);
+        mSatelliteCarrierText = text;
+        updateCarrierText();
+    }
+
     protected void updateCarrierText() {
         Trace.beginSection("CarrierTextManager#updateCarrierText");
         boolean allSimsMissing = true;
@@ -411,6 +440,12 @@
             airplaneMode = true;
         }
 
+        String currentSatelliteText = mSatelliteCarrierText;
+        if (currentSatelliteText != null) {
+            mLogger.logUsingSatelliteText(currentSatelliteText);
+            displayText = currentSatelliteText;
+        }
+
         final CarrierTextCallbackInfo info = new CarrierTextCallbackInfo(
                 displayText,
                 carrierNames,
@@ -616,11 +651,20 @@
         return list;
     }
 
+    private void cancelSatelliteCollectionJob(String reason) {
+        Job job = mSatelliteConnectionJob;
+        if (job != null) {
+            job.cancel(new CancellationException(reason));
+        }
+    }
+
     /** Injectable Buildeer for {@#link CarrierTextManager}. */
     public static class Builder {
         private final Context mContext;
         private final String mSeparator;
         private final WifiRepository mWifiRepository;
+        private final DeviceBasedSatelliteViewModel mDeviceBasedSatelliteViewModel;
+        private final JavaAdapter mJavaAdapter;
         private final TelephonyManager mTelephonyManager;
         private final TelephonyListenerManager mTelephonyListenerManager;
         private final WakefulnessLifecycle mWakefulnessLifecycle;
@@ -637,6 +681,8 @@
                 Context context,
                 @Main Resources resources,
                 @Nullable WifiRepository wifiRepository,
+                DeviceBasedSatelliteViewModel deviceBasedSatelliteViewModel,
+                JavaAdapter javaAdapter,
                 TelephonyManager telephonyManager,
                 TelephonyListenerManager telephonyListenerManager,
                 WakefulnessLifecycle wakefulnessLifecycle,
@@ -648,6 +694,8 @@
             mSeparator = resources.getString(
                     com.android.internal.R.string.kg_text_message_separator);
             mWifiRepository = wifiRepository;
+            mDeviceBasedSatelliteViewModel = deviceBasedSatelliteViewModel;
+            mJavaAdapter = javaAdapter;
             mTelephonyManager = telephonyManager;
             mTelephonyListenerManager = telephonyListenerManager;
             mWakefulnessLifecycle = wakefulnessLifecycle;
@@ -682,9 +730,20 @@
         public CarrierTextManager build() {
             mLogger.setLocation(mDebugLocation);
             return new CarrierTextManager(
-                    mContext, mSeparator, mShowAirplaneMode, mShowMissingSim, mWifiRepository,
-                    mTelephonyManager, mTelephonyListenerManager, mWakefulnessLifecycle,
-                    mMainExecutor, mBgExecutor, mKeyguardUpdateMonitor, mLogger);
+                    mContext,
+                    mSeparator,
+                    mShowAirplaneMode,
+                    mShowMissingSim,
+                    mWifiRepository,
+                    mDeviceBasedSatelliteViewModel,
+                    mJavaAdapter,
+                    mTelephonyManager,
+                    mTelephonyListenerManager,
+                    mWakefulnessLifecycle,
+                    mMainExecutor,
+                    mBgExecutor,
+                    mKeyguardUpdateMonitor,
+                    mLogger);
         }
     }
 
diff --git a/packages/SystemUI/src/com/android/keyguard/logging/CarrierTextManagerLogger.kt b/packages/SystemUI/src/com/android/keyguard/logging/CarrierTextManagerLogger.kt
index cb474d3..48fea55 100644
--- a/packages/SystemUI/src/com/android/keyguard/logging/CarrierTextManagerLogger.kt
+++ b/packages/SystemUI/src/com/android/keyguard/logging/CarrierTextManagerLogger.kt
@@ -77,6 +77,15 @@
         )
     }
 
+    fun logUsingSatelliteText(satelliteText: String) {
+        buffer.log(
+            TAG,
+            LogLevel.VERBOSE,
+            { str1 = satelliteText },
+            { "┣ updateCarrierText: using satellite text. text=$str1" },
+        )
+    }
+
     /** De-structures the info object so that we don't have to generate new strings */
     fun logCallbackSentFromUpdate(info: CarrierTextCallbackInfo) {
         buffer.log(
@@ -129,6 +138,7 @@
         const val REASON_ON_TELEPHONY_CAPABLE = 2
         const val REASON_SIM_ERROR_STATE_CHANGED = 3
         const val REASON_ACTIVE_DATA_SUB_CHANGED = 4
+        const val REASON_SATELLITE_CHANGED = 5
 
         @Retention(AnnotationRetention.SOURCE)
         @IntDef(
@@ -138,6 +148,7 @@
                     REASON_ON_TELEPHONY_CAPABLE,
                     REASON_SIM_ERROR_STATE_CHANGED,
                     REASON_ACTIVE_DATA_SUB_CHANGED,
+                    REASON_SATELLITE_CHANGED,
                 ]
         )
         annotation class CarrierTextRefreshReason
@@ -148,6 +159,7 @@
                 REASON_ON_TELEPHONY_CAPABLE -> "ON_TELEPHONY_CAPABLE"
                 REASON_SIM_ERROR_STATE_CHANGED -> "SIM_ERROR_STATE_CHANGED"
                 REASON_ACTIVE_DATA_SUB_CHANGED -> "ACTIVE_DATA_SUB_CHANGED"
+                REASON_SATELLITE_CHANGED -> "SATELLITE_CHANGED"
                 else -> "unknown"
             }
     }
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 4129b3a..b80ff38 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
@@ -42,6 +42,8 @@
 import com.android.systemui.statusbar.pipeline.mobile.util.SubscriptionManagerProxyImpl
 import com.android.systemui.statusbar.pipeline.satellite.data.DeviceBasedSatelliteRepository
 import com.android.systemui.statusbar.pipeline.satellite.data.prod.DeviceBasedSatelliteRepositoryImpl
+import com.android.systemui.statusbar.pipeline.satellite.ui.viewmodel.DeviceBasedSatelliteViewModel
+import com.android.systemui.statusbar.pipeline.satellite.ui.viewmodel.DeviceBasedSatelliteViewModelImpl
 import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
 import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepositoryImpl
 import com.android.systemui.statusbar.pipeline.shared.ui.binder.CollapsedStatusBarViewBinder
@@ -85,6 +87,11 @@
         impl: DeviceBasedSatelliteRepositoryImpl
     ): DeviceBasedSatelliteRepository
 
+    @Binds
+    abstract fun deviceBasedSatelliteViewModel(
+        impl: DeviceBasedSatelliteViewModelImpl
+    ): DeviceBasedSatelliteViewModel
+
     @Binds abstract fun wifiRepository(impl: WifiRepositorySwitcher): WifiRepository
 
     @Binds abstract fun wifiInteractor(impl: WifiInteractorImpl): WifiInteractor
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt
index a0291b8..f2255f3 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt
@@ -16,13 +16,16 @@
 
 package com.android.systemui.statusbar.pipeline.satellite.ui.viewmodel
 
+import android.content.Context
 import com.android.systemui.common.shared.model.Icon
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.log.LogBuffer
 import com.android.systemui.log.core.LogLevel
+import com.android.systemui.res.R
 import com.android.systemui.statusbar.pipeline.airplane.data.repository.AirplaneModeRepository
 import com.android.systemui.statusbar.pipeline.dagger.OemSatelliteInputLog
 import com.android.systemui.statusbar.pipeline.satellite.domain.interactor.DeviceBasedSatelliteInteractor
+import com.android.systemui.statusbar.pipeline.satellite.shared.model.SatelliteConnectionState
 import com.android.systemui.statusbar.pipeline.satellite.ui.model.SatelliteIconModel
 import javax.inject.Inject
 import kotlin.time.Duration.Companion.seconds
@@ -42,15 +45,30 @@
  * View-Model for the device-based satellite icon. This icon will only show in the status bar if
  * satellite is available AND all other service states are considered OOS.
  */
+interface DeviceBasedSatelliteViewModel {
+    /**
+     * The satellite icon that should be displayed, or null if no satellite icon should be
+     * displayed.
+     */
+    val icon: StateFlow<Icon?>
+
+    /**
+     * The satellite-related text that should be used as the carrier text string when satellite is
+     * active, or null if the carrier text string shouldn't include any satellite information.
+     */
+    val carrierText: StateFlow<String?>
+}
+
 @OptIn(ExperimentalCoroutinesApi::class)
-class DeviceBasedSatelliteViewModel
+class DeviceBasedSatelliteViewModelImpl
 @Inject
 constructor(
+    context: Context,
     interactor: DeviceBasedSatelliteInteractor,
     @Application scope: CoroutineScope,
     airplaneModeRepository: AirplaneModeRepository,
     @OemSatelliteInputLog logBuffer: LogBuffer,
-) {
+) : DeviceBasedSatelliteViewModel {
     private val shouldShowIcon: Flow<Boolean> =
         interactor.areAllConnectionsOutOfService.flatMapLatest { allOos ->
             if (!allOos) {
@@ -87,7 +105,7 @@
             }
             .stateIn(scope, SharingStarted.WhileSubscribed(), false)
 
-    val icon: StateFlow<Icon?> =
+    override val icon: StateFlow<Icon?> =
         combine(
                 shouldActuallyShowIcon,
                 interactor.connectionState,
@@ -101,6 +119,26 @@
             }
             .stateIn(scope, SharingStarted.WhileSubscribed(), null)
 
+    override val carrierText: StateFlow<String?> =
+        combine(
+                shouldActuallyShowIcon,
+                interactor.connectionState,
+            ) { shouldShow, connectionState ->
+                if (shouldShow) {
+                    when (connectionState) {
+                        SatelliteConnectionState.Connected ->
+                            context.getString(R.string.satellite_connected_carrier_text)
+                        SatelliteConnectionState.On ->
+                            context.getString(R.string.satellite_not_connected_carrier_text)
+                        SatelliteConnectionState.Off,
+                        SatelliteConnectionState.Unknown -> null
+                    }
+                } else {
+                    null
+                }
+            }
+            .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
     companion object {
         private const val TAG = "DeviceBasedSatelliteViewModel"
         private val DELAY_DURATION = 10.seconds
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/CarrierTextManagerTest.java b/packages/SystemUI/tests/src/com/android/keyguard/CarrierTextManagerTest.java
index 303ae97..11d31c6 100644
--- a/packages/SystemUI/tests/src/com/android/keyguard/CarrierTextManagerTest.java
+++ b/packages/SystemUI/tests/src/com/android/keyguard/CarrierTextManagerTest.java
@@ -58,12 +58,15 @@
 import com.android.keyguard.logging.CarrierTextManagerLogger;
 import com.android.systemui.SysuiTestCase;
 import com.android.systemui.keyguard.WakefulnessLifecycle;
+import com.android.systemui.kosmos.KosmosJavaAdapter;
 import com.android.systemui.log.LogBufferHelperKt;
 import com.android.systemui.res.R;
+import com.android.systemui.statusbar.pipeline.satellite.ui.viewmodel.FakeDeviceBasedSatelliteViewModel;
 import com.android.systemui.statusbar.pipeline.wifi.data.repository.FakeWifiRepository;
 import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel;
 import com.android.systemui.telephony.TelephonyListenerManager;
 import com.android.systemui.util.concurrency.FakeExecutor;
+import com.android.systemui.util.kotlin.JavaAdapter;
 import com.android.systemui.util.time.FakeSystemClock;
 
 import org.junit.Before;
@@ -78,6 +81,8 @@
 import java.util.HashMap;
 import java.util.List;
 
+import kotlinx.coroutines.test.TestScope;
+
 @SmallTest
 @RunWith(AndroidTestingRunner.class)
 public class CarrierTextManagerTest extends SysuiTestCase {
@@ -99,6 +104,8 @@
             TEST_CARRIER, TEST_CARRIER, NAME_SOURCE_CARRIER_ID, 0xFFFFFF, "",
             DATA_ROAMING_ENABLE, null, null, null, null, false, null, "");
     private FakeWifiRepository mWifiRepository = new FakeWifiRepository();
+    private final FakeDeviceBasedSatelliteViewModel mSatelliteViewModel =
+            new FakeDeviceBasedSatelliteViewModel();
     @Mock
     private WakefulnessLifecycle mWakefulnessLifecycle;
     @Mock
@@ -124,6 +131,10 @@
             new CarrierTextManagerLogger(
                     LogBufferHelperKt.logcatLogBuffer("CarrierTextManagerLog"));
 
+    private final KosmosJavaAdapter mKosmos = new KosmosJavaAdapter(this);
+    private final TestScope mTestScope = mKosmos.getTestScope();
+    private final JavaAdapter mJavaAdapter = new JavaAdapter(mTestScope.getBackgroundScope());
+
     private Void checkMainThread(InvocationOnMock inv) {
         assertThat(mMainExecutor.isExecuting()).isTrue();
         assertThat(mBgExecutor.isExecuting()).isFalse();
@@ -156,9 +167,18 @@
         when(mTelephonyManager.getActiveModemCount()).thenReturn(3);
 
         mCarrierTextManager = new CarrierTextManager.Builder(
-                mContext, mContext.getResources(), mWifiRepository,
-                mTelephonyManager, mTelephonyListenerManager, mWakefulnessLifecycle, mMainExecutor,
-                mBgExecutor, mKeyguardUpdateMonitor, mLogger)
+                mContext,
+                mContext.getResources(),
+                mWifiRepository,
+                mSatelliteViewModel,
+                mJavaAdapter,
+                mTelephonyManager,
+                mTelephonyListenerManager,
+                mWakefulnessLifecycle,
+                mMainExecutor,
+                mBgExecutor,
+                mKeyguardUpdateMonitor,
+                mLogger)
                 .setShowAirplaneMode(true)
                 .setShowMissingSim(true)
                 .build();
@@ -211,6 +231,8 @@
                 getContextSpyForStickyBroadcast(stickyIntent),
                 mContext.getResources(),
                 mWifiRepository,
+                mSatelliteViewModel,
+                mJavaAdapter,
                 mTelephonyManager,
                 mTelephonyListenerManager,
                 mWakefulnessLifecycle,
@@ -451,6 +473,107 @@
     }
 
     @Test
+    public void carrierText_satelliteTextNull_notUsed() {
+        reset(mCarrierTextCallback);
+        List<SubscriptionInfo> list = new ArrayList<>();
+        list.add(TEST_SUBSCRIPTION);
+        when(mKeyguardUpdateMonitor.getSimState(anyInt())).thenReturn(
+                TelephonyManager.SIM_STATE_READY);
+        when(mKeyguardUpdateMonitor.getFilteredSubscriptionInfo()).thenReturn(list);
+        mKeyguardUpdateMonitor.mServiceStates = new HashMap<>();
+
+        // WHEN the satellite text is null
+        mSatelliteViewModel.getCarrierText().setValue(null);
+        mTestScope.getTestScheduler().runCurrent();
+
+        ArgumentCaptor<CarrierTextManager.CarrierTextCallbackInfo> captor =
+                ArgumentCaptor.forClass(
+                        CarrierTextManager.CarrierTextCallbackInfo.class);
+        FakeExecutor.exhaustExecutors(mMainExecutor, mBgExecutor);
+
+        // THEN the default subscription carrier text is used
+        verify(mCarrierTextCallback).updateCarrierInfo(captor.capture());
+        assertThat(captor.getValue().carrierText).isEqualTo(TEST_CARRIER);
+    }
+
+    @Test
+    public void carrierText_satelliteTextUpdates_autoTriggersCallback() {
+        reset(mCarrierTextCallback);
+        List<SubscriptionInfo> list = new ArrayList<>();
+        list.add(TEST_SUBSCRIPTION);
+        when(mKeyguardUpdateMonitor.getSimState(anyInt())).thenReturn(
+                TelephonyManager.SIM_STATE_READY);
+        when(mKeyguardUpdateMonitor.getFilteredSubscriptionInfo()).thenReturn(list);
+        mKeyguardUpdateMonitor.mServiceStates = new HashMap<>();
+
+        // WHEN the satellite text is set
+        mSatelliteViewModel.getCarrierText().setValue("Test satellite text");
+        mTestScope.getTestScheduler().runCurrent();
+
+        // THEN we should automatically re-trigger #updateCarrierText and get callback info
+        ArgumentCaptor<CarrierTextManager.CarrierTextCallbackInfo> captor =
+                ArgumentCaptor.forClass(
+                        CarrierTextManager.CarrierTextCallbackInfo.class);
+        FakeExecutor.exhaustExecutors(mMainExecutor, mBgExecutor);
+        verify(mCarrierTextCallback).updateCarrierInfo(captor.capture());
+        // AND use the satellite text as the carrier text
+        assertThat(captor.getValue().carrierText).isEqualTo("Test satellite text");
+
+        // WHEN the satellite text is reset to null
+        reset(mCarrierTextCallback);
+        mSatelliteViewModel.getCarrierText().setValue(null);
+        mTestScope.getTestScheduler().runCurrent();
+
+        // THEN we should automatically re-trigger #updateCarrierText and get callback info
+        // that doesn't include the satellite info
+        FakeExecutor.exhaustExecutors(mMainExecutor, mBgExecutor);
+        verify(mCarrierTextCallback).updateCarrierInfo(captor.capture());
+        assertThat(captor.getValue().carrierText).isEqualTo(TEST_CARRIER);
+    }
+
+    @Test
+    public void carrierText_updatedWhileNotListening_getsNewValueWhenListening() {
+        reset(mCarrierTextCallback);
+        List<SubscriptionInfo> list = new ArrayList<>();
+        list.add(TEST_SUBSCRIPTION);
+        when(mKeyguardUpdateMonitor.getSimState(anyInt())).thenReturn(
+                TelephonyManager.SIM_STATE_READY);
+        when(mKeyguardUpdateMonitor.getFilteredSubscriptionInfo()).thenReturn(list);
+        mKeyguardUpdateMonitor.mServiceStates = new HashMap<>();
+
+        mSatelliteViewModel.getCarrierText().setValue("Old satellite text");
+        mTestScope.getTestScheduler().runCurrent();
+
+        ArgumentCaptor<CarrierTextManager.CarrierTextCallbackInfo> captor =
+                ArgumentCaptor.forClass(
+                        CarrierTextManager.CarrierTextCallbackInfo.class);
+        FakeExecutor.exhaustExecutors(mMainExecutor, mBgExecutor);
+        verify(mCarrierTextCallback).updateCarrierInfo(captor.capture());
+        assertThat(captor.getValue().carrierText).isEqualTo("Old satellite text");
+
+        // WHEN we stop listening
+        reset(mCarrierTextCallback);
+        mCarrierTextManager.setListening(null);
+
+        // AND the satellite text updates
+        mSatelliteViewModel.getCarrierText().setValue("New satellite text");
+
+        // THEN we don't get new callback info because we aren't listening
+        verify(mCarrierTextCallback, never()).updateCarrierInfo(any());
+
+        // WHEN we start listening again
+        reset(mCarrierTextCallback);
+        mCarrierTextManager.setListening(mCarrierTextCallback);
+
+        // THEN we should automatically re-trigger #updateCarrierText and get callback info
+        // that includes the new satellite text
+        mTestScope.getTestScheduler().runCurrent();
+        FakeExecutor.exhaustExecutors(mMainExecutor, mBgExecutor);
+        verify(mCarrierTextCallback).updateCarrierInfo(captor.capture());
+        assertThat(captor.getValue().carrierText).isEqualTo("New satellite text");
+    }
+
+    @Test
     public void testCreateInfo_noSubscriptions() {
         reset(mCarrierTextCallback);
         when(mKeyguardUpdateMonitor.getFilteredSubscriptionInfo()).thenReturn(
@@ -471,6 +594,28 @@
     }
 
     @Test
+    public void testCarrierText_oneValidSubscription() {
+        reset(mCarrierTextCallback);
+        List<SubscriptionInfo> list = new ArrayList<>();
+        list.add(TEST_SUBSCRIPTION);
+        when(mKeyguardUpdateMonitor.getSimState(anyInt())).thenReturn(
+                TelephonyManager.SIM_STATE_READY);
+        when(mKeyguardUpdateMonitor.getFilteredSubscriptionInfo()).thenReturn(list);
+
+        mKeyguardUpdateMonitor.mServiceStates = new HashMap<>();
+
+        ArgumentCaptor<CarrierTextManager.CarrierTextCallbackInfo> captor =
+                ArgumentCaptor.forClass(
+                        CarrierTextManager.CarrierTextCallbackInfo.class);
+
+        mCarrierTextManager.updateCarrierText();
+        FakeExecutor.exhaustExecutors(mMainExecutor, mBgExecutor);
+        verify(mCarrierTextCallback).updateCarrierInfo(captor.capture());
+
+        assertThat(captor.getValue().carrierText).isEqualTo(TEST_CARRIER);
+    }
+
+    @Test
     public void testCarrierText_twoValidSubscriptions() {
         reset(mCarrierTextCallback);
         List<SubscriptionInfo> list = new ArrayList<>();
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt
index 64f19b6..8eea29b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt
@@ -21,11 +21,13 @@
 import com.android.systemui.common.shared.model.Icon
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.log.core.FakeLogBuffer
+import com.android.systemui.res.R
 import com.android.systemui.statusbar.pipeline.airplane.data.repository.FakeAirplaneModeRepository
 import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor
 import com.android.systemui.statusbar.pipeline.mobile.util.FakeMobileMappingsProxy
 import com.android.systemui.statusbar.pipeline.satellite.data.prod.FakeDeviceBasedSatelliteRepository
 import com.android.systemui.statusbar.pipeline.satellite.domain.interactor.DeviceBasedSatelliteInteractor
+import com.android.systemui.statusbar.pipeline.satellite.shared.model.SatelliteConnectionState
 import com.android.systemui.statusbar.pipeline.shared.data.repository.FakeConnectivityRepository
 import com.android.systemui.statusbar.pipeline.wifi.data.repository.FakeWifiRepository
 import com.android.systemui.statusbar.pipeline.wifi.domain.interactor.WifiInteractorImpl
@@ -76,7 +78,8 @@
             )
 
         underTest =
-            DeviceBasedSatelliteViewModel(
+            DeviceBasedSatelliteViewModelImpl(
+                context,
                 interactor,
                 testScope.backgroundScope,
                 airplaneModeRepository,
@@ -124,6 +127,7 @@
             assertThat(latest).isNull()
         }
 
+    @OptIn(ExperimentalCoroutinesApi::class)
     @Test
     fun icon_nullWhenShouldNotShow_isEmergencyOnly() =
         testScope.runTest {
@@ -298,4 +302,313 @@
             // THEN icon is set because the device lost wifi connection
             assertThat(latest).isInstanceOf(Icon::class.java)
         }
+
+    @Test
+    fun carrierText_nullWhenShouldNotShow_satelliteNotAllowed() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is not allowed
+            repo.isSatelliteAllowedForCurrentLocation.value = false
+
+            // GIVEN all icons are OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is disabled
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            // THEN carrier text is null because we should not be showing it
+            assertThat(latest).isNull()
+        }
+
+    @Test
+    fun carrierText_nullWhenShouldNotShow_notAllOos() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is allowed + connected
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // GIVEN all icons are not OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = true
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is disabled
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            // THEN carrier text is null because we have service
+            assertThat(latest).isNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_nullWhenShouldNotShow_isEmergencyOnly() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is allowed + connected
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // GIVEN all icons are OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is disabled
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            // THEN carrier text is set because we don't have service
+            assertThat(latest).isNotNull()
+
+            // GIVEN the connection is emergency only
+            i1.isEmergencyOnly.value = true
+
+            // THEN carrier text is null because we have emergency connection
+            assertThat(latest).isNull()
+        }
+
+    @Test
+    fun carrierText_nullWhenShouldNotShow_apmIsEnabled() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is allowed + connected
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // GIVEN all icons are OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is enabled
+            airplaneModeRepository.setIsAirplaneMode(true)
+
+            // THEN carrier text is null because we should not be showing it
+            assertThat(latest).isNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_satelliteIsOn() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is allowed + connected
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // GIVEN all icons are OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is disabled
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            // THEN carrier text is set because we don't have service
+            assertThat(latest).isNotNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_hysteresisWhenEnablingText() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is allowed + connected
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // GIVEN all icons are OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is disabled
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            // THEN carrier text is null because of the hysteresis
+            assertThat(latest).isNull()
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            // THEN carrier text is set after the delay
+            assertThat(latest).isNotNull()
+
+            // GIVEN apm is enabled
+            airplaneModeRepository.setIsAirplaneMode(true)
+
+            // THEN carrier text is null immediately
+            assertThat(latest).isNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_deviceIsProvisioned() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is allowed + connected
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // GIVEN all icons are OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is disabled
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            // GIVEN device is not provisioned
+            deviceProvisionedRepository.setDeviceProvisioned(false)
+
+            // THEN carrier text is null because the device is not provisioned
+            assertThat(latest).isNull()
+
+            // GIVEN device becomes provisioned
+            deviceProvisionedRepository.setDeviceProvisioned(true)
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            // THEN carrier text is null because the device is not provisioned
+            assertThat(latest).isNotNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_wifiIsActive() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // GIVEN satellite is allowed + connected
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // GIVEN all icons are OOS
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+
+            // GIVEN apm is disabled
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            // GIVEN device is provisioned
+            deviceProvisionedRepository.setDeviceProvisioned(true)
+
+            // GIVEN wifi network is active
+            wifiRepository.setWifiNetwork(WifiNetworkModel.Active(networkId = 0, level = 1))
+
+            // THEN carrier text is null because the device is connected to wifi
+            assertThat(latest).isNull()
+
+            // GIVEN device loses wifi connection
+            wifiRepository.setWifiNetwork(WifiNetworkModel.Invalid("test"))
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            // THEN carrier text is set because the device lost wifi connection
+            assertThat(latest).isNotNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_connectionStateUnknown_null() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // Set up the conditions for satellite to be enabled
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            repo.connectionState.value = SatelliteConnectionState.Unknown
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            assertThat(latest).isNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_connectionStateOff_null() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // Set up the conditions for satellite to be enabled
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            repo.connectionState.value = SatelliteConnectionState.Off
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            assertThat(latest).isNull()
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_connectionStateOn_notConnectedString() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // Set up the conditions for satellite to be enabled
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            repo.connectionState.value = SatelliteConnectionState.On
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            assertThat(latest)
+                .isEqualTo(context.getString(R.string.satellite_not_connected_carrier_text))
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun carrierText_connectionStateConnected_connectedString() =
+        testScope.runTest {
+            val latest by collectLastValue(underTest.carrierText)
+
+            // Set up the conditions for satellite to be enabled
+            repo.isSatelliteAllowedForCurrentLocation.value = true
+            val i1 = mobileIconsInteractor.getMobileConnectionInteractorForSubId(1)
+            i1.isInService.value = false
+            i1.isEmergencyOnly.value = false
+            airplaneModeRepository.setIsAirplaneMode(false)
+
+            repo.connectionState.value = SatelliteConnectionState.Connected
+
+            // Wait for delay to be completed
+            advanceTimeBy(10.seconds)
+
+            assertThat(latest)
+                .isEqualTo(context.getString(R.string.satellite_connected_carrier_text))
+        }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/FakeDeviceBasedSatelliteViewModel.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/FakeDeviceBasedSatelliteViewModel.kt
new file mode 100644
index 0000000..f125ef12
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/FakeDeviceBasedSatelliteViewModel.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.satellite.ui.viewmodel
+
+import com.android.systemui.common.shared.model.Icon
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeDeviceBasedSatelliteViewModel : DeviceBasedSatelliteViewModel {
+    override val icon = MutableStateFlow<Icon?>(null)
+    override val carrierText = MutableStateFlow<String?>(null)
+}