Introduce the ability to change the app icon.

This commit is contained in:
Nicholas
2023-05-18 19:02:05 -04:00
committed by Greyson Parrelli
parent 7a555d127f
commit c963e99dca
67 changed files with 2697 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.appearance
import android.os.Build
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import org.signal.core.util.concurrent.observe
@@ -67,6 +68,15 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
}
)
if (Build.VERSION.SDK_INT >= 26) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__app_icon),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_appIconActivity)
}
)
}
radioListPref(
title = DSLSettingsText.from(R.string.preferences_chats__message_text_size),
listItems = messageFontSizeLabels,

View File

@@ -0,0 +1,306 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconPreset
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconUtility
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppIconSelectionFragment : ComposeFragment() {
private lateinit var appIconUtility: AppIconUtility
override fun onAttach(context: Context) {
super.onAttach(context)
appIconUtility = AppIconUtility(context)
}
@Composable
override fun FragmentContent() {
Scaffolds.Settings(
title = stringResource(id = R.string.preferences__app_icon),
onNavigationClick = {
findNavController().popBackStack()
},
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
IconSelectionScreen(appIconUtility.currentAppIcon, ::updateAppIcon, ::openLearnMore, Modifier.padding(contentPadding))
}
}
private fun updateAppIcon(preset: AppIconPreset) {
if (!appIconUtility.isCurrentlySelected(preset)) {
appIconUtility.setNewAppIcon(preset)
}
}
private fun openLearnMore() {
findNavController().safeNavigate(R.id.action_appIconSelectionFragment_to_appIconTutorialFragment)
}
/**
* Screen allowing the user to view all the possible icon and select a new one to use.
*/
@Composable
fun IconSelectionScreen(activeIcon: AppIconPreset, onItemConfirmed: (AppIconPreset) -> Unit, onWarningClick: () -> Unit, modifier: Modifier = Modifier) {
var showDialog: Boolean by remember { mutableStateOf(false) }
var pendingIcon: AppIconPreset by remember {
mutableStateOf(activeIcon)
}
if (showDialog) {
ChangeIconDialog(
pendingIcon = pendingIcon,
onConfirm = {
onItemConfirmed(pendingIcon)
showDialog = false
},
onDismiss = {
pendingIcon = activeIcon
showDialog = false
}
)
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
Spacer(modifier = Modifier.size(12.dp))
CaveatWarning(
onClick = onWarningClick,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
enumValues<AppIconPreset>().toList().chunked(COLUMN_COUNT).map { it.toImmutableList() }.forEach { items ->
IconRow(
presets = items,
isSelected = { it == pendingIcon },
onItemClick = {
pendingIcon = it
showDialog = true
}
)
}
}
}
}
@Composable
fun ChangeIconDialog(pendingIcon: AppIconPreset, onConfirm: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = onConfirm
) {
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_ok))
}
},
dismissButton = {
TextButton(
onClick = onDismiss
) {
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_cancel))
}
},
icon = {
AppIcon(preset = pendingIcon, isSelected = false, onClick = {})
},
title = {
Text(
text = stringResource(id = R.string.preferences__app_icon_dialog_title, stringResource(id = pendingIcon.labelResId)),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Text(
text = stringResource(id = R.string.preferences__app_icon_dialog_description),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
}
)
}
/**
* Composable rendering the one row of icons that the user may choose from.
*/
@Composable
fun IconRow(presets: ImmutableList<AppIconPreset>, isSelected: (AppIconPreset) -> Boolean, onItemClick: (AppIconPreset) -> Unit, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth()) {
presets.forEach { preset ->
val currentlySelected = isSelected(preset)
IconGridElement(
preset = preset,
isSelected = currentlySelected,
onClickHandler = {
if (!currentlySelected) {
onItemClick(preset)
}
},
modifier = Modifier
.padding(vertical = 18.dp)
.weight(1f)
)
}
}
}
/**
* Composable rendering an individual icon inside that grid, including the black border of the selected icon.
*/
@Composable
fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClickHandler: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
val boxModifier = Modifier.size(64.dp)
Box(
modifier = if (isSelected) boxModifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else boxModifier
) {
AppIcon(preset = preset, isSelected = isSelected, onClickHandler, modifier = Modifier.align(Alignment.Center))
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(id = preset.labelResId),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Composable rendering the multiple layers of an adaptive icon onto one flattened rasterized Canvas.
*/
@Composable
fun AppIcon(preset: AppIconPreset, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val bitmapSize: Dp = if (isSelected) 48.dp else 64.dp
val imageModifier = modifier
.size(bitmapSize)
.graphicsLayer(
shape = CircleShape,
shadowElevation = if (isSelected) 4f else 8f,
clip = true
)
.clickable(onClick = onClick)
Image(
painterResource(id = preset.iconPreviewResId),
contentDescription = stringResource(id = preset.labelResId),
modifier = imageModifier
)
}
/**
* A clickable "learn more" block of text.
*/
@Composable
fun CaveatWarning(onClick: () -> Unit, modifier: Modifier = Modifier) {
val learnMoreString = stringResource(R.string.preferences__app_icon_learn_more)
val completeString = stringResource(R.string.preferences__app_icon_warning_learn_more)
val learnMoreStartIndex = completeString.indexOf(learnMoreString).coerceAtLeast(0)
val learnMoreEndIndex = learnMoreStartIndex + learnMoreString.length
val doesStringEndWithLearnMore = learnMoreEndIndex >= completeString.lastIndex
val annotatedText = buildAnnotatedString {
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
append(completeString.substring(0, learnMoreStartIndex))
}
pushStringAnnotation(
tag = URL_TAG,
annotation = LEARN_MORE_TAG
)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary
)
) {
append(learnMoreString)
}
pop()
if (!doesStringEndWithLearnMore) {
append(completeString.substring(learnMoreEndIndex, completeString.lastIndex))
}
}
ClickableText(
text = annotatedText,
onClick = { _ ->
onClick()
},
style = MaterialTheme.typography.bodyMedium,
modifier = modifier
)
}
@Preview
@Composable
private fun MainScreenPreview() {
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
}
companion object {
val TAG = Log.tag(AppIconSelectionFragment::class.java)
private const val LEARN_MORE_TAG = "learn_more"
private const val URL_TAG = "URL"
private const val COLUMN_COUNT = 4
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
class AppIconTutorialFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
Scaffolds.Settings(
title = "",
onNavigationClick = {
findNavController().popBackStack()
},
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
TutorialScreen(Modifier.padding(contentPadding))
}
}
@Composable
fun TutorialScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.align(Alignment.Center)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
val borderShape = RoundedCornerShape(12.dp)
Text(
text = stringResource(R.string.preferences__app_icon_warning),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 20.dp)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.clip(borderShape)
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
) {
Image(
painter = painterResource(R.drawable.app_icon_tutorial_apps_homescreen),
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.widthIn(max = 328.dp)
)
}
Text(
text = stringResource(id = R.string.preferences__app_icon_notification_warning),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 20.dp)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.clip(borderShape)
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
) {
Image(
painter = painterResource(R.drawable.app_icon_tutorial_notification),
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.widthIn(max = 328.dp)
)
}
}
}
}
@Preview
@Composable
private fun TutorialScreenPreview() {
TutorialScreen()
}
companion object {
val TAG = Log.tag(AppIconTutorialFragment::class.java)
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
import android.content.ComponentName
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.R
enum class AppIconPreset(private val componentName: String, @DrawableRes val iconPreviewResId: Int, @StringRes val labelResId: Int) {
DEFAULT(".RoutingActivity", R.drawable.ic_app_icon_default_top_preview, R.string.app_name),
WHITE(".RoutingActivityAltWhite", R.drawable.ic_app_icon_signal_white_top_preview, R.string.app_name),
COLOR(".RoutingActivityAltColor", R.drawable.ic_app_icon_signal_color_top_preview, R.string.app_name),
DARK(".RoutingActivityAltDark", R.drawable.ic_app_icon_signal_dark_top_preview, R.string.app_name),
DARK_VARIANT(".RoutingActivityAltDarkVariant", R.drawable.ic_app_icon_signal_dark_variant_top_preview, R.string.app_name),
CHAT(".RoutingActivityAltChat", R.drawable.ic_app_icon_chat_top_preview, R.string.app_name),
BUBBLES(".RoutingActivityAltBubbles", R.drawable.ic_app_icon_bubbles_top_preview, R.string.app_name),
YELLOW(".RoutingActivityAltYellow", R.drawable.ic_app_icon_yellow_top_preview, R.string.app_name),
NEWS(".RoutingActivityAltNews", R.drawable.ic_app_icon_news_top_preview, R.string.app_icon_label_news),
NOTES(".RoutingActivityAltNotes", R.drawable.ic_app_icon_notes_top_preview, R.string.app_icon_label_notes),
WEATHER(".RoutingActivityAltWeather", R.drawable.ic_app_icon_weather_top_preview, R.string.app_icon_label_weather),
WAVES(".RoutingActivityAltWaves", R.drawable.ic_app_icon_waves_top_preview, R.string.app_icon_label_waves);
fun getComponentName(context: Context): ComponentName {
val applicationContext = context.applicationContext
return ComponentName(applicationContext, applicationContext.packageName + componentName)
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import org.signal.core.util.logging.Log
class AppIconUtility(context: Context) {
private val applicationContext: Context = context.applicationContext
private val pm = applicationContext.packageManager
val currentAppIcon by lazy { readCurrentAppIconFromPackageManager() }
fun isCurrentlySelected(preset: AppIconPreset): Boolean {
return preset == currentAppIcon
}
fun currentAppIconComponentName(): ComponentName {
return currentAppIcon.getComponentName(applicationContext) ?: AppIconPreset.DEFAULT.getComponentName(applicationContext)
}
fun setNewAppIcon(desiredAppIcon: AppIconPreset) {
currentAppIcon.let {
pm.setComponentEnabledSetting(it.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
}
pm.setComponentEnabledSetting(desiredAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
}
private fun readCurrentAppIconFromPackageManager(): AppIconPreset {
val activeIcon = enumValues<AppIconPreset>().firstOrNull {
val componentName = it.getComponentName(applicationContext)
val componentEnabledSetting = pm.getComponentEnabledSetting(componentName)
Log.d(TAG, "Found $componentName with state of $componentEnabledSetting")
if (it == AppIconPreset.DEFAULT && componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) {
return it
}
componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
return if (activeIcon == null) {
setNewAppIcon(AppIconPreset.DEFAULT)
AppIconPreset.DEFAULT
} else {
activeIcon
}
}
companion object {
private const val TAG = "AppIconUtility"
}
}

View File

@@ -17,6 +17,7 @@ import com.google.common.collect.Sets;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconUtility;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
@@ -173,8 +174,10 @@ public final class ConversationUtil {
List<ShortcutInfoCompat> shortcuts = new ArrayList<>(rankedRecipients.size());
ComponentName activityName = new AppIconUtility(context).currentAppIconComponentName();
for (int i = 0; i < rankedRecipients.size(); i++) {
ShortcutInfoCompat info = buildShortcutInfo(context, rankedRecipients.get(i), i, Direction.NONE);
ShortcutInfoCompat info = buildShortcutInfo(context, activityName, rankedRecipients.get(i), i, Direction.NONE);
shortcuts.add(info);
}
@@ -188,7 +191,10 @@ public final class ConversationUtil {
*/
@WorkerThread
private static boolean pushShortcutForRecipientInternal(@NonNull Context context, @NonNull Recipient recipient, int rank, @NonNull Direction direction) {
ShortcutInfoCompat shortcutInfo = buildShortcutInfo(context, recipient, rank, direction);
ComponentName activityName = new AppIconUtility(context).currentAppIconComponentName();
ShortcutInfoCompat shortcutInfo = buildShortcutInfo(context, activityName, recipient, rank, direction);
return ShortcutManagerCompat.pushDynamicShortcut(context, shortcutInfo);
}
@@ -203,6 +209,7 @@ public final class ConversationUtil {
*/
@WorkerThread
private static @NonNull ShortcutInfoCompat buildShortcutInfo(@NonNull Context context,
@NonNull ComponentName activity,
@NonNull Recipient recipient,
int rank,
@NonNull Direction direction)
@@ -222,7 +229,7 @@ public final class ConversationUtil {
.setIcon(AvatarUtil.getIconCompatForShortcut(context, resolved))
.setPersons(persons)
.setCategories(Sets.newHashSet(CATEGORY_SHARE_TARGET))
.setActivity(new ComponentName(context, "org.thoughtcrime.securesms.RoutingActivity"))
.setActivity(activity)
.setRank(rank)
.setLocusId(new LocusIdCompat(shortcutId));