From de12691cfe4872fc9ebd8d5d66cb48c22c477d95 Mon Sep 17 00:00:00 2001 From: Jeffrey Starke Date: Thu, 22 May 2025 11:09:53 -0400 Subject: [PATCH] Add new sticker pack preview screen. Adds a new jetpack compose and kotlin implementation of the sticker pack preview screen, which will soon replace `StickerPackPreviewActivity.java` and `StickerPackPreviewViewModel.java`. --- .../database/model/StickerPackRecord.kt | 21 + .../stickers/StickerPackPreviewActivity.java | 2 +- .../stickers/StickerPackPreviewActivityV2.kt | 425 ++++++++++++++++++ .../StickerPackPreviewRepository.java | 2 +- .../stickers/StickerPackPreviewViewModelV2.kt | 124 +++++ .../stickers/StickerPreviewDataFactory.kt | 14 + app/src/main/res/values/strings.xml | 5 + 7 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewViewModelV2.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerPackRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerPackRecord.kt index aea2aa132b..7275019ad0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerPackRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerPackRecord.kt @@ -1,5 +1,8 @@ package org.thoughtcrime.securesms.database.model +import android.net.Uri +import org.signal.core.util.orNull +import org.thoughtcrime.securesms.stickers.StickerUrl import java.util.Optional /** @@ -31,3 +34,21 @@ value class StickerPackId(val value: String) */ @JvmInline value class StickerPackKey(val value: String) + +data class StickerPackParams( + val id: StickerPackId, + val key: StickerPackKey +) { + companion object { + fun fromExternalUri(uri: Uri?): StickerPackParams? { + if (uri == null) return null + return (StickerUrl.parseActionUri(uri) ?: StickerUrl.parseShareLink(uri.toString())) + .map { parseResult -> + StickerPackParams( + id = StickerPackId(parseResult.first()), + key = StickerPackKey(parseResult.second()) + ) + }.orNull() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java index dbba0d7c25..7b54824882 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java @@ -176,7 +176,7 @@ public final class StickerPackPreviewActivity extends PassphraseRequiredActivity private void initViewModel(@NonNull String packId, @NonNull String packKey) { viewModel = new ViewModelProvider(this, new StickerPackPreviewViewModel.Factory(getApplication(), - new StickerPackPreviewRepository(this), + new StickerPackPreviewRepository(), StickerManagementRepository.INSTANCE) ).get(StickerPackPreviewViewModel.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt new file mode 100644 index 0000000000..0c68a24925 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt @@ -0,0 +1,425 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.stickers + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.load.Key +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.util.orNull +import org.signal.core.util.toOptional +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.GlideImage +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs +import org.thoughtcrime.securesms.database.model.StickerPackId +import org.thoughtcrime.securesms.database.model.StickerPackKey +import org.thoughtcrime.securesms.database.model.StickerPackParams +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.sharing.MultiShareArgs +import org.thoughtcrime.securesms.stickers.StickerPackPreviewUiState.ContentState +import org.thoughtcrime.securesms.stickers.StickerPackPreviewUiState.NavTarget +import org.thoughtcrime.securesms.util.DeviceProperties +import org.thoughtcrime.securesms.util.viewModel +import java.text.NumberFormat +import kotlin.jvm.optionals.getOrElse + +/** + * Shows the contents of a pack and allows the user to install it (if not installed) or remove it + * (if installed). This is also the handler for sticker pack deep links. + */ +class StickerPackPreviewActivityV2 : PassphraseRequiredActivity() { + companion object { + @JvmStatic + fun createIntent( + packId: StickerPackId, + packKey: StickerPackKey + ): Intent { + return Intent(Intent.ACTION_VIEW, StickerUrl.createActionUri(packId.value, packKey.value)).apply { + addCategory(Intent.CATEGORY_DEFAULT) + addCategory(Intent.CATEGORY_BROWSABLE) + } + } + } + + private val viewModel: StickerPackPreviewViewModelV2 by viewModel { + StickerPackPreviewViewModelV2( + params = StickerPackParams.fromExternalUri(intent.data) + ) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + + setContent { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + uiState.navTarget?.let(::navigateToTarget) + uiState.userMessage?.let(::showUserMessage) + + SignalTheme { + StickerPackPreviewScreen( + uiState = uiState, + callbacks = object : StickerPackPreviewScreenCallbacks { + override fun onNavigateTo(target: NavTarget) = navigateToTarget(target) + override fun onForwardClick(params: StickerPackParams) = openShareSheet(params) + override fun onInstallClick(params: StickerPackParams) = viewModel.installStickerPack(params) + override fun onUninstallClick(params: StickerPackParams) = viewModel.uninstallStickerPack(params) + } + ) + } + } + } + + private fun navigateToTarget(target: NavTarget) { + lifecycleScope.launch { + when (target) { + is NavTarget.Up -> { + lifecycleScope.launch { + if (target.delay != null) delay(target.delay.inWholeMilliseconds) + onBackPressedDispatcher.onBackPressed() + } + } + } + viewModel.setNavTargetConsumed() + } + } + + private fun showUserMessage(type: StickerPackPreviewUiState.MessageType) { + when (type) { + StickerPackPreviewUiState.MessageType.STICKER_PACK_LOAD_FAILED -> { + Toast.makeText(this, R.string.StickerPackPreviewActivity_failed_to_load_sticker_pack, Toast.LENGTH_SHORT).show() + } + } + viewModel.setUserMessageConsumed() + } + + private fun openShareSheet(params: StickerPackParams) { + supportFragmentManager.setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY, this) { _: String?, result: Bundle -> + if (result.getBoolean(MultiselectForwardFragment.RESULT_SENT, false)) { + navigateToTarget(NavTarget.Up()) + } + } + + MultiselectForwardFragment.showBottomSheet( + supportFragmentManager = supportFragmentManager, + multiselectForwardFragmentArgs = MultiselectForwardFragmentArgs( + multiShareArgs = listOf( + MultiShareArgs.Builder() + .withDraftText(StickerUrl.createShareLink(params.id.value, params.key.value)) + .build() + ), + title = R.string.StickerManagement_share_sheet_title + ) + ) + } +} + +interface StickerPackPreviewScreenCallbacks { + fun onNavigateTo(target: NavTarget) + fun onForwardClick(params: StickerPackParams) + fun onInstallClick(params: StickerPackParams) + fun onUninstallClick(params: StickerPackParams) + + object Empty : StickerPackPreviewScreenCallbacks { + override fun onNavigateTo(target: NavTarget) = Unit + override fun onForwardClick(params: StickerPackParams) = Unit + override fun onInstallClick(params: StickerPackParams) = Unit + override fun onUninstallClick(params: StickerPackParams) = Unit + } +} + +@Composable +private fun StickerPackPreviewScreen( + uiState: StickerPackPreviewUiState, + callbacks: StickerPackPreviewScreenCallbacks +) { + Scaffold( + topBar = { + TopAppBar( + stickerManifest = if (uiState.contentState is ContentState.HasData) uiState.contentState.stickerManifest else null, + onNavigateUp = { callbacks.onNavigateTo(NavTarget.Up()) }, + onForwardClick = callbacks::onForwardClick + ) + } + ) { padding -> + when (uiState.contentState) { + is ContentState.Loading -> { + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is ContentState.HasData -> { + uiState.navTarget?.let(callbacks::onNavigateTo) + + StickerPackPreviewContent( + contentState = uiState.contentState, + onInstallClick = callbacks::onInstallClick, + onUninstallClick = callbacks::onUninstallClick, + modifier = Modifier.padding(padding) + ) + } + + is ContentState.DataUnavailable -> Unit + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + stickerManifest: StickerManifest?, + onNavigateUp: () -> Unit, + onForwardClick: (params: StickerPackParams) -> Unit +) { + Scaffolds.DefaultTopAppBar( + title = "", // TODO collapse title into top app bar on scroll + titleContent = { _, text -> Text(text = text, style = MaterialTheme.typography.titleLarge) }, + navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24), + navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description), + onNavigationClick = onNavigateUp, + actions = { + if (stickerManifest != null) { + IconButton( + onClick = { onForwardClick(stickerManifest.params) }, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_forward_24), + contentDescription = stringResource(R.string.StickerManagement_menu_forward_pack) + ) + } + } + } + ) +} + +@Composable +private fun StickerPackPreviewContent( + modifier: Modifier = Modifier, + contentState: ContentState.HasData, + onInstallClick: (params: StickerPackParams) -> Unit, + onUninstallClick: (params: StickerPackParams) -> Unit +) { + Column( + modifier = modifier.padding(top = 0.dp, bottom = 12.dp, start = 16.dp, end = 16.dp) + ) { + StickerPackInfo( + stickerManifest = contentState.stickerManifest, + modifier = Modifier.fillMaxWidth() + ) + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 96.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + modifier = Modifier + .padding(top = 32.dp) + .weight(1f) + ) { + items( + items = contentState.stickerManifest.stickers, + key = { it.id } + ) { item -> + StickerImage( + sticker = item, + modifier = Modifier.size(96.dp) + ) + } + } + + val actionButtonModifier = Modifier + .width(412.dp) + .align(Alignment.CenterHorizontally) + .padding(top = 12.dp) + + if (contentState.isPackInstalled) { + Buttons.LargeTonal( + content = { Text(text = stringResource(R.string.StickerManagement_menu_remove_pack)) }, + onClick = { onUninstallClick(contentState.stickerManifest.params) }, + colors = ButtonDefaults.filledTonalButtonColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = actionButtonModifier + ) + } else { + Buttons.LargeTonal( + content = { Text(text = stringResource(R.string.StickerManagement_menu_install_pack)) }, + onClick = { onInstallClick(contentState.stickerManifest.params) }, + modifier = actionButtonModifier + ) + } + } +} + +@Composable +private fun StickerPackInfo( + modifier: Modifier = Modifier, + stickerManifest: StickerManifest +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + stickerManifest.cover.orNull()?.let { cover -> + StickerImage( + sticker = cover, + modifier = Modifier + .padding(bottom = 12.dp) + .size(80.dp) + ) + } + + stickerManifest.title.orNull()?.let { title -> + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium + ) + + if (BlessedPacks.contains(stickerManifest.packId)) { + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20), + contentDescription = null, + modifier = Modifier + .padding(horizontal = 6.dp) + .size(24.dp) + ) + } + } + } + + Text( + text = stickerManifest.author + .filter { it.isNotBlank() } + .getOrElse { stringResource(R.string.StickerManagement_author_unknown) }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp) + ) + + Text( + text = pluralStringResource( + R.plurals.StickerManagement_sticker_pack_preview_sticker_count, + stickerManifest.stickers.size, + NumberFormat.getInstance().format(stickerManifest.stickers.size) + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun StickerImage( + modifier: Modifier = Modifier, + sticker: StickerManifest.Sticker +) { + if (!LocalInspectionMode.current) { + GlideImage( + model = sticker.imageModel, + enableApngAnimation = DeviceProperties.shouldAllowApngStickerAnimation(LocalContext.current), + modifier = modifier + ) + } else { + Image( + painter = painterResource(id = R.drawable.ic_avatar_tucan), + contentDescription = null, + modifier = modifier + ) + } +} + +@SignalPreview +@Composable +private fun HasDataPreview() { + Previews.Preview { + val cover = StickerManifest.Sticker( + "packId0", + "packKey0", + 0, + "👍", + null + ) + + StickerPackPreviewScreen( + uiState = StickerPackPreviewUiState( + contentState = ContentState.HasData( + stickerManifest = StickerManifest( + cover.packId, + cover.packKey, + "Misbrands (The world's most hated IT stickers extended)".toOptional(), + "Sticker Pack Author".toOptional(), + cover.toOptional(), + StickerPreviewDataFactory.manifestStickers(33) + ), + isPackInstalled = false + ) + ), + callbacks = StickerPackPreviewScreenCallbacks.Empty + ) + } +} + +private val StickerManifest.Sticker.imageModel: Key + get() = uri + .map(DecryptableStreamUriLoader::DecryptableUri) + .getOrElse { StickerRemoteUri(packId, packKey, id) } + +private val StickerManifest.params: StickerPackParams + get() = StickerPackParams( + id = StickerPackId(packId), + key = StickerPackKey(packKey) + ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewRepository.java index cf574297b7..f75dc01e43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewRepository.java @@ -32,7 +32,7 @@ public final class StickerPackPreviewRepository { private final StickerTable stickerDatabase; private final SignalServiceMessageReceiver receiver; - public StickerPackPreviewRepository(@NonNull Context context) { + public StickerPackPreviewRepository() { this.receiver = AppDependencies.getSignalServiceMessageReceiver(); this.stickerDatabase = SignalDatabase.stickers(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewViewModelV2.kt new file mode 100644 index 0000000000..28f8d4313a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewViewModelV2.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.stickers + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.util.orNull +import org.thoughtcrime.securesms.database.model.StickerPackParams +import org.thoughtcrime.securesms.stickers.StickerPackPreviewUiState.ContentState +import kotlin.jvm.optionals.getOrElse +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class StickerPackPreviewViewModelV2( + params: StickerPackParams? +) : ViewModel() { + private val stickerPreviewRepo: StickerPackPreviewRepository = StickerPackPreviewRepository() + private val _uiState = MutableStateFlow(StickerPackPreviewUiState(contentState = ContentState.Loading)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + if (params != null) { + loadData(params) + } else { + showDataUnavailableState() + } + } + + private fun loadData(params: StickerPackParams) { + stickerPreviewRepo.getStickerManifest(params.id.value, params.key.value) { result -> + val stickerManifest = result.map { it.manifest }.orNull() + if (stickerManifest != null) { + _uiState.update { previousState -> + previousState.copy( + contentState = ContentState.HasData( + stickerManifest = stickerManifest, + isPackInstalled = result.map { it.isInstalled }.getOrElse { false } + ) + ) + } + } else { + showDataUnavailableState() + } + } + } + + fun installStickerPack(params: StickerPackParams) { + viewModelScope.launch { + StickerManagementRepository.installStickerPack(packId = params.id, packKey = params.key, notify = true) + updateInstalledState(true) + } + } + + fun uninstallStickerPack(params: StickerPackParams) { + viewModelScope.launch { + StickerManagementRepository.uninstallStickerPacks(mapOf(params.id to params.key)) + updateInstalledState(false) + } + } + + private fun updateInstalledState(isInstalled: Boolean) { + _uiState.update { previousState -> + previousState.copy( + contentState = if (previousState.contentState is ContentState.HasData) { + previousState.contentState.copy(isPackInstalled = isInstalled) + } else { + previousState.contentState + }, + navTarget = StickerPackPreviewUiState.NavTarget.Up(delay = 500.milliseconds) + ) + } + } + + private fun showDataUnavailableState() { + _uiState.update { previousState -> + previousState.copy( + contentState = ContentState.DataUnavailable, + userMessage = StickerPackPreviewUiState.MessageType.STICKER_PACK_LOAD_FAILED, + navTarget = StickerPackPreviewUiState.NavTarget.Up(delay = 1.seconds) + ) + } + } + + fun setNavTargetConsumed() { + _uiState.update { previousState -> previousState.copy(navTarget = null) } + } + + fun setUserMessageConsumed() { + _uiState.update { previousState -> previousState.copy(userMessage = null) } + } +} + +data class StickerPackPreviewUiState( + val contentState: ContentState, + val userMessage: MessageType? = null, + val navTarget: NavTarget? = null +) { + sealed interface ContentState { + data object Loading : ContentState + data object DataUnavailable : ContentState + + data class HasData( + val stickerManifest: StickerManifest, + val isPackInstalled: Boolean + ) : ContentState + } + + sealed interface NavTarget { + data class Up(val delay: Duration? = null) : NavTarget + } + + enum class MessageType { + STICKER_PACK_LOAD_FAILED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewDataFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewDataFactory.kt index 68fde81911..d15e7c39d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewDataFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewDataFactory.kt @@ -67,4 +67,18 @@ object StickerPreviewDataFactory { isBlessed = isBlessed, sortOrder = 0 ) + + fun manifestStickers(count: Int): List = buildList { + for (index in 0 until count) { + add( + StickerManifest.Sticker( + "packId-$index", + "packKey-$index", + index, + "😎", + "image/webp" + ) + ) + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b15b5e651e..fc86d0a699 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2843,6 +2843,11 @@ %1$s sticker pack removed. %1$s sticker packs removed. + + + %1$s sticker + %1$s stickers + Forward