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

Commit bfd4d32b authored by Wes Okuhara's avatar Wes Okuhara
Browse files

Screen Capture: Load icons in background and create button view model

Creates ScreenCaptureIconProvider which is reponsible for loading the
icon drawables in the UI background thread. This avoids UI jank if the
buttons were loaded in the main UI thread. Also declares view models for
the toolbar's radio button items.

Bug: 422855266
Test: atest ScreenCaptureViewModelTest
Flag: com.android.systemui.desktop_screen_capture
Change-Id: I774737d731de4897b6df50c134b7f92ad091185c
parent 248f1a24
Loading
Loading
Loading
Loading
+13 −53
Original line number Diff line number Diff line
@@ -24,13 +24,8 @@ import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.systemui.common.shared.model.Icon as IconModel
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.res.R
import com.android.systemui.screencapture.ui.viewmodel.ScreenCaptureRegion
import com.android.systemui.screencapture.ui.viewmodel.ScreenCaptureType
import com.android.systemui.screencapture.ui.viewmodel.ScreenCaptureViewModel

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -41,55 +36,20 @@ fun PreCaptureToolbar(
    onCloseClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // TODO(b/422855266): Preload icons in the view model to avoid loading icons in UI thread and
    // improve performance
    val screenshotIcon =
        IconModel.Resource(res = R.drawable.ic_screen_capture_camera, contentDescription = null)
    val screenRecordIcon =
        IconModel.Resource(res = R.drawable.ic_screenrecord, contentDescription = null)
    val screenshotFullscreenIcon =
        IconModel.Resource(res = R.drawable.ic_screen_capture_fullscreen, contentDescription = null)
    val screenshotRegionIcon =
        IconModel.Resource(res = R.drawable.ic_screen_capture_region, contentDescription = null)
    val screenshotWindowIcon =
        IconModel.Resource(res = R.drawable.ic_screen_capture_window, contentDescription = null)
    val moreOptionsIcon =
        IconModel.Resource(res = R.drawable.ic_more_vert, contentDescription = null)

    val captureRegionButtonItems =
        listOf(
            RadioButtonGroupItem(
                isSelected = (viewModel.captureRegion == ScreenCaptureRegion.APP_WINDOW),
                onClick = { viewModel.updateCaptureRegion(ScreenCaptureRegion.APP_WINDOW) },
                icon = screenshotWindowIcon,
            ),
            RadioButtonGroupItem(
                isSelected = (viewModel.captureRegion == ScreenCaptureRegion.PARTIAL),
                onClick = { viewModel.updateCaptureRegion(ScreenCaptureRegion.PARTIAL) },
                icon = screenshotRegionIcon,
            ),
            RadioButtonGroupItem(
                isSelected = (viewModel.captureRegion == ScreenCaptureRegion.FULLSCREEN),
                onClick = { viewModel.updateCaptureRegion(ScreenCaptureRegion.FULLSCREEN) },
                icon = screenshotFullscreenIcon,
            ),
        )

    val captureTypeButtonItems =
        listOf(
            RadioButtonGroupItem(
                isSelected = (viewModel.captureType == ScreenCaptureType.SCREEN_RECORD),
                onClick = { viewModel.updateCaptureType(ScreenCaptureType.SCREEN_RECORD) },
                icon = screenRecordIcon,
                label = stringResource(id = R.string.screen_capture_toolbar_record_button),
            ),
        viewModel.captureTypeButtonViewModels.map {
            RadioButtonGroupItem(
                isSelected = (viewModel.captureType == ScreenCaptureType.SCREENSHOT),
                onClick = { viewModel.updateCaptureType(ScreenCaptureType.SCREENSHOT) },
                icon = screenshotIcon,
                label = stringResource(id = R.string.screen_capture_toolbar_capture_button),
            ),
                label = it.label,
                icon = it.icon,
                isSelected = it.isSelected,
                onClick = it.onClick,
            )
        }

    val captureRegionButtonItems =
        viewModel.captureRegionButtonViewModels.map {
            RadioButtonGroupItem(icon = it.icon, isSelected = it.isSelected, onClick = it.onClick)
        }

    Toolbar(expanded = expanded, onCloseClick = onCloseClick, modifier = modifier) {
        Row {
@@ -98,7 +58,7 @@ fun PreCaptureToolbar(
                onCheckedChange = {},
                shape = IconButtonDefaults.smallSquareShape,
            ) {
                Icon(icon = moreOptionsIcon)
                viewModel.icons?.let { Icon(icon = it.moreOptions) }
            }

            Spacer(Modifier.size(8.dp))
+1 −7
Original line number Diff line number Diff line
@@ -47,13 +47,7 @@ data class RadioButtonGroupItem(
    val onClick: () -> Unit,
    val icon: IconModel? = null,
    val label: String? = null,
) {
    init {
        require(icon != null || label != null) {
            "A ButtonItem must have at least an icon or a label (or both)."
        }
    }
}
)

/** A group of N icon buttons where any single icon button is selected at a time. */
@Composable
+27 −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.screencapture.ui.viewmodel

import com.android.systemui.common.shared.model.Icon

/** Models a single button within the RadioButtonGroup. */
data class RadioButtonGroupItemViewModel(
    val label: String? = null,
    val icon: Icon? = null,
    val isSelected: Boolean,
    val onClick: () -> Unit,
)
+79 −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.screencapture.ui.viewmodel

import android.annotation.DrawableRes
import android.annotation.SuppressLint
import android.content.Context
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.UiBackground
import com.android.systemui.res.R
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext

data class ScreenCaptureIcons(
    val screenshot: Icon,
    val screenRecord: Icon,
    val fullscreen: Icon,
    val region: Icon,
    val appWindow: Icon,
    val moreOptions: Icon,
)

class ScreenCaptureIconProvider
@Inject
constructor(
    @Application private val context: Context,
    @UiBackground private val uiBackgroundContext: CoroutineContext,
) {
    private val _icons = MutableStateFlow<ScreenCaptureIcons?>(null)

    /** Static set of icons used in the UI. */
    val icons = _icons.asStateFlow()

    /** Loads all icon drawables in the UI background thread and emits them to [icons]. */
    suspend fun collectIcons() {
        flow {
                emit(
                    ScreenCaptureIcons(
                        screenshot = loadIcon(R.drawable.ic_screen_capture_camera),
                        screenRecord = loadIcon(R.drawable.ic_screenrecord),
                        fullscreen = loadIcon(R.drawable.ic_screen_capture_fullscreen),
                        region = loadIcon(R.drawable.ic_screen_capture_region),
                        appWindow = loadIcon(R.drawable.ic_screen_capture_window),
                        moreOptions = loadIcon(R.drawable.ic_more_vert),
                    )
                )
            }
            .collect(_icons)
    }

    /**
     * Load the icon drawables in the UI background thread to avoid loading them on the main UI
     * thread which can cause UI jank or dropped frames.
     */
    @SuppressLint("UseCompatLoadingForDrawables")
    private suspend fun loadIcon(@DrawableRes resourceId: Int): Icon.Loaded {
        val drawable = withContext(uiBackgroundContext) { context.getDrawable(resourceId)!! }
        return Icon.Loaded(drawable = drawable, res = resourceId, contentDescription = null)
    }
}
+79 −20
Original line number Diff line number Diff line
@@ -16,12 +16,16 @@

package com.android.systemui.screencapture.ui.viewmodel

import androidx.compose.runtime.getValue
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import android.content.Context
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.lifecycle.HydratedActivatable
import com.android.systemui.res.R
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch

enum class ScreenCaptureType {
    SCREENSHOT,
@@ -35,26 +39,38 @@ enum class ScreenCaptureRegion {
}

/** Models state for the Screen Capture UI */
class ScreenCaptureViewModel @AssistedInject constructor() : ExclusiveActivatable() {
    private val hydrator = Hydrator("ScreenCaptureViewModel.hydrator")

class ScreenCaptureViewModel
@AssistedInject
constructor(
    @Application private val context: Context,
    private val iconProvider: ScreenCaptureIconProvider,
) : HydratedActivatable() {
    private val captureTypeSource = MutableStateFlow(ScreenCaptureType.SCREENSHOT)
    private val captureRegionSource = MutableStateFlow(ScreenCaptureRegion.FULLSCREEN)

    val icons: ScreenCaptureIcons? by iconProvider.icons.hydratedStateOf()

    // TODO(b/423697394) Init default value to be user's previously selected option
    val captureType by
        hydrator.hydratedStateOf(
            traceName = "captureType",
            initialValue = ScreenCaptureType.SCREENSHOT,
            source = captureTypeSource,
        )
    val captureType: ScreenCaptureType by captureTypeSource.hydratedStateOf()

    // TODO(b/423697394) Init default value to be user's previously selected option
    val captureRegion by
        hydrator.hydratedStateOf(
            traceName = "captureRegion",
            initialValue = ScreenCaptureRegion.FULLSCREEN,
            source = captureRegionSource,
    val captureRegion: ScreenCaptureRegion by captureRegionSource.hydratedStateOf()

    val captureTypeButtonViewModels: List<RadioButtonGroupItemViewModel> by
        combine(captureTypeSource, iconProvider.icons) { selectedType, icons ->
                generateCaptureTypeButtonViewModels(selectedType, icons)
            }
            .hydratedStateOf(
                initialValue = generateCaptureTypeButtonViewModels(captureTypeSource.value, null)
            )

    val captureRegionButtonViewModels: List<RadioButtonGroupItemViewModel> by
        combine(captureRegionSource, iconProvider.icons) { selectedRegion, icons ->
                generateCaptureRegionButtonViewModels(selectedRegion, icons)
            }
            .hydratedStateOf(
                initialValue =
                    generateCaptureRegionButtonViewModels(captureRegionSource.value, null)
            )

    fun updateCaptureType(selectedType: ScreenCaptureType) {
@@ -65,8 +81,51 @@ class ScreenCaptureViewModel @AssistedInject constructor() : ExclusiveActivatabl
        captureRegionSource.value = selectedRegion
    }

    override suspend fun onActivated(): Nothing {
        hydrator.activate()
    override suspend fun onActivated() {
        coroutineScope { launch { iconProvider.collectIcons() } }
    }

    private fun generateCaptureTypeButtonViewModels(
        selectedType: ScreenCaptureType,
        icons: ScreenCaptureIcons?,
    ): List<RadioButtonGroupItemViewModel> {
        return listOf(
            RadioButtonGroupItemViewModel(
                icon = icons?.screenRecord,
                label = context.getString(R.string.screen_capture_toolbar_record_button),
                isSelected = selectedType == ScreenCaptureType.SCREEN_RECORD,
                onClick = { updateCaptureType(ScreenCaptureType.SCREEN_RECORD) },
            ),
            RadioButtonGroupItemViewModel(
                icon = icons?.screenshot,
                label = context.getString(R.string.screen_capture_toolbar_capture_button),
                isSelected = selectedType == ScreenCaptureType.SCREENSHOT,
                onClick = { updateCaptureType(ScreenCaptureType.SCREENSHOT) },
            ),
        )
    }

    private fun generateCaptureRegionButtonViewModels(
        selectedRegion: ScreenCaptureRegion,
        icons: ScreenCaptureIcons?,
    ): List<RadioButtonGroupItemViewModel> {
        return listOf(
            RadioButtonGroupItemViewModel(
                icon = icons?.appWindow,
                isSelected = (selectedRegion == ScreenCaptureRegion.APP_WINDOW),
                onClick = { updateCaptureRegion(ScreenCaptureRegion.APP_WINDOW) },
            ),
            RadioButtonGroupItemViewModel(
                icon = icons?.region,
                isSelected = (selectedRegion == ScreenCaptureRegion.PARTIAL),
                onClick = { updateCaptureRegion(ScreenCaptureRegion.PARTIAL) },
            ),
            RadioButtonGroupItemViewModel(
                icon = icons?.fullscreen,
                isSelected = (selectedRegion == ScreenCaptureRegion.FULLSCREEN),
                onClick = { updateCaptureRegion(ScreenCaptureRegion.FULLSCREEN) },
            ),
        )
    }

    @AssistedFactory
Loading