Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 06f6baec authored by Lucas Silva's avatar Lucas Silva Committed by Android (Google) Code Review
Browse files

Merge "Implement lock icon for glanceable hub v2" into main

parents 97d868d7 e7a62122
Loading
Loading
Loading
Loading
+5 −9
Original line number Diff line number Diff line
@@ -19,13 +19,11 @@ package com.android.systemui.communal.ui.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntRect
@@ -35,6 +33,7 @@ import com.android.compose.animation.scene.ContentScope
import com.android.systemui.communal.domain.interactor.CommunalSettingsInteractor
import com.android.systemui.communal.smartspace.SmartspaceInteractionHandler
import com.android.systemui.communal.ui.compose.section.AmbientStatusBarSection
import com.android.systemui.communal.ui.compose.section.CommunalLockSection
import com.android.systemui.communal.ui.compose.section.CommunalPopupSection
import com.android.systemui.communal.ui.compose.section.CommunalToDreamButtonSection
import com.android.systemui.communal.ui.compose.section.HubOnboardingSection
@@ -43,7 +42,6 @@ import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
import com.android.systemui.keyguard.ui.composable.blueprint.BlueprintAlignmentLines
import com.android.systemui.keyguard.ui.composable.section.BottomAreaSection
import com.android.systemui.keyguard.ui.composable.section.LockSection
import com.android.systemui.res.R
import com.android.systemui.statusbar.phone.SystemUIDialogFactory
import javax.inject.Inject
import kotlin.math.min
@@ -58,6 +56,7 @@ constructor(
    private val communalSettingsInteractor: CommunalSettingsInteractor,
    private val dialogFactory: SystemUIDialogFactory,
    private val lockSection: LockSection,
    private val communalLockSection: CommunalLockSection,
    private val bottomAreaSection: BottomAreaSection,
    private val ambientStatusBarSection: AmbientStatusBarSection,
    private val communalPopupSection: CommunalPopupSection,
@@ -88,12 +87,9 @@ constructor(
                        with(hubOnboardingSection) { BottomSheet() }
                    }
                    if (communalSettingsInteractor.isV2FlagEnabled()) {
                        Icon(
                            painter = painterResource(id = R.drawable.ic_lock),
                            contentDescription = null,
                            tint = MaterialTheme.colorScheme.onPrimaryContainer,
                            modifier = Modifier.element(Communal.Elements.LockIcon),
                        )
                        with(communalLockSection) {
                            LockIcon(modifier = Modifier.element(Communal.Elements.LockIcon))
                        }
                    } else {
                        with(lockSection) {
                            LockIcon(
+150 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.communal.ui.compose.section

import android.content.Context
import android.util.DisplayMetrics
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.viewinterop.AndroidView
import com.android.compose.animation.scene.ContentScope
import com.android.compose.animation.scene.ElementKey
import com.android.systemui.biometrics.AuthController
import com.android.systemui.communal.ui.binder.CommunalLockIconViewBinder
import com.android.systemui.communal.ui.viewmodel.CommunalLockIconViewModel
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.flags.FeatureFlagsClassic
import com.android.systemui.flags.Flags
import com.android.systemui.keyguard.ui.composable.blueprint.BlueprintAlignmentLines
import com.android.systemui.keyguard.ui.view.DeviceEntryIconView
import com.android.systemui.log.LogBuffer
import com.android.systemui.log.LongPressHandlingViewLogger
import com.android.systemui.log.dagger.LongPressTouchLog
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.res.R
import com.android.systemui.statusbar.VibratorHelper
import dagger.Lazy
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope

class CommunalLockSection
@Inject
constructor(
    @Application private val applicationScope: CoroutineScope,
    private val windowManager: WindowManager,
    private val authController: AuthController,
    private val viewModel: Lazy<CommunalLockIconViewModel>,
    private val falsingManager: Lazy<FalsingManager>,
    private val vibratorHelper: Lazy<VibratorHelper>,
    private val featureFlags: FeatureFlagsClassic,
    @LongPressTouchLog private val logBuffer: LogBuffer,
) {
    @Composable
    fun ContentScope.LockIcon(modifier: Modifier = Modifier) {
        val context = LocalContext.current

        AndroidView(
            factory = { context ->
                DeviceEntryIconView(
                        context,
                        null,
                        logger = LongPressHandlingViewLogger(logBuffer, tag = TAG),
                    )
                    .apply {
                        id = R.id.device_entry_icon_view
                        CommunalLockIconViewBinder.bind(
                            applicationScope,
                            this,
                            viewModel.get(),
                            falsingManager.get(),
                            vibratorHelper.get(),
                        )
                    }
            },
            modifier =
                modifier.element(LockIconElementKey).layout { measurable, _ ->
                    val lockIconBounds = lockIconBounds(context)
                    val placeable =
                        measurable.measure(
                            Constraints.fixed(
                                width = lockIconBounds.width,
                                height = lockIconBounds.height,
                            )
                        )
                    layout(
                        width = placeable.width,
                        height = placeable.height,
                        alignmentLines =
                            mapOf(
                                BlueprintAlignmentLines.LockIcon.Left to lockIconBounds.left,
                                BlueprintAlignmentLines.LockIcon.Top to lockIconBounds.top,
                                BlueprintAlignmentLines.LockIcon.Right to lockIconBounds.right,
                                BlueprintAlignmentLines.LockIcon.Bottom to lockIconBounds.bottom,
                            ),
                    ) {
                        placeable.place(0, 0)
                    }
                },
        )
    }

    /** Returns the bounds of the lock icon, in window view coordinates. */
    private fun lockIconBounds(context: Context): IntRect {
        val windowViewBounds = windowManager.currentWindowMetrics.bounds
        var widthPx = windowViewBounds.right.toFloat()
        if (featureFlags.isEnabled(Flags.LOCKSCREEN_ENABLE_LANDSCAPE)) {
            val insets = windowManager.currentWindowMetrics.windowInsets
            // Assumed to be initially neglected as there are no left or right insets in portrait.
            // However, on landscape, these insets need to included when calculating the midpoint.
            @Suppress("DEPRECATION")
            widthPx -= (insets.systemWindowInsetLeft + insets.systemWindowInsetRight).toFloat()
        }
        val defaultDensity =
            DisplayMetrics.DENSITY_DEVICE_STABLE.toFloat() /
                DisplayMetrics.DENSITY_DEFAULT.toFloat()
        val lockIconRadiusPx = (defaultDensity * 36).toInt()

        val scaleFactor = authController.scaleFactor
        val bottomPaddingPx =
            context.resources.getDimensionPixelSize(
                com.android.systemui.customization.R.dimen.lock_icon_margin_bottom
            )
        val heightPx = windowViewBounds.bottom.toFloat()
        val (center, radius) =
            Pair(
                IntOffset(
                    x = (widthPx / 2).toInt(),
                    y = (heightPx - ((bottomPaddingPx + lockIconRadiusPx) * scaleFactor)).toInt(),
                ),
                (lockIconRadiusPx * scaleFactor).toInt(),
            )

        return IntRect(center, radius)
    }

    companion object {
        private const val TAG = "CommunalLockSection"
    }
}

private val LockIconElementKey = ElementKey("CommunalLockIcon")
+138 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.communal.view.viewmodel

import android.platform.test.flag.junit.FlagsParameterization
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.accessibility.domain.interactor.accessibilityInteractor
import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
import com.android.systemui.authentication.domain.interactor.AuthenticationResult
import com.android.systemui.authentication.domain.interactor.authenticationInteractor
import com.android.systemui.common.ui.domain.interactor.configurationInteractor
import com.android.systemui.communal.ui.viewmodel.CommunalLockIconViewModel
import com.android.systemui.deviceentry.domain.interactor.deviceEntryInteractor
import com.android.systemui.deviceentry.domain.interactor.deviceEntrySourceInteractor
import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor
import com.android.systemui.flags.andSceneContainer
import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
import com.android.systemui.keyguard.ui.view.DeviceEntryIconView
import com.android.systemui.keyguard.ui.viewmodel.DeviceEntryIconViewModel
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.backgroundScope
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.scene.shared.flag.SceneContainerFlag
import com.android.systemui.statusbar.phone.statusBarKeyguardViewManager
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import org.junit.Test
import org.junit.runner.RunWith
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(ParameterizedAndroidJunit4::class)
class CommunalLockIconViewModelTest(flags: FlagsParameterization) : SysuiTestCase() {
    companion object {
        @JvmStatic
        @Parameters(name = "{0}")
        fun getParams(): List<FlagsParameterization> {
            return FlagsParameterization.allCombinationsOf().andSceneContainer()
        }
    }

    init {
        mSetFlagsRule.setFlagsParameterization(flags)
    }

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()

    private val Kosmos.underTest by
        Kosmos.Fixture {
            CommunalLockIconViewModel(
                context = context,
                configurationInteractor = configurationInteractor,
                deviceEntryInteractor = deviceEntryInteractor,
                keyguardInteractor = keyguardInteractor,
                keyguardViewController = { statusBarKeyguardViewManager },
                deviceEntrySourceInteractor = deviceEntrySourceInteractor,
                accessibilityInteractor = accessibilityInteractor,
            )
        }

    @Test
    fun isLongPressEnabled_unlocked() =
        kosmos.runTest {
            val isLongPressEnabled by collectLastValue(underTest.isLongPressEnabled)
            setLockscreenDismissible()
            assertThat(isLongPressEnabled).isTrue()
        }

    @Test
    fun isLongPressEnabled_lock() =
        kosmos.runTest {
            val isLongPressEnabled by collectLastValue(underTest.isLongPressEnabled)
            if (!SceneContainerFlag.isEnabled) {
                fakeKeyguardRepository.setKeyguardDismissible(false)
            }
            assertThat(isLongPressEnabled).isFalse()
        }

    @Test
    fun iconType_locked() =
        kosmos.runTest {
            val viewAttributes by collectLastValue(underTest.viewAttributes)
            if (!SceneContainerFlag.isEnabled) {
                fakeKeyguardRepository.setKeyguardDismissible(false)
            }
            assertThat(viewAttributes?.type).isEqualTo(DeviceEntryIconView.IconType.LOCK)
        }

    @Test
    fun iconType_unlocked() =
        kosmos.runTest {
            val viewAttributes by collectLastValue(underTest.viewAttributes)
            setLockscreenDismissible()
            assertThat(viewAttributes?.type).isEqualTo(DeviceEntryIconView.IconType.UNLOCK)
        }

    private suspend fun Kosmos.setLockscreenDismissible() {
        if (SceneContainerFlag.isEnabled) {
            // Need to set up a collection for the authentication to be propagated.
            backgroundScope.launch { kosmos.deviceUnlockedInteractor.deviceUnlockStatus.collect {} }
            assertThat(
                    kosmos.authenticationInteractor.authenticate(
                        FakeAuthenticationRepository.DEFAULT_PIN
                    )
                )
                .isEqualTo(AuthenticationResult.SUCCEEDED)
        } else {
            fakeKeyguardRepository.setKeyguardDismissible(true)
        }
        testScope.advanceTimeBy(
            DeviceEntryIconViewModel.UNLOCKED_DELAY_MS * 2
        ) // wait for unlocked delay
    }
}
+162 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.communal.ui.binder

import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.util.Log
import android.util.StateSet
import android.view.HapticFeedbackConstants
import android.view.View
import androidx.core.view.isInvisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.systemui.common.ui.view.LongPressHandlingView
import com.android.systemui.communal.ui.viewmodel.CommunalLockIconViewModel
import com.android.systemui.keyguard.ui.view.DeviceEntryIconView
import com.android.systemui.lifecycle.repeatWhenAttached
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.res.R
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.kotlin.DisposableHandles
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle

object CommunalLockIconViewBinder {
    private const val TAG = "CommunalLockIconViewBinder"

    /**
     * Updates UI for:
     * - device entry containing view (parent view for the below views)
     *     - long-press handling view (transparent, no UI)
     *     - foreground icon view (lock/unlock)
     */
    @SuppressLint("ClickableViewAccessibility")
    @JvmStatic
    fun bind(
        applicationScope: CoroutineScope,
        view: DeviceEntryIconView,
        viewModel: CommunalLockIconViewModel,
        falsingManager: FalsingManager,
        vibratorHelper: VibratorHelper,
    ): DisposableHandle {
        val disposables = DisposableHandles()
        val longPressHandlingView = view.longPressHandlingView
        val fgIconView = view.iconView
        val bgView = view.bgView
        longPressHandlingView.listener =
            object : LongPressHandlingView.Listener {
                override fun onLongPressDetected(
                    view: View,
                    x: Int,
                    y: Int,
                    isA11yAction: Boolean,
                ) {
                    if (
                        !isA11yAction && falsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)
                    ) {
                        Log.d(
                            TAG,
                            "Long press rejected because it is not a11yAction " +
                                "and it is a falseLongTap",
                        )
                        return
                    }
                    vibratorHelper.performHapticFeedback(view, HapticFeedbackConstants.CONFIRM)
                    applicationScope.launch {
                        view.clearFocus()
                        view.clearAccessibilityFocus()
                        viewModel.onUserInteraction()
                    }
                }
            }

        longPressHandlingView.isInvisible = false
        view.isClickable = true
        longPressHandlingView.longPressDuration = {
            view.resources.getInteger(R.integer.config_lockIconLongPress).toLong()
        }
        bgView.visibility = View.GONE

        disposables +=
            view.repeatWhenAttached {
                repeatOnLifecycle(Lifecycle.State.CREATED) {
                    launch("$TAG#viewModel.isLongPressEnabled") {
                        viewModel.isLongPressEnabled.collect { isEnabled ->
                            longPressHandlingView.setLongPressHandlingEnabled(isEnabled)
                        }
                    }
                    launch("$TAG#viewModel.accessibilityDelegateHint") {
                        viewModel.accessibilityDelegateHint.collect { hint ->
                            view.accessibilityHintType = hint
                            if (hint != DeviceEntryIconView.AccessibilityHintType.NONE) {
                                view.setOnClickListener {
                                    vibratorHelper.performHapticFeedback(
                                        view,
                                        HapticFeedbackConstants.CONFIRM,
                                    )
                                    applicationScope.launch {
                                        view.clearFocus()
                                        view.clearAccessibilityFocus()
                                        viewModel.onUserInteraction()
                                    }
                                }
                            } else {
                                view.setOnClickListener(null)
                            }
                        }
                    }
                }
            }

        disposables +=
            fgIconView.repeatWhenAttached {
                repeatOnLifecycle(Lifecycle.State.STARTED) {
                    // Start with an empty state
                    fgIconView.setImageState(StateSet.NOTHING, /* merge */ false)
                    launch("$TAG#fpIconView.viewModel") {
                        viewModel.viewAttributes.collect { attributes ->
                            if (attributes.type.contentDescriptionResId != -1) {
                                fgIconView.contentDescription =
                                    fgIconView.resources.getString(
                                        attributes.type.contentDescriptionResId
                                    )
                            }
                            fgIconView.imageTintList = ColorStateList.valueOf(attributes.tint)
                            fgIconView.setPadding(
                                attributes.padding,
                                attributes.padding,
                                attributes.padding,
                                attributes.padding,
                            )
                            // Set image state at the end after updating other view state. This
                            // method forces the ImageView to recompute the bounds of the drawable.
                            fgIconView.setImageState(
                                view.getIconState(attributes.type, false),
                                /* merge */ false,
                            )
                            // Invalidate, just in case the padding changes just after icon changes
                            fgIconView.invalidate()
                        }
                    }
                }
            }
        return disposables
    }
}
+148 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading