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

Commit 1cea9b2d authored by Behnam Heydarshahi's avatar Behnam Heydarshahi
Browse files

Flashlight Strength Slider & Dialog UI

Flag: com.android.systemui.flashlight_strength
Fixes: 399465569
Test: atest FlashlightSliderViewModelTest FlashlightDialogDelegateTest
Change-Id: If699b5fdbab72ab6f361ab60ab12541acad71d99
parent 66883bcd
Loading
Loading
Loading
Loading
+81 −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.flashlight.ui.dialog

import android.platform.test.annotations.EnableFlags
import android.testing.TestableLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.Expandable
import com.android.systemui.animation.mockDialogTransitionAnimator
import com.android.systemui.kosmos.runTest
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@SmallTest
@EnableFlags(com.android.systemui.Flags.FLAG_FLASHLIGHT_STRENGTH)
@RunWith(AndroidJUnit4::class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
class FlashlightDialogDelegateTest : SysuiTestCase() {

    val kosmos = testKosmos()

    @Test
    fun createAndShowDialog_whenNoExpandable_dialogIsShowing() =
        kosmos.runTest {
            val underTest = kosmos.flashlightDialogDelegate

            val dialog = underTest.createDialog()

            assertThat(dialog.isShowing).isFalse()

            underTest.showDialog()

            assertThat(dialog.isShowing).isTrue()
        }

    @Test
    fun showDialog_withExpandable_animates() =
        kosmos.runTest {
            val underTest = flashlightDialogDelegateWithMockAnimator
            val expandable = mock<Expandable> {}
            whenever(expandable.dialogTransitionController(any())).thenReturn(mock())

            underTest.showDialog(expandable)

            verify(mockDialogTransitionAnimator).show(any(), any(), anyBoolean())
        }

    @Test
    fun showDialog_withoutExpandable_doesNotAnimate() =
        kosmos.runTest {
            val underTest = flashlightDialogDelegateWithMockAnimator

            underTest.showDialog()

            verify(mockDialogTransitionAnimator, never()).show(any(), any(), anyBoolean())
        }
}
+192 −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.flashlight.ui.viewmodel

import android.hardware.camera2.CameraManager.TorchCallback
import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.camera.cameraManager
import com.android.systemui.flashlight.data.repository.startFlashlightRepository
import com.android.systemui.flashlight.domain.interactor.flashlightInteractor
import com.android.systemui.flashlight.shared.model.FlashlightModel
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.kotlin.verify

@SmallTest
@EnableFlags(com.android.systemui.Flags.FLAG_FLASHLIGHT_STRENGTH)
@RunWith(AndroidJUnit4::class)
class FlashlightSliderViewModelTest : SysuiTestCase() {

    val kosmos = testKosmos()
    val underTest = kosmos.flashlightSlicerViewModelFactory.create()

    @Before
    fun setUp() {
        kosmos.startFlashlightRepository(true)
        underTest.activateIn(kosmos.testScope)
    }

    @Test
    fun doNothing_initiallyNullAndThenLoadsInitialState() =
        kosmos.runTest {
            assertThat(underTest.currentFlashlightLevel).isNull()
            runCurrent()
            assertThat(underTest.currentFlashlightLevel)
                .isEqualTo(FlashlightModel.Available.Level(false, DEFAULT_LEVEL, MAX_LEVEL))
        }

    @Test
    fun setLevel_updatesState() =
        kosmos.runTest {
            runCurrent()
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(DEFAULT_LEVEL)

            underTest.setFlashlightLevel(1)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel).isNotNull()
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(1)

            underTest.setFlashlightLevel(2)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(2)
        }

    @Test
    fun setLevel0_stateDisablesAtDefaultLevel() =
        kosmos.runTest {
            underTest.setFlashlightLevel(0)
            runCurrent()

            val actualLevel = underTest.currentFlashlightLevel!!.level
            val enabled = underTest.currentFlashlightLevel!!.enabled

            assertThat(actualLevel).isEqualTo(DEFAULT_LEVEL)
            assertThat(enabled).isEqualTo(false)
        }

    @Test(expected = IllegalArgumentException::class)
    fun setLevelBelowZero_stateUnchanged() =
        kosmos.runTest {
            runCurrent()

            val originalState = underTest.currentFlashlightLevel!!

            underTest.setFlashlightLevel(-1)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel).isEqualTo(originalState)
        }

    @Test
    fun setLevelMax_stateMax() =
        kosmos.runTest {
            runCurrent()

            underTest.setFlashlightLevel(MAX_LEVEL)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(MAX_LEVEL)
        }

    @Test
    fun setLevel_whenCameraInUse_levelRemainsUnchanged() =
        kosmos.runTest {
            val torchCallbackCaptor = ArgumentCaptor.forClass(TorchCallback::class.java)
            runCurrent()
            verify(cameraManager).registerTorchCallback(any(), torchCallbackCaptor.capture())

            underTest.setFlashlightLevel(1)
            runCurrent()
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(1)

            torchCallbackCaptor.value.onTorchModeUnavailable(DEFAULT_ID)
            runCurrent()

            underTest.setFlashlightLevel(2)
            runCurrent()
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(1)
        }

    @Test(expected = IllegalArgumentException::class)
    fun setLevelAboveMax_stateUnchanged() =
        kosmos.runTest {
            runCurrent()
            val originalState = underTest.currentFlashlightLevel!!

            underTest.setFlashlightLevel(MAX_LEVEL + 1)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel).isEqualTo(originalState)
        }

    @Test
    fun updateInteractor_updatesState() =
        kosmos.runTest {
            flashlightInteractor.setEnabled(true)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel!!.enabled).isEqualTo(true)
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(DEFAULT_LEVEL)
            assertThat(underTest.currentFlashlightLevel!!.max).isEqualTo(MAX_LEVEL)

            flashlightInteractor.setLevel(1)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel!!.enabled).isEqualTo(true)
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(1)

            flashlightInteractor.setLevel(2)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel!!.enabled).isEqualTo(true)
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(2)

            // instead it can disable the flashlight
            flashlightInteractor.setEnabled(false)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel!!.enabled).isEqualTo(false)
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(DEFAULT_LEVEL)

            // can set level at max
            flashlightInteractor.setLevel(MAX_LEVEL)
            runCurrent()

            assertThat(underTest.currentFlashlightLevel!!.enabled).isEqualTo(true)
            assertThat(underTest.currentFlashlightLevel!!.level).isEqualTo(MAX_LEVEL)
        }

    private companion object {
        const val MAX_LEVEL = 45
        const val DEFAULT_LEVEL = 21
        const val DEFAULT_ID = "ID"
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -924,6 +924,10 @@
    <string name="quick_settings_flashlight_label">Flashlight</string>
    <!-- QuickSettings: Flashlight, used when it's not available due to camera in use [CHAR LIMIT=NONE] -->
    <string name="quick_settings_flashlight_camera_in_use">Camera in use</string>

    <!-- Flashlight dialog title [CHAR LIMIT=NONE] -->
    <string name="flashlight_dialog_title">Flashlight Strength</string>

    <!-- QuickSettings: Cellular detail panel title [CHAR LIMIT=NONE] -->
    <string name="quick_settings_cellular_detail_title">Mobile data</string>
    <!-- QuickSettings: Cellular detail panel, data usage title [CHAR LIMIT=NONE] -->
+1 −1
Original line number Diff line number Diff line
@@ -61,7 +61,7 @@ import kotlinx.coroutines.withTimeoutOrNull
 * used to enable/disable or set level of flashlight
 */
interface FlashlightRepository {
    val state: Flow<FlashlightModel>
    val state: StateFlow<FlashlightModel>

    val deviceSupportsFlashlight: Boolean

+132 −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.flashlight.ui.composable

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.flashlight.ui.viewmodel.FlashlightSliderViewModel
import com.android.systemui.haptics.slider.SeekableSliderTrackerConfig
import com.android.systemui.haptics.slider.SliderHapticFeedbackConfig
import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
import com.android.systemui.lifecycle.rememberViewModel

@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun FlashlightSlider(
    levelValue: Int,
    valueRange: IntRange,
    hapticsViewModelFactory: SliderHapticsViewModel.Factory,
    onLevelChanged: (Int) -> Unit,
) {

    var value by remember(levelValue) { mutableIntStateOf(levelValue) }

    val animatedValue by
        animateFloatAsState(targetValue = value.toFloat(), label = "FlashlightSliderAnimatedValue")
    val interactionSource = remember { MutableInteractionSource() }
    val floatValueRange = valueRange.first.toFloat()..valueRange.last.toFloat()

    val hapticsViewModel: SliderHapticsViewModel =
        rememberViewModel(traceName = "SliderHapticsViewModel") {
            hapticsViewModelFactory.create(
                interactionSource,
                floatValueRange,
                Orientation.Horizontal,
                SliderHapticFeedbackConfig(
                    maxVelocityToScale = 1f /* slider progress(from 0 to 1) per sec */
                ),
                SeekableSliderTrackerConfig(),
            )
        }

    Slider(
        value = animatedValue,
        valueRange = floatValueRange,
        onValueChange = {
            hapticsViewModel.onValueChange(it)
            value = it.toInt()
            onLevelChanged(it.toInt())
        },
        onValueChangeFinished = { hapticsViewModel.onValueChangeEnded() },
        interactionSource = interactionSource,
        thumb = {
            SliderDefaults.Thumb(
                interactionSource = interactionSource,
                thumbSize = DpSize(Constants.ThumbWidth, Constants.ThumbHeight),
            )
        },
        track = { sliderState ->
            SliderDefaults.Track(
                sliderState = sliderState,
                modifier = Modifier.height(40.dp),
                trackCornerSize = Constants.SliderTrackRoundedCorner,
                trackInsideCornerSize = Constants.TrackInsideCornerSize,
                drawStopIndicator = null,
                thumbTrackGapSize = Constants.ThumbTrackGapSize,
            )
        },
    )
}

@Composable
fun FlashlightSliderContainer(viewModel: FlashlightSliderViewModel, modifier: Modifier = Modifier) {
    val currentState = viewModel.currentFlashlightLevel ?: return
    val levelValue =
        if (currentState.enabled) {
            currentState.level
        } else { // flashlight off
            Constants
                .POSITION_ZERO // even if the "level" has been reset to "default" on the backend
        }

    val maxLevel = currentState.max

    Box(modifier = modifier.fillMaxWidth().sysuiResTag("flashlight_slider")) {
        FlashlightSlider(
            levelValue = levelValue,
            valueRange = Constants.POSITION_ZERO..maxLevel,
            hapticsViewModelFactory = viewModel.hapticsViewModelFactory,
            onLevelChanged = { viewModel.setFlashlightLevel(it) },
        )
    }
}

private object Constants {
    val SliderTrackRoundedCorner = 12.dp
    val ThumbTrackGapSize = 6.dp
    val ThumbHeight = 52.dp
    val ThumbWidth = 4.dp
    val TrackInsideCornerSize = 2.dp
    const val POSITION_ZERO = 0
}
Loading