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

Commit fcd301bf authored by Anton Potapov's avatar Anton Potapov Committed by Android (Google) Code Review
Browse files

Merge "Add ability to show snackbars outside of SmallScreenPostRecordingActivity" into main

parents a19bf133 fd0e6beb
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -368,6 +368,8 @@
    <string name="screen_record_capture_target_choose_app">Choose an app to record</string>
    <!-- Snackbar shows after the screen recording is saved [CHAR LIMIT=20] -->
    <string name="screen_record_video_saved">Video saved</string>
    <!-- Snackbar shows after the screen recording is deleted. Undo action label [CHAR LIMIT=20] -->
    <string name="screen_record_undo">Undo</string>
    <!-- Button to retake a screen recording [CHAR LIMIT=20] -->
    <string name="screen_record_retake">Retake</string>
    <!-- Button to edit a screen recording [CHAR LIMIT=20] -->
+9 −0
Original line number Diff line number Diff line
@@ -1054,6 +1054,15 @@
        <item name="android:windowCloseOnTouchOutside">false</item>
    </style>

    <style name="ScreenCapture.PostRecord.SnackbarDialog" parent="@style/Theme.SystemUI.Dialog">
        <item name="android:backgroundDimEnabled">false</item>
        <item name="android:showWhenLocked">true</item>
        <item name="android:windowBackground">@color/transparent</item>
        <item name="android:enforceNavigationBarContrast">false</item>
        <item name="android:windowIsFloating">false</item>
        <item name="android:windowNoTitle">true</item>
    </style>

    <style name="TextAppearance.ScreenRecord" />

    <style name="TextAppearance.ScreenRecord.SwitchLabel">
+160 −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.record.smallscreen.ui

import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.WindowManager
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.res.R
import com.android.systemui.screencapture.common.ui.viewmodel.DrawableLoaderViewModelImpl
import com.android.systemui.screencapture.record.smallscreen.ui.compose.PostRecordSnackbar
import com.android.systemui.screencapture.record.smallscreen.ui.compose.SnackbarVisualsWithIcon
import com.android.systemui.statusbar.phone.DialogDelegate
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialogFactory
import com.android.systemui.statusbar.phone.create
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject

class PostRecordSnackbarDialogs
@Inject
constructor(
    @Application private val context: Context,
    private val dialogFactory: SystemUIDialogFactory,
    private val drawableViewModel: DrawableLoaderViewModelImpl,
    private val activityStarter: ActivityStarter,
) {

    fun showVideoSaved() {
        showSnackbar(
            SnackbarVisualsWithIcon(
                iconRes = R.drawable.ic_sync_saved_locally,
                message = context.getString(R.string.screen_record_video_saved),
            )
        )
    }

    fun showVideoDeleted(uri: Uri) {
        showSnackbar(
            visuals =
                SnackbarVisualsWithIcon(
                    iconRes = R.drawable.ic_screenshot_delete,
                    message = context.getString(R.string.screen_record_video_saved),
                    actionLabel = context.getString(R.string.screen_record_undo),
                ),
            onActionPerformed = {
                activityStarter.startActivity(
                    SmallScreenPostRecordingActivity.getStartingIntent(context, uri),
                    true,
                )
            },
            onDismissed = {
                val file = uri.path?.let(::File)
                with(file ?: return@showSnackbar) {
                    if (exists()) {
                        delete()
                    }
                }
            },
        )
    }

    private fun showSnackbar(
        visuals: SnackbarVisualsWithIcon,
        onActionPerformed: (() -> Unit)? = null,
        onDismissed: (() -> Unit)? = null,
    ) {
        val actionHandler =
            ActionHandler(onActionPerformed = onActionPerformed, onDismissed = onDismissed)
        dialogFactory
            .create(
                theme = R.style.ScreenCapture_PostRecord_SnackbarDialog,
                dialogDelegate = SnackbarDialogDelegate { actionHandler.notifyDismiss() },
            ) { dialog ->
                val snackbarHostState = remember { SnackbarHostState() }
                LaunchedEffect(visuals, onActionPerformed) {
                    when (snackbarHostState.showSnackbar(visuals)) {
                        SnackbarResult.ActionPerformed -> actionHandler.notifyAction()
                        SnackbarResult.Dismissed -> actionHandler.notifyDismiss()
                    }
                    dialog.dismissWithoutAnimation()
                }
                Box(contentAlignment = Alignment.Center) {
                    SnackbarHost(hostState = snackbarHostState) { data ->
                        PostRecordSnackbar(viewModel = drawableViewModel, data = data)
                    }
                }
            }
            .show()
    }
}

/** Ensures that only either [onActionPerformed] or [onDismissed] is called */
private class ActionHandler(
    private val onActionPerformed: (() -> Unit)? = null,
    private val onDismissed: (() -> Unit)? = null,
) {
    private val notified = AtomicBoolean(false)

    fun notifyDismiss() {
        if (notified.compareAndSet(false, true)) {
            onDismissed?.invoke()
        }
    }

    fun notifyAction() {
        if (notified.compareAndSet(false, true)) {
            onActionPerformed?.invoke()
        }
    }
}

private class SnackbarDialogDelegate(private val onDismissed: () -> Unit) :
    DialogDelegate<SystemUIDialog> {

    override fun onCreate(dialog: SystemUIDialog, savedInstanceState: Bundle?) {
        super.onCreate(dialog, savedInstanceState)
        dialog.setOnDismissListener { onDismissed() }
        with(dialog.window!!) {
            setGravity(Gravity.TOP)
            addFlags(
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
                    WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
                    WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
                    WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
            )
            addPrivateFlags(WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY)
            setWindowAnimations(-1)
        }
    }

    override fun getWidth(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.MATCH_PARENT

    override fun getHeight(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.WRAP_CONTENT
}
+16 −87
Original line number Diff line number Diff line
@@ -16,6 +16,9 @@

package com.android.systemui.screencapture.record.smallscreen.ui

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -38,29 +41,19 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.booleanResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.media3.common.MimeTypes
import com.android.compose.PlatformOutlinedButton
import com.android.compose.theme.PlatformTheme
import com.android.systemui.lifecycle.rememberViewModel
@@ -78,6 +71,7 @@ class SmallScreenPostRecordingActivity
constructor(
    private val videoPlayer: VideoPlayer,
    private val viewModelFactory: PostRecordingViewModel.Factory,
    private val postRecordSnackbarDialogs: PostRecordSnackbarDialogs,
) : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
@@ -93,19 +87,7 @@ constructor(
                viewModelFactory.create(intent.data ?: error("Data URI is missing"))
            }

        var shouldShowSavedToast by rememberSaveable { mutableStateOf(true) }
        val snackbarHostState = remember { SnackbarHostState() }

        LaunchedEffect(shouldShowSavedToast) {
            if (!shouldShowSavedToast) return@LaunchedEffect
            shouldShowSavedToast = true
            snackbarHostState.showSnackbar(
                SnackbarVisualsWithIcon(
                    iconRes = R.drawable.ic_sync_saved_locally,
                    message = getString(R.string.screen_record_video_saved),
                )
            )
        }
        LaunchedEffect(Unit) { postRecordSnackbarDialogs.showVideoSaved() }

        val shouldUseFlatBottomBar =
            booleanResource(R.bool.screen_record_post_recording_flat_bottom_bar)
@@ -142,7 +124,10 @@ constructor(
                        modifier = rowModifier,
                    )
                    PostRecordButton(
                        onClick = { viewModel.delete() },
                        onClick = {
                            postRecordSnackbarDialogs.showVideoDeleted(viewModel.videoUri)
                            finish()
                        },
                        drawableLoaderViewModel = viewModel,
                        iconRes = R.drawable.ic_screenshot_delete,
                        labelRes = R.string.screen_record_delete,
@@ -184,63 +169,15 @@ constructor(
                    modifier = Modifier.size(24.dp),
                )
            }
            SnackbarHost(
                hostState = snackbarHostState,
                modifier = Modifier.align(Alignment.TopCenter),
            ) { data ->
                PostRecordSnackbar(viewModel = viewModel, data = data, modifier = Modifier)
            }
        }
        }
    }

@Composable
private fun PostRecordSnackbar(
    viewModel: DrawableLoaderViewModel,
    data: SnackbarData,
    modifier: Modifier = Modifier,
) {
    val visuals = data.visuals as? SnackbarVisualsWithIcon ?: return
    Row(
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        verticalAlignment = Alignment.CenterVertically,
        modifier =
            modifier
                .background(
                    color = MaterialTheme.colorScheme.inverseSurface,
                    shape = RoundedCornerShape(percent = 50),
                )
                .padding(start = 12.dp, end = 20.dp)
                .height(48.dp),
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier =
                Modifier.background(
                        color = MaterialTheme.colorScheme.inverseOnSurface,
                        shape = CircleShape,
                    )
                    .size(24.dp),
        ) {
            LoadingIcon(
                icon = loadIcon(viewModel, visuals.iconRes, null).value,
                tint = MaterialTheme.colorScheme.inverseSurface,
                modifier = modifier.size(16.dp),
            )
        }
        Text(
            text = visuals.message,
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.inverseOnSurface,
        )
        if (visuals.actionLabel != null) {
            TextButton(onClick = data::performAction) {
                Text(
                    text = visuals.actionLabel,
                    style = MaterialTheme.typography.labelLarge,
                    color = MaterialTheme.colorScheme.inverseOnSurface,
                )
            }
    companion object {

        fun getStartingIntent(context: Context, videoUri: Uri): Intent {
            return Intent(context, SmallScreenPostRecordingActivity::class.java)
                .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
                .setDataAndType(videoUri, MimeTypes.VIDEO_MP4)
        }
    }
}
@@ -268,11 +205,3 @@ private fun PostRecordButton(
        Text(text = stringResource(labelRes), style = MaterialTheme.typography.labelLarge)
    }
}

private data class SnackbarVisualsWithIcon(
    override val message: String,
    @DrawableRes val iconRes: Int,
    override val actionLabel: String? = null,
    override val withDismissAction: Boolean = true,
    override val duration: SnackbarDuration = SnackbarDuration.Short,
) : SnackbarVisuals
+100 −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.record.smallscreen.ui.compose

import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.systemui.screencapture.common.ui.compose.LoadingIcon
import com.android.systemui.screencapture.common.ui.compose.loadIcon
import com.android.systemui.screencapture.common.ui.viewmodel.DrawableLoaderViewModel

data class SnackbarVisualsWithIcon(
    override val message: String,
    @DrawableRes val iconRes: Int,
    override val actionLabel: String? = null,
    override val withDismissAction: Boolean = true,
    override val duration: SnackbarDuration = SnackbarDuration.Short,
) : SnackbarVisuals

@Composable
fun PostRecordSnackbar(
    viewModel: DrawableLoaderViewModel,
    data: SnackbarData,
    modifier: Modifier = Modifier,
) {
    val visuals = data.visuals as? SnackbarVisualsWithIcon ?: return
    Row(
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        verticalAlignment = Alignment.CenterVertically,
        modifier =
            modifier
                .background(
                    color = MaterialTheme.colorScheme.inverseSurface,
                    shape = RoundedCornerShape(percent = 50),
                )
                .padding(start = 12.dp, end = 20.dp)
                .height(48.dp),
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier =
                Modifier.background(
                        color = MaterialTheme.colorScheme.inverseOnSurface,
                        shape = CircleShape,
                    )
                    .size(24.dp),
        ) {
            LoadingIcon(
                icon = loadIcon(viewModel, visuals.iconRes, null).value,
                tint = MaterialTheme.colorScheme.inverseSurface,
                modifier = modifier.size(16.dp),
            )
        }
        Text(
            text = visuals.message,
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.inverseOnSurface,
        )
        if (visuals.actionLabel != null) {
            TextButton(onClick = data::performAction) {
                Text(
                    text = visuals.actionLabel,
                    style = MaterialTheme.typography.labelLarge,
                    color = MaterialTheme.colorScheme.inverseOnSurface,
                )
            }
        }
    }
}
Loading