Swap to generated pseudo-language for stress tests.
This commit is contained in:
parent
c4fbd3411b
commit
5ade9c4817
23 changed files with 839 additions and 124 deletions
|
|
@ -25,6 +25,7 @@ plugins {
|
|||
id("com.squareup.wire")
|
||||
id("translations")
|
||||
id("licenses")
|
||||
id("pseudo-locales")
|
||||
}
|
||||
|
||||
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
|
||||
|
|
|
|||
|
|
@ -68,16 +68,6 @@ import java.time.format.DateTimeFormatter
|
|||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val DAY_TO_STARTING_LETTER: Map<DayOfWeek, Int> = mapOf(
|
||||
DayOfWeek.SUNDAY to R.string.EditNotificationProfileSchedule__sunday_first_letter,
|
||||
DayOfWeek.MONDAY to R.string.EditNotificationProfileSchedule__monday_first_letter,
|
||||
DayOfWeek.TUESDAY to R.string.EditNotificationProfileSchedule__tuesday_first_letter,
|
||||
DayOfWeek.WEDNESDAY to R.string.EditNotificationProfileSchedule__wednesday_first_letter,
|
||||
DayOfWeek.THURSDAY to R.string.EditNotificationProfileSchedule__thursday_first_letter,
|
||||
DayOfWeek.FRIDAY to R.string.EditNotificationProfileSchedule__friday_first_letter,
|
||||
DayOfWeek.SATURDAY to R.string.EditNotificationProfileSchedule__saturday_first_letter
|
||||
)
|
||||
|
||||
@VisibleForTesting
|
||||
object EditNotificationProfileScheduleTestTags {
|
||||
const val SCROLLER = "scroller"
|
||||
|
|
@ -312,6 +302,7 @@ private fun DayToggle(
|
|||
}
|
||||
val textColor = if (checked && enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val dayName = remember(day, locale) { day.getDisplayName(TextStyle.FULL, locale) }
|
||||
val dayLetter = remember(day, locale) { day.getDisplayName(TextStyle.NARROW, locale) }
|
||||
|
||||
val textStyle = MaterialTheme.typography.bodyLarge
|
||||
val circleSize = with(LocalDensity.current) { maxOf(32.dp, textStyle.lineHeight.toDp() + 8.dp) }
|
||||
|
|
@ -336,7 +327,7 @@ private fun DayToggle(
|
|||
.background(color = background, shape = CircleShape)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(DAY_TO_STARTING_LETTER.getValue(day)),
|
||||
text = dayLetter,
|
||||
style = textStyle,
|
||||
color = textColor
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.platform.testTag
|
||||
|
|
@ -117,8 +116,6 @@ fun AddGroupDetailsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
private val MIN_HEIGHT_FOR_STICKY_HEADER = 480.dp
|
||||
|
||||
@Composable
|
||||
private fun AddGroupDetailsContent(
|
||||
state: AddGroupDetailsState,
|
||||
|
|
@ -133,8 +130,6 @@ private fun AddGroupDetailsContent(
|
|||
}
|
||||
}
|
||||
|
||||
val isHeaderSticky = LocalConfiguration.current.screenHeightDp.dp >= MIN_HEIGHT_FOR_STICKY_HEADER
|
||||
|
||||
val header: @Composable () -> Unit = {
|
||||
Column {
|
||||
NameAndAvatar(state = state, focusRequester = focusRequester, onEvent = onEvent)
|
||||
|
|
@ -151,22 +146,15 @@ private fun AddGroupDetailsContent(
|
|||
}
|
||||
}
|
||||
|
||||
if (isHeaderSticky) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
header()
|
||||
MemberList(state = state, onEvent = onEvent, modifier = Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
MemberList(state = state, onEvent = onEvent, header = header, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
MemberList(state = state, onEvent = onEvent, header = header, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberList(
|
||||
state: AddGroupDetailsState,
|
||||
onEvent: (AddGroupDetailsEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
header: (@Composable () -> Unit)? = null
|
||||
header: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
|
|
@ -176,10 +164,8 @@ private fun MemberList(
|
|||
.imePadding()
|
||||
.testTag(AddGroupDetailsTestTags.SCROLLER)
|
||||
) {
|
||||
if (header != null) {
|
||||
item(key = "header") {
|
||||
header()
|
||||
}
|
||||
item(key = "header") {
|
||||
header()
|
||||
}
|
||||
|
||||
if (state.members.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -5934,20 +5934,6 @@
|
|||
<string name="EditNotificationProfileSchedule__start">Start</string>
|
||||
<!-- Label for showing the end time for the schedule -->
|
||||
<string name="EditNotificationProfileSchedule__end">End</string>
|
||||
<!-- First letter of Sunday -->
|
||||
<string name="EditNotificationProfileSchedule__sunday_first_letter">S</string>
|
||||
<!-- First letter of Monday -->
|
||||
<string name="EditNotificationProfileSchedule__monday_first_letter">M</string>
|
||||
<!-- First letter of Tuesday -->
|
||||
<string name="EditNotificationProfileSchedule__tuesday_first_letter">T</string>
|
||||
<!-- First letter of Wednesday -->
|
||||
<string name="EditNotificationProfileSchedule__wednesday_first_letter">W</string>
|
||||
<!-- First letter of Thursday -->
|
||||
<string name="EditNotificationProfileSchedule__thursday_first_letter">T</string>
|
||||
<!-- First letter of Friday -->
|
||||
<string name="EditNotificationProfileSchedule__friday_first_letter">F</string>
|
||||
<!-- First letter of Saturday -->
|
||||
<string name="EditNotificationProfileSchedule__saturday_first_letter">S</string>
|
||||
<!-- Title of select time dialog shown when setting start time for schedule -->
|
||||
<string name="EditNotificationProfileSchedule__set_start_time">Set start time</string>
|
||||
<!-- Title of select time dialog shown when setting end time for schedule -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.tasks.CacheableTask
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.signal.buildtools.PseudoLanguage
|
||||
|
||||
/**
|
||||
* Writes every [PseudoLanguage] translation of a module's English strings as a resource directory of its own.
|
||||
*/
|
||||
@CacheableTask
|
||||
abstract class GeneratePseudoLocalesTask : DefaultTask() {
|
||||
@get:InputFile
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val englishStrings: RegularFileProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDirectory: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val english = englishStrings.get().asFile
|
||||
val output = outputDirectory.get().asFile
|
||||
output.deleteRecursively()
|
||||
|
||||
for (language in PseudoLanguage.entries) {
|
||||
val directory = output.resolve(language.valuesDirectoryName).apply { mkdirs() }
|
||||
directory.resolve("strings.xml").writeText(language.translateStringsXml(english))
|
||||
}
|
||||
}
|
||||
}
|
||||
22
build-logic/plugins/src/main/java/pseudo-locales.gradle.kts
Normal file
22
build-logic/plugins/src/main/java/pseudo-locales.gradle.kts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import com.android.build.api.variant.AndroidComponentsExtension
|
||||
|
||||
/**
|
||||
* Adds the [org.signal.buildtools.PseudoLanguage] translations of a module's English strings to its debug builds, which
|
||||
* unit tests and previews use, so layouts can be stress tested against them without shipping them. Apply it after
|
||||
* the Android plugin.
|
||||
*/
|
||||
|
||||
val moduleEnglishStrings = layout.projectDirectory.file("src/main/res/values/strings.xml")
|
||||
|
||||
extensions.configure<AndroidComponentsExtension<*, *, *>>("androidComponents") {
|
||||
onVariants(selector().withBuildType("debug")) { variant ->
|
||||
if (!moduleEnglishStrings.asFile.exists()) {
|
||||
return@onVariants
|
||||
}
|
||||
|
||||
val task = tasks.register<GeneratePseudoLocalesTask>("generate${variant.name.replaceFirstChar { it.uppercase() }}PseudoLocales") {
|
||||
englishStrings.set(moduleEnglishStrings)
|
||||
}
|
||||
variant.sources.res?.addGeneratedSourceDirectory(task, GeneratePseudoLocalesTask::outputDirectory)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ plugins {
|
|||
// Instead, plugin versions are controlled through the dependencies block in the build.gradle.kts.
|
||||
id("com.android.library")
|
||||
id("ktlint")
|
||||
id("pseudo-locales")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.buildtools
|
||||
|
||||
import org.w3c.dom.Element
|
||||
import org.w3c.dom.Node
|
||||
import org.w3c.dom.Text
|
||||
import java.io.File
|
||||
import java.io.StringWriter
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import javax.xml.transform.OutputKeys
|
||||
import javax.xml.transform.TransformerFactory
|
||||
import javax.xml.transform.dom.DOMSource
|
||||
import javax.xml.transform.stream.StreamResult
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* A stand-in for a family of translations, generated from the English string resources so that layouts can be
|
||||
* stress tested against it before real translations land. Each is written to a locale from ISO 639's private-use
|
||||
* range, [locale], which no real translation will ever use.
|
||||
*
|
||||
* Each string grows to the length our translations reach for an English string of its length, about the 99th
|
||||
* percentile across every locale: short strings grow the most. Words grow by [wordGrowth], about how much longer the
|
||||
* family's typical word is than English's, and words copied from the string make up the rest. Letters are accented so
|
||||
* that tall glyphs are measured too. No word grows past [MAX_WORD_LENGTH], about the longest word in a German, Finnish
|
||||
* or Dutch string at the 95th percentile.
|
||||
*/
|
||||
enum class PseudoLanguage(val locale: String, private val wordGrowth: Float) {
|
||||
/** More words, a little longer, with the same places to break between them, like French or Dutch. */
|
||||
EXPANSIVE("qaa", wordGrowth = 1.25f),
|
||||
|
||||
/** Longer words joined into compounds with nowhere to break, like German or Finnish. */
|
||||
COMPOUND("qab", wordGrowth = 1.5f);
|
||||
|
||||
/** The resource directory the language is written to. */
|
||||
val valuesDirectoryName: String = "values-b+$locale"
|
||||
|
||||
/**
|
||||
* Leaves format specifiers, escapes, markup and URLs alone.
|
||||
*/
|
||||
fun translate(text: String): String {
|
||||
return translate(text, protectedMask(text)).text
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a `strings.xml` file, leaving out the strings that aren't translatable. Each string's markup is kept,
|
||||
* as is anything inside an `xliff:g`.
|
||||
*/
|
||||
fun translateStringsXml(englishXml: File): String {
|
||||
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(englishXml)
|
||||
val resources = document.documentElement
|
||||
|
||||
for (node in resources.childNodes.toList()) {
|
||||
if (node is Text && node.data.isBlank()) {
|
||||
continue
|
||||
}
|
||||
|
||||
val keep = node is Element && node.tagName in STRING_RESOURCES && node.getAttribute("translatable") != "false"
|
||||
if (!keep) {
|
||||
resources.removeChild(node)
|
||||
continue
|
||||
}
|
||||
|
||||
node as Element
|
||||
if (node.tagName == "string") {
|
||||
translateElement(node)
|
||||
} else {
|
||||
node.childNodes.toList().filterIsInstance<Element>().filter { it.tagName == "item" }.forEach(::translateElement)
|
||||
}
|
||||
}
|
||||
|
||||
val writer = StringWriter()
|
||||
TransformerFactory.newInstance().newTransformer().apply {
|
||||
setOutputProperty(OutputKeys.ENCODING, "UTF-8")
|
||||
}.transform(DOMSource(document), StreamResult(writer))
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the text across all of [element]'s text nodes as one string, so that it grows by the right amount,
|
||||
* then hands each node back its share.
|
||||
*/
|
||||
private fun translateElement(element: Element) {
|
||||
val segments = mutableListOf<Pair<Text, Boolean>>()
|
||||
collectText(element, insideXliff = false, segments)
|
||||
|
||||
val text = segments.joinToString("") { it.first.data }
|
||||
if (text.trimStart().startsWith("@") || text.trimStart().startsWith("?")) {
|
||||
return
|
||||
}
|
||||
|
||||
val mask = protectedMask(text)
|
||||
var offset = 0
|
||||
for ((node, insideXliff) in segments) {
|
||||
if (insideXliff) {
|
||||
mask.fill(true, offset, offset + node.data.length)
|
||||
}
|
||||
offset += node.data.length
|
||||
}
|
||||
|
||||
val translated = translate(text, mask)
|
||||
offset = 0
|
||||
for ((node, _) in segments) {
|
||||
val end = offset + node.data.length
|
||||
node.data = translated.text.substring(translated.offsets[offset], translated.offsets[end])
|
||||
offset = end
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectText(node: Node, insideXliff: Boolean, segments: MutableList<Pair<Text, Boolean>>) {
|
||||
for (child in node.childNodes.toList()) {
|
||||
when (child) {
|
||||
is Text -> segments += child to insideXliff
|
||||
is Element -> collectText(child, insideXliff || child.tagName == "xliff:g", segments)
|
||||
else -> node.removeChild(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun translate(text: String, protected: BooleanArray): Translation {
|
||||
val words = mutableListOf<IntRange>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
if (protected[i] || !text[i].isLetter()) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
val start = i
|
||||
while (i < text.length && !protected[i] && text[i].isLetter()) {
|
||||
i++
|
||||
}
|
||||
words += start until i
|
||||
}
|
||||
|
||||
if (words.isEmpty()) {
|
||||
return Translation(text, IntArray(text.length + 1) { it })
|
||||
}
|
||||
|
||||
val stretched = words.map { word -> stretch(text.substring(word.first, word.last + 1), ceil(word.count() * wordGrowth).toInt()) }
|
||||
|
||||
val unprotectedLength = protected.count { !it }
|
||||
val targetLength = ceil(unprotectedLength * expansion(unprotectedLength)).toInt()
|
||||
var missing = targetLength - unprotectedLength - stretched.sumOf { it.length } + words.sumOf { it.count() }
|
||||
val padding = mutableListOf<String>()
|
||||
while (missing > 1) {
|
||||
val word = stretched[padding.size % stretched.size].lowercase().take(missing - 1)
|
||||
padding += word
|
||||
missing -= word.length + 1
|
||||
}
|
||||
|
||||
// Each word, keyed by where it starts, replaced by its translation and with any padding after the last
|
||||
val replacements = mutableMapOf<Int, Pair<Int, String>>()
|
||||
words.forEachIndexed { index, word -> replacements[word.first] = word.last + 1 to stretched[index] }
|
||||
|
||||
// Words with no whitespace between them, like "device's" or "auto-download", can't be broken apart, so they are
|
||||
// one unit and only whole units are joined
|
||||
val units = mutableListOf(mutableListOf(0))
|
||||
for (index in 1 until words.size) {
|
||||
val gap = text.substring(words[index - 1].last + 1, words[index].first)
|
||||
if (gap.none { it.isWhitespace() }) {
|
||||
units.last() += index
|
||||
} else {
|
||||
units += mutableListOf(index)
|
||||
}
|
||||
}
|
||||
val unitLengths = units.map { unit -> words[unit.last()].last + 1 - words[unit.first()].first + unit.sumOf { stretched[it].length - words[it].count() } }
|
||||
|
||||
var runLength = unitLengths.first()
|
||||
for (index in 1 until units.size) {
|
||||
val previous = words[units[index - 1].last()]
|
||||
val next = words[units[index].first()]
|
||||
if (joins(runLength, unitLengths[index]) && next.first == previous.last + 2 && text[previous.last + 1] == ' ') {
|
||||
replacements[previous.last + 1] = previous.last + 2 to ""
|
||||
runLength += unitLengths[index]
|
||||
} else {
|
||||
runLength = unitLengths[index]
|
||||
}
|
||||
}
|
||||
|
||||
val last = words.last()
|
||||
replacements[last.first] = last.last + 1 to buildString {
|
||||
append(stretched.last())
|
||||
for (word in padding) {
|
||||
if (joins(runLength, word.length)) {
|
||||
runLength += word.length
|
||||
} else {
|
||||
append(' ')
|
||||
runLength = word.length
|
||||
}
|
||||
append(word)
|
||||
}
|
||||
}
|
||||
|
||||
val offsets = IntArray(text.length + 1)
|
||||
val translated = StringBuilder()
|
||||
var position = 0
|
||||
while (position < text.length) {
|
||||
offsets[position] = translated.length
|
||||
val replacement = replacements[position]
|
||||
if (replacement != null) {
|
||||
val (end, value) = replacement
|
||||
translated.append(value)
|
||||
(position + 1 until end).forEach { offsets[it] = translated.length }
|
||||
position = end
|
||||
} else {
|
||||
translated.append(text[position])
|
||||
position++
|
||||
}
|
||||
}
|
||||
offsets[text.length] = translated.length
|
||||
|
||||
return Translation(translated.toString(), offsets)
|
||||
}
|
||||
|
||||
private fun joins(runLength: Int, nextLength: Int): Boolean {
|
||||
return this == COMPOUND && runLength + nextLength <= MAX_WORD_LENGTH
|
||||
}
|
||||
|
||||
/** [offsets] maps each position in the original text to where it ended up in [text]. */
|
||||
private class Translation(val text: String, val offsets: IntArray)
|
||||
|
||||
companion object {
|
||||
const val MAX_WORD_LENGTH = 20
|
||||
|
||||
private val STRING_RESOURCES = setOf("string", "plurals", "string-array")
|
||||
|
||||
private val PROTECTED = listOf(
|
||||
// Format specifiers, e.g. %1$s, %d, %.1f, %%
|
||||
Regex("""%(\d+\$)?[-#+ 0,(<]*\d*(\.\d+)?[a-zA-Z%]"""),
|
||||
// Resource escapes, e.g. \n, \', …
|
||||
Regex("""\\u[0-9a-fA-F]{4}|\\."""),
|
||||
// Markup inside CDATA, e.g. <b> or <a href="...">, and entities
|
||||
Regex("""<[^>]*>|&#?\w+;"""),
|
||||
Regex("""[a-zA-Z][a-zA-Z0-9+.-]*://[^\s"'<>]+""")
|
||||
)
|
||||
|
||||
private const val ACCENTED_UPPER = "ÅƁÇĐÉƑĜĤÎĴĶĹṀÑÖÞǪŔŠŢÛṼŴẊÝŽ"
|
||||
private const val ACCENTED_LOWER = "åƀçđéƒĝĥîĵķĺɱñöþǫŕšţûṽŵẋýž"
|
||||
|
||||
private fun protectedMask(text: String): BooleanArray {
|
||||
val mask = BooleanArray(text.length)
|
||||
PROTECTED.forEach { regex -> regex.findAll(text).forEach { match -> mask.fill(true, match.range.first, match.range.last + 1) } }
|
||||
return mask
|
||||
}
|
||||
|
||||
/** How much longer translations of an English string of [length] characters get, at about the 99th percentile. */
|
||||
private fun expansion(length: Int): Float {
|
||||
return when {
|
||||
length <= 10 -> 3.25f
|
||||
length <= 20 -> 2.45f
|
||||
length <= 40 -> 2f
|
||||
length <= 80 -> 1.7f
|
||||
else -> 1.56f
|
||||
}
|
||||
}
|
||||
|
||||
private fun stretch(word: String, length: Int): String {
|
||||
val accented = word.map(::accent)
|
||||
return buildString {
|
||||
accented.forEach(::append)
|
||||
while (this.length < min(length, MAX_WORD_LENGTH)) {
|
||||
append(accented[(this.length - accented.size) % accented.size].lowercaseChar())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun accent(char: Char): Char {
|
||||
return when (char) {
|
||||
in 'A'..'Z' -> ACCENTED_UPPER[char - 'A']
|
||||
in 'a'..'z' -> ACCENTED_LOWER[char - 'a']
|
||||
else -> char
|
||||
}
|
||||
}
|
||||
|
||||
private fun org.w3c.dom.NodeList.toList(): List<Node> = List(length) { item(it) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.buildtools
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class PseudoLanguageTest {
|
||||
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `short strings grow the most, accented`() {
|
||||
assertEquals("Çåñçéĺçå çåñçéĺçå çå", PseudoLanguage.EXPANSIVE.translate("Cancel"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long strings grow less`() {
|
||||
val english = "Messages and calls are end-to-end encrypted and nobody else can read or listen to them"
|
||||
|
||||
assertEquals(135, PseudoLanguage.EXPANSIVE.translate(english).length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expansive grows words a little and adds more of them`() {
|
||||
assertEquals("Ĝöĝ ţöţ îţî ĝöĝ ţöţ îţî ĝö", PseudoLanguage.EXPANSIVE.translate("Go to it"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compound joins words up to the longest word length`() {
|
||||
assertEquals("Ĝöĝţöţîţîĝöĝţöţîţîĝö", PseudoLanguage.COMPOUND.translate("Go to it"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compound counts words joined by an apostrophe or hyphen as one word`() {
|
||||
listOf("Use your device\\'s biometrics or a security key", "Media auto-download").forEach { english ->
|
||||
val translated = PseudoLanguage.COMPOUND.translate(english)
|
||||
|
||||
assertTrue(translated, translated.split(' ').all { it.replace("\\", "").length <= PseudoLanguage.MAX_WORD_LENGTH })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no word grows past the longest word length`() {
|
||||
val words = PseudoLanguage.COMPOUND.translate("Notifications").split(' ')
|
||||
|
||||
assertEquals(PseudoLanguage.MAX_WORD_LENGTH, words.first().length)
|
||||
assertTrue(words.all { it.length <= PseudoLanguage.MAX_WORD_LENGTH })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format specifiers, escapes and urls are left alone`() {
|
||||
val translated = PseudoLanguage.COMPOUND.translate("""Delete %1${'$'}s and %2${'$'}d others?\nIt\'s 100%% at https://signal.org/donate \u2026""")
|
||||
|
||||
listOf("%1\$s", " %2\$d ", "\\n", "\\'", "100%%", "https://signal.org/donate", "\\u2026").forEach {
|
||||
assertTrue("Expected $it in $translated", translated.contains(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text without letters is unchanged`() {
|
||||
assertEquals("%1\$s: %2\$d", PseudoLanguage.EXPANSIVE.translate("%1\$s: %2\$d"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strings xml keeps only translatable strings, plurals and arrays`() {
|
||||
val translated = translateXml(
|
||||
"""
|
||||
<string name="a">Cancel</string>
|
||||
<string name="b" translatable="false">Signal</string>
|
||||
<dimen name="c">4dp</dimen>
|
||||
<plurals name="d"><item quantity="one">%d minute</item><item quantity="other">%d minutes</item></plurals>
|
||||
<string-array name="e"><item>Never</item></string-array>
|
||||
"""
|
||||
)
|
||||
|
||||
assertTrue(translated, translated.contains("""<string name="a">Çåñçéĺçå çåñçéĺçå çå</string>"""))
|
||||
assertFalse(translated.contains("Signal"))
|
||||
assertFalse(translated.contains("dimen"))
|
||||
assertTrue(translated, translated.contains("""<item quantity="one">%d ɱîñûţéɱî ɱîñûţéɱî ɱîñû</item>"""))
|
||||
assertTrue(translated, translated.contains("<item>Ñéṽéŕñé ñéṽéŕñé ñ</item>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markup is kept around the text it covered`() {
|
||||
val translated = translateXml("""<string name="a">Tap <b>here</b> now</string>""")
|
||||
|
||||
assertTrue(translated, translated.contains("""<string name="a">Ţåþţ <b>ĥéŕéĥ</b> ñöŵñ"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xliff placeholders and references are not translated`() {
|
||||
val translated = translateXml(
|
||||
"""
|
||||
<string name="a">Hi <xliff:g id="name">Alice</xliff:g></string>
|
||||
<string name="b">@string/a</string>
|
||||
"""
|
||||
)
|
||||
|
||||
assertTrue(translated, translated.contains("""<xliff:g id="name">Alice</xliff:g>"""))
|
||||
assertTrue(translated, translated.contains("""<string name="b">@string/a</string>"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html inside cdata keeps its tags`() {
|
||||
val translated = translateXml("""<string name="a"><![CDATA[Read the <a href="https://signal.org">terms</a>]]></string>""")
|
||||
|
||||
assertTrue(translated, translated.contains("""<a href="https://signal.org">ţéŕɱšţ"""))
|
||||
assertTrue(translated, translated.contains("</a>"))
|
||||
}
|
||||
|
||||
private fun translateXml(strings: String): String {
|
||||
val file = temporaryFolder.newFile("strings.xml")
|
||||
file.writeText("""<?xml version="1.0" encoding="utf-8"?><resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">$strings</resources>""")
|
||||
return PseudoLanguage.EXPANSIVE.translateStringsXml(file)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,20 +10,24 @@ import androidx.annotation.VisibleForTesting
|
|||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -37,13 +41,18 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import org.signal.core.ui.R
|
||||
|
|
@ -71,6 +80,8 @@ object Pickers {
|
|||
const val TEST_TAG_DURATION_UNIT_WHEEL = "duration-unit-wheel"
|
||||
|
||||
private val WheelItemHeight = 48.dp
|
||||
private val WheelItemPadding = 8.dp
|
||||
private val MinWheelWidth = 48.dp
|
||||
|
||||
/**
|
||||
* A scrolling wheel that snaps to one of [items], the Compose counterpart to a framework `NumberPicker`. The selected
|
||||
|
|
@ -108,7 +119,7 @@ object Pickers {
|
|||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.widthIn(min = 64.dp)
|
||||
.widthIn(min = MinWheelWidth)
|
||||
.height(WheelItemHeight * 3)
|
||||
) {
|
||||
LazyColumn(
|
||||
|
|
@ -136,7 +147,14 @@ object Pickers {
|
|||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
minFontSize = 12.sp,
|
||||
maxFontSize = MaterialTheme.typography.titleMedium.fontSize
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = WheelItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -216,31 +234,54 @@ object Pickers {
|
|||
) {
|
||||
val unit = units[unitIndex]
|
||||
val values = (unit.minValue..unit.maxValue).toList()
|
||||
val labels = units.map { stringResource(it.label) }
|
||||
val labelWidth = widestWheelItem(labels)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = WheelItemHeight * 3)
|
||||
) {
|
||||
Wheel(
|
||||
items = values.map { it.toString() },
|
||||
selectedIndex = values.indexOf(value),
|
||||
onSelectedIndexChange = { onValueChange(values[it]) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag(TEST_TAG_DURATION_VALUE_WHEEL)
|
||||
)
|
||||
val spacing = 16.dp
|
||||
val available = maxWidth - spacing
|
||||
val unitWidth = maxOf(available / 2, minOf(labelWidth, available - MinWheelWidth))
|
||||
|
||||
Wheel(
|
||||
items = units.map { stringResource(it.label) },
|
||||
selectedIndex = unitIndex,
|
||||
onSelectedIndexChange = onUnitIndexChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag(TEST_TAG_DURATION_UNIT_WHEEL)
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Wheel(
|
||||
items = values.map { it.toString() },
|
||||
selectedIndex = values.indexOf(value),
|
||||
onSelectedIndexChange = { onValueChange(values[it]) },
|
||||
modifier = Modifier
|
||||
.width(available - unitWidth)
|
||||
.testTag(TEST_TAG_DURATION_VALUE_WHEEL)
|
||||
)
|
||||
|
||||
Wheel(
|
||||
items = labels,
|
||||
selectedIndex = unitIndex,
|
||||
onSelectedIndexChange = onUnitIndexChange,
|
||||
modifier = Modifier
|
||||
.width(unitWidth)
|
||||
.testTag(TEST_TAG_DURATION_UNIT_WHEEL)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How wide a [Wheel] has to be to show the widest of [items] at full size. */
|
||||
@Composable
|
||||
private fun widestWheelItem(items: List<String>): Dp {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium)
|
||||
val density = LocalDensity.current
|
||||
|
||||
return remember(items, style, density) {
|
||||
val widestPx = items.maxOfOrNull { textMeasurer.measure(it, style, maxLines = 1, softWrap = false).size.width } ?: 0
|
||||
with(density) { widestPx.toDp() } + WheelItemPadding * 2
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,12 +309,14 @@ object Pickers {
|
|||
@PreviewWrapper(SignalPreviewWrapper::class)
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DurationDialogPreview() {
|
||||
Pickers.DurationDialog(
|
||||
title = "Custom time",
|
||||
initialDuration = 3.hours,
|
||||
units = listOf(DurationPickerUnit.MINUTES, DurationPickerUnit.HOURS, DurationPickerUnit.DAYS),
|
||||
onConfirm = {},
|
||||
onDismiss = {}
|
||||
)
|
||||
private fun DurationWheelsPreview() {
|
||||
Surface {
|
||||
Pickers.DurationWheels(
|
||||
units = listOf(DurationPickerUnit.MINUTES, DurationPickerUnit.HOURS, DurationPickerUnit.DAYS),
|
||||
unitIndex = 1,
|
||||
value = 3,
|
||||
onUnitIndexChange = {},
|
||||
onValueChange = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
/**
|
||||
* Every config a layout stress test checks by default, as previews, so a failing one can be opened by name.
|
||||
*
|
||||
* The pseudo-languages ("qaa" and "qab") are generated into debug builds only, so the previews that use them render
|
||||
* English in any other build. StressTestPreviewTest in :screener fails when these drift from the stress tests.
|
||||
*/
|
||||
@Preview(name = "phone", device = "spec:width=360dp,height=780dp", group = "stress test")
|
||||
@Preview(name = "phone + font 2x", device = "spec:width=360dp,height=780dp", fontScale = 2f, group = "stress test")
|
||||
@Preview(name = "landscape + display 1.3x + RTL + pseudo-expansive", device = "spec:width=600dp,height=277dp", locale = "b+qaa+Arab", group = "stress test")
|
||||
@Preview(name = "tablet + font 2x + RTL + pseudo-compound", device = "spec:width=1280dp,height=800dp", fontScale = 2f, locale = "b+qab+Arab", group = "stress test")
|
||||
@Preview(name = "landscape + display 1.3x + font 2x + pseudo-compound", device = "spec:width=600dp,height=277dp", fontScale = 2f, locale = "b+qab", group = "stress test")
|
||||
@Preview(name = "tablet + pseudo-expansive", device = "spec:width=1280dp,height=800dp", locale = "b+qaa", group = "stress test")
|
||||
@Preview(name = "phone + display 1.3x + RTL", device = "spec:width=277dp,height=600dp", locale = "b+en+Arab", group = "stress test")
|
||||
@Preview(name = "phone + pseudo-compound", device = "spec:width=360dp,height=780dp", locale = "b+qab", group = "stress test")
|
||||
@Preview(name = "phone + font 2x + pseudo-expansive", device = "spec:width=360dp,height=780dp", fontScale = 2f, locale = "b+qaa", group = "stress test")
|
||||
@Preview(name = "landscape", device = "spec:width=780dp,height=360dp", group = "stress test")
|
||||
@Preview(name = "tablet + display 1.3x", device = "spec:width=985dp,height=615dp", group = "stress test")
|
||||
annotation class StressTestPreview
|
||||
|
|
@ -13,39 +13,53 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.Hyphens
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.CoreUiDependencies
|
||||
import org.signal.core.ui.compose.ProvideIncognitoKeyboard
|
||||
|
||||
/**
|
||||
* Headings and labels hyphenate a word too long for its line, rather than breaking it at an arbitrary letter. This also
|
||||
* lets lines break at a hyphen already in the text, which they otherwise don't. Body text is left alone.
|
||||
*/
|
||||
private val typography = Typography().run {
|
||||
copy(
|
||||
displayLarge = displayLarge.copy(hyphens = Hyphens.Auto),
|
||||
displayMedium = displayMedium.copy(hyphens = Hyphens.Auto),
|
||||
displaySmall = displaySmall.copy(hyphens = Hyphens.Auto),
|
||||
headlineLarge = headlineLarge.copy(
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
letterSpacing = 0.sp,
|
||||
hyphens = Hyphens.Auto
|
||||
),
|
||||
headlineMedium = headlineMedium.copy(
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
letterSpacing = 0.sp,
|
||||
hyphens = Hyphens.Auto
|
||||
),
|
||||
headlineSmall = headlineSmall.copy(hyphens = Hyphens.Auto),
|
||||
titleLarge = titleLarge.copy(
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
letterSpacing = 0.sp,
|
||||
hyphens = Hyphens.Auto
|
||||
),
|
||||
titleMedium = titleMedium.copy(
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.0125.sp,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontStyle = FontStyle.Normal
|
||||
fontStyle = FontStyle.Normal,
|
||||
hyphens = Hyphens.Auto
|
||||
),
|
||||
titleSmall = titleSmall.copy(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
letterSpacing = 0.0125.sp
|
||||
letterSpacing = 0.0125.sp,
|
||||
hyphens = Hyphens.Auto
|
||||
),
|
||||
bodyLarge = bodyLarge.copy(
|
||||
fontSize = 16.sp,
|
||||
|
|
@ -65,17 +79,20 @@ private val typography = Typography().run {
|
|||
labelLarge = labelLarge.copy(
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.0107.sp
|
||||
letterSpacing = 0.0107.sp,
|
||||
hyphens = Hyphens.Auto
|
||||
),
|
||||
labelMedium = labelMedium.copy(
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.0192.sp
|
||||
letterSpacing = 0.0192.sp,
|
||||
hyphens = Hyphens.Auto
|
||||
),
|
||||
labelSmall = labelSmall.copy(
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.025.sp
|
||||
letterSpacing = 0.025.sp,
|
||||
hyphens = Hyphens.Auto
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ package org.signal.core.ui
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.screener.LayoutStressTest
|
||||
|
|
@ -23,11 +21,6 @@ abstract class SignalLayoutStressTest : LayoutStressTest() {
|
|||
|
||||
override val previewAnnotations = listOf("@PreviewWrapper(SignalPreviewWrapper::class)")
|
||||
|
||||
@Before
|
||||
fun bypassStressTests() {
|
||||
Assume.assumeTrue("Layout stress tests are temporarily bypassed", false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Wrap(content: @Composable () -> Unit) {
|
||||
SignalTheme {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ 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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -28,19 +29,24 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.Hyphens
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.appsettings.R
|
||||
import org.signal.appsettings.appSettingsScreen
|
||||
|
|
@ -50,7 +56,10 @@ import org.signal.core.ui.compose.SignalIcons
|
|||
import org.signal.core.ui.compose.SignalPreviewWrapper
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
private const val COLUMN_COUNT = 4
|
||||
private const val MAX_COLUMN_COUNT = 4
|
||||
private const val MIN_COLUMN_COUNT = 2
|
||||
private val IconSize = 64.dp
|
||||
private val LabelPadding = 4.dp
|
||||
|
||||
@VisibleForTesting
|
||||
object AppIconSelectionTestTags {
|
||||
|
|
@ -92,20 +101,13 @@ fun AppIconSelectionScreen(
|
|||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(
|
||||
IconGrid(
|
||||
selectedIcon = state.selectedIcon,
|
||||
onEvent = onEvent,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AppIconPreset.entries.chunked(COLUMN_COUNT).forEach { presets ->
|
||||
IconRow(
|
||||
presets = presets,
|
||||
selectedIcon = state.selectedIcon,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,10 +158,53 @@ private fun ChangeIconDialog(pendingIcon: AppIconPreset, onEvent: (AppIconSelect
|
|||
}
|
||||
|
||||
/**
|
||||
* One row of icons that the user may choose from.
|
||||
* The icons to choose from, in as many columns (up to [MAX_COLUMN_COUNT]) as leave room for the longest word of any
|
||||
* label at the user's font size, so labels wrap between words rather than inside them. Fewer columns reflow the grid
|
||||
* rather than shrinking the labels.
|
||||
*/
|
||||
@Composable
|
||||
private fun IconRow(presets: List<AppIconPreset>, selectedIcon: AppIconPreset, onEvent: (AppIconSelectionEvent) -> Unit) {
|
||||
private fun IconGrid(selectedIcon: AppIconPreset, onEvent: (AppIconSelectionEvent) -> Unit, modifier: Modifier = Modifier) {
|
||||
val labelWidth = widestWord(AppIconPreset.entries.map { presetLabel(it) })
|
||||
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cellWidth = maxOf(IconSize, labelWidth + LabelPadding * 2)
|
||||
val columnCount = (maxWidth / cellWidth).toInt().coerceIn(MIN_COLUMN_COUNT, MAX_COLUMN_COUNT)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AppIconPreset.entries.chunked(columnCount).forEach { presets ->
|
||||
IconRow(
|
||||
presets = presets,
|
||||
columnCount = columnCount,
|
||||
selectedIcon = selectedIcon,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun widestWord(labels: List<String>): Dp {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val style = MaterialTheme.typography.bodySmall
|
||||
val density = LocalDensity.current
|
||||
|
||||
return remember(labels, style, density) {
|
||||
val words = labels.flatMap { it.split(' ') }
|
||||
val widestPx = words.maxOfOrNull { textMeasurer.measure(it, style, maxLines = 1, softWrap = false).size.width } ?: 0
|
||||
with(density) { widestPx.toDp() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of icons that the user may choose from. A short last row keeps its icons in the same columns as the rows
|
||||
* above it.
|
||||
*/
|
||||
@Composable
|
||||
private fun IconRow(presets: List<AppIconPreset>, columnCount: Int, selectedIcon: AppIconPreset, onEvent: (AppIconSelectionEvent) -> Unit) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
presets.forEach { preset ->
|
||||
IconGridElement(
|
||||
|
|
@ -172,6 +217,10 @@ private fun IconRow(presets: List<AppIconPreset>, selectedIcon: AppIconPreset, o
|
|||
.testTag(AppIconSelectionTestTags.icon(preset))
|
||||
)
|
||||
}
|
||||
|
||||
repeat(columnCount - presets.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +233,7 @@ private fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClick:
|
|||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val boxModifier = Modifier.size(64.dp)
|
||||
val boxModifier = Modifier.size(IconSize)
|
||||
Box(
|
||||
modifier = if (isSelected) boxModifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else boxModifier
|
||||
) {
|
||||
|
|
@ -195,7 +244,8 @@ private fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClick:
|
|||
text = presetLabel(preset),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall.copy(hyphens = Hyphens.Auto),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LabelPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +308,10 @@ private fun CaveatWarning(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
|||
|
||||
@Composable
|
||||
private fun presetLabel(preset: AppIconPreset): String {
|
||||
if (preset.labelResId == null && LocalInspectionMode.current) {
|
||||
return "Signal"
|
||||
}
|
||||
|
||||
return stringResource(preset.labelResId ?: LocalContext.current.applicationInfo.labelRes)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ package org.signal.screener
|
|||
/**
|
||||
* Describes the preview function that a failure message suggests, so the problem can be reproduced in the IDE.
|
||||
*
|
||||
* [functionName] names the suggested preview function, [annotations] are added above its `@Preview`s, and
|
||||
* [functionName] names the suggested preview function, [annotations] are added below its preview annotations, and
|
||||
* [contentHint] goes in its body to say what content to render.
|
||||
*/
|
||||
data class PreviewTemplate(
|
||||
|
|
@ -16,9 +16,35 @@ data class PreviewTemplate(
|
|||
val annotations: List<String> = emptyList(),
|
||||
val contentHint: String = "The content that failed"
|
||||
) {
|
||||
internal fun render(configs: List<StressConfig>): String {
|
||||
|
||||
companion object {
|
||||
/** The multi-preview annotation in core/ui that previews every config in [StressConfig.Default]. */
|
||||
const val STRESS_TEST_PREVIEW = "@StressTestPreview"
|
||||
|
||||
/** The preview group [STRESS_TEST_PREVIEW]'s previews are in. */
|
||||
const val STRESS_TEST_PREVIEW_GROUP = "stress test"
|
||||
}
|
||||
|
||||
/**
|
||||
* A test that ran the default configs is reproduced with [STRESS_TEST_PREVIEW], pointing at the previews of
|
||||
* [failingConfigs]. Any other test gets one preview per failing config.
|
||||
*/
|
||||
internal fun render(failingConfigs: List<StressConfig>, allConfigs: List<StressConfig>): String {
|
||||
val usesStressTestPreview = allConfigs == StressConfig.Default
|
||||
|
||||
return buildString {
|
||||
configs.forEach { appendLine(it.toPreviewAnnotation()) }
|
||||
if (usesStressTestPreview) {
|
||||
appendLine("Reproduce in the IDE with this preview, and look at its ${failingConfigs.joinToString { "\"${it.name}\"" }} previews:")
|
||||
} else {
|
||||
appendLine("Reproduce in the IDE with a preview like this:")
|
||||
}
|
||||
appendLine()
|
||||
|
||||
if (usesStressTestPreview) {
|
||||
appendLine(STRESS_TEST_PREVIEW)
|
||||
} else {
|
||||
failingConfigs.forEach { appendLine(it.toPreviewAnnotation()) }
|
||||
}
|
||||
annotations.forEach { appendLine(it) }
|
||||
appendLine("@Composable")
|
||||
appendLine("private fun $functionName() {")
|
||||
|
|
|
|||
|
|
@ -124,9 +124,7 @@ private fun failureMessage(problems: List<LayoutProblem>, configs: List<StressCo
|
|||
appendLine("Layout checks failed, ${grouped.size} problem(s):")
|
||||
grouped.forEach { appendLine(" - $it") }
|
||||
appendLine()
|
||||
appendLine("Reproduce in the IDE with a preview like this:")
|
||||
appendLine()
|
||||
append(previewTemplate.render(failingConfigs))
|
||||
append(previewTemplate.render(failingConfigs, configs))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ data class StressConfig(
|
|||
|
||||
/**
|
||||
* `@Preview` has no layout direction, so RTL is expressed the way `@RtlPreview` does it: as the locale written in
|
||||
* Arabic script.
|
||||
* Arabic script. Three-letter languages, like the pseudo-languages, can only be written as BCP-47 qualifiers.
|
||||
*/
|
||||
internal fun toPreviewAnnotation(): String {
|
||||
internal fun toPreviewAnnotation(group: String? = null): String {
|
||||
val args = mutableListOf(
|
||||
"name = \"$name\"",
|
||||
"device = \"spec:width=${windowSize.width.value.toInt()}dp,height=${windowSize.height.value.toInt()}dp\""
|
||||
|
|
@ -99,12 +99,16 @@ data class StressConfig(
|
|||
args += "locale = \"$previewLocale\""
|
||||
}
|
||||
|
||||
if (group != null) {
|
||||
args += "group = \"$group\""
|
||||
}
|
||||
|
||||
return "@Preview(${args.joinToString()})"
|
||||
}
|
||||
|
||||
private fun previewLocale(): String? {
|
||||
val parts = (locale ?: if (layoutDirection == LayoutDirection.Rtl) "en" else return null).split("-")
|
||||
val subtags = if (layoutDirection == LayoutDirection.Rtl) listOf(parts.first(), "Arab") + parts.drop(1) else parts
|
||||
return if (subtags.size == 1) subtags.single() else "b+" + subtags.joinToString("+")
|
||||
return if (subtags.size == 1 && subtags.single().length == 2) subtags.single() else "b+" + subtags.joinToString("+")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
screener/src/main/java/org/signal/screener/StressLanguage.kt
Normal file
36
screener/src/main/java/org/signal/screener/StressLanguage.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.screener
|
||||
|
||||
/**
|
||||
* The language a [StressMatrix] renders string resources in, by BCP-47 tag in [locale].
|
||||
*/
|
||||
sealed interface StressLanguage {
|
||||
val label: String
|
||||
val locale: String
|
||||
|
||||
/**
|
||||
* A real translation, such as "de". Layouts tested against one can start failing whenever new translations land,
|
||||
* so prefer a [PseudoLanguage] for tests that gate merges.
|
||||
*/
|
||||
data class Translation(override val locale: String) : StressLanguage {
|
||||
override val label: String = locale
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translations generated from the English strings into debug builds by the `pseudo-locales` build plugin, which
|
||||
* describes how each one stretches them. A layout fails against one as soon as its code or English copy changes,
|
||||
* rather than when real translations next land. Only strings from Signal modules are translated: framework and
|
||||
* library strings stay English.
|
||||
*/
|
||||
enum class PseudoLanguage(override val label: String, override val locale: String) : StressLanguage {
|
||||
/** More words, a little longer, with the same places to break between them, like French or Dutch. */
|
||||
EXPANSIVE("pseudo-expansive", "qaa"),
|
||||
|
||||
/** Longer words joined into compounds with nowhere to break, like German or Finnish. */
|
||||
COMPOUND("pseudo-compound", "qab")
|
||||
}
|
||||
|
|
@ -12,14 +12,14 @@ private const val DEVICE = 0
|
|||
private const val DISPLAY_SIZE = 1
|
||||
private const val FONT_SCALE = 2
|
||||
private const val LAYOUT_DIRECTION = 3
|
||||
private const val LOCALE = 4
|
||||
private const val LANGUAGE = 4
|
||||
|
||||
/**
|
||||
* The dimensions a layout should pass its checks under, combined into [StressConfig]s.
|
||||
*
|
||||
* [displaySizes] are the system "Display size" setting's scale factors, which shrink a device's window in dp.
|
||||
* The default [locales] are German, which has the longest unbreakable words of our Latin-script translations, and
|
||||
* French, which has the longest short UI strings. The first value of each dimension is its everyday default.
|
||||
* The default [languages] are generated from the English strings, so they don't change when translations land.
|
||||
* The first value of each dimension is its everyday default, and a null language is English.
|
||||
*/
|
||||
data class StressMatrix(
|
||||
val devices: Map<String, DpSize> = mapOf(
|
||||
|
|
@ -30,7 +30,7 @@ data class StressMatrix(
|
|||
val displaySizes: List<Float> = listOf(1f, LARGEST_DISPLAY_SIZE),
|
||||
val fontScales: List<Float> = listOf(1f, 2f),
|
||||
val layoutDirections: List<LayoutDirection> = listOf(LayoutDirection.Ltr, LayoutDirection.Rtl),
|
||||
val locales: List<String?> = listOf(null, "de", "fr")
|
||||
val languages: List<StressLanguage?> = listOf(null, PseudoLanguage.EXPANSIVE, PseudoLanguage.COMPOUND)
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
|
@ -39,7 +39,7 @@ data class StressMatrix(
|
|||
}
|
||||
|
||||
private val dimensions: List<Int>
|
||||
get() = listOf(devices.size, displaySizes.size, fontScales.size, layoutDirections.size, locales.size)
|
||||
get() = listOf(devices.size, displaySizes.size, fontScales.size, layoutDirections.size, languages.size)
|
||||
|
||||
/** Every combination of every dimension. */
|
||||
fun exhaustive(): List<StressConfig> {
|
||||
|
|
@ -95,14 +95,14 @@ data class StressMatrix(
|
|||
val displaySize = displaySizes[this[DISPLAY_SIZE]]
|
||||
val fontScale = fontScales[this[FONT_SCALE]]
|
||||
val layoutDirection = layoutDirections[this[LAYOUT_DIRECTION]]
|
||||
val locale = locales[this[LOCALE]]
|
||||
val language = languages[this[LANGUAGE]]
|
||||
|
||||
val name = buildList {
|
||||
add(deviceName)
|
||||
if (displaySize != 1f) add("display ${StressConfig.formatScale(displaySize)}x")
|
||||
if (fontScale != 1f) add("font ${StressConfig.formatScale(fontScale)}x")
|
||||
if (layoutDirection == LayoutDirection.Rtl) add("RTL")
|
||||
if (locale != null) add(locale)
|
||||
if (language != null) add(language.label)
|
||||
}.joinToString(" + ")
|
||||
|
||||
return StressConfig(
|
||||
|
|
@ -110,7 +110,7 @@ data class StressMatrix(
|
|||
windowSize = StressConfig.WindowSizes.atDisplaySize(deviceSize, displaySize),
|
||||
fontScale = fontScale,
|
||||
layoutDirection = layoutDirection,
|
||||
locale = locale
|
||||
locale = language?.locale
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,12 @@ class PreviewTemplateTest {
|
|||
fun `template lists the failing configs above its function`() {
|
||||
val template = PreviewTemplate(functionName = "MyScreenPreview", annotations = listOf("@PreviewWrapper(SignalPreviewWrapper::class)"), contentHint = "MyScreen()")
|
||||
|
||||
assertThat(template.render(listOf(StressConfig.Baseline, StressConfig.LargestFontLandscape))).isEqualTo(
|
||||
val configs = listOf(StressConfig.Baseline, StressConfig.LargestFontLandscape)
|
||||
|
||||
assertThat(template.render(failingConfigs = configs, allConfigs = configs)).isEqualTo(
|
||||
"""
|
||||
Reproduce in the IDE with a preview like this:
|
||||
|
||||
@Preview(name = "baseline", device = "spec:width=360dp,height=780dp")
|
||||
@Preview(name = "font 2x + landscape", device = "spec:width=780dp,height=360dp", fontScale = 2f)
|
||||
@PreviewWrapper(SignalPreviewWrapper::class)
|
||||
|
|
@ -62,4 +66,37 @@ class PreviewTemplateTest {
|
|||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `template for the default configs uses StressTestPreview and names the failing previews`() {
|
||||
val template = PreviewTemplate(functionName = "MyScreenPreview", annotations = listOf("@PreviewWrapper(SignalPreviewWrapper::class)"), contentHint = "MyScreen()")
|
||||
val failing = StressConfig.Default.take(2)
|
||||
|
||||
assertThat(template.render(failingConfigs = failing, allConfigs = StressConfig.Default)).isEqualTo(
|
||||
"""
|
||||
Reproduce in the IDE with this preview, and look at its "${failing[0].name}", "${failing[1].name}" previews:
|
||||
|
||||
@StressTestPreview
|
||||
@PreviewWrapper(SignalPreviewWrapper::class)
|
||||
@Composable
|
||||
private fun MyScreenPreview() {
|
||||
// MyScreen()
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `group is added last`() {
|
||||
assertThat(StressConfig.Baseline.toPreviewAnnotation(group = "stress test"))
|
||||
.isEqualTo("""@Preview(name = "baseline", device = "spec:width=360dp,height=780dp", group = "stress test")""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pseudo-languages become bcp-47 qualifiers`() {
|
||||
val config = StressConfig(name = "pseudo-compound + RTL", layoutDirection = LayoutDirection.Rtl, locale = PseudoLanguage.COMPOUND.locale)
|
||||
|
||||
assertThat(config.toPreviewAnnotation())
|
||||
.isEqualTo("""@Preview(name = "pseudo-compound + RTL", device = "spec:width=360dp,height=780dp", locale = "b+qab+Arab")""")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue