Sticker management v2 - Display available and installed stickers.

This commit is contained in:
Jeffrey Starke
2025-04-17 09:54:30 -04:00
committed by Cody Henthorne
parent e442c27555
commit 3d1895500c
9 changed files with 489 additions and 26 deletions

View File

@@ -123,7 +123,7 @@ public final class StickerManagementActivity extends PassphraseRequiredActivity
}
private void initViewModel() {
StickerManagementRepository repository = new StickerManagementRepository(this);
StickerManagementRepository repository = new StickerManagementRepository();
viewModel = new ViewModelProvider(this, new StickerManagementViewModel.Factory(getApplication(), repository)).get(StickerManagementViewModel.class);
viewModel.init();

View File

@@ -10,9 +10,13 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,16 +35,22 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Dividers
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.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus
import org.thoughtcrime.securesms.util.viewModel
/**
* Displays all of the available and installed sticker packs, enabling installation, uninstallation, and sorting.
*/
class StickerManagementActivityV2 : PassphraseRequiredActivity() {
companion object {
@JvmStatic
@@ -84,7 +94,7 @@ private fun StickerManagementScreen(
val pages = listOf(
Page(
title = stringResource(R.string.StickerManagement_available_tab_label),
getContent = { AvailableStickersContent(uiState.availablePacks) }
getContent = { AvailableStickersContent(blessedPacks = uiState.availableBlessedPacks, availablePacks = uiState.availablePacks) }
),
Page(
title = stringResource(R.string.StickerManagement_installed_tab_label),
@@ -164,23 +174,64 @@ private fun PagerTab(
@Composable
private fun AvailableStickersContent(
packs: List<AvailableStickerPack>
blessedPacks: List<AvailableStickerPack>,
availablePacks: List<AvailableStickerPack>,
modifier: Modifier = Modifier
) {
if (packs.isEmpty()) {
if (blessedPacks.isEmpty() && availablePacks.isEmpty()) {
EmptyView(text = stringResource(R.string.StickerManagement_available_tab_empty_text))
} else {
// TODO show available stickers list
LazyColumn(
contentPadding = PaddingValues(top = 8.dp),
modifier = modifier.fillMaxHeight()
) {
if (blessedPacks.isNotEmpty()) {
item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_signal_artist_series_header)) }
items(
items = blessedPacks,
key = { it.record.packId }
) {
AvailableStickerPackRow(it)
}
}
if (blessedPacks.isNotEmpty() && availablePacks.isNotEmpty()) {
item { Dividers.Default() }
}
if (availablePacks.isNotEmpty()) {
item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_stickers_you_received_header)) }
items(
items = availablePacks,
key = { it.record.packId }
) {
AvailableStickerPackRow(it)
}
}
}
}
}
@Composable
private fun InstalledStickersContent(
packs: List<InstalledStickerPack>
packs: List<InstalledStickerPack>,
modifier: Modifier = Modifier
) {
if (packs.isEmpty()) {
EmptyView(text = stringResource(R.string.StickerManagement_installed_tab_empty_text))
} else {
// TODO show installed stickers list
LazyColumn(
contentPadding = PaddingValues(top = 8.dp),
modifier = modifier.fillMaxHeight()
) {
item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_installed_stickers_header)) }
items(
items = packs,
key = { it.record.packId }
) {
InstalledStickerPackRow(it)
}
}
}
}
@@ -210,3 +261,58 @@ private fun StickerManagementScreenEmptyStatePreview() {
)
}
}
@SignalPreview
@Composable
private fun AvailableStickersContentPreview() {
Previews.Preview {
AvailableStickersContent(
blessedPacks = listOf(
StickerPreviewDataFactory.availablePack(
title = "Swoon / Faces",
author = "Swoon",
isBlessed = true
)
),
availablePacks = listOf(
StickerPreviewDataFactory.availablePack(
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = false,
downloadStatus = DownloadStatus.InProgress(progressPercent = 22.0)
),
StickerPreviewDataFactory.availablePack(
title = "Day by Day",
author = "Miguel Ángel Camprubí",
isBlessed = false,
downloadStatus = DownloadStatus.Downloaded
)
)
)
}
}
@SignalPreview
@Composable
private fun InstalledStickersContentPreview() {
Previews.Preview {
InstalledStickersContent(
packs = listOf(
StickerPreviewDataFactory.installedPack(
title = "Swoon / Faces",
author = "Swoon",
isBlessed = true
),
StickerPreviewDataFactory.installedPack(
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = true
),
StickerPreviewDataFactory.installedPack(
title = "Day by Day",
author = "Miguel Ángel Camprubí"
)
)
)
}
}

View File

@@ -46,12 +46,12 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter<String
private final List<StickerSection> sections = new ArrayList<StickerSection>(3) {{
StickerSection yourStickers = new StickerSection(TAG_YOUR_STICKERS,
R.string.StickerManagementAdapter_installed_stickers,
R.string.StickerManagement_installed_stickers_header,
R.string.StickerManagementAdapter_no_stickers_installed,
new ArrayList<>(),
0);
StickerSection messageStickers = new StickerSection(TAG_MESSAGE_STICKERS,
R.string.StickerManagementAdapter_stickers_you_received,
R.string.StickerManagement_stickers_you_received_header,
R.string.StickerManagementAdapter_stickers_from_incoming_messages_will_appear_here,
new ArrayList<>(),
yourStickers.size());
@@ -127,17 +127,17 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter<String
@NonNull List<StickerPackRecord> blessedPacks)
{
StickerSection yourStickers = new StickerSection(TAG_YOUR_STICKERS,
R.string.StickerManagementAdapter_installed_stickers,
R.string.StickerManagement_installed_stickers_header,
R.string.StickerManagementAdapter_no_stickers_installed,
installedPacks,
0);
StickerSection blessedStickers = new StickerSection(TAG_BLESSED_STICKERS,
R.string.StickerManagementAdapter_signal_artist_series,
R.string.StickerManagement_signal_artist_series_header,
0,
blessedPacks,
yourStickers.size());
StickerSection messageStickers = new StickerSection(TAG_MESSAGE_STICKERS,
R.string.StickerManagementAdapter_stickers_you_received,
R.string.StickerManagement_stickers_you_received_header,
R.string.StickerManagementAdapter_stickers_from_incoming_messages_will_appear_here,
availablePacks,
yourStickers.size() + (blessedPacks.isEmpty() ? 0 : blessedStickers.size()));
@@ -270,7 +270,7 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter<String
}
title.setText(titleBuilder);
author.setText(stickerPack.authorOptional.orElse(itemView.getResources().getString(R.string.StickerManagementAdapter_unknown)));
author.setText(stickerPack.authorOptional.orElse(itemView.getResources().getString(R.string.StickerManagement_author_unknown)));
divider.setVisibility(lastInList ? View.GONE : View.VISIBLE);
requestManager.load(new DecryptableUri(stickerPack.cover.uri))

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.stickers;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
@@ -16,19 +15,16 @@ import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackOperationJob;
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.ArrayList;
import java.util.List;
final class StickerManagementRepository {
private final Context context;
private final StickerTable stickerDatabase;
private final AttachmentTable attachmentDatabase;
StickerManagementRepository(@NonNull Context context) {
this.context = context.getApplicationContext();
StickerManagementRepository() {
this.stickerDatabase = SignalDatabase.stickers();
this.attachmentDatabase = SignalDatabase.attachments();
}

View File

@@ -10,13 +10,36 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.thoughtcrime.securesms.database.model.StickerPackRecord
import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus
class StickerManagementViewModelV2 : ViewModel() {
private val stickerManagementRepo = StickerManagementRepository()
private val _uiState = MutableStateFlow(StickerManagementUiState())
val uiState: StateFlow<StickerManagementUiState> = _uiState.asStateFlow()
init {
stickerManagementRepo.deleteOrphanedStickerPacks()
stickerManagementRepo.fetchUnretrievedReferencePacks()
loadStickerPacks()
}
private fun loadStickerPacks() {
stickerManagementRepo.getStickerPacks { result ->
_uiState.value = _uiState.value.copy(
availableBlessedPacks = result.blessedPacks
.map { AvailableStickerPack(record = it, isBlessed = true, downloadStatus = DownloadStatus.NotDownloaded) },
availablePacks = result.availablePacks
.map { AvailableStickerPack(record = it, isBlessed = false, downloadStatus = DownloadStatus.NotDownloaded) },
installedPacks = result.installedPacks
.mapIndexed { index, record -> InstalledStickerPack(record = record, isBlessed = BlessedPacks.contains(record.packId), sortOrder = index) }
)
}
}
}
data class StickerManagementUiState(
val availableBlessedPacks: List<AvailableStickerPack> = emptyList(),
val availablePacks: List<AvailableStickerPack> = emptyList(),
val installedPacks: List<InstalledStickerPack> = emptyList(),
val isMultiSelectMode: Boolean = false
@@ -35,7 +58,8 @@ data class AvailableStickerPack(
}
data class InstalledStickerPack(
private val record: StickerPackRecord,
val record: StickerPackRecord,
val isBlessed: Boolean,
val sortOrder: Int,
val isSelected: Boolean
val isSelected: Boolean = false
)

View File

@@ -0,0 +1,260 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.stickers
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.nullIfBlank
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.GlideImage
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus
@Composable
fun StickerPackSectionHeader(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 24.dp, vertical = 12.dp)
)
}
@Composable
fun AvailableStickerPackRow(
pack: AvailableStickerPack,
onStartDownloadClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.padding(start = 24.dp, top = 12.dp, end = 12.dp, bottom = 12.dp)
) {
StickerPackInfo(
coverImageUri = DecryptableUri(pack.record.cover.uri),
title = pack.record.title,
author = pack.record.author.nullIfBlank(),
showOfficialBadge = pack.isBlessed,
modifier = Modifier.weight(1f)
)
// TODO show TransferProgressIndicator based on download state
IconButtons.IconButton(
size = 48.dp,
onClick = onStartDownloadClick,
content = {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_arrow_circle_down_24),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
contentDescription = stringResource(R.string.StickerManagement_accessibility_download_pack, pack.record.title)
)
}
)
}
}
@Composable
fun InstalledStickerPackRow(
pack: InstalledStickerPack,
multiSelectModeEnabled: Boolean = false,
checked: Boolean = false,
onCheckedChange: (Boolean) -> Unit = {},
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.padding(12.dp)
) {
if (multiSelectModeEnabled) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = Modifier.padding(end = 8.dp)
)
}
StickerPackInfo(
coverImageUri = DecryptableUri(pack.record.cover.uri),
title = pack.record.title,
author = pack.record.author.nullIfBlank(),
showOfficialBadge = pack.isBlessed,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_drag_handle),
contentDescription = stringResource(R.string.StickerManagement_accessibility_drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier
.padding(horizontal = 12.dp)
.size(24.dp)
)
}
}
@Composable
private fun StickerPackInfo(
coverImageUri: DecryptableUri,
title: String,
author: String?,
showOfficialBadge: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth()
) {
GlideImage(
model = coverImageUri,
modifier = Modifier
.padding(end = 16.dp)
.size(56.dp)
)
Column(
modifier = Modifier.align(Alignment.CenterVertically)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (showOfficialBadge) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20),
contentDescription = null,
modifier = Modifier
.padding(horizontal = 4.dp)
.size(16.dp)
)
}
}
Text(
text = author ?: stringResource(R.string.StickerManagement_author_unknown),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@SignalPreview
@Composable
private fun StickerPackSectionHeaderPreview() = SignalTheme {
StickerPackSectionHeader(
text = "Signal artist series"
)
}
@SignalPreview
@Composable
private fun AvailableStickerPackRowPreviewBlessed() = SignalTheme {
AvailableStickerPackRow(
pack = StickerPreviewDataFactory.availablePack(
title = "Swoon / Faces",
author = "Swoon",
isBlessed = true
)
)
}
@SignalPreview
@Composable
private fun AvailableStickerPackRowPreviewNotBlessed() = SignalTheme {
AvailableStickerPackRow(
pack = StickerPreviewDataFactory.availablePack(
title = "Day by Day",
author = "Miguel Ángel Camprubí",
isBlessed = false,
downloadStatus = DownloadStatus.NotDownloaded
)
)
}
@SignalPreview
@Composable
private fun AvailableStickerPackRowPreviewDownloading() = SignalTheme {
AvailableStickerPackRow(
pack = StickerPreviewDataFactory.availablePack(
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = false,
downloadStatus = DownloadStatus.InProgress(progressPercent = 22.0)
)
)
}
@SignalPreview
@Composable
private fun AvailableStickerPackRowPreviewDownloaded() = SignalTheme {
AvailableStickerPackRow(
pack = StickerPreviewDataFactory.availablePack(
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = false,
downloadStatus = DownloadStatus.Downloaded
)
)
}
@SignalPreview
@Composable
private fun InstalledStickerPackRowPreview() = SignalTheme {
InstalledStickerPackRow(
multiSelectModeEnabled = false,
pack = StickerPreviewDataFactory.installedPack(
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = true
)
)
}
@SignalPreview
@Composable
private fun InstalledStickerPackRowSelectModePreview() = SignalTheme {
InstalledStickerPackRow(
multiSelectModeEnabled = true,
pack = StickerPreviewDataFactory.installedPack(
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = true
)
)
}

View File

@@ -177,7 +177,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 StickerManagementRepository(this))
new StickerManagementRepository())
).get(StickerPackPreviewViewModel.class);
viewModel.getStickerManifest(packId, packKey).observe(this, manifest -> {

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.stickers
import org.thoughtcrime.securesms.database.model.StickerPackRecord
import org.thoughtcrime.securesms.database.model.StickerRecord
import java.util.UUID
/**
* Generates sample sticker data to use in compose UI previews.
*/
object StickerPreviewDataFactory {
fun availablePack(
packId: String = UUID.randomUUID().toString(),
title: String,
author: String,
isBlessed: Boolean = false,
downloadStatus: AvailableStickerPack.DownloadStatus = AvailableStickerPack.DownloadStatus.NotDownloaded
): AvailableStickerPack = AvailableStickerPack(
record = StickerPackRecord(
packId = packId,
packKey = "packKey",
title = title,
author = author,
cover = StickerRecord(
rowId = 11,
packId = packId,
packKey = "packKey",
stickerId = 111,
emoji = "",
contentType = "image/webp",
size = 1111,
isCover = true
),
isInstalled = false
),
isBlessed = isBlessed,
downloadStatus = downloadStatus
)
fun installedPack(
packId: String = UUID.randomUUID().toString(),
title: String,
author: String,
isBlessed: Boolean = false
): InstalledStickerPack = InstalledStickerPack(
record = StickerPackRecord(
packId = packId,
packKey = "packKey",
title = title,
author = author,
cover = StickerRecord(
rowId = 11,
packId = packId,
packKey = "packKey",
stickerId = 111,
emoji = "",
contentType = "image/webp",
size = 1111,
isCover = true
),
isInstalled = true
),
isBlessed = isBlessed,
sortOrder = 0
)
}