diff options
44 files changed, 2208 insertions, 410 deletions
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index 604b37da600d..0caea7f8084c 100644 --- a/core/java/android/app/Notification.java +++ b/core/java/android/app/Notification.java @@ -6595,6 +6595,11 @@ public class Notification implements Parcelable * @hide */ public RemoteViews createCompactHeadsUpContentView() { + // Don't show compact heads up for FSI notifications. + if (mN.fullScreenIntent != null) { + return createHeadsUpContentView(/* increasedHeight= */ false); + } + if (mStyle != null) { final RemoteViews styleView = mStyle.makeCompactHeadsUpContentView(); if (styleView != null) { @@ -10352,7 +10357,7 @@ public class Notification implements Parcelable @Nullable @Override public RemoteViews makeCompactHeadsUpContentView() { - // TODO(b/336228700): Apply minimal HUN treatment for Call Style. + // Use existing heads up for call style. return makeHeadsUpContentView(false); } diff --git a/ktfmt_includes.txt b/ktfmt_includes.txt index fe4750381fd0..0ac6265ecf27 100644 --- a/ktfmt_includes.txt +++ b/ktfmt_includes.txt @@ -5,8 +5,6 @@ -packages/SystemUI/checks/src/com/android/internal/systemui/lint/BroadcastSentViaContextDetector.kt -packages/SystemUI/checks/src/com/android/internal/systemui/lint/RegisterReceiverViaContextDetector.kt -packages/SystemUI/checks/src/com/android/internal/systemui/lint/SoftwareBitmapDetector.kt --packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt --packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt -packages/SystemUI/monet/src/com/android/systemui/monet/ColorScheme.kt -packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/View.kt -packages/SystemUI/shared/src/com/android/systemui/flags/Flag.kt diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java index 3c788b18429c..7bceb2ce99b0 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java @@ -2341,8 +2341,8 @@ public class BubbleStackView extends FrameLayout showScrim(true, null /* runnable */); updateBubbleShadows(mIsExpanded); - updateBadges(false /* setBadgeForCollapsedStack */); mBubbleContainer.setActiveController(mExpandedAnimationController); + updateBadges(false /* setBadgeForCollapsedStack */); updateOverflowVisibility(); updatePointerPosition(false /* forIme */); mExpandedAnimationController.expandFromStack(() -> { diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java index 9bf9fa749373..b41454d932a5 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java @@ -65,7 +65,7 @@ public class DesktopModeStatus { "persist.wm.debug.desktop_use_window_shadows_focused_window", false); /** - * Flag to indicate whether to apply shadows to windows in desktop mode. + * Flag to indicate whether to use rounded corners for windows in desktop mode. */ private static final boolean USE_ROUNDED_CORNERS = SystemProperties.getBoolean( "persist.wm.debug.desktop_use_rounded_corners", true); diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt index f539a23d9cb0..bdeab797d165 100644 --- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt @@ -28,16 +28,16 @@ import android.text.format.DateFormat import android.util.AttributeSet import android.util.MathUtils.constrainedMap import android.util.TypedValue -import android.view.View.MeasureSpec.EXACTLY import android.view.View +import android.view.View.MeasureSpec.EXACTLY import android.widget.TextView import com.android.app.animation.Interpolators import com.android.internal.annotations.VisibleForTesting import com.android.systemui.animation.GlyphCallback import com.android.systemui.animation.TextAnimator import com.android.systemui.customization.R -import com.android.systemui.log.core.LogcatOnlyMessageBuffer import com.android.systemui.log.core.LogLevel +import com.android.systemui.log.core.LogcatOnlyMessageBuffer import com.android.systemui.log.core.Logger import com.android.systemui.log.core.MessageBuffer import java.io.PrintWriter @@ -47,11 +47,13 @@ import java.util.TimeZone import kotlin.math.min /** - * Displays the time with the hour positioned above the minutes. (ie: 09 above 30 is 9:30) - * The time's text color is a gradient that changes its colors based on its controller. + * Displays the time with the hour positioned above the minutes (ie: 09 above 30 is 9:30). The + * time's text color is a gradient that changes its colors based on its controller. */ @SuppressLint("AppCompatCustomView") -class AnimatableClockView @JvmOverloads constructor( +class AnimatableClockView +@JvmOverloads +constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, @@ -63,7 +65,9 @@ class AnimatableClockView @JvmOverloads constructor( get() = field ?: DEFAULT_LOGGER var messageBuffer: MessageBuffer get() = logger.buffer - set(value) { logger = Logger(value, TAG) } + set(value) { + logger = Logger(value, TAG) + } var hasCustomPositionUpdatedAnimation: Boolean = false var migratedClocks: Boolean = false @@ -77,16 +81,13 @@ class AnimatableClockView @JvmOverloads constructor( private var format: CharSequence? = null private var descFormat: CharSequence? = null - @ColorInt - private var dozingColor = 0 - - @ColorInt - private var lockScreenColor = 0 + @ColorInt private var dozingColor = 0 + @ColorInt private var lockScreenColor = 0 private var lineSpacingScale = 1f private val chargeAnimationDelay: Int private var textAnimator: TextAnimator? = null - private var onTextAnimatorInitialized: Runnable? = null + private var onTextAnimatorInitialized: ((TextAnimator) -> Unit)? = null private var translateForCenterAnimation = false private val parentWidth: Int @@ -94,9 +95,11 @@ class AnimatableClockView @JvmOverloads constructor( // last text size which is not constrained by view height private var lastUnconstrainedTextSize: Float = Float.MAX_VALUE - @VisibleForTesting var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = - { layout, invalidateCb -> - TextAnimator(layout, NUM_CLOCK_FONT_ANIMATION_STEPS, invalidateCb) } + + @VisibleForTesting + var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = { layout, invalidateCb -> + TextAnimator(layout, NUM_CLOCK_FONT_ANIMATION_STEPS, invalidateCb) + } // Used by screenshot tests to provide stability @VisibleForTesting var isAnimationEnabled: Boolean = true @@ -109,40 +112,55 @@ class AnimatableClockView @JvmOverloads constructor( get() = if (useBoldedVersion()) lockScreenWeightInternal + 100 else lockScreenWeightInternal /** - * The number of pixels below the baseline. For fonts that support languages such as - * Burmese, this space can be significant and should be accounted for when computing layout. + * The number of pixels below the baseline. For fonts that support languages such as Burmese, + * this space can be significant and should be accounted for when computing layout. */ - val bottom get() = paint?.fontMetrics?.bottom ?: 0f + val bottom: Float + get() = paint?.fontMetrics?.bottom ?: 0f init { - val animatableClockViewAttributes = context.obtainStyledAttributes( - attrs, R.styleable.AnimatableClockView, defStyleAttr, defStyleRes - ) + val animatableClockViewAttributes = + context.obtainStyledAttributes( + attrs, + R.styleable.AnimatableClockView, + defStyleAttr, + defStyleRes + ) try { - dozingWeightInternal = animatableClockViewAttributes.getInt( - R.styleable.AnimatableClockView_dozeWeight, - /* default = */ 100 - ) - lockScreenWeightInternal = animatableClockViewAttributes.getInt( - R.styleable.AnimatableClockView_lockScreenWeight, - /* default = */ 300 - ) - chargeAnimationDelay = animatableClockViewAttributes.getInt( - R.styleable.AnimatableClockView_chargeAnimationDelay, /* default = */ 200 - ) + dozingWeightInternal = + animatableClockViewAttributes.getInt( + R.styleable.AnimatableClockView_dozeWeight, + /* default = */ 100 + ) + lockScreenWeightInternal = + animatableClockViewAttributes.getInt( + R.styleable.AnimatableClockView_lockScreenWeight, + /* default = */ 300 + ) + chargeAnimationDelay = + animatableClockViewAttributes.getInt( + R.styleable.AnimatableClockView_chargeAnimationDelay, + /* default = */ 200 + ) } finally { animatableClockViewAttributes.recycle() } - val textViewAttributes = context.obtainStyledAttributes( - attrs, android.R.styleable.TextView, - defStyleAttr, defStyleRes - ) + val textViewAttributes = + context.obtainStyledAttributes( + attrs, + android.R.styleable.TextView, + defStyleAttr, + defStyleRes + ) try { - isSingleLineInternal = textViewAttributes.getBoolean( - android.R.styleable.TextView_singleLine, /* default = */ false) + isSingleLineInternal = + textViewAttributes.getBoolean( + android.R.styleable.TextView_singleLine, + /* default = */ false + ) } finally { textViewAttributes.recycle() } @@ -156,9 +174,7 @@ class AnimatableClockView @JvmOverloads constructor( refreshFormat() } - /** - * Whether to use a bolded version based on the user specified fontWeightAdjustment. - */ + /** Whether to use a bolded version based on the user specified fontWeightAdjustment. */ fun useBoldedVersion(): Boolean { // "Bold text" fontWeightAdjustment is 300. return resources.configuration.fontWeightAdjustment > 100 @@ -169,25 +185,30 @@ class AnimatableClockView @JvmOverloads constructor( contentDescription = DateFormat.format(descFormat, time) val formattedText = DateFormat.format(format, time) logger.d({ "refreshTime: new formattedText=$str1" }) { str1 = formattedText?.toString() } - // Setting text actually triggers a layout pass (because the text view is set to - // wrap_content width and TextView always relayouts for this). Avoid needless - // relayout if the text didn't actually change. - if (!TextUtils.equals(text, formattedText)) { - text = formattedText - logger.d({ "refreshTime: done setting new time text to: $str1" }) { - str1 = formattedText?.toString() - } - // Because the TextLayout may mutate under the hood as a result of the new text, we - // notify the TextAnimator that it may have changed and request a measure/layout. A - // crash will occur on the next invocation of setTextStyle if the layout is mutated - // without being notified TextInterpolator being notified. - if (layout != null) { - textAnimator?.updateLayout(layout) - logger.d("refreshTime: done updating textAnimator layout") - } - requestLayout() - logger.d("refreshTime: after requestLayout") + + // Setting text actually triggers a layout pass in TextView (because the text view is set to + // wrap_content width and TextView always relayouts for this). This avoids needless relayout + // if the text didn't actually change. + if (TextUtils.equals(text, formattedText)) { + return } + + text = formattedText + logger.d({ "refreshTime: done setting new time text to: $str1" }) { + str1 = formattedText?.toString() + } + + // Because the TextLayout may mutate under the hood as a result of the new text, we notify + // the TextAnimator that it may have changed and request a measure/layout. A crash will + // occur on the next invocation of setTextStyle if the layout is mutated without being + // notified TextInterpolator being notified. + if (layout != null) { + textAnimator?.updateLayout(layout) + logger.d("refreshTime: done updating textAnimator layout") + } + + requestLayout() + logger.d("refreshTime: after requestLayout") } fun onTimeZoneChanged(timeZone: TimeZone?) { @@ -206,19 +227,27 @@ class AnimatableClockView @JvmOverloads constructor( @SuppressLint("DrawAllocation") override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { logger.d("onMeasure") - if (migratedClocks && !isSingleLineInternal && - MeasureSpec.getMode(heightMeasureSpec) == EXACTLY) { + + if ( + migratedClocks && + !isSingleLineInternal && + MeasureSpec.getMode(heightMeasureSpec) == EXACTLY + ) { // Call straight into TextView.setTextSize to avoid setting lastUnconstrainedTextSize - super.setTextSize(TypedValue.COMPLEX_UNIT_PX, - min(lastUnconstrainedTextSize, MeasureSpec.getSize(heightMeasureSpec) / 2F)) + super.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + min(lastUnconstrainedTextSize, MeasureSpec.getSize(heightMeasureSpec) / 2F) + ) } super.onMeasure(widthMeasureSpec, heightMeasureSpec) val animator = textAnimator if (animator == null) { - textAnimator = textAnimatorFactory(layout, ::invalidate) - onTextAnimatorInitialized?.run() - onTextAnimatorInitialized = null + textAnimator = + textAnimatorFactory(layout, ::invalidate)?.also { + onTextAnimatorInitialized?.invoke(it) + onTextAnimatorInitialized = null + } } else { animator.updateLayout(layout) } @@ -243,15 +272,13 @@ class AnimatableClockView @JvmOverloads constructor( canvas.translate(parentWidth / 4f, 0f) } - logger.d({ "onDraw($str1)"}) { str1 = text.toString() } + logger.d({ "onDraw($str1)" }) { str1 = text.toString() } // intentionally doesn't call super.onDraw here or else the text will be rendered twice textAnimator?.draw(canvas) canvas.restore() } override fun invalidate() { - @Suppress("UNNECESSARY_SAFE_CALL") - // logger won't be initialized when called by TextView's constructor logger.d("invalidate") super.invalidate() } @@ -325,6 +352,7 @@ class AnimatableClockView @JvmOverloads constructor( if (textAnimator == null) { return } + logger.d("animateFoldAppear") setTextStyle( weight = lockScreenWeightInternal, @@ -348,10 +376,11 @@ class AnimatableClockView @JvmOverloads constructor( } fun animateCharge(isDozing: () -> Boolean) { + // Skip charge animation if dozing animation is already playing. if (textAnimator == null || textAnimator!!.isRunning()) { - // Skip charge animation if dozing animation is already playing. return } + logger.d("animateCharge") val startAnimPhase2 = Runnable { setTextStyle( @@ -409,10 +438,9 @@ class AnimatableClockView @JvmOverloads constructor( /** * Set text style with an optional animation. - * - * By passing -1 to weight, the view preserves its current weight. - * By passing -1 to textSize, the view preserves its current text size. - * By passing null to color, the view preserves its current color. + * - By passing -1 to weight, the view preserves its current weight. + * - By passing -1 to textSize, the view preserves its current text size. + * - By passing null to color, the view preserves its current color. * * @param weight text weight. * @param textSize font size. @@ -428,8 +456,8 @@ class AnimatableClockView @JvmOverloads constructor( delay: Long, onAnimationEnd: Runnable? ) { - if (textAnimator != null) { - textAnimator?.setTextStyle( + textAnimator?.let { + it.setTextStyle( weight = weight, textSize = textSize, color = color, @@ -439,23 +467,24 @@ class AnimatableClockView @JvmOverloads constructor( delay = delay, onAnimationEnd = onAnimationEnd ) - textAnimator?.glyphFilter = glyphFilter - } else { - // when the text animator is set, update its start values - onTextAnimatorInitialized = Runnable { - textAnimator?.setTextStyle( - weight = weight, - textSize = textSize, - color = color, - animate = false, - duration = duration, - interpolator = interpolator, - delay = delay, - onAnimationEnd = onAnimationEnd - ) - textAnimator?.glyphFilter = glyphFilter - } + it.glyphFilter = glyphFilter } + ?: run { + // when the text animator is set, update its start values + onTextAnimatorInitialized = { textAnimator -> + textAnimator.setTextStyle( + weight = weight, + textSize = textSize, + color = color, + animate = false, + duration = duration, + interpolator = interpolator, + delay = delay, + onAnimationEnd = onAnimationEnd + ) + textAnimator.glyphFilter = glyphFilter + } + } } private fun setTextStyle( @@ -483,12 +512,13 @@ class AnimatableClockView @JvmOverloads constructor( fun refreshFormat(use24HourFormat: Boolean) { Patterns.update(context) - format = when { - isSingleLineInternal && use24HourFormat -> Patterns.sClockView24 - !isSingleLineInternal && use24HourFormat -> DOUBLE_LINE_FORMAT_24_HOUR - isSingleLineInternal && !use24HourFormat -> Patterns.sClockView12 - else -> DOUBLE_LINE_FORMAT_12_HOUR - } + format = + when { + isSingleLineInternal && use24HourFormat -> Patterns.sClockView24 + !isSingleLineInternal && use24HourFormat -> DOUBLE_LINE_FORMAT_24_HOUR + isSingleLineInternal && !use24HourFormat -> Patterns.sClockView12 + else -> DOUBLE_LINE_FORMAT_12_HOUR + } logger.d({ "refreshFormat($str1)" }) { str1 = format?.toString() } descFormat = if (use24HourFormat) Patterns.sClockView24 else Patterns.sClockView12 @@ -510,10 +540,10 @@ class AnimatableClockView @JvmOverloads constructor( pw.println(" time=$time") } - private val moveToCenterDelays + private val moveToCenterDelays: List<Int> get() = if (isLayoutRtl) MOVE_LEFT_DELAYS else MOVE_RIGHT_DELAYS - private val moveToSideDelays + private val moveToSideDelays: List<Int> get() = if (isLayoutRtl) MOVE_RIGHT_DELAYS else MOVE_LEFT_DELAYS /** @@ -531,7 +561,7 @@ class AnimatableClockView @JvmOverloads constructor( fun offsetGlyphsForStepClockAnimation( clockStartLeft: Int, clockMoveDirection: Int, - moveFraction: Float + moveFraction: Float, ) { val isMovingToCenter = if (isLayoutRtl) clockMoveDirection < 0 else clockMoveDirection > 0 val currentMoveAmount = left - clockStartLeft @@ -558,8 +588,8 @@ class AnimatableClockView @JvmOverloads constructor( * * @param distance is the total distance in pixels to offset the glyphs when animation * completes. Negative distance means we are animating the position towards the center. - * @param fraction fraction of the clock movement. 0 means it is at the beginning, and 1 - * means it finished moving. + * @param fraction fraction of the clock movement. 0 means it is at the beginning, and 1 means + * it finished moving. */ fun offsetGlyphsForStepClockAnimation( distance: Float, @@ -568,13 +598,17 @@ class AnimatableClockView @JvmOverloads constructor( for (i in 0 until NUM_DIGITS) { val dir = if (isLayoutRtl) -1 else 1 val digitFraction = - getDigitFraction(digit = i, isMovingToCenter = distance > 0, fraction = fraction) + getDigitFraction( + digit = i, + isMovingToCenter = distance > 0, + fraction = fraction, + ) val moveAmountForDigit = dir * distance * digitFraction glyphOffsets[i] = moveAmountForDigit if (distance > 0) { - // If distance > 0 then we are moving from the left towards the center. - // We need ensure that the glyphs are offset to the initial position. + // If distance > 0 then we are moving from the left towards the center. We need to + // ensure that the glyphs are offset to the initial position. glyphOffsets[i] -= dir * distance } } @@ -582,27 +616,25 @@ class AnimatableClockView @JvmOverloads constructor( } private fun getDigitFraction(digit: Int, isMovingToCenter: Boolean, fraction: Float): Float { - // The delay for the digit, in terms of fraction (i.e. the digit should not move - // during 0.0 - 0.1). - val digitInitialDelay = - if (isMovingToCenter) { - moveToCenterDelays[digit] * MOVE_DIGIT_STEP - } else { - moveToSideDelays[digit] * MOVE_DIGIT_STEP - } + // The delay for the digit, in terms of fraction. + // (i.e. the digit should not move during 0.0 - 0.1). + val delays = if (isMovingToCenter) moveToCenterDelays else moveToSideDelays + val digitInitialDelay = delays[digit] * MOVE_DIGIT_STEP return MOVE_INTERPOLATOR.getInterpolation( - constrainedMap( - 0.0f, - 1.0f, - digitInitialDelay, - digitInitialDelay + AVAILABLE_ANIMATION_TIME, - fraction, - ) + constrainedMap( + /* rangeMin= */ 0.0f, + /* rangeMax= */ 1.0f, + /* valueMin= */ digitInitialDelay, + /* valueMax= */ digitInitialDelay + AVAILABLE_ANIMATION_TIME, + /* value= */ fraction, ) + ) } - // DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often. - // This is an optimization to ensure we only recompute the patterns when the inputs change. + /** + * DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often. This + * is a cache optimization to ensure we only recompute the patterns when the inputs change. + */ private object Patterns { var sClockView12: String? = null var sClockView24: String? = null @@ -610,21 +642,22 @@ class AnimatableClockView @JvmOverloads constructor( fun update(context: Context) { val locale = Locale.getDefault() - val res = context.resources - val clockView12Skel = res.getString(R.string.clock_12hr_format) - val clockView24Skel = res.getString(R.string.clock_24hr_format) - val key = locale.toString() + clockView12Skel + clockView24Skel - if (key == sCacheKey) return - - val clockView12 = DateFormat.getBestDateTimePattern(locale, clockView12Skel) - sClockView12 = clockView12 - - // CLDR insists on adding an AM/PM indicator even though it wasn't in the skeleton - // format. The following code removes the AM/PM indicator if we didn't want it. - if (!clockView12Skel.contains("a")) { - sClockView12 = clockView12.replace("a".toRegex(), "").trim { it <= ' ' } + val clockView12Skel = context.resources.getString(R.string.clock_12hr_format) + val clockView24Skel = context.resources.getString(R.string.clock_24hr_format) + val key = "$locale$clockView12Skel$clockView24Skel" + if (key == sCacheKey) { + return } + sClockView12 = + DateFormat.getBestDateTimePattern(locale, clockView12Skel).let { + // CLDR insists on adding an AM/PM indicator even though it wasn't in the format + // string. The following code removes the AM/PM indicator if we didn't want it. + if (!clockView12Skel.contains("a")) { + it.replace("a".toRegex(), "").trim { it <= ' ' } + } else it + } + sClockView24 = DateFormat.getBestDateTimePattern(locale, clockView24Skel) sCacheKey = key } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt index 3d8159e70061..9c9ee53d9c56 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt @@ -24,7 +24,6 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues import com.android.systemui.util.settings.FakeSettings -import com.google.common.truth.Truth import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -66,7 +65,7 @@ class ColorInversionRepositoryImplTest : SysuiTestCase() { runCurrent() - Truth.assertThat(actualValue).isFalse() + assertThat(actualValue).isFalse() } @Test diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/NightDisplayRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/NightDisplayRepositoryTest.kt new file mode 100644 index 000000000000..ca824cbdd53b --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/NightDisplayRepositoryTest.kt @@ -0,0 +1,203 @@ +/* + * 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.accessibility.data.repository + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.provider.Settings +import android.testing.LeakCheck +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.mockito.argumentCaptor +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.settings.fakeGlobalSettings +import com.android.systemui.util.settings.fakeSettings +import com.android.systemui.utils.leaks.FakeLocationController +import com.google.common.truth.Truth.assertThat +import java.time.LocalTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.verify + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayRepositoryTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val testUser = UserHandle.of(1)!! + private val testStartTime = LocalTime.MIDNIGHT + private val testEndTime = LocalTime.NOON + private val colorDisplayManager = + mock<ColorDisplayManager> { + whenever(nightDisplayAutoMode).thenReturn(ColorDisplayManager.AUTO_MODE_DISABLED) + whenever(isNightDisplayActivated).thenReturn(false) + whenever(nightDisplayCustomStartTime).thenReturn(testStartTime) + whenever(nightDisplayCustomEndTime).thenReturn(testEndTime) + } + private val locationController = FakeLocationController(LeakCheck()) + private val nightDisplayListener = mock<NightDisplayListener>() + private val listenerBuilder = + mock<NightDisplayListenerModule.Builder> { + whenever(setUser(ArgumentMatchers.anyInt())).thenReturn(this) + whenever(build()).thenReturn(nightDisplayListener) + } + private val globalSettings = kosmos.fakeGlobalSettings + private val secureSettings = kosmos.fakeSettings + private val testDispatcher = StandardTestDispatcher() + private val scope = TestScope(testDispatcher) + private val userScopedColorDisplayManager = + mock<UserScopedService<ColorDisplayManager>> { + whenever(forUser(eq(testUser))).thenReturn(colorDisplayManager) + } + + private val underTest = + NightDisplayRepository( + testDispatcher, + scope.backgroundScope, + globalSettings, + secureSettings, + listenerBuilder, + userScopedColorDisplayManager, + locationController, + ) + + @Before + fun setup() { + enrollInForcedNightDisplayAutoMode(INITIALLY_FORCE_AUTO_MODE, testUser) + } + + @Test + fun nightDisplayState_matchesAutoMode() = + scope.runTest { + enrollInForcedNightDisplayAutoMode(INITIALLY_FORCE_AUTO_MODE, testUser) + val callbackCaptor = argumentCaptor<NightDisplayListener.Callback>() + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + + runCurrent() + + verify(nightDisplayListener).setCallback(callbackCaptor.capture()) + val callback = callbackCaptor.value + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_DISABLED) + + callback.onAutoModeChanged(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + + callback.onCustomStartTimeChanged(testStartTime) + assertThat(lastState!!.startTime).isEqualTo(testStartTime) + + callback.onCustomEndTimeChanged(testEndTime) + assertThat(lastState!!.endTime).isEqualTo(testEndTime) + + callback.onAutoModeChanged(ColorDisplayManager.AUTO_MODE_TWILIGHT) + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_TWILIGHT) + } + + @Test + fun nightDisplayState_matchesIsNightDisplayActivated() = + scope.runTest { + val callbackCaptor = argumentCaptor<NightDisplayListener.Callback>() + + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + verify(nightDisplayListener).setCallback(callbackCaptor.capture()) + val callback = callbackCaptor.value + assertThat(lastState!!.isActivated) + .isEqualTo(colorDisplayManager.isNightDisplayActivated) + + callback.onActivated(true) + assertThat(lastState!!.isActivated).isTrue() + + callback.onActivated(false) + assertThat(lastState!!.isActivated).isFalse() + } + + @Test + fun nightDisplayState_matchesController_initiallyCustomAutoMode() = + scope.runTest { + whenever(colorDisplayManager.nightDisplayAutoMode) + .thenReturn(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + } + + @Test + fun nightDisplayState_matchesController_initiallyTwilightAutoMode() = + scope.runTest { + whenever(colorDisplayManager.nightDisplayAutoMode) + .thenReturn(ColorDisplayManager.AUTO_MODE_TWILIGHT) + + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_TWILIGHT) + } + + @Test + fun nightDisplayState_matchesForceAutoMode() = + scope.runTest { + enrollInForcedNightDisplayAutoMode(false, testUser) + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + assertThat(lastState!!.shouldForceAutoMode).isEqualTo(false) + + enrollInForcedNightDisplayAutoMode(true, testUser) + assertThat(lastState!!.shouldForceAutoMode).isEqualTo(true) + } + + private fun enrollInForcedNightDisplayAutoMode(enroll: Boolean, userHandle: UserHandle) { + globalSettings.putString( + Settings.Global.NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE, + if (enroll) NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE + else NIGHT_DISPLAY_FORCED_AUTO_MODE_UNAVAILABLE + ) + secureSettings.putIntForUser( + Settings.Secure.NIGHT_DISPLAY_AUTO_MODE, + if (enroll) NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET else NIGHT_DISPLAY_AUTO_MODE_RAW_SET, + userHandle.identifier + ) + } + + private companion object { + const val INITIALLY_FORCE_AUTO_MODE = false + const val NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE = "1" + const val NIGHT_DISPLAY_FORCED_AUTO_MODE_UNAVAILABLE = "0" + const val NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET = -1 + const val NIGHT_DISPLAY_AUTO_MODE_RAW_SET = 0 + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractorTest.kt new file mode 100644 index 000000000000..a0aa2d4a9a6c --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractorTest.kt @@ -0,0 +1,98 @@ +/* + * 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.qs.tiles.impl.night.domain.interactor + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.testing.LeakCheck +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.settings.fakeGlobalSettings +import com.android.systemui.util.settings.fakeSettings +import com.android.systemui.util.time.DateFormatUtil +import com.android.systemui.utils.leaks.FakeLocationController +import com.google.common.truth.Truth.assertThat +import java.time.LocalTime +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt + +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayTileDataInteractorTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val testUser = UserHandle.of(1)!! + private val testStartTime = LocalTime.MIDNIGHT + private val testEndTime = LocalTime.NOON + private val colorDisplayManager = + mock<ColorDisplayManager> { + whenever(nightDisplayAutoMode).thenReturn(ColorDisplayManager.AUTO_MODE_DISABLED) + whenever(isNightDisplayActivated).thenReturn(false) + whenever(nightDisplayCustomStartTime).thenReturn(testStartTime) + whenever(nightDisplayCustomEndTime).thenReturn(testEndTime) + } + private val locationController = FakeLocationController(LeakCheck()) + private val nightDisplayListener = mock<NightDisplayListener>() + private val listenerBuilder = + mock<NightDisplayListenerModule.Builder> { + whenever(setUser(anyInt())).thenReturn(this) + whenever(build()).thenReturn(nightDisplayListener) + } + private val globalSettings = kosmos.fakeGlobalSettings + private val secureSettings = kosmos.fakeSettings + private val dateFormatUtil = mock<DateFormatUtil> { whenever(is24HourFormat).thenReturn(false) } + private val testDispatcher = StandardTestDispatcher() + private val scope = TestScope(testDispatcher) + private val userScopedColorDisplayManager = + mock<UserScopedService<ColorDisplayManager>> { + whenever(forUser(eq(testUser))).thenReturn(colorDisplayManager) + } + private val nightDisplayRepository = + NightDisplayRepository( + testDispatcher, + scope.backgroundScope, + globalSettings, + secureSettings, + listenerBuilder, + userScopedColorDisplayManager, + locationController, + ) + + private val underTest: NightDisplayTileDataInteractor = + NightDisplayTileDataInteractor(context, dateFormatUtil, nightDisplayRepository) + + @Test + fun availability_matchesColorDisplayManager() = runTest { + val availability by collectLastValue(underTest.availability(testUser)) + + val expectedAvailability = ColorDisplayManager.isNightDisplayAvailable(context) + assertThat(availability).isEqualTo(expectedAvailability) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractorTest.kt new file mode 100644 index 000000000000..adc8bcba5a5c --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractorTest.kt @@ -0,0 +1,177 @@ +/* + * 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.qs.tiles.impl.night.domain.interactor + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.provider.Settings +import android.testing.LeakCheck +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.tiles.base.actions.FakeQSTileIntentUserInputHandler +import com.android.systemui.qs.tiles.base.actions.intentInputs +import com.android.systemui.qs.tiles.base.interactor.QSTileInputTestKtx +import com.android.systemui.qs.tiles.impl.custom.qsTileLogger +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.settings.fakeGlobalSettings +import com.android.systemui.util.settings.fakeSettings +import com.android.systemui.utils.leaks.FakeLocationController +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.verify + +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayTileUserActionInteractorTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val qsTileIntentUserActionHandler = FakeQSTileIntentUserInputHandler() + private val testUser = UserHandle.of(1) + private val colorDisplayManager = + mock<ColorDisplayManager> { + whenever(nightDisplayAutoMode).thenReturn(ColorDisplayManager.AUTO_MODE_DISABLED) + whenever(isNightDisplayActivated).thenReturn(false) + } + private val locationController = FakeLocationController(LeakCheck()) + private val nightDisplayListener = mock<NightDisplayListener>() + private val listenerBuilder = + mock<NightDisplayListenerModule.Builder> { + whenever(setUser(ArgumentMatchers.anyInt())).thenReturn(this) + whenever(build()).thenReturn(nightDisplayListener) + } + private val globalSettings = kosmos.fakeGlobalSettings + private val secureSettings = kosmos.fakeSettings + private val testDispatcher = StandardTestDispatcher() + private val scope = TestScope(testDispatcher) + private val userScopedColorDisplayManager = + mock<UserScopedService<ColorDisplayManager>> { + whenever(forUser(eq(testUser))).thenReturn(colorDisplayManager) + } + private val nightDisplayRepository = + NightDisplayRepository( + testDispatcher, + scope.backgroundScope, + globalSettings, + secureSettings, + listenerBuilder, + userScopedColorDisplayManager, + locationController, + ) + + private val underTest = + NightDisplayTileUserActionInteractor( + nightDisplayRepository, + qsTileIntentUserActionHandler, + kosmos.qsTileLogger + ) + + @Test + fun handleClick_inactive_activates() = + scope.runTest { + val startingModel = NightDisplayTileModel.AutoModeOff(false, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(true) + } + + @Test + fun handleClick_active_disables() = + scope.runTest { + val startingModel = NightDisplayTileModel.AutoModeOff(true, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(false) + } + + @Test + fun handleClick_whenAutoModeTwilight_flipsState() = + scope.runTest { + val originalState = true + val startingModel = NightDisplayTileModel.AutoModeTwilight(originalState, false, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(!originalState) + } + + @Test + fun handleClick_whenAutoModeCustom_flipsState() = + scope.runTest { + val originalState = true + val startingModel = + NightDisplayTileModel.AutoModeCustom(originalState, false, null, null, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(!originalState) + } + + @Test + fun handleLongClickWhenEnabled() = + scope.runTest { + val enabledState = true + + underTest.handleInput( + QSTileInputTestKtx.longClick( + NightDisplayTileModel.AutoModeOff(enabledState, false), + testUser + ) + ) + + assertThat(qsTileIntentUserActionHandler.handledInputs).hasSize(1) + + val intentInput = qsTileIntentUserActionHandler.intentInputs.last() + val actualIntentAction = intentInput.intent.action + val expectedIntentAction = Settings.ACTION_NIGHT_DISPLAY_SETTINGS + assertThat(actualIntentAction).isEqualTo(expectedIntentAction) + } + + @Test + fun handleLongClickWhenDisabled() = + scope.runTest { + val enabledState = false + + underTest.handleInput( + QSTileInputTestKtx.longClick( + NightDisplayTileModel.AutoModeOff(enabledState, false), + testUser + ) + ) + + assertThat(qsTileIntentUserActionHandler.handledInputs).hasSize(1) + + val intentInput = qsTileIntentUserActionHandler.intentInputs.last() + val actualIntentAction = intentInput.intent.action + val expectedIntentAction = Settings.ACTION_NIGHT_DISPLAY_SETTINGS + assertThat(actualIntentAction).isEqualTo(expectedIntentAction) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapperTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapperTest.kt new file mode 100644 index 000000000000..5d2e7013c2f4 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapperTest.kt @@ -0,0 +1,315 @@ +/* + * 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.qs.tiles.impl.night.ui + +import android.graphics.drawable.TestStubDrawable +import android.service.quicksettings.Tile +import android.text.TextUtils +import android.widget.Switch +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.tiles.base.logging.QSTileLogger +import com.android.systemui.qs.tiles.impl.custom.QSTileStateSubject +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.impl.night.qsNightDisplayTileConfig +import com.android.systemui.qs.tiles.viewmodel.QSTileState +import com.android.systemui.res.R +import com.android.systemui.util.mockito.mock +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayTileMapperTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val config = kosmos.qsNightDisplayTileConfig + + private val testStartTime = LocalTime.MIDNIGHT + private val testEndTime = LocalTime.NOON + + private lateinit var mapper: NightDisplayTileMapper + + @Before + fun setup() { + mapper = + NightDisplayTileMapper( + context.orCreateTestableResources + .apply { + addOverride(R.drawable.qs_nightlight_icon_on, TestStubDrawable()) + addOverride(R.drawable.qs_nightlight_icon_off, TestStubDrawable()) + } + .resources, + context.theme, + mock<QSTileLogger>(), + ) + } + + @Test + fun disabledModel_whenAutoModeOff() { + val inputModel = NightDisplayTileModel.AutoModeOff(false, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_INACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + /** Force enable does not change the mode by itself. */ + @Test + fun disabledModel_whenAutoModeOff_whenForceEnable() { + val inputModel = NightDisplayTileModel.AutoModeOff(false, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_INACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_whenAutoModeOff() { + val inputModel = NightDisplayTileModel.AutoModeOff(true, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_ACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_forceAutoMode_whenAutoModeOff() { + val inputModel = NightDisplayTileModel.AutoModeOff(true, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_ACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeTwilight_locationOff() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(true, false, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = createNightDisplayTileState(QSTileState.ActivationState.ACTIVE, null) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeTwilight_locationOn() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(true, false, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString(R.string.quick_settings_night_secondary_label_until_sunrise) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeTwilight_locationOn() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(false, false, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString(R.string.quick_settings_night_secondary_label_on_at_sunset) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeTwilight_locationOff() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(false, false, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = createNightDisplayTileState(QSTileState.ActivationState.INACTIVE, null) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeCustom_24Hour() { + val inputModel = + NightDisplayTileModel.AutoModeCustom(false, false, testStartTime, null, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString( + R.string.quick_settings_night_secondary_label_on_at, + formatter24Hour.format(testStartTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeCustom_12Hour() { + val inputModel = + NightDisplayTileModel.AutoModeCustom(false, false, testStartTime, null, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString( + R.string.quick_settings_night_secondary_label_on_at, + formatter12Hour.format(testStartTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + /** Should have the same outcome as [disabledModel_autoModeCustom_12Hour] */ + @Test + fun disabledModel_autoModeCustom_12Hour_isEnrolledForcedAutoMode() { + val inputModel = + NightDisplayTileModel.AutoModeCustom(false, true, testStartTime, null, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString( + R.string.quick_settings_night_secondary_label_on_at, + formatter12Hour.format(testStartTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeCustom_24Hour() { + val inputModel = NightDisplayTileModel.AutoModeCustom(true, false, null, testEndTime, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString( + R.string.quick_settings_secondary_label_until, + formatter24Hour.format(testEndTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeCustom_12Hour() { + val inputModel = NightDisplayTileModel.AutoModeCustom(true, false, null, testEndTime, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString( + R.string.quick_settings_secondary_label_until, + formatter12Hour.format(testEndTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + /** Should have the same state as [enabledModel_autoModeCustom_24Hour] */ + @Test + fun enabledModel_autoModeCustom_24Hour_forceEnabled() { + val inputModel = NightDisplayTileModel.AutoModeCustom(true, true, null, testEndTime, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString( + R.string.quick_settings_secondary_label_until, + formatter24Hour.format(testEndTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + private fun createNightDisplayTileState( + activationState: QSTileState.ActivationState, + secondaryLabel: String? + ): QSTileState { + val label = context.getString(R.string.quick_settings_night_display_label) + + val contentDescription = + if (TextUtils.isEmpty(secondaryLabel)) label + else TextUtils.concat(label, ", ", secondaryLabel) + return QSTileState( + { + Icon.Loaded( + context.getDrawable( + if (activationState == QSTileState.ActivationState.ACTIVE) + R.drawable.qs_nightlight_icon_on + else R.drawable.qs_nightlight_icon_off + )!!, + null + ) + }, + label, + activationState, + secondaryLabel, + setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK), + contentDescription, + null, + QSTileState.SideViewIcon.None, + QSTileState.EnabledState.ENABLED, + Switch::class.qualifiedName + ) + } + + private companion object { + val formatter12Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("hh:mm a") + val formatter24Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/StatusBarStateControllerImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/StatusBarStateControllerImplTest.kt index 21dc953e79d1..f06e04b70809 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/StatusBarStateControllerImplTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/StatusBarStateControllerImplTest.kt @@ -33,7 +33,10 @@ import com.android.systemui.flags.EnableSceneContainer import com.android.systemui.flags.parameterizeSceneContainerFlag import com.android.systemui.jank.interactionJankMonitor import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository +import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository import com.android.systemui.keyguard.domain.interactor.keyguardClockInteractor +import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor +import com.android.systemui.keyguard.shared.model.KeyguardState import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus import com.android.systemui.kosmos.testScope import com.android.systemui.plugins.statusbar.StatusBarStateController @@ -69,7 +72,7 @@ class StatusBarStateControllerImplTest(flags: FlagsParameterization) : SysuiTest private val kosmos = testKosmos() private val testScope = kosmos.testScope - + private val keyguardTransitionRepository = kosmos.fakeKeyguardTransitionRepository private val mockDarkAnimator = mock<ObjectAnimator>() private lateinit var underTest: StatusBarStateControllerImpl @@ -98,6 +101,7 @@ class StatusBarStateControllerImplTest(flags: FlagsParameterization) : SysuiTest uiEventLogger, { kosmos.interactionJankMonitor }, JavaAdapter(testScope.backgroundScope), + { kosmos.keyguardTransitionInteractor }, { kosmos.shadeInteractor }, { kosmos.deviceUnlockedInteractor }, { kosmos.sceneInteractor }, @@ -330,4 +334,25 @@ class StatusBarStateControllerImplTest(flags: FlagsParameterization) : SysuiTest assertThat(currentScene).isEqualTo(Scenes.QuickSettings) assertThat(statusBarState).isEqualTo(StatusBarState.SHADE) } + + @Test + fun leaveOpenOnKeyguard_whenGone_isFalse() = + testScope.runTest { + underTest.start() + underTest.setLeaveOpenOnKeyguardHide(true) + + keyguardTransitionRepository.sendTransitionSteps( + from = KeyguardState.AOD, + to = KeyguardState.LOCKSCREEN, + testScope = testScope, + ) + assertThat(underTest.leaveOpenOnKeyguardHide()).isEqualTo(true) + + keyguardTransitionRepository.sendTransitionSteps( + from = KeyguardState.LOCKSCREEN, + to = KeyguardState.GONE, + testScope = testScope, + ) + assertThat(underTest.leaveOpenOnKeyguardHide()).isEqualTo(false) + } } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayChangeEvent.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayChangeEvent.kt new file mode 100644 index 000000000000..8f071e4bc874 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayChangeEvent.kt @@ -0,0 +1,28 @@ +/* + * 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.accessibility.data.model + +import java.time.LocalTime + +sealed interface NightDisplayChangeEvent { + data class OnAutoModeChanged(val autoMode: Int) : NightDisplayChangeEvent + data class OnActivatedChanged(val isActivated: Boolean) : NightDisplayChangeEvent + data class OnCustomStartTimeChanged(val startTime: LocalTime?) : NightDisplayChangeEvent + data class OnCustomEndTimeChanged(val endTime: LocalTime?) : NightDisplayChangeEvent + data class OnForceAutoModeChanged(val shouldForceAutoMode: Boolean) : NightDisplayChangeEvent + data class OnLocationEnabledChanged(val locationEnabled: Boolean) : NightDisplayChangeEvent +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayState.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayState.kt new file mode 100644 index 000000000000..196876e541b1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayState.kt @@ -0,0 +1,29 @@ +/* + * 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.accessibility.data.model + +import java.time.LocalTime + +/** models the state of NightDisplayRepository */ +data class NightDisplayState( + val autoMode: Int = 0, + val isActivated: Boolean = true, + val startTime: LocalTime? = null, + val endTime: LocalTime? = null, + val shouldForceAutoMode: Boolean = false, + val locationEnabled: Boolean = false, +) diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/NightDisplayRepository.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/NightDisplayRepository.kt new file mode 100644 index 000000000000..bf44fabc31c4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/NightDisplayRepository.kt @@ -0,0 +1,196 @@ +/* + * 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.accessibility.data.repository + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.provider.Settings +import com.android.systemui.accessibility.data.model.NightDisplayChangeEvent +import com.android.systemui.accessibility.data.model.NightDisplayState +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.statusbar.policy.LocationController +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.kotlin.isLocationEnabledFlow +import com.android.systemui.util.settings.GlobalSettings +import com.android.systemui.util.settings.SecureSettings +import com.android.systemui.util.settings.SettingsProxyExt.observerFlow +import java.time.LocalTime +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +class NightDisplayRepository +@Inject +constructor( + @Background private val bgCoroutineContext: CoroutineContext, + @Application private val scope: CoroutineScope, + private val globalSettings: GlobalSettings, + private val secureSettings: SecureSettings, + private val nightDisplayListenerBuilder: NightDisplayListenerModule.Builder, + private val colorDisplayManagerUserScopedService: UserScopedService<ColorDisplayManager>, + private val locationController: LocationController, +) { + private val stateFlowUserMap = mutableMapOf<Int, Flow<NightDisplayState>>() + + fun nightDisplayState(user: UserHandle): Flow<NightDisplayState> = + stateFlowUserMap.getOrPut(user.identifier) { + return merge( + colorDisplayManagerChangeEventFlow(user), + shouldForceAutoMode(user).map { + NightDisplayChangeEvent.OnForceAutoModeChanged(it) + }, + locationController.isLocationEnabledFlow().map { + NightDisplayChangeEvent.OnLocationEnabledChanged(it) + } + ) + .scan(initialState(user)) { state, event -> + when (event) { + is NightDisplayChangeEvent.OnActivatedChanged -> + state.copy(isActivated = event.isActivated) + is NightDisplayChangeEvent.OnAutoModeChanged -> + state.copy(autoMode = event.autoMode) + is NightDisplayChangeEvent.OnCustomStartTimeChanged -> + state.copy(startTime = event.startTime) + is NightDisplayChangeEvent.OnCustomEndTimeChanged -> + state.copy(endTime = event.endTime) + is NightDisplayChangeEvent.OnForceAutoModeChanged -> + state.copy(shouldForceAutoMode = event.shouldForceAutoMode) + is NightDisplayChangeEvent.OnLocationEnabledChanged -> + state.copy(locationEnabled = event.locationEnabled) + } + } + .conflate() + .onStart { emit(initialState(user)) } + .flowOn(bgCoroutineContext) + .stateIn(scope, SharingStarted.WhileSubscribed(), NightDisplayState()) + } + + /** Track changes in night display enabled state and its auto mode */ + private fun colorDisplayManagerChangeEventFlow(user: UserHandle) = callbackFlow { + val nightDisplayListener = nightDisplayListenerBuilder.setUser(user.identifier).build() + val nightDisplayCallback = + object : NightDisplayListener.Callback { + override fun onActivated(activated: Boolean) { + trySend(NightDisplayChangeEvent.OnActivatedChanged(activated)) + } + + override fun onAutoModeChanged(autoMode: Int) { + trySend(NightDisplayChangeEvent.OnAutoModeChanged(autoMode)) + } + + override fun onCustomStartTimeChanged(startTime: LocalTime?) { + trySend(NightDisplayChangeEvent.OnCustomStartTimeChanged(startTime)) + } + + override fun onCustomEndTimeChanged(endTime: LocalTime?) { + trySend(NightDisplayChangeEvent.OnCustomEndTimeChanged(endTime)) + } + } + nightDisplayListener.setCallback(nightDisplayCallback) + awaitClose { nightDisplayListener.setCallback(null) } + } + + /** @return true when the option to force auto mode is available and a value has not been set */ + private fun shouldForceAutoMode(userHandle: UserHandle): Flow<Boolean> = + combine(isForceAutoModeAvailable, isDisplayAutoModeRawNotSet(userHandle)) { + isForceAutoModeAvailable, + isDisplayAutoModeRawNotSet, + -> + isForceAutoModeAvailable && isDisplayAutoModeRawNotSet + } + + private val isForceAutoModeAvailable: Flow<Boolean> = + globalSettings + .observerFlow(IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME) + .onStart { emit(Unit) } + .map { + globalSettings.getString(IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME) == + NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE + } + .distinctUntilChanged() + + /** Inspired by [ColorDisplayService.getNightDisplayAutoModeRawInternal] */ + private fun isDisplayAutoModeRawNotSet(userHandle: UserHandle): Flow<Boolean> = + if (userHandle.identifier == UserHandle.USER_NULL) { + flowOf(IS_AUTO_MODE_RAW_NOT_SET_DEFAULT) + } else { + secureSettings + .observerFlow(userHandle.identifier, DISPLAY_AUTO_MODE_RAW_SETTING_NAME) + .onStart { emit(Unit) } + .map { + secureSettings.getIntForUser( + DISPLAY_AUTO_MODE_RAW_SETTING_NAME, + userHandle.identifier + ) == NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET + } + } + .distinctUntilChanged() + + suspend fun setNightDisplayAutoMode(autoMode: Int, user: UserHandle) { + withContext(bgCoroutineContext) { + colorDisplayManagerUserScopedService.forUser(user).nightDisplayAutoMode = autoMode + } + } + + suspend fun setNightDisplayActivated(activated: Boolean, user: UserHandle) { + withContext(bgCoroutineContext) { + colorDisplayManagerUserScopedService.forUser(user).isNightDisplayActivated = activated + } + } + + private fun initialState(user: UserHandle): NightDisplayState { + val colorDisplayManager = colorDisplayManagerUserScopedService.forUser(user) + return NightDisplayState( + colorDisplayManager.nightDisplayAutoMode, + colorDisplayManager.isNightDisplayActivated, + colorDisplayManager.nightDisplayCustomStartTime, + colorDisplayManager.nightDisplayCustomEndTime, + globalSettings.getString(IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME) == + NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE && + secureSettings.getIntForUser(DISPLAY_AUTO_MODE_RAW_SETTING_NAME, user.identifier) == + NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET, + locationController.isLocationEnabled, + ) + } + + private companion object { + const val NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET = -1 + const val NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE = "1" + const val IS_AUTO_MODE_RAW_NOT_SET_DEFAULT = true + const val IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME = + Settings.Global.NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE + const val DISPLAY_AUTO_MODE_RAW_SETTING_NAME = Settings.Secure.NIGHT_DISPLAY_AUTO_MODE + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt b/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt index 54dd6d00aa48..ed9597ddf559 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt +++ b/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt @@ -41,6 +41,10 @@ import com.android.systemui.qs.tiles.impl.inversion.domain.ColorInversionTileMap import com.android.systemui.qs.tiles.impl.inversion.domain.interactor.ColorInversionTileDataInteractor import com.android.systemui.qs.tiles.impl.inversion.domain.interactor.ColorInversionUserActionInteractor import com.android.systemui.qs.tiles.impl.inversion.domain.model.ColorInversionTileModel +import com.android.systemui.qs.tiles.impl.night.domain.interactor.NightDisplayTileDataInteractor +import com.android.systemui.qs.tiles.impl.night.domain.interactor.NightDisplayTileUserActionInteractor +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.impl.night.ui.NightDisplayTileMapper import com.android.systemui.qs.tiles.impl.onehanded.domain.OneHandedModeTileDataInteractor import com.android.systemui.qs.tiles.impl.onehanded.domain.OneHandedModeTileUserActionInteractor import com.android.systemui.qs.tiles.impl.onehanded.domain.model.OneHandedModeTileModel @@ -117,6 +121,7 @@ interface QSAccessibilityModule { const val FONT_SCALING_TILE_SPEC = "font_scaling" const val REDUCE_BRIGHTNESS_TILE_SPEC = "reduce_brightness" const val ONE_HANDED_TILE_SPEC = "onehanded" + const val NIGHT_DISPLAY_TILE_SPEC = "night" @Provides @IntoMap @@ -279,5 +284,41 @@ interface QSAccessibilityModule { mapper, ) else StubQSTileViewModel + + @Provides + @IntoMap + @StringKey(NIGHT_DISPLAY_TILE_SPEC) + fun provideNightDisplayTileConfig(uiEventLogger: QsEventLogger): QSTileConfig = + QSTileConfig( + tileSpec = TileSpec.create(NIGHT_DISPLAY_TILE_SPEC), + uiConfig = + QSTileUIConfig.Resource( + iconRes = R.drawable.qs_nightlight_icon_off, + labelRes = R.string.quick_settings_night_display_label, + ), + instanceId = uiEventLogger.getNewInstanceId(), + ) + + /** + * Inject NightDisplay Tile into tileViewModelMap in QSModule. The tile is hidden behind a + * flag. + */ + @Provides + @IntoMap + @StringKey(NIGHT_DISPLAY_TILE_SPEC) + fun provideNightDisplayTileViewModel( + factory: QSTileViewModelFactory.Static<NightDisplayTileModel>, + mapper: NightDisplayTileMapper, + stateInteractor: NightDisplayTileDataInteractor, + userActionInteractor: NightDisplayTileUserActionInteractor + ): QSTileViewModel = + if (Flags.qsNewTilesFuture()) + factory.create( + TileSpec.create(NIGHT_DISPLAY_TILE_SPEC), + userActionInteractor, + stateInteractor, + mapper, + ) + else StubQSTileViewModel } } diff --git a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java index e00137e3045e..11e6f7a8c38c 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java @@ -225,6 +225,13 @@ public class FrameworkServicesModule { @Provides @Singleton + static UserScopedService<ColorDisplayManager> provideScopedColorDisplayManager( + Context context) { + return new UserScopedServiceImpl<>(context, ColorDisplayManager.class); + } + + @Provides + @Singleton static CrossWindowBlurListeners provideCrossWindowBlurListeners() { return CrossWindowBlurListeners.getInstance(); } diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt index e32bfcf81fe2..7f3274c09037 100644 --- a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt @@ -134,7 +134,7 @@ constructor( TransitionInfo( ownerName = "", from = KeyguardState.OFF, - to = KeyguardState.LOCKSCREEN, + to = KeyguardState.OFF, animator = null ) ) @@ -266,6 +266,14 @@ constructor( } override suspend fun emitInitialStepsFromOff(to: KeyguardState) { + _currentTransitionInfo.value = + TransitionInfo( + ownerName = "KeyguardTransitionRepository(boot)", + from = KeyguardState.OFF, + to = to, + animator = null + ) + emitTransition( TransitionStep( KeyguardState.OFF, diff --git a/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt b/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt index b515ce07cc02..278352c6f69b 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt @@ -28,6 +28,7 @@ import com.android.systemui.log.ConstantStringsLoggerImpl import com.android.systemui.log.LogBuffer import com.android.systemui.log.core.LogLevel.DEBUG import com.android.systemui.log.core.LogLevel.ERROR +import com.android.systemui.log.core.LogLevel.INFO import com.android.systemui.log.core.LogLevel.VERBOSE import com.android.systemui.log.dagger.QSConfigLog import com.android.systemui.log.dagger.QSLog @@ -56,6 +57,9 @@ constructor( fun d(@CompileTimeConstant msg: String, arg: Any) { buffer.log(TAG, DEBUG, { str1 = arg.toString() }, { "$msg: $str1" }) } + fun i(@CompileTimeConstant msg: String, arg: Any) { + buffer.log(TAG, INFO, { str1 = arg.toString() }, { "$msg: $str1" }) + } fun logTileAdded(tileSpec: String) { buffer.log(TAG, DEBUG, { str1 = tileSpec }, { "[$str1] Tile added" }) diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt index 065e89f10ef6..f0d72065397d 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt @@ -175,6 +175,26 @@ constructor( ) } + /** Log with level [LogLevel.WARNING] */ + fun logWarning( + tileSpec: TileSpec, + message: String, + ) { + tileSpec + .getLogBuffer() + .log(tileSpec.getLogTag(), LogLevel.WARNING, { str1 = message }, { str1!! }) + } + + /** Log with level [LogLevel.INFO] */ + fun logInfo( + tileSpec: TileSpec, + message: String, + ) { + tileSpec + .getLogBuffer() + .log(tileSpec.getLogTag(), LogLevel.INFO, { str1 = message }, { str1!! }) + } + fun logCustomTileUserActionDelivered(tileSpec: TileSpec) { tileSpec .getLogBuffer() diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt index d1c80309a1cc..bd2f2c987ccf 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt @@ -17,15 +17,15 @@ package com.android.systemui.qs.tiles.impl.location.domain.interactor import android.os.UserHandle -import com.android.systemui.common.coroutine.ConflatedCallbackFlow import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor import com.android.systemui.qs.tiles.impl.location.domain.model.LocationTileModel import com.android.systemui.statusbar.policy.LocationController +import com.android.systemui.util.kotlin.isLocationEnabledFlow import javax.inject.Inject -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map /** Observes location state changes providing the [LocationTileModel]. */ class LocationTileDataInteractor @@ -38,19 +38,7 @@ constructor( user: UserHandle, triggers: Flow<DataUpdateTrigger> ): Flow<LocationTileModel> = - ConflatedCallbackFlow.conflatedCallbackFlow { - val initialValue = locationController.isLocationEnabled - trySend(LocationTileModel(initialValue)) - - val callback = - object : LocationController.LocationChangeCallback { - override fun onLocationSettingsChanged(locationEnabled: Boolean) { - trySend(LocationTileModel(locationEnabled)) - } - } - locationController.addCallback(callback) - awaitClose { locationController.removeCallback(callback) } - } + locationController.isLocationEnabledFlow().map { LocationTileModel(it) } override fun availability(user: UserHandle): Flow<Boolean> = flowOf(true) } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractor.kt new file mode 100644 index 000000000000..88bd224881b5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractor.kt @@ -0,0 +1,88 @@ +/* + * 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.qs.tiles.impl.night.domain.interactor + +import android.content.Context +import android.hardware.display.ColorDisplayManager +import android.os.UserHandle +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger +import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.util.time.DateFormatUtil +import java.time.LocalTime +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** Observes screen record state changes providing the [NightDisplayTileModel]. */ +class NightDisplayTileDataInteractor +@Inject +constructor( + @Application private val context: Context, + private val dateFormatUtil: DateFormatUtil, + private val nightDisplayRepository: NightDisplayRepository, +) : QSTileDataInteractor<NightDisplayTileModel> { + + override fun tileData( + user: UserHandle, + triggers: Flow<DataUpdateTrigger> + ): Flow<NightDisplayTileModel> = + nightDisplayRepository.nightDisplayState(user).map { + generateModel( + it.autoMode, + it.isActivated, + it.startTime, + it.endTime, + it.shouldForceAutoMode, + it.locationEnabled + ) + } + + /** This checks resources and there fore does not make a binder call. */ + override fun availability(user: UserHandle): Flow<Boolean> = + flowOf(ColorDisplayManager.isNightDisplayAvailable(context)) + + private fun generateModel( + autoMode: Int, + isNightDisplayActivated: Boolean, + customStartTime: LocalTime?, + customEndTime: LocalTime?, + shouldForceAutoMode: Boolean, + locationEnabled: Boolean, + ): NightDisplayTileModel { + if (autoMode == ColorDisplayManager.AUTO_MODE_TWILIGHT) { + return NightDisplayTileModel.AutoModeTwilight( + isNightDisplayActivated, + shouldForceAutoMode, + locationEnabled, + ) + } else if (autoMode == ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) { + return NightDisplayTileModel.AutoModeCustom( + isNightDisplayActivated, + shouldForceAutoMode, + customStartTime, + customEndTime, + dateFormatUtil.is24HourFormat, + ) + } else { // auto mode off + return NightDisplayTileModel.AutoModeOff(isNightDisplayActivated, shouldForceAutoMode) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractor.kt new file mode 100644 index 000000000000..5cee8c49527d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractor.kt @@ -0,0 +1,64 @@ +/* + * 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.qs.tiles.impl.night.domain.interactor + +import android.content.Intent +import android.hardware.display.ColorDisplayManager.AUTO_MODE_CUSTOM_TIME +import android.provider.Settings +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.accessibility.qs.QSAccessibilityModule +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandler +import com.android.systemui.qs.tiles.base.interactor.QSTileInput +import com.android.systemui.qs.tiles.base.interactor.QSTileUserActionInteractor +import com.android.systemui.qs.tiles.base.logging.QSTileLogger +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.viewmodel.QSTileUserAction +import javax.inject.Inject + +/** Handles night display tile clicks. */ +class NightDisplayTileUserActionInteractor +@Inject +constructor( + private val nightDisplayRepository: NightDisplayRepository, + private val qsTileIntentUserActionHandler: QSTileIntentUserInputHandler, + private val qsLogger: QSTileLogger, +) : QSTileUserActionInteractor<NightDisplayTileModel> { + override suspend fun handleInput(input: QSTileInput<NightDisplayTileModel>): Unit = + with(input) { + when (action) { + is QSTileUserAction.Click -> { + // Enroll in forced auto mode if eligible. + if (data.isEnrolledInForcedNightDisplayAutoMode) { + nightDisplayRepository.setNightDisplayAutoMode(AUTO_MODE_CUSTOM_TIME, user) + qsLogger.logInfo(spec, "Enrolled in forced night display auto mode") + } + nightDisplayRepository.setNightDisplayActivated(!data.isActivated, user) + } + is QSTileUserAction.LongClick -> { + qsTileIntentUserActionHandler.handle( + action.expandable, + Intent(Settings.ACTION_NIGHT_DISPLAY_SETTINGS) + ) + } + } + } + + companion object { + val spec = TileSpec.create(QSAccessibilityModule.NIGHT_DISPLAY_TILE_SPEC) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/model/NightDisplayTileModel.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/model/NightDisplayTileModel.kt new file mode 100644 index 000000000000..6b1bd5bc3512 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/model/NightDisplayTileModel.kt @@ -0,0 +1,41 @@ +/* + * 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.qs.tiles.impl.night.domain.model + +import java.time.LocalTime + +/** Data model for night display tile */ +sealed interface NightDisplayTileModel { + val isActivated: Boolean + val isEnrolledInForcedNightDisplayAutoMode: Boolean + data class AutoModeTwilight( + override val isActivated: Boolean, + override val isEnrolledInForcedNightDisplayAutoMode: Boolean, + val isLocationEnabled: Boolean + ) : NightDisplayTileModel + data class AutoModeCustom( + override val isActivated: Boolean, + override val isEnrolledInForcedNightDisplayAutoMode: Boolean, + val startTime: LocalTime?, + val endTime: LocalTime?, + val is24HourFormat: Boolean + ) : NightDisplayTileModel + data class AutoModeOff( + override val isActivated: Boolean, + override val isEnrolledInForcedNightDisplayAutoMode: Boolean + ) : NightDisplayTileModel +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapper.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapper.kt new file mode 100644 index 000000000000..5c2dcfcaf37c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapper.kt @@ -0,0 +1,128 @@ +/* + * 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.qs.tiles.impl.night.ui + +import android.content.res.Resources +import android.service.quicksettings.Tile +import android.text.TextUtils +import androidx.annotation.StringRes +import com.android.systemui.accessibility.qs.QSAccessibilityModule +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.base.interactor.QSTileDataToStateMapper +import com.android.systemui.qs.tiles.base.logging.QSTileLogger +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.viewmodel.QSTileConfig +import com.android.systemui.qs.tiles.viewmodel.QSTileState +import com.android.systemui.res.R +import java.time.DateTimeException +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +/** Maps [NightDisplayTileModel] to [QSTileState]. */ +class NightDisplayTileMapper +@Inject +constructor( + @Main private val resources: Resources, + private val theme: Resources.Theme, + private val logger: QSTileLogger, +) : QSTileDataToStateMapper<NightDisplayTileModel> { + override fun map(config: QSTileConfig, data: NightDisplayTileModel): QSTileState = + QSTileState.build(resources, theme, config.uiConfig) { + label = resources.getString(R.string.quick_settings_night_display_label) + supportedActions = + setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK) + sideViewIcon = QSTileState.SideViewIcon.None + + if (data.isActivated) { + activationState = QSTileState.ActivationState.ACTIVE + val loadedIcon = + Icon.Loaded( + resources.getDrawable(R.drawable.qs_nightlight_icon_on, theme), + contentDescription = null + ) + icon = { loadedIcon } + } else { + activationState = QSTileState.ActivationState.INACTIVE + val loadedIcon = + Icon.Loaded( + resources.getDrawable(R.drawable.qs_nightlight_icon_off, theme), + contentDescription = null + ) + icon = { loadedIcon } + } + + secondaryLabel = getSecondaryLabel(data, resources) + + contentDescription = + if (TextUtils.isEmpty(secondaryLabel)) label + else TextUtils.concat(label, ", ", secondaryLabel) + } + + private fun getSecondaryLabel( + data: NightDisplayTileModel, + resources: Resources + ): CharSequence? { + when (data) { + is NightDisplayTileModel.AutoModeTwilight -> { + if (!data.isLocationEnabled) { + return null + } else { + return resources.getString( + if (data.isActivated) + R.string.quick_settings_night_secondary_label_until_sunrise + else R.string.quick_settings_night_secondary_label_on_at_sunset + ) + } + } + is NightDisplayTileModel.AutoModeOff -> { + val subtitleArray = resources.getStringArray(R.array.tile_states_night) + return subtitleArray[ + if (data.isActivated) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE] + } + is NightDisplayTileModel.AutoModeCustom -> { + // User-specified time, approximated to the nearest hour. + @StringRes val toggleTimeStringRes: Int + val toggleTime: LocalTime + if (data.isActivated) { + toggleTime = data.endTime ?: return null + toggleTimeStringRes = R.string.quick_settings_secondary_label_until + } else { + toggleTime = data.startTime ?: return null + toggleTimeStringRes = R.string.quick_settings_night_secondary_label_on_at + } + + try { + val formatter = if (data.is24HourFormat) formatter24Hour else formatter12Hour + val formatArg = formatter.format(toggleTime) + return resources.getString(toggleTimeStringRes, formatArg) + } catch (exception: DateTimeException) { + logger.logWarning(spec, exception.message.toString()) + return null + } + } + } + } + + private companion object { + val formatter12Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("hh:mm a") + val formatter24Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + val spec = TileSpec.create(QSAccessibilityModule.NIGHT_DISPLAY_TILE_SPEC) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java index 70632d5aa27a..79218ae4ca20 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java @@ -18,6 +18,7 @@ package com.android.systemui.statusbar; import static com.android.internal.jank.InteractionJankMonitor.CUJ_LOCKSCREEN_TRANSITION_FROM_AOD; import static com.android.internal.jank.InteractionJankMonitor.CUJ_LOCKSCREEN_TRANSITION_TO_AOD; +import static com.android.systemui.keyguard.shared.model.KeyguardState.GONE; import static com.android.systemui.util.kotlin.JavaAdapterKt.combineFlows; import android.animation.Animator; @@ -49,6 +50,7 @@ import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteract import com.android.systemui.deviceentry.shared.model.DeviceUnlockStatus; import com.android.systemui.keyguard.MigrateClocksToBlueprint; import com.android.systemui.keyguard.domain.interactor.KeyguardClockInteractor; +import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor; import com.android.systemui.plugins.statusbar.StatusBarStateController.StateListener; import com.android.systemui.res.R; import com.android.systemui.scene.domain.interactor.SceneInteractor; @@ -108,6 +110,7 @@ public class StatusBarStateControllerImpl implements private final UiEventLogger mUiEventLogger; private final Lazy<InteractionJankMonitor> mInteractionJankMonitorLazy; private final JavaAdapter mJavaAdapter; + private final Lazy<KeyguardTransitionInteractor> mKeyguardTransitionInteractorLazy; private final Lazy<ShadeInteractor> mShadeInteractorLazy; private final Lazy<DeviceUnlockedInteractor> mDeviceUnlockedInteractorLazy; private final Lazy<SceneInteractor> mSceneInteractorLazy; @@ -175,6 +178,7 @@ public class StatusBarStateControllerImpl implements UiEventLogger uiEventLogger, Lazy<InteractionJankMonitor> interactionJankMonitorLazy, JavaAdapter javaAdapter, + Lazy<KeyguardTransitionInteractor> keyguardTransitionInteractor, Lazy<ShadeInteractor> shadeInteractorLazy, Lazy<DeviceUnlockedInteractor> deviceUnlockedInteractorLazy, Lazy<SceneInteractor> sceneInteractorLazy, @@ -182,6 +186,7 @@ public class StatusBarStateControllerImpl implements mUiEventLogger = uiEventLogger; mInteractionJankMonitorLazy = interactionJankMonitorLazy; mJavaAdapter = javaAdapter; + mKeyguardTransitionInteractorLazy = keyguardTransitionInteractor; mShadeInteractorLazy = shadeInteractorLazy; mDeviceUnlockedInteractorLazy = deviceUnlockedInteractorLazy; mSceneInteractorLazy = sceneInteractorLazy; @@ -193,6 +198,14 @@ public class StatusBarStateControllerImpl implements @Override public void start() { + mJavaAdapter.alwaysCollectFlow( + mKeyguardTransitionInteractorLazy.get().isFinishedInState(GONE), + (Boolean isFinishedInState) -> { + if (isFinishedInState) { + setLeaveOpenOnKeyguardHide(false); + } + }); + mJavaAdapter.alwaysCollectFlow(mShadeInteractorLazy.get().isAnyExpanded(), this::onShadeOrQsExpanded); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HeadsUpStyleProvider.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HeadsUpStyleProvider.kt index 816e5c132432..db3cf5abe618 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HeadsUpStyleProvider.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HeadsUpStyleProvider.kt @@ -17,6 +17,7 @@ package com.android.systemui.statusbar.notification.row import android.app.Flags +import com.android.systemui.statusbar.data.repository.StatusBarModeRepositoryStore import javax.inject.Inject /** @@ -27,11 +28,14 @@ interface HeadsUpStyleProvider { fun shouldApplyCompactStyle(): Boolean } -class HeadsUpStyleProviderImpl @Inject constructor() : HeadsUpStyleProvider { +class HeadsUpStyleProviderImpl +@Inject +constructor(private val statusBarModeRepositoryStore: StatusBarModeRepositoryStore) : + HeadsUpStyleProvider { - /** - * TODO(b/270709257) This feature is under development. This method returns Compact when the - * flag is enabled for fish fooding purpose. - */ - override fun shouldApplyCompactStyle(): Boolean = Flags.compactHeadsUpNotification() + override fun shouldApplyCompactStyle(): Boolean { + // Use compact HUN for immersive mode. + return Flags.compactHeadsUpNotification() && + statusBarModeRepositoryStore.defaultDisplay.isInFullscreenMode.value + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java index be6bef74565a..23674b24d63e 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java @@ -2184,7 +2184,9 @@ public class CentralSurfacesImpl implements CoreStartable, CentralSurfaces { } if (mStatusBarStateController.leaveOpenOnKeyguardHide()) { if (!mStatusBarStateController.isKeyguardRequested()) { - mStatusBarStateController.setLeaveOpenOnKeyguardHide(false); + if (!MigrateClocksToBlueprint.isEnabled()) { + mStatusBarStateController.setLeaveOpenOnKeyguardHide(false); + } } long delay = mKeyguardStateController.calculateGoingToFullShadeDelay(); mLockscreenShadeTransitionController.onHideKeyguard(delay, previousState); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt index d9d909a49781..fc54f140dec5 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt @@ -33,6 +33,18 @@ data class SubscriptionModel( */ val isOpportunistic: Boolean = false, + /** + * True if this subscription **only** supports non-terrestrial networks (NTN) and false + * otherwise. (non-terrestrial == satellite) + * + * Note that we intend to filter these subscriptions out, because these connections are actually + * supported by + * [com.android.systemui.statusbar.pipeline.satellite.data.DeviceBasedSatelliteRepository]. See + * [com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor] for + * the filtering. + */ + val isExclusivelyNonTerrestrial: Boolean = false, + /** Subscriptions in the same group may be filtered or treated as a single subscription */ val groupUuid: ParcelUuid? = null, diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt index 22785979f3ae..425c58b0074b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt @@ -23,6 +23,7 @@ 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.SubscriptionModel import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel import kotlinx.coroutines.flow.StateFlow @@ -76,7 +77,17 @@ interface MobileConnectionRepository { */ val isInService: StateFlow<Boolean> - /** Reflects [android.telephony.ServiceState.isUsingNonTerrestrialNetwork] */ + /** + * 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: StateFlow<Boolean> /** True if [android.telephony.SignalStrength] told us that this connection is using GSM */ diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt index 5d91ef323ead..0073e9cd3dd8 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt @@ -424,6 +424,7 @@ constructor( SubscriptionModel( subscriptionId = subscriptionId, isOpportunistic = isOpportunistic, + isExclusivelyNonTerrestrial = isOnlyNonTerrestrialNetwork, groupUuid = groupUuid, carrierName = carrierName.toString(), profileClass = profileClass, diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt index d555c47f4da2..91d7ca65b30d 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt @@ -172,21 +172,33 @@ constructor( private val unfilteredSubscriptions: Flow<List<SubscriptionModel>> = mobileConnectionsRepo.subscriptions - /** - * Any filtering that we can do based purely on the info of each subscription. Currently this - * only applies the ProfileClass-based filter, but if we need other they can go here - */ + /** Any filtering that we can do based purely on the info of each subscription individually. */ private val subscriptionsBasedFilteredSubs = - unfilteredSubscriptions.map { subs -> applyProvisioningFilter(subs) }.distinctUntilChanged() + unfilteredSubscriptions + .map { it.filterBasedOnProvisioning().filterBasedOnNtn() } + .distinctUntilChanged() - private fun applyProvisioningFilter(subs: List<SubscriptionModel>): List<SubscriptionModel> = + private fun List<SubscriptionModel>.filterBasedOnProvisioning(): List<SubscriptionModel> = if (!featureFlagsClassic.isEnabled(FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS)) { - subs + this } else { - subs.filter { it.profileClass != PROFILE_CLASS_PROVISIONING } + 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: @@ -204,12 +216,8 @@ constructor( subscriptionsBasedFilteredSubs, mobileConnectionsRepo.activeMobileDataSubscriptionId, connectivityRepository.vcnSubId, - ) { unfilteredSubs, activeId, vcnSubId -> - filterSubsBasedOnOpportunistic( - unfilteredSubs, - activeId, - vcnSubId, - ) + ) { preFilteredSubs, activeId, vcnSubId -> + filterSubsBasedOnOpportunistic(preFilteredSubs, activeId, vcnSubId) } .distinctUntilChanged() .logDiffsForTable( diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/LocationControllerExt.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/LocationControllerExt.kt new file mode 100644 index 000000000000..ee1b5655f7be --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/LocationControllerExt.kt @@ -0,0 +1,37 @@ +/* + * 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 + +import com.android.systemui.statusbar.policy.LocationController +import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onStart + +fun LocationController.isLocationEnabledFlow(): Flow<Boolean> { + return conflatedCallbackFlow { + val locationCallback = + object : LocationController.LocationChangeCallback { + override fun onLocationSettingsChanged(locationEnabled: Boolean) { + trySend(locationEnabled) + } + } + addCallback(locationCallback) + awaitClose { removeCallback(locationCallback) } + } + .onStart { emit(isLocationEnabled) } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java index 3793970394a8..5b47c94bcc90 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java @@ -446,6 +446,7 @@ public class NotificationPanelViewControllerBaseTest extends SysuiTestCase { mUiEventLogger, () -> mKosmos.getInteractionJankMonitor(), mJavaAdapter, + () -> mKeyguardTransitionInteractor, () -> mShadeInteractor, () -> mKosmos.getDeviceUnlockedInteractor(), () -> mKosmos.getSceneInteractor(), @@ -600,6 +601,7 @@ public class NotificationPanelViewControllerBaseTest extends SysuiTestCase { new UiEventLoggerFake(), () -> mKosmos.getInteractionJankMonitor(), mJavaAdapter, + () -> mKeyguardTransitionInteractor, () -> mShadeInteractor, () -> mKosmos.getDeviceUnlockedInteractor(), () -> mKosmos.getSceneInteractor(), diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/HeadsUpStyleProviderImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/HeadsUpStyleProviderImplTest.kt new file mode 100644 index 000000000000..5e50af39203f --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/HeadsUpStyleProviderImplTest.kt @@ -0,0 +1,75 @@ +/* + * 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.notification.row + +import android.app.Flags.FLAG_COMPACT_HEADS_UP_NOTIFICATION +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.data.repository.FakeStatusBarModeRepository +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class HeadsUpStyleProviderImplTest : SysuiTestCase() { + + @Rule @JvmField val setFlagsRule = SetFlagsRule() + + private lateinit var statusBarModeRepositoryStore: FakeStatusBarModeRepository + private lateinit var headsUpStyleProvider: HeadsUpStyleProviderImpl + + @Before + fun setUp() { + statusBarModeRepositoryStore = FakeStatusBarModeRepository() + statusBarModeRepositoryStore.defaultDisplay.isInFullscreenMode.value = true + + headsUpStyleProvider = HeadsUpStyleProviderImpl(statusBarModeRepositoryStore) + } + + @Test + @DisableFlags(FLAG_COMPACT_HEADS_UP_NOTIFICATION) + fun shouldApplyCompactStyle_returnsFalse_whenCompactFlagDisabled() { + assertThat(headsUpStyleProvider.shouldApplyCompactStyle()).isFalse() + } + + @Test + @EnableFlags(FLAG_COMPACT_HEADS_UP_NOTIFICATION) + fun shouldApplyCompactStyle_returnsTrue_whenImmersiveModeEnabled() { + // GIVEN + statusBarModeRepositoryStore.defaultDisplay.isInFullscreenMode.value = true + + // THEN + assertThat(headsUpStyleProvider.shouldApplyCompactStyle()).isTrue() + } + + @Test + @EnableFlags(FLAG_COMPACT_HEADS_UP_NOTIFICATION) + fun shouldApplyCompactStyle_returnsFalse_whenImmersiveModeDisabled() { + // GIVEN + statusBarModeRepositoryStore.defaultDisplay.isInFullscreenMode.value = false + + // THEN + assertThat(headsUpStyleProvider.shouldApplyCompactStyle()).isFalse() + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt index b5525b1ce8e9..36df61d287a1 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt @@ -295,6 +295,50 @@ class MobileConnectionsRepositoryTest : SysuiTestCase() { } @Test + fun subscriptions_subIsOnlyNtn_modelHasExclusivelyNtnTrue() = + testScope.runTest { + val latest by collectLastValue(underTest.subscriptions) + + val onlyNtnSub = + mock<SubscriptionInfo>().also { + whenever(it.isOnlyNonTerrestrialNetwork).thenReturn(true) + whenever(it.subscriptionId).thenReturn(45) + whenever(it.groupUuid).thenReturn(GROUP_1) + whenever(it.carrierName).thenReturn("NTN only") + whenever(it.profileClass).thenReturn(PROFILE_CLASS_UNSET) + } + + whenever(subscriptionManager.completeActiveSubscriptionInfoList) + .thenReturn(listOf(onlyNtnSub)) + getSubscriptionCallback().onSubscriptionsChanged() + + assertThat(latest).hasSize(1) + assertThat(latest!![0].isExclusivelyNonTerrestrial).isTrue() + } + + @Test + fun subscriptions_subIsNotOnlyNtn_modelHasExclusivelyNtnFalse() = + testScope.runTest { + val latest by collectLastValue(underTest.subscriptions) + + val notOnlyNtnSub = + mock<SubscriptionInfo>().also { + whenever(it.isOnlyNonTerrestrialNetwork).thenReturn(false) + whenever(it.subscriptionId).thenReturn(45) + whenever(it.groupUuid).thenReturn(GROUP_1) + whenever(it.carrierName).thenReturn("NTN only") + whenever(it.profileClass).thenReturn(PROFILE_CLASS_UNSET) + } + + whenever(subscriptionManager.completeActiveSubscriptionInfoList) + .thenReturn(listOf(notOnlyNtnSub)) + getSubscriptionCallback().onSubscriptionsChanged() + + assertThat(latest).hasSize(1) + assertThat(latest!![0].isExclusivelyNonTerrestrial).isFalse() + } + + @Test fun testSubscriptions_carrierMergedOnly_listHasCarrierMerged() = testScope.runTest { val latest by collectLastValue(underTest.subscriptions) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt index 0b14be1eefbd..0f9cbfa66b5b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt @@ -42,14 +42,11 @@ import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import java.util.UUID import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield -import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -68,7 +65,7 @@ class MobileIconsInteractorTest : SysuiTestCase() { set(Flags.FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS, true) } - private val testDispatcher = UnconfinedTestDispatcher() + private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) private val tableLogBuffer = @@ -113,17 +110,12 @@ class MobileIconsInteractorTest : SysuiTestCase() { ) } - @After fun tearDown() {} - @Test fun filteredSubscriptions_default() = testScope.runTest { - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) assertThat(latest).isEqualTo(listOf<SubscriptionModel>()) - - job.cancel() } // Based on the logic from the old pipeline, we'll never filter subs when there are more than 2 @@ -133,12 +125,9 @@ class MobileIconsInteractorTest : SysuiTestCase() { connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_3_OPP, SUB_4_OPP)) connectionsRepository.setActiveMobileDataSubscriptionId(SUB_4_ID) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) assertThat(latest).isEqualTo(listOf(SUB_1, SUB_3_OPP, SUB_4_OPP)) - - job.cancel() } @Test @@ -146,12 +135,9 @@ class MobileIconsInteractorTest : SysuiTestCase() { testScope.runTest { connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2)) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) assertThat(latest).isEqualTo(listOf(SUB_1, SUB_2)) - - job.cancel() } @Test @@ -160,12 +146,9 @@ class MobileIconsInteractorTest : SysuiTestCase() { connectionsRepository.setSubscriptions(listOf(SUB_3_OPP, SUB_4_OPP)) connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) assertThat(latest).isEqualTo(listOf(SUB_3_OPP, SUB_4_OPP)) - - job.cancel() } @Test @@ -180,12 +163,9 @@ class MobileIconsInteractorTest : SysuiTestCase() { connectionsRepository.setSubscriptions(listOf(sub1, sub2)) connectionsRepository.setActiveMobileDataSubscriptionId(SUB_1_ID) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) assertThat(latest).isEqualTo(listOf(sub1, sub2)) - - job.cancel() } @Test @@ -202,13 +182,10 @@ class MobileIconsInteractorTest : SysuiTestCase() { whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) .thenReturn(false) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) // Filtered subscriptions should show the active one when the config is false assertThat(latest).isEqualTo(listOf(sub3)) - - job.cancel() } @Test @@ -225,13 +202,10 @@ class MobileIconsInteractorTest : SysuiTestCase() { whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) .thenReturn(false) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) // Filtered subscriptions should show the active one when the config is false assertThat(latest).isEqualTo(listOf(sub4)) - - job.cancel() } @Test @@ -248,14 +222,11 @@ class MobileIconsInteractorTest : SysuiTestCase() { whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) .thenReturn(true) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) // Filtered subscriptions should show the primary (non-opportunistic) if the config is // true assertThat(latest).isEqualTo(listOf(sub1)) - - job.cancel() } @Test @@ -272,14 +243,11 @@ class MobileIconsInteractorTest : SysuiTestCase() { whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) .thenReturn(true) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) // Filtered subscriptions should show the primary (non-opportunistic) if the config is // true assertThat(latest).isEqualTo(listOf(sub1)) - - job.cancel() } @Test @@ -297,12 +265,9 @@ class MobileIconsInteractorTest : SysuiTestCase() { whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) .thenReturn(false) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) assertThat(latest).isEqualTo(listOf(sub3)) - - job.cancel() } @Test @@ -320,12 +285,9 @@ class MobileIconsInteractorTest : SysuiTestCase() { whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) .thenReturn(false) - var latest: List<SubscriptionModel>? = null - val job = underTest.filteredSubscriptions.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.filteredSubscriptions) assertThat(latest).isEqualTo(listOf(sub1)) - - job.cancel() } @Test @@ -446,313 +408,345 @@ class MobileIconsInteractorTest : SysuiTestCase() { } @Test + fun filteredSubscriptions_subNotExclusivelyNonTerrestrial_hasSub() = + testScope.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() = + testScope.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() = + testScope.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() = + testScope.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() = testScope.runTest { CONNECTION_1.setDataEnabled(true) - var latest: Boolean? = null - val job = - underTest.activeDataConnectionHasDataEnabled.onEach { latest = it }.launchIn(this) - assertThat(latest).isTrue() + val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled) - job.cancel() + assertThat(latest).isTrue() } @Test fun activeDataConnection_turnedOff() = testScope.runTest { CONNECTION_1.setDataEnabled(true) - var latest: Boolean? = null - val job = - underTest.activeDataConnectionHasDataEnabled.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled) CONNECTION_1.setDataEnabled(false) - yield() assertThat(latest).isFalse() - - job.cancel() } @Test fun activeDataConnection_invalidSubId() = testScope.runTest { - var latest: Boolean? = null - val job = - underTest.activeDataConnectionHasDataEnabled.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled) connectionsRepository.setActiveMobileDataSubscriptionId(INVALID_SUBSCRIPTION_ID) - yield() // An invalid active subId should tell us that data is off assertThat(latest).isFalse() - - job.cancel() } @Test fun failedConnection_default_validated_notFailed() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isDefaultConnectionFailed) connectionsRepository.mobileIsDefault.value = true connectionsRepository.defaultConnectionIsValidated.value = true - yield() assertThat(latest).isFalse() - - job.cancel() } @Test fun failedConnection_notDefault_notValidated_notFailed() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isDefaultConnectionFailed) connectionsRepository.mobileIsDefault.value = false connectionsRepository.defaultConnectionIsValidated.value = false - yield() assertThat(latest).isFalse() - - job.cancel() } @Test fun failedConnection_default_notValidated_failed() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isDefaultConnectionFailed) connectionsRepository.mobileIsDefault.value = true connectionsRepository.defaultConnectionIsValidated.value = false - yield() assertThat(latest).isTrue() - - job.cancel() } @Test fun failedConnection_carrierMergedDefault_notValidated_failed() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isDefaultConnectionFailed) connectionsRepository.hasCarrierMergedConnection.value = true connectionsRepository.defaultConnectionIsValidated.value = false - yield() assertThat(latest).isTrue() - - job.cancel() } /** Regression test for b/275076959. */ @Test fun failedConnection_dataSwitchInSameGroup_notFailed() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this) + 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() - - job.cancel() } @Test fun failedConnection_dataSwitchNotInSameGroup_isFailed() = testScope.runTest { - var latestConnectionFailed: Boolean? = null - val job = - underTest.isDefaultConnectionFailed - .onEach { latestConnectionFailed = it } - .launchIn(this) + 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(latestConnectionFailed).isTrue() - - job.cancel() + assertThat(latest).isTrue() } @Test fun alwaysShowDataRatIcon_configHasTrue() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.alwaysShowDataRatIcon.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.alwaysShowDataRatIcon) val config = MobileMappings.Config() config.alwaysShowDataRatIcon = true connectionsRepository.defaultDataSubRatConfig.value = config - yield() assertThat(latest).isTrue() - - job.cancel() } @Test fun alwaysShowDataRatIcon_configHasFalse() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.alwaysShowDataRatIcon.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.alwaysShowDataRatIcon) val config = MobileMappings.Config() config.alwaysShowDataRatIcon = false connectionsRepository.defaultDataSubRatConfig.value = config - yield() assertThat(latest).isFalse() - - job.cancel() } @Test fun alwaysUseCdmaLevel_configHasTrue() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.alwaysUseCdmaLevel.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.alwaysUseCdmaLevel) val config = MobileMappings.Config() config.alwaysShowCdmaRssi = true connectionsRepository.defaultDataSubRatConfig.value = config - yield() assertThat(latest).isTrue() - - job.cancel() } @Test fun alwaysUseCdmaLevel_configHasFalse() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.alwaysUseCdmaLevel.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.alwaysUseCdmaLevel) val config = MobileMappings.Config() config.alwaysShowCdmaRssi = false connectionsRepository.defaultDataSubRatConfig.value = config - yield() assertThat(latest).isFalse() - - job.cancel() } @Test fun isSingleCarrier_zeroSubscriptions_false() = testScope.runTest { - var latest: Boolean? = true - val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isSingleCarrier) connectionsRepository.setSubscriptions(emptyList()) - assertThat(latest).isFalse() - job.cancel() + assertThat(latest).isFalse() } @Test fun isSingleCarrier_oneSubscription_true() = testScope.runTest { - var latest: Boolean? = false - val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isSingleCarrier) connectionsRepository.setSubscriptions(listOf(SUB_1)) - assertThat(latest).isTrue() - job.cancel() + assertThat(latest).isTrue() } @Test fun isSingleCarrier_twoSubscriptions_false() = testScope.runTest { - var latest: Boolean? = true - val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isSingleCarrier) connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2)) - assertThat(latest).isFalse() - job.cancel() + assertThat(latest).isFalse() } @Test fun isSingleCarrier_updates() = testScope.runTest { - var latest: Boolean? = false - val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isSingleCarrier) connectionsRepository.setSubscriptions(listOf(SUB_1)) assertThat(latest).isTrue() connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2)) assertThat(latest).isFalse() - - job.cancel() } @Test fun mobileIsDefault_mobileFalseAndCarrierMergedFalse_false() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.mobileIsDefault.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.mobileIsDefault) connectionsRepository.mobileIsDefault.value = false connectionsRepository.hasCarrierMergedConnection.value = false assertThat(latest).isFalse() - - job.cancel() } @Test fun mobileIsDefault_mobileTrueAndCarrierMergedFalse_true() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.mobileIsDefault.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.mobileIsDefault) connectionsRepository.mobileIsDefault.value = true connectionsRepository.hasCarrierMergedConnection.value = false assertThat(latest).isTrue() - - job.cancel() } /** Regression test for b/272586234. */ @Test fun mobileIsDefault_mobileFalseAndCarrierMergedTrue_true() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.mobileIsDefault.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.mobileIsDefault) connectionsRepository.mobileIsDefault.value = false connectionsRepository.hasCarrierMergedConnection.value = true assertThat(latest).isTrue() - - job.cancel() } @Test fun mobileIsDefault_updatesWhenRepoUpdates() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.mobileIsDefault.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.mobileIsDefault) connectionsRepository.mobileIsDefault.value = true assertThat(latest).isTrue() @@ -762,8 +756,6 @@ class MobileIconsInteractorTest : SysuiTestCase() { connectionsRepository.hasCarrierMergedConnection.value = true assertThat(latest).isTrue() - - job.cancel() } // The data switch tests are mostly testing the [forcingCellularValidation] flow, but that flow @@ -772,95 +764,79 @@ class MobileIconsInteractorTest : SysuiTestCase() { @Test fun dataSwitch_inSameGroup_validatedMatchesPreviousValue_expiresAfter2s() = testScope.runTest { - var latestConnectionFailed: Boolean? = null - val job = - underTest.isDefaultConnectionFailed - .onEach { latestConnectionFailed = it } - .launchIn(this) + 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 advanceTimeBy(1000) - assertThat(latestConnectionFailed).isFalse() + assertThat(latest).isFalse() // After 2s, the force validation expires so the connection updates to failed advanceTimeBy(1001) - assertThat(latestConnectionFailed).isTrue() - - job.cancel() + assertThat(latest).isTrue() } @Test fun dataSwitch_inSameGroup_notValidated_immediatelyMarkedAsFailed() = testScope.runTest { - var latestConnectionFailed: Boolean? = null - val job = - underTest.isDefaultConnectionFailed - .onEach { latestConnectionFailed = it } - .launchIn(this) + val latest by collectLastValue(underTest.isDefaultConnectionFailed) connectionsRepository.mobileIsDefault.value = true connectionsRepository.defaultConnectionIsValidated.value = false + runCurrent() connectionsRepository.activeSubChangedInGroupEvent.emit(Unit) - assertThat(latestConnectionFailed).isTrue() - - job.cancel() + assertThat(latest).isTrue() } @Test fun dataSwitch_loseValidation_thenSwitchHappens_clearsForcedBit() = testScope.runTest { - var latestConnectionFailed: Boolean? = null - val job = - underTest.isDefaultConnectionFailed - .onEach { latestConnectionFailed = it } - .launchIn(this) + 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(latestConnectionFailed).isFalse() + assertThat(latest).isFalse() advanceTimeBy(1000) - assertThat(latestConnectionFailed).isFalse() + assertThat(latest).isFalse() // ... but expires after 2s advanceTimeBy(1001) - assertThat(latestConnectionFailed).isTrue() - - job.cancel() + assertThat(latest).isTrue() } @Test fun dataSwitch_whileAlreadyForcingValidation_resetsClock() = testScope.runTest { - var latestConnectionFailed: Boolean? = null - val job = - underTest.isDefaultConnectionFailed - .onEach { latestConnectionFailed = it } - .launchIn(this) + val latest by collectLastValue(underTest.isDefaultConnectionFailed) connectionsRepository.mobileIsDefault.value = true connectionsRepository.defaultConnectionIsValidated.value = true + runCurrent() connectionsRepository.activeSubChangedInGroupEvent.emit(Unit) @@ -869,44 +845,37 @@ class MobileIconsInteractorTest : SysuiTestCase() { // 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 advanceTimeBy(1500) - assertThat(latestConnectionFailed).isFalse() + assertThat(latest).isFalse() // 2.001s from the second event advanceTimeBy(501) - assertThat(latestConnectionFailed).isTrue() - - job.cancel() + assertThat(latest).isTrue() } @Test fun isForceHidden_repoHasMobileHidden_true() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.isForceHidden.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isForceHidden) connectivityRepository.setForceHiddenIcons(setOf(ConnectivitySlot.MOBILE)) assertThat(latest).isTrue() - - job.cancel() } @Test fun isForceHidden_repoDoesNotHaveMobileHidden_false() = testScope.runTest { - var latest: Boolean? = null - val job = underTest.isForceHidden.onEach { latest = it }.launchIn(this) + val latest by collectLastValue(underTest.isForceHidden) connectivityRepository.setForceHiddenIcons(setOf(ConnectivitySlot.WIFI)) assertThat(latest).isFalse() - - job.cancel() } @Test diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/plugins/statusbar/StatusBarStateControllerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/plugins/statusbar/StatusBarStateControllerKosmos.kt index 3762497656c6..ec56327c1101 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/plugins/statusbar/StatusBarStateControllerKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/plugins/statusbar/StatusBarStateControllerKosmos.kt @@ -20,6 +20,7 @@ import com.android.internal.logging.uiEventLogger import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor import com.android.systemui.jank.interactionJankMonitor import com.android.systemui.keyguard.domain.interactor.keyguardClockInteractor +import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor import com.android.systemui.kosmos.Kosmos import com.android.systemui.scene.domain.interactor.sceneInteractor import com.android.systemui.shade.domain.interactor.shadeInteractor @@ -33,6 +34,7 @@ var Kosmos.statusBarStateController: SysuiStatusBarStateController by uiEventLogger, { interactionJankMonitor }, mock(), + { keyguardTransitionInteractor }, { shadeInteractor }, { deviceUnlockedInteractor }, { sceneInteractor }, diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/night/NightDisplayTileKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/night/NightDisplayTileKosmos.kt new file mode 100644 index 000000000000..5c21ab6e7fa8 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/night/NightDisplayTileKosmos.kt @@ -0,0 +1,24 @@ +/* + * 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.qs.tiles.impl.night + +import com.android.systemui.accessibility.qs.QSAccessibilityModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.qsEventLogger + +val Kosmos.qsNightDisplayTileConfig by + Kosmos.Fixture { QSAccessibilityModule.provideNightDisplayTileConfig(qsEventLogger) } diff --git a/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java b/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java index 0049213cbf55..d932bd4e6d20 100644 --- a/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java +++ b/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java @@ -32,6 +32,7 @@ import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; +import android.os.Bundle; import android.os.Environment; import android.os.HandlerThread; import android.os.LocaleList; @@ -101,6 +102,11 @@ class LocaleManagerBackupHelper { // the application setting the app-locale itself. private final SharedPreferences mDelegateAppLocalePackages; private final BroadcastReceiver mUserMonitor; + // To determine whether an app is pre-archived, check for Intent.EXTRA_ARCHIVAL upon receiving + // the initial PACKAGE_ADDED broadcast. If it is indeed pre-archived, perform the data + // restoration during the second PACKAGE_ADDED broadcast, which is sent subsequently when the + // app is installed. + private final Set<String> mPkgsToRestore; LocaleManagerBackupHelper(LocaleManagerService localeManagerService, PackageManager packageManager, HandlerThread broadcastHandlerThread) { @@ -119,6 +125,7 @@ class LocaleManagerBackupHelper { mStagedData = stagedData; mDelegateAppLocalePackages = delegateAppLocalePackages != null ? delegateAppLocalePackages : createPersistedInfo(); + mPkgsToRestore = new ArraySet<>(); mUserMonitor = new UserMonitor(); IntentFilter filter = new IntentFilter(); @@ -251,6 +258,9 @@ class LocaleManagerBackupHelper { LocalesInfo localesInfo = pkgStates.get(pkgName); // Check if the application is already installed for the concerned user. if (isPackageInstalledForUser(pkgName, userId)) { + if (mPkgsToRestore != null) { + mPkgsToRestore.remove(pkgName); + } // Don't apply the restore if the locales have already been set for the app. checkExistingLocalesAndApplyRestore(pkgName, localesInfo, userId); } else { @@ -279,23 +289,18 @@ class LocaleManagerBackupHelper { /** * <p><b>Note:</b> This is invoked by service's common monitor - * {@link LocaleManagerServicePackageMonitor#onPackageAdded} when a new package is + * {@link LocaleManagerServicePackageMonitor#onPackageAddedWithExtras} when a new package is * added on device. */ - void onPackageAdded(String packageName, int uid) { - try { - synchronized (mStagedDataLock) { - cleanStagedDataForOldEntriesLocked(); - - int userId = UserHandle.getUserId(uid); - if (mStagedData.contains(userId)) { - // Perform lazy restore only if the staged data exists. - doLazyRestoreLocked(packageName, userId); - } + void onPackageAddedWithExtras(String packageName, int uid, Bundle extras) { + boolean archived = false; + if (extras != null) { + archived = extras.getBoolean(Intent.EXTRA_ARCHIVAL, false); + if (archived && mPkgsToRestore != null) { + mPkgsToRestore.add(packageName); } - } catch (Exception e) { - Slog.e(TAG, "Exception in onPackageAdded.", e); } + checkStageDataAndApplyRestore(packageName, uid); } /** @@ -305,6 +310,10 @@ class LocaleManagerBackupHelper { */ void onPackageUpdateFinished(String packageName, int uid) { int userId = UserHandle.getUserId(uid); + if (mPkgsToRestore != null && mPkgsToRestore.contains(packageName)) { + mPkgsToRestore.remove(packageName); + checkStageDataAndApplyRestore(packageName, uid); + } cleanApplicationLocalesIfNeeded(packageName, userId); } @@ -338,6 +347,25 @@ class LocaleManagerBackupHelper { } } + private void checkStageDataAndApplyRestore(String packageName, int uid) { + try { + synchronized (mStagedDataLock) { + cleanStagedDataForOldEntriesLocked(); + + int userId = UserHandle.getUserId(uid); + if (mStagedData.contains(userId)) { + if (mPkgsToRestore != null) { + mPkgsToRestore.remove(packageName); + } + // Perform lazy restore only if the staged data exists. + doLazyRestoreLocked(packageName, userId); + } + } + } catch (Exception e) { + Slog.e(TAG, "Exception in onPackageAdded.", e); + } + } + private boolean isPackageInstalledForUser(String packageName, int userId) { PackageInfo pkgInfo = null; try { diff --git a/services/core/java/com/android/server/locales/LocaleManagerServicePackageMonitor.java b/services/core/java/com/android/server/locales/LocaleManagerServicePackageMonitor.java index ecd3614b4ff4..e0a050fbf157 100644 --- a/services/core/java/com/android/server/locales/LocaleManagerServicePackageMonitor.java +++ b/services/core/java/com/android/server/locales/LocaleManagerServicePackageMonitor.java @@ -17,6 +17,7 @@ package com.android.server.locales; import android.annotation.NonNull; +import android.os.Bundle; import android.os.UserHandle; import com.android.internal.content.PackageMonitor; @@ -48,8 +49,8 @@ final class LocaleManagerServicePackageMonitor extends PackageMonitor { } @Override - public void onPackageAdded(String packageName, int uid) { - mBackupHelper.onPackageAdded(packageName, uid); + public void onPackageAddedWithExtras(String packageName, int uid, Bundle extras) { + mBackupHelper.onPackageAddedWithExtras(packageName, uid, extras); } @Override diff --git a/services/core/java/com/android/server/pm/Settings.java b/services/core/java/com/android/server/pm/Settings.java index 1309e44af6d3..41d6288d4411 100644 --- a/services/core/java/com/android/server/pm/Settings.java +++ b/services/core/java/com/android/server/pm/Settings.java @@ -2139,10 +2139,17 @@ public final class Settings implements Watchable, Snappable, ResilientAtomicFile continue; } + ComponentName unflattenOriginalComponentName = ComponentName.unflattenFromString( + originalComponentName); + if (unflattenOriginalComponentName == null) { + Slog.d(TAG, "Incorrect component name from the attributes"); + continue; + } + activityInfos.add( new ArchiveState.ArchiveActivityInfo( title, - ComponentName.unflattenFromString(originalComponentName), + unflattenOriginalComponentName, iconPath, monochromeIconPath)); } diff --git a/services/core/java/com/android/server/wm/InputConfigAdapter.java b/services/core/java/com/android/server/wm/InputConfigAdapter.java index 119fafde6f77..ae6e72464555 100644 --- a/services/core/java/com/android/server/wm/InputConfigAdapter.java +++ b/services/core/java/com/android/server/wm/InputConfigAdapter.java @@ -20,8 +20,6 @@ import android.os.InputConfig; import android.view.InputWindowHandle.InputConfigFlags; import android.view.WindowManager.LayoutParams; -import java.util.List; - /** * A helper to determine the {@link InputConfigFlags} that control the behavior of an input window * from several WM attributes. @@ -47,7 +45,7 @@ class InputConfigAdapter { * input configurations that can be mapped directly from a corresponding LayoutParams input * feature. */ - private static final List<FlagMapping> INPUT_FEATURE_TO_CONFIG_MAP = List.of( + private static final FlagMapping[] INPUT_FEATURE_TO_CONFIG_MAP = { new FlagMapping( LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL, InputConfig.NO_INPUT_CHANNEL, false /* inverted */), @@ -59,7 +57,8 @@ class InputConfigAdapter { InputConfig.SPY, false /* inverted */), new FlagMapping( LayoutParams.INPUT_FEATURE_SENSITIVE_FOR_PRIVACY, - InputConfig.SENSITIVE_FOR_PRIVACY, false /* inverted */)); + InputConfig.SENSITIVE_FOR_PRIVACY, false /* inverted */) + }; @InputConfigFlags private static final int INPUT_FEATURE_TO_CONFIG_MASK = @@ -72,7 +71,7 @@ class InputConfigAdapter { * NOTE: The layout params flag {@link LayoutParams#FLAG_NOT_FOCUSABLE} is not handled by this * adapter, and must be handled explicitly. */ - private static final List<FlagMapping> LAYOUT_PARAM_FLAG_TO_CONFIG_MAP = List.of( + private static final FlagMapping[] LAYOUT_PARAM_FLAG_TO_CONFIG_MAP = { new FlagMapping( LayoutParams.FLAG_NOT_TOUCHABLE, InputConfig.NOT_TOUCHABLE, false /* inverted */), @@ -84,7 +83,8 @@ class InputConfigAdapter { InputConfig.WATCH_OUTSIDE_TOUCH, false /* inverted */), new FlagMapping( LayoutParams.FLAG_SLIPPERY, - InputConfig.SLIPPERY, false /* inverted */)); + InputConfig.SLIPPERY, false /* inverted */) + }; @InputConfigFlags private static final int LAYOUT_PARAM_FLAG_TO_CONFIG_MASK = @@ -119,7 +119,7 @@ class InputConfigAdapter { } @InputConfigFlags - private static int applyMapping(int flags, List<FlagMapping> flagToConfigMap) { + private static int applyMapping(int flags, FlagMapping[] flagToConfigMap) { int inputConfig = 0; for (final FlagMapping mapping : flagToConfigMap) { final boolean flagSet = (flags & mapping.mFlag) != 0; @@ -131,7 +131,7 @@ class InputConfigAdapter { } @InputConfigFlags - private static int computeMask(List<FlagMapping> flagToConfigMap) { + private static int computeMask(FlagMapping[] flagToConfigMap) { int mask = 0; for (final FlagMapping mapping : flagToConfigMap) { mask |= mapping.mInputConfig; diff --git a/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java index 40ecaf1770a9..7dd1847114c8 100644 --- a/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java +++ b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java @@ -42,6 +42,7 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Binder; +import android.os.Bundle; import android.os.HandlerThread; import android.os.LocaleList; import android.os.Process; @@ -488,7 +489,7 @@ public class LocaleManagerBackupRestoreTest { setUpPackageInstalled(pkgNameA); - mPackageMonitor.onPackageAdded(pkgNameA, DEFAULT_UID); + mPackageMonitor.onPackageAddedWithExtras(pkgNameA, DEFAULT_UID, new Bundle()); verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameA, DEFAULT_USER_ID, LocaleList.forLanguageTags(langTagsA), false, FrameworkStatsLog @@ -504,7 +505,7 @@ public class LocaleManagerBackupRestoreTest { setUpPackageInstalled(pkgNameB); - mPackageMonitor.onPackageAdded(pkgNameB, DEFAULT_UID); + mPackageMonitor.onPackageAddedWithExtras(pkgNameB, DEFAULT_UID, new Bundle()); verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameB, DEFAULT_USER_ID, LocaleList.forLanguageTags(langTagsB), true, FrameworkStatsLog @@ -518,6 +519,66 @@ public class LocaleManagerBackupRestoreTest { } @Test + public void testRestore_appInstalledAfterSUW_restoresFromStage_ArchiveEnabled() + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + HashMap<String, LocalesInfo> pkgLocalesMap = new HashMap<>(); + String pkgNameA = "com.android.myAppA"; + String pkgNameB = "com.android.myAppB"; + String langTagsA = "ru"; + String langTagsB = "hi,fr"; + LocalesInfo localesInfoA = new LocalesInfo(langTagsA, false); + LocalesInfo localesInfoB = new LocalesInfo(langTagsB, true); + pkgLocalesMap.put(pkgNameA, localesInfoA); + pkgLocalesMap.put(pkgNameB, localesInfoB); + writeTestPayload(out, pkgLocalesMap); + setUpPackageNotInstalled(pkgNameA); + setUpPackageNotInstalled(pkgNameB); + setUpLocalesForPackage(pkgNameA, LocaleList.getEmptyLocaleList()); + setUpLocalesForPackage(pkgNameB, LocaleList.getEmptyLocaleList()); + setUpPackageNamesForSp(new ArraySet<>()); + + Bundle bundle = new Bundle(); + bundle.putBoolean(Intent.EXTRA_ARCHIVAL, true); + mPackageMonitor.onPackageAddedWithExtras(pkgNameA, DEFAULT_UID, bundle); + mPackageMonitor.onPackageAddedWithExtras(pkgNameB, DEFAULT_UID, bundle); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verifyNothingRestored(); + + setUpPackageInstalled(pkgNameA); + + mPackageMonitor.onPackageUpdateFinished(pkgNameA, DEFAULT_UID); + + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameA, DEFAULT_USER_ID, + LocaleList.forLanguageTags(langTagsA), false, FrameworkStatsLog + .APPLICATION_LOCALES_CHANGED__CALLER__CALLER_BACKUP_RESTORE); + + mBackupHelper.persistLocalesModificationInfo(DEFAULT_USER_ID, pkgNameA, false, false); + + verify(mMockSpEditor, times(0)).putStringSet(anyString(), any()); + + pkgLocalesMap.remove(pkgNameA); + + verifyStageDataForUser(pkgLocalesMap, DEFAULT_CREATION_TIME_MILLIS, DEFAULT_USER_ID); + + setUpPackageInstalled(pkgNameB); + + mPackageMonitor.onPackageUpdateFinished(pkgNameB, DEFAULT_UID); + + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameB, DEFAULT_USER_ID, + LocaleList.forLanguageTags(langTagsB), true, FrameworkStatsLog + .APPLICATION_LOCALES_CHANGED__CALLER__CALLER_BACKUP_RESTORE); + + mBackupHelper.persistLocalesModificationInfo(DEFAULT_USER_ID, pkgNameB, true, false); + + verify(mMockSpEditor, times(1)).putStringSet(Integer.toString(DEFAULT_USER_ID), + new ArraySet<>(Arrays.asList(pkgNameB))); + checkStageDataDoesNotExist(DEFAULT_USER_ID); + } + + @Test public void testRestore_appInstalledAfterSUWAndLocalesAlreadySet_restoresNothing() throws Exception { final ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -535,7 +596,7 @@ public class LocaleManagerBackupRestoreTest { setUpPackageInstalled(DEFAULT_PACKAGE_NAME); setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, LocaleList.forLanguageTags("hi,mr")); - mPackageMonitor.onPackageAdded(DEFAULT_PACKAGE_NAME, DEFAULT_UID); + mPackageMonitor.onPackageAddedWithExtras(DEFAULT_PACKAGE_NAME, DEFAULT_UID, new Bundle()); // Since locales are already set, we should not restore anything for it. verifyNothingRestored(); @@ -612,7 +673,7 @@ public class LocaleManagerBackupRestoreTest { DEFAULT_CREATION_TIME_MILLIS + RETENTION_PERIOD.minusHours(1).toMillis()); setUpPackageInstalled(pkgNameA); - mPackageMonitor.onPackageAdded(pkgNameA, DEFAULT_UID); + mPackageMonitor.onPackageAddedWithExtras(pkgNameA, DEFAULT_UID, new Bundle()); verify(mMockLocaleManagerService, times(1)).setApplicationLocales( pkgNameA, DEFAULT_USER_ID, LocaleList.forLanguageTags(langTagsA), false, @@ -627,7 +688,7 @@ public class LocaleManagerBackupRestoreTest { DEFAULT_CREATION_TIME_MILLIS + RETENTION_PERIOD.plusSeconds(1).toMillis()); setUpPackageInstalled(pkgNameB); - mPackageMonitor.onPackageAdded(pkgNameB, DEFAULT_UID); + mPackageMonitor.onPackageAddedWithExtras(pkgNameB, DEFAULT_UID, new Bundle()); verify(mMockLocaleManagerService, times(0)).setApplicationLocales(eq(pkgNameB), anyInt(), any(), anyBoolean(), anyInt()); diff --git a/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/MainActivity.java b/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/MainActivity.java index e3f84c19653b..f1260008ca59 100644 --- a/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/MainActivity.java +++ b/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/MainActivity.java @@ -24,6 +24,7 @@ import static com.android.server.inputmethod.multisessiontest.TestRequestConstan import android.app.Activity; import android.os.Bundle; +import android.os.Process; import android.util.Log; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; @@ -47,8 +48,9 @@ public final class MainActivity extends ConcurrentUserActivityBase { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - Log.v(TAG, "Create MainActivity as user " + getUserId() + " on display " - + getDisplayId()); + Log.v(TAG, "Create MainActivity as user " + + Process.myUserHandle().getIdentifier() + " on display " + + getDisplay().getDisplayId()); setContentView(R.layout.main_activity); mImm = getSystemService(InputMethodManager.class); mEditor = requireViewById(R.id.edit_text); |