diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt b/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt index cae953be84..1fe59bf3f2 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt @@ -12,8 +12,12 @@ import android.graphics.RectF import android.graphics.Typeface import android.graphics.pdf.PdfDocument import android.net.Uri +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint import androidx.annotation.StringRes import androidx.core.content.ContextCompat +import androidx.core.graphics.withTranslation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.signal.core.util.Result @@ -23,14 +27,13 @@ import org.signal.signallogin.fonts.MonoTypeface import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState import java.io.ByteArrayOutputStream import java.io.IOException -import org.signal.core.ui.R as CoreUiR +import kotlin.math.ceil /** - * Renders the keys that make up a Signal Login into a single-page PDF, styled to match - * [org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen]: the Signal logo up top, then each key - * in a rounded block, with the recovery key broken into character groups. + * Renders the keys that make up a Signal Login into a single-page PDF: a miniature of the credential card up top, + * a title and explainer, then each key in a rounded block, with the recovery key broken into character groups. * - * All dimensions are in PostScript points (1/72 inch), on an A4 page. + * All dimensions are in PostScript points (1/72 inch), on a US Letter page, and are taken directly from the design. */ object SignalLoginPdfRenderer { @@ -41,30 +44,50 @@ object SignalLoginPdfRenderer { return context.getString(R.string.SignalLoginViewDetailsScreen__signal_login_pdf) } - private const val PAGE_WIDTH = 595 - private const val PAGE_HEIGHT = 842 - private const val MARGIN = 56f + private const val PAGE_WIDTH = 612 + private const val PAGE_HEIGHT = 792 - private const val LOGO_WIDTH = 140f - private const val LOGO_BOTTOM_SPACING = 28f + private const val CARD_TOP = 120f + private const val CARD_WIDTH = 98f + private const val CARD_HEIGHT = 56f - private const val HEADER_TEXT_SIZE = 12f - private const val HEADER_TOP_PADDING = 16f - private const val HEADER_BOTTOM_PADDING = 12f + private const val TITLE_TOP_SPACING = 16f + private const val TITLE_TEXT_SIZE = 18f + private const val TITLE_LINE_HEIGHT = 24f + private const val TITLE_LETTER_SPACING_EM = -0.014f - private const val KEY_TEXT_SIZE = 13f - private const val KEY_LINE_HEIGHT = 21f - private const val KEY_LETTER_SPACING_EM = 0.08f + private const val BODY_TOP_SPACING = 8f + private const val BODY_WIDTH = 390f + private const val BODY_TEXT_SIZE = 14f + private const val BODY_LINE_HEIGHT = 22f + private const val BODY_LETTER_SPACING_EM = -0.006f + private const val HEADER_TOP_SPACING = 32f + private const val HEADER_TEXT_SIZE = 14f + private const val HEADER_LINE_HEIGHT = 22f + private const val HEADER_LETTER_SPACING_EM = -0.006f + private const val HEADER_BOTTOM_SPACING = 12f + + private const val BLOCK_WIDTH = 380f private const val BLOCK_CORNER_RADIUS = 18f - private const val BLOCK_HORIZONTAL_PADDING = 28f - private const val BLOCK_VERTICAL_PADDING = 20f + private const val BLOCK_HORIZONTAL_PADDING = 24f + private const val BLOCK_VERTICAL_PADDING = 16f + + private const val KEY_TEXT_SIZE = 14f + private const val KEY_LETTER_SPACING_EM = 0.07f + private const val ACCOUNT_KEY_LINE_HEIGHT = 20f + private const val RECOVERY_KEY_LINE_HEIGHT = 24f private const val GROUPS_PER_ROW = 4 - /** Light-theme colorSurface2 and onSurface, respectively. The PDF is always rendered as if in light theme. */ - private const val BLOCK_COLOR = 0xFFEDF0F6.toInt() - private const val TEXT_COLOR = 0xFF1B1B1D.toInt() + private const val BLOCK_LEFT = (PAGE_WIDTH - BLOCK_WIDTH) / 2f + private const val CONTENT_LEFT = BLOCK_LEFT + BLOCK_HORIZONTAL_PADDING + private const val CONTENT_WIDTH = BLOCK_WIDTH - 2 * BLOCK_HORIZONTAL_PADDING + + /** The PDF is always rendered as if in light theme, so these are fixed rather than pulled from the theme. */ + private const val BLOCK_COLOR = 0xFFF4F4F5.toInt() + private const val TEXT_COLOR = 0xFF000000.toInt() + private const val BODY_TEXT_COLOR = 0xFF4D4D4D.toInt() /** * Renders the credentials in [state] to a PDF and writes it to [uri]. @@ -108,76 +131,150 @@ object SignalLoginPdfRenderer { } private fun drawPage(context: Context, canvas: Canvas, state: SignalLoginViewDetailsState) { - val headerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - textSize = HEADER_TEXT_SIZE - color = TEXT_COLOR - } - - val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + val titlePaint = textPaint(TITLE_TEXT_SIZE, TITLE_LETTER_SPACING_EM, TEXT_COLOR, semiBold = true) + val bodyPaint = textPaint(BODY_TEXT_SIZE, BODY_LETTER_SPACING_EM, BODY_TEXT_COLOR, semiBold = false) + val headerPaint = textPaint(HEADER_TEXT_SIZE, HEADER_LETTER_SPACING_EM, TEXT_COLOR, semiBold = true) + val keyPaint = textPaint(KEY_TEXT_SIZE, KEY_LETTER_SPACING_EM, TEXT_COLOR, semiBold = false).apply { typeface = MonoTypeface.typeface(context) - textSize = KEY_TEXT_SIZE - letterSpacing = KEY_LETTER_SPACING_EM - color = TEXT_COLOR } - var y = drawLogo(context, canvas, top = MARGIN) + LOGO_BOTTOM_SPACING + var y = drawCard(context, canvas, top = CARD_TOP) - y = drawSectionHeader(canvas, headerPaint, context.getString(R.string.SignalLoginViewDetailsScreen__account_key), y) - y = drawKeyBlock(canvas, keyPaint, rows = listOf(listOf(state.accountKey)), top = y) + y += TITLE_TOP_SPACING + y += drawText( + canvas = canvas, + text = context.getString(R.string.SignalLoginPdf__your_signal_login), + paint = titlePaint, + left = (PAGE_WIDTH - BODY_WIDTH) / 2f, + top = y, + width = BODY_WIDTH, + lineHeight = TITLE_LINE_HEIGHT, + alignment = Layout.Alignment.ALIGN_CENTER + ) - y = drawSectionHeader(canvas, headerPaint, context.getString(R.string.SignalLoginViewDetailsScreen__recovery_key), y) - drawKeyBlock(canvas, keyPaint, rows = state.recoveryKeyGroups.chunked(GROUPS_PER_ROW), top = y) + y += BODY_TOP_SPACING + y += drawText( + canvas = canvas, + text = context.getString(R.string.SignalLoginPdf__store_this_in_a_safe_place), + paint = bodyPaint, + left = (PAGE_WIDTH - BODY_WIDTH) / 2f, + top = y, + width = BODY_WIDTH, + lineHeight = BODY_LINE_HEIGHT, + alignment = Layout.Alignment.ALIGN_CENTER + ) + + y = drawSectionHeader(canvas, headerPaint, context.getString(R.string.SignalLoginPdf__account_id), top = y) + y = drawKeyBlock(canvas, top = y, rowCount = 1, lineHeight = ACCOUNT_KEY_LINE_HEIGHT) { contentTop -> + drawText( + canvas = canvas, + text = state.accountKey, + paint = keyPaint, + left = CONTENT_LEFT, + top = contentTop, + width = CONTENT_WIDTH, + lineHeight = ACCOUNT_KEY_LINE_HEIGHT, + alignment = Layout.Alignment.ALIGN_CENTER + ) + } + + val rows = state.recoveryKeyGroups.chunked(GROUPS_PER_ROW) + y = drawSectionHeader(canvas, headerPaint, context.getString(R.string.SignalLoginPdf__recovery_key), top = y) + drawKeyBlock(canvas, top = y, rowCount = rows.size, lineHeight = RECOVERY_KEY_LINE_HEIGHT) { contentTop -> + drawRecoveryKeyGroups(canvas, keyPaint, rows, contentTop) + } } - /** Draws the Signal logo centered at the top of the page, returning the y position of its bottom edge. */ - private fun drawLogo(context: Context, canvas: Canvas, top: Float): Float { - val logo = requireNotNull(ContextCompat.getDrawable(context, CoreUiR.drawable.image_signal_logo_wordmark_light)) - val height = LOGO_WIDTH * logo.intrinsicHeight / logo.intrinsicWidth - val left = (PAGE_WIDTH - LOGO_WIDTH) / 2f + /** Draws the miniature credential card artwork centered at the top of the page, returning the y position of its bottom edge. */ + private fun drawCard(context: Context, canvas: Canvas, top: Float): Float { + val card = requireNotNull(ContextCompat.getDrawable(context, R.drawable.image_signal_login_card_x_small)) + val left = (PAGE_WIDTH - CARD_WIDTH) / 2f - logo.setBounds(left.toInt(), top.toInt(), (left + LOGO_WIDTH).toInt(), (top + height).toInt()) - logo.draw(canvas) + card.setBounds(left.toInt(), top.toInt(), (left + CARD_WIDTH).toInt(), (top + CARD_HEIGHT).toInt()) + card.draw(canvas) + + return top + CARD_HEIGHT + } + + /** Draws a section header above a key block, returning the y position the block should start at. */ + private fun drawSectionHeader(canvas: Canvas, paint: TextPaint, text: String, top: Float): Float { + val headerTop = top + HEADER_TOP_SPACING + val height = drawText( + canvas = canvas, + text = text, + paint = paint, + left = CONTENT_LEFT, + top = headerTop, + width = CONTENT_WIDTH, + lineHeight = HEADER_LINE_HEIGHT, + alignment = Layout.Alignment.ALIGN_NORMAL + ) + + return headerTop + height + HEADER_BOTTOM_SPACING + } + + /** + * Draws a rounded block sized to hold [rowCount] rows of [lineHeight], invoking [drawContent] with the y position + * its content starts at. Returns the y position of the block's bottom edge. + */ + private fun drawKeyBlock(canvas: Canvas, top: Float, rowCount: Int, lineHeight: Float, drawContent: (Float) -> Unit): Float { + val height = 2 * BLOCK_VERTICAL_PADDING + rowCount * lineHeight + val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = BLOCK_COLOR } + + canvas.drawRoundRect(RectF(BLOCK_LEFT, top, BLOCK_LEFT + BLOCK_WIDTH, top + height), BLOCK_CORNER_RADIUS, BLOCK_CORNER_RADIUS, backgroundPaint) + drawContent(top + BLOCK_VERTICAL_PADDING) return top + height } - /** Draws a section header above a key block, returning the y position content below it should start at. */ - private fun drawSectionHeader(canvas: Canvas, paint: Paint, text: String, top: Float): Float { - val textTop = top + HEADER_TOP_PADDING - canvas.drawText(text, MARGIN, textTop - paint.fontMetrics.ascent, paint) - return textTop + paint.fontMetrics.let { it.descent - it.ascent } + HEADER_BOTTOM_PADDING + /** Draws the recovery key groups as evenly-spaced columns spanning the width of the block's content area. */ + private fun drawRecoveryKeyGroups(canvas: Canvas, paint: TextPaint, rows: List>, top: Float) { + val groupWidth = rows.flatten().maxOfOrNull { paint.measureText(it) } ?: 0f + val columnSpacing = (CONTENT_WIDTH - groupWidth) / (GROUPS_PER_ROW - 1) + + rows.forEachIndexed { rowIndex, row -> + row.forEachIndexed { columnIndex, group -> + drawText( + canvas = canvas, + text = group, + paint = paint, + left = CONTENT_LEFT + columnIndex * columnSpacing, + top = top + rowIndex * RECOVERY_KEY_LINE_HEIGHT, + width = groupWidth, + lineHeight = RECOVERY_KEY_LINE_HEIGHT, + alignment = Layout.Alignment.ALIGN_NORMAL + ) + } + } } /** - * Draws a rounded block containing rows of key text, returning the y position of the block's bottom edge. - * Rows with a single entry are drawn left-aligned; rows with multiple groups are spaced evenly across the - * block, mirroring how the screen lays out recovery key groups. + * Draws [text] wrapped to [width], with each line occupying [lineHeight] and its glyphs centered within that, + * matching how the design lays text out. Returns the total height consumed. */ - private fun drawKeyBlock(canvas: Canvas, paint: Paint, rows: List>, top: Float): Float { - val blockWidth = PAGE_WIDTH - 2 * MARGIN - val blockHeight = 2 * BLOCK_VERTICAL_PADDING + rows.size * KEY_LINE_HEIGHT + private fun drawText(canvas: Canvas, text: String, paint: TextPaint, left: Float, top: Float, width: Float, lineHeight: Float, alignment: Layout.Alignment): Float { + val glyphHeight = paint.fontMetrics.let { it.descent - it.ascent } + val layout = StaticLayout.Builder + .obtain(text, 0, text.length, paint, ceil(width).toInt()) + .setAlignment(alignment) + .setIncludePad(false) + .setLineSpacing(lineHeight - glyphHeight, 1f) + .build() - val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = BLOCK_COLOR } - canvas.drawRoundRect(RectF(MARGIN, top, MARGIN + blockWidth, top + blockHeight), BLOCK_CORNER_RADIUS, BLOCK_CORNER_RADIUS, backgroundPaint) - - val innerLeft = MARGIN + BLOCK_HORIZONTAL_PADDING - val innerWidth = blockWidth - 2 * BLOCK_HORIZONTAL_PADDING - val groupWidth = rows.flatten().maxOfOrNull { paint.measureText(it) } ?: 0f - - var lineTop = top + BLOCK_VERTICAL_PADDING - for (row in rows) { - val spacing = if (row.size > 1) (innerWidth - GROUPS_PER_ROW * groupWidth) / (GROUPS_PER_ROW - 1) else 0f - val baseline = lineTop + (KEY_LINE_HEIGHT - (paint.fontMetrics.descent - paint.fontMetrics.ascent)) / 2f - paint.fontMetrics.ascent - - row.forEachIndexed { index, group -> - canvas.drawText(group, innerLeft + index * (groupWidth + spacing), baseline, paint) - } - - lineTop += KEY_LINE_HEIGHT + canvas.withTranslation(left, top + (lineHeight - glyphHeight) / 2f) { + layout.draw(this) } - return top + blockHeight + return layout.lineCount * lineHeight + } + + private fun textPaint(textSize: Float, letterSpacing: Float, color: Int, semiBold: Boolean): TextPaint { + return TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + this.typeface = if (semiBold) Typeface.create("sans-serif-medium", Typeface.NORMAL) else Typeface.SANS_SERIF + this.textSize = textSize + this.letterSpacing = letterSpacing + this.color = color + } } } diff --git a/lib/signal-login/src/main/res/drawable/image_signal_login_card_x_small.xml b/lib/signal-login/src/main/res/drawable/image_signal_login_card_x_small.xml new file mode 100644 index 0000000000..85770af9b1 --- /dev/null +++ b/lib/signal-login/src/main/res/drawable/image_signal_login_card_x_small.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/signal-login/src/main/res/values/strings.xml b/lib/signal-login/src/main/res/values/strings.xml index 50569532b9..f4a9520f52 100644 --- a/lib/signal-login/src/main/res/values/strings.xml +++ b/lib/signal-login/src/main/res/values/strings.xml @@ -27,4 +27,14 @@ Unable to save PDF. Please try again. Can\'t save PDF to the selected location. + + + + Your Signal Login + + Store this in a safe place, your login info is required to recover your account and data. Never share it with anyone. + + Account ID + + Recovery Key