From 284a6ae667d3638b02bea520d312725c97309285 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 30 Mar 2022 12:00:29 -0400 Subject: [PATCH] Add defaults for script/text font pairings and guessing of script based on body contents. Co-authored-by: Alex Hart --- .../securesms/fonts/FontManifest.kt | 28 +-- .../securesms/fonts/FontTypefaceProvider.kt | 59 ++++--- .../org/thoughtcrime/securesms/fonts/Fonts.kt | 167 +++++++++++++++--- .../securesms/fonts/SupportedScript.kt | 17 ++ .../securesms/fonts/TextToScript.kt | 50 ++++++ .../securesms/fonts/TypefaceHelper.kt | 39 ++++ .../securesms/jobs/FontDownloaderJob.kt | 5 +- .../ImageEditorModelRenderMediaTransform.java | 2 +- .../v2/text/TextStoryPostCreationViewModel.kt | 21 ++- .../v2/text/TextStoryPostTextEntryFragment.kt | 4 + .../scribbles/ImageEditorFragment.java | 6 +- .../securesms/stories/StoryTextPostView.kt | 15 +- .../thoughtcrime/securesms/util/LocaleUtil.kt | 33 ++++ .../crop/WallpaperCropViewModel.java | 2 +- .../securesms/fonts/TextToScriptTest.kt | 80 +++++++++ 15 files changed, 436 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/fonts/SupportedScript.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/fonts/TextToScript.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/fonts/TypefaceHelper.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/LocaleUtil.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/fonts/TextToScriptTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/FontManifest.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontManifest.kt index 14289e08e4..b6cb72c83c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/fonts/FontManifest.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontManifest.kt @@ -28,25 +28,25 @@ data class FontManifest( * @param chineseSimplified Hans Script Fonts */ data class FontScripts( - @JsonProperty("latin-extended") val latinExtended: FontScript, - @JsonProperty("cyrillic-extended") val cyrillicExtended: FontScript, - val devanagari: FontScript, - @JsonProperty("chinese-traditional-hk") val chineseTraditionalHk: FontScript, - @JsonProperty("chinese-traditional") val chineseTraditional: FontScript, - @JsonProperty("chinese-simplified") val chineseSimplified: FontScript, - val arabic: FontScript, - val japanese: FontScript, + @JsonProperty("latin-extended") val latinExtended: FontScript?, + @JsonProperty("cyrillic-extended") val cyrillicExtended: FontScript?, + val devanagari: FontScript?, + @JsonProperty("chinese-traditional-hk") val chineseTraditionalHk: FontScript?, + @JsonProperty("chinese-traditional") val chineseTraditional: FontScript?, + @JsonProperty("chinese-simplified") val chineseSimplified: FontScript?, + val arabic: FontScript?, + val japanese: FontScript?, ) /** * A collection of fonts for a specific script */ data class FontScript( - val regular: String, - val bold: String, - val serif: String, - val script: String, - val condensed: String + val regular: String?, + val bold: String?, + val serif: String?, + val script: String?, + val condensed: String? ) companion object { @@ -76,7 +76,7 @@ data class FontManifest( objectMapper.readValue(it, FontManifest::class.java) } } catch (e: Exception) { - Log.w(TAG, "Failed to load manifest from disk") + Log.w(TAG, "Failed to load manifest from disk", e) return null } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/FontTypefaceProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontTypefaceProvider.kt index 184bb1dffd..1d3405b9a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/fonts/FontTypefaceProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontTypefaceProvider.kt @@ -2,41 +2,44 @@ package org.thoughtcrime.securesms.fonts import android.content.Context import android.graphics.Typeface -import android.os.Build import org.signal.imageeditor.core.Renderer import org.signal.imageeditor.core.RendererContext +import org.thoughtcrime.securesms.util.FutureTaskListener +import org.thoughtcrime.securesms.util.LocaleUtil +import java.util.Locale +import java.util.concurrent.ExecutionException /** * RenderContext TypefaceProvider that provides typefaces using TextFont. */ -object FontTypefaceProvider : RendererContext.TypefaceProvider { - override fun getSelectedTypeface(context: Context, renderer: Renderer, invalidate: RendererContext.Invalidate): Typeface { - return getTypeface() - // TODO [cody] Need to rework Fonts.kt to not hit network on main, reverting to old typeface for now -// return when (val fontResult = Fonts.resolveFont(context, Locale.getDefault(), TextFont.BOLD)) { -// is Fonts.FontResult.Immediate -> fontResult.typeface -// is Fonts.FontResult.Async -> { -// fontResult.future.addListener(object : FutureTaskListener { -// override fun onSuccess(result: Typeface?) { -// invalidate.onInvalidate(renderer) -// } -// -// override fun onFailure(exception: ExecutionException?) = Unit -// }) -// -// fontResult.placeholder -// } -// } - } +class FontTypefaceProvider : RendererContext.TypefaceProvider { - private fun getTypeface(): Typeface { - return if (Build.VERSION.SDK_INT < 26) { - Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - } else { - Typeface.Builder("") - .setFallback("sans-serif") - .setWeight(900) - .build() + private var cachedTypeface: Typeface? = null + private var cachedLocale: Locale? = null + + override fun getSelectedTypeface(context: Context, renderer: Renderer, invalidate: RendererContext.Invalidate): Typeface { + val typeface = cachedTypeface + if (typeface != null && cachedLocale == LocaleUtil.getFirstLocale()) { + return typeface + } + + return when (val fontResult = Fonts.resolveFont(context, TextFont.BOLD)) { + is Fonts.FontResult.Immediate -> { + cachedTypeface = fontResult.typeface + cachedLocale = LocaleUtil.getFirstLocale() + fontResult.typeface + } + is Fonts.FontResult.Async -> { + fontResult.future.addListener(object : FutureTaskListener { + override fun onSuccess(result: Typeface?) { + invalidate.onInvalidate(renderer) + } + + override fun onFailure(exception: ExecutionException?) = Unit + }) + + fontResult.placeholder + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt index 9a9782e0a0..3dbc01c7c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt @@ -7,6 +7,7 @@ import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.s3.S3 import org.thoughtcrime.securesms.util.ListenableFutureTask +import org.thoughtcrime.securesms.util.LocaleUtil import java.io.File import java.util.Collections import java.util.Locale @@ -46,15 +47,16 @@ object Fonts { } /** - * Attempts to retrieve a Typeface for the given font / locale combination + * Attempts to retrieve a Typeface for the given font / guessed script and default locales combination * * @param context An application context - * @param locale The locale the content will be displayed in * @param font The desired font + * @param guessedScript The script likely being used based on text content + * * @return a FontResult that represents either a Typeface or a task retrieving a Typeface. */ @WorkerThread - fun resolveFont(context: Context, locale: Locale, font: TextFont): FontResult { + fun resolveFont(context: Context, font: TextFont, guessedScript: SupportedScript = SupportedScript.UNKNOWN): FontResult { synchronized(this) { val errorFallback = FontResult.Immediate(Typeface.create(font.fallbackFamily, font.fallbackStyle)) val version = FontVersion.get(context) @@ -66,11 +68,21 @@ object Fonts { Log.d(TAG, "Loaded manifest.") - val fontScript = resolveScriptNameFromLocale(locale, manifest) ?: return errorFallback + val localeDefaults: List = LocaleUtil.getLocaleDefaults() + val supportedScript: SupportedScript = getSupportedScript(localeDefaults, guessedScript) + val fontScript = resolveFontScriptFromScriptName(supportedScript, manifest) + if (fontScript == null) { + Log.d(TAG, "Manifest does not have an entry for $supportedScript. Using default.") + return FontResult.Immediate(getDefaultFontForScriptAndStyle(supportedScript, font)) + } Log.d(TAG, "Loaded script for locale.") val fontNetworkPath = getScriptPath(font, fontScript) + if (fontNetworkPath == null) { + Log.d(TAG, "Manifest does not contain a network path for $supportedScript. Using default.") + return FontResult.Immediate(getDefaultFontForScriptAndStyle(supportedScript, font)) + } val fontLocalPath = FontFileMap.getNameOnDisk(context, version, fontNetworkPath) @@ -80,7 +92,7 @@ object Fonts { } val fontDownloadKey = FontDownloadKey( - version, locale, font + version, supportedScript, font ) val taskInProgress = taskCache[fontDownloadKey] @@ -93,7 +105,7 @@ object Fonts { } else { Log.d(TAG, "Could not find a task in progress. Returning new async.") val newTask = ListenableFutureTask { - val newLocalPath = downloadFont(context, locale, font, version, manifest) + val newLocalPath = downloadFont(context, supportedScript, font, version, manifest) Log.d(TAG, "Finished download, $newLocalPath") val typeface = newLocalPath?.let { loadFontIntoTypeface(context, version, it) } ?: errorFallback.typeface @@ -112,6 +124,69 @@ object Fonts { } } + private fun getDefaultFontForScriptAndStyle(supportedScript: SupportedScript, font: TextFont): Typeface { + return when (supportedScript) { + SupportedScript.CYRILLIC -> { + when (font) { + TextFont.REGULAR -> Typeface.SANS_SERIF + TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + TextFont.SERIF -> Typeface.SERIF + TextFont.SCRIPT -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SERIF, "semibold", TypefaceHelper.Weight.SEMI_BOLD) + TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "light", TypefaceHelper.Weight.LIGHT) + } + } + SupportedScript.DEVANAGARI -> { + when (font) { + TextFont.REGULAR -> Typeface.SANS_SERIF + TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + TextFont.SERIF -> Typeface.SANS_SERIF + TextFont.SCRIPT -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "light", TypefaceHelper.Weight.LIGHT) + } + } + SupportedScript.CHINESE_TRADITIONAL_HK, + SupportedScript.CHINESE_TRADITIONAL, + SupportedScript.CHINESE_SIMPLIFIED, + SupportedScript.UNKNOWN_CJK -> { + when (font) { + TextFont.REGULAR -> Typeface.SANS_SERIF + TextFont.BOLD -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "semibold", TypefaceHelper.Weight.SEMI_BOLD) + TextFont.SERIF -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "thin", TypefaceHelper.Weight.THIN) + TextFont.SCRIPT -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "light", TypefaceHelper.Weight.LIGHT) + TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "demilight", TypefaceHelper.Weight.DEMI_LIGHT) + } + } + SupportedScript.ARABIC -> { + when (font) { + TextFont.REGULAR -> Typeface.SANS_SERIF + TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + TextFont.SERIF -> Typeface.SERIF + TextFont.SCRIPT -> Typeface.create(Typeface.SERIF, Typeface.BOLD) + TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "black", TypefaceHelper.Weight.BLACK) + } + } + SupportedScript.JAPANESE -> { + when (font) { + TextFont.REGULAR -> Typeface.SANS_SERIF + TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + TextFont.SERIF -> Typeface.SERIF + TextFont.SCRIPT -> Typeface.create(Typeface.SERIF, Typeface.BOLD) + TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "medium", TypefaceHelper.Weight.MEDIUM) + } + } + SupportedScript.LATIN, + SupportedScript.UNKNOWN -> { + when (font) { + TextFont.REGULAR -> Typeface.SANS_SERIF + TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + TextFont.SERIF -> Typeface.SERIF + TextFont.SCRIPT -> Typeface.create(Typeface.SERIF, Typeface.BOLD) + TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "black", TypefaceHelper.Weight.BLACK) + } + } + } + } + @WorkerThread private fun loadFontIntoTypeface(context: Context, fontVersion: FontVersion, fontLocalPath: String): Typeface? { return try { @@ -146,9 +221,13 @@ object Fonts { * Downloads the given font file from S3 */ @WorkerThread - private fun downloadFont(context: Context, locale: Locale, font: TextFont, fontVersion: FontVersion, fontManifest: FontManifest): String? { - val script: FontManifest.FontScript = resolveScriptNameFromLocale(locale, fontManifest) ?: return null - val path = getScriptPath(font, script) + private fun downloadFont(context: Context, supportedScript: SupportedScript?, font: TextFont, fontVersion: FontVersion, fontManifest: FontManifest): String? { + if (supportedScript == null) { + return null + } + + val script: FontManifest.FontScript = resolveFontScriptFromScriptName(supportedScript, fontManifest) ?: return null + val path = getScriptPath(font, script) ?: return null val networkPath = "$BASE_STATIC_BUCKET_URL/${fontVersion.id}/$path" val localUUID = UUID.randomUUID().toString() val localPath = "${fontVersion.path}/" + localUUID @@ -162,7 +241,7 @@ object Fonts { } } - private fun getScriptPath(font: TextFont, script: FontManifest.FontScript): String { + private fun getScriptPath(font: TextFont, script: FontManifest.FontScript): String? { return when (font) { TextFont.REGULAR -> script.regular TextFont.BOLD -> script.bold @@ -172,22 +251,62 @@ object Fonts { } } - private fun resolveScriptNameFromLocale(locale: Locale, fontManifest: FontManifest): FontManifest.FontScript? { - val fontScript: FontManifest.FontScript = when (ScriptUtil.getScript(locale).apply { Log.d(TAG, "Getting Script for $this") }) { - ScriptUtil.LATIN -> fontManifest.scripts.latinExtended - ScriptUtil.ARABIC -> fontManifest.scripts.arabic - ScriptUtil.CHINESE_SIMPLIFIED -> fontManifest.scripts.chineseSimplified - ScriptUtil.CHINESE_TRADITIONAL -> fontManifest.scripts.chineseTraditional - ScriptUtil.CYRILLIC -> fontManifest.scripts.cyrillicExtended - ScriptUtil.DEVANAGARI -> fontManifest.scripts.devanagari - ScriptUtil.JAPANESE -> fontManifest.scripts.japanese - else -> return null + private fun getSupportedScript(locales: List, guessedScript: SupportedScript): SupportedScript { + if (guessedScript != SupportedScript.UNKNOWN && guessedScript != SupportedScript.UNKNOWN_CJK) { + return guessedScript + } else if (guessedScript == SupportedScript.UNKNOWN_CJK) { + val likelyScript: SupportedScript? = locales.mapNotNull { + try { + when (it.isO3Country) { + "HKG" -> SupportedScript.CHINESE_TRADITIONAL_HK + "CHN" -> SupportedScript.CHINESE_SIMPLIFIED + "TWN" -> SupportedScript.CHINESE_TRADITIONAL + "JPN" -> SupportedScript.JAPANESE + else -> null + } + } catch (e: java.util.MissingResourceException) { + Log.w(TAG, "Unable to get ISO-3 country code for: $it") + null + } + }.firstOrNull() + + if (likelyScript != null) { + return likelyScript + } } - return if (fontScript == fontManifest.scripts.chineseSimplified && locale.isO3Country == "HKG") { - fontManifest.scripts.chineseTraditionalHk + val locale = locales.first() + val supportedScript: SupportedScript = when (ScriptUtil.getScript(locale).also { Log.d(TAG, "Getting Script for $it") }) { + ScriptUtil.LATIN -> SupportedScript.LATIN + ScriptUtil.ARABIC -> SupportedScript.ARABIC + ScriptUtil.CHINESE_SIMPLIFIED -> SupportedScript.CHINESE_SIMPLIFIED + ScriptUtil.CHINESE_TRADITIONAL -> SupportedScript.CHINESE_TRADITIONAL + ScriptUtil.CYRILLIC -> SupportedScript.CYRILLIC + ScriptUtil.DEVANAGARI -> SupportedScript.DEVANAGARI + ScriptUtil.JAPANESE -> SupportedScript.JAPANESE + else -> SupportedScript.UNKNOWN + } + + return if (supportedScript == SupportedScript.CHINESE_SIMPLIFIED && locale.isO3Country == "HKG") { + SupportedScript.CHINESE_TRADITIONAL_HK } else { - fontScript + supportedScript + } + } + + private fun resolveFontScriptFromScriptName(supportedScript: SupportedScript?, fontManifest: FontManifest): FontManifest.FontScript? { + return when (supportedScript.also { Log.d(TAG, "Getting Script for $it") }) { + SupportedScript.LATIN -> fontManifest.scripts.latinExtended + SupportedScript.ARABIC -> fontManifest.scripts.arabic + SupportedScript.CHINESE_SIMPLIFIED -> fontManifest.scripts.chineseSimplified + SupportedScript.CHINESE_TRADITIONAL -> fontManifest.scripts.chineseTraditional + SupportedScript.CYRILLIC -> fontManifest.scripts.cyrillicExtended + SupportedScript.DEVANAGARI -> fontManifest.scripts.devanagari + SupportedScript.JAPANESE -> fontManifest.scripts.japanese + SupportedScript.CHINESE_TRADITIONAL_HK -> fontManifest.scripts.chineseTraditionalHk + SupportedScript.UNKNOWN_CJK -> null + SupportedScript.UNKNOWN -> null + null -> null } } @@ -201,7 +320,7 @@ object Fonts { private data class FontDownloadKey( val version: FontVersion, - val locale: Locale, + val script: SupportedScript, val font: TextFont ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/SupportedScript.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/SupportedScript.kt new file mode 100644 index 0000000000..c880de90eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/SupportedScript.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.fonts + +/** + * Scripts with font support for Stories + */ +enum class SupportedScript { + LATIN, + CYRILLIC, + DEVANAGARI, + CHINESE_TRADITIONAL_HK, + CHINESE_TRADITIONAL, + CHINESE_SIMPLIFIED, + UNKNOWN_CJK, + ARABIC, + JAPANESE, + UNKNOWN +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/TextToScript.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/TextToScript.kt new file mode 100644 index 0000000000..c3132f324d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/TextToScript.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.fonts + +/** + * Attempt to guess the script based on the unicode characters used. The script + * with the most use in a string will be picked. Tie goes to [SupportedScript.LATIN]. + * Unicode does not cleanly separate Hong Kong and Chinese character ranges so some + * other means must be used to distinguish it (e.g., Locale). + */ +object TextToScript { + + private val LATIN_RANGES: List = listOf(0x0000..0x024F, 0x1E00..0x1EFF, 0x2C60..0x2C7F, 0xA720..0xA7FF, 0xAB30..0xAB6F) + private val CYRILLIC_RANGES: List = listOf(0x0400..0x04FF, 0x0500..0x052F, 0x1C80..0x1C8F, 0x2DE0..0x2DFF, 0xA640..0xA69F) + private val DEVANAGARI_RANGES: List = listOf(0x0900..0x097F, 0xA8E0..0xA8FF, 0x30A0..0x30FF) + private val CJK_RANGES: List = listOf(0x31C0..0x31EF, 0x3300..0x33FF, 0x4E00..0x9FFF, 0xF900..0xFAFF, 0xFE30..0xFE4F, 0x20000..0x2EBEF, 0x2F800..0x2FA1F) + private val CJK_JAPANESE_RANGES: List = listOf(0x3040..0x309F, 0x30A0..0x30FF, 0x3190..0x319F) + private val ARABIC_RANGES: List = listOf(0x0600..0x06FF, 0x0750..0x077F, 0x0870..0x089F, 0x08A0..0x08FF) + + private val allRanges = mapOf( + SupportedScript.LATIN to LATIN_RANGES, + SupportedScript.CYRILLIC to CYRILLIC_RANGES, + SupportedScript.DEVANAGARI to DEVANAGARI_RANGES, + SupportedScript.UNKNOWN_CJK to CJK_RANGES, + SupportedScript.JAPANESE to CJK_JAPANESE_RANGES, + SupportedScript.ARABIC to ARABIC_RANGES + ) + + fun guessScript(text: CharSequence): SupportedScript { + val scriptCounts: MutableMap = SupportedScript.values().associate { it to 0 }.toMutableMap() + val input = text.toString() + + for (i in 0 until input.codePointCount(0, input.length)) { + val codePoint = input.codePointAt(i) + for ((script, ranges) in allRanges) { + if (ranges.contains(codePoint)) { + scriptCounts[script] = scriptCounts[script]!! + 1 + } + } + } + + val most: SupportedScript = scriptCounts.maxByOrNull { it.value }?.key ?: SupportedScript.UNKNOWN + + return if (most == SupportedScript.UNKNOWN_CJK && scriptCounts[SupportedScript.JAPANESE]!! > 0) { + SupportedScript.JAPANESE + } else { + most + } + } + + private fun List.contains(x: Int): Boolean = any { x in it } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/TypefaceHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/TypefaceHelper.kt new file mode 100644 index 0000000000..548f9d0204 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/TypefaceHelper.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.fonts + +import android.graphics.Typeface +import android.os.Build + +/** + * API independent, best-effort way of creating [Typeface]s of various family/weight. + */ +object TypefaceHelper { + fun typefaceFor(family: Family, weightName: String, weight: Weight): Typeface { + return when { + Build.VERSION.SDK_INT >= 28 -> Typeface.create( + Typeface.create(family.familyName, Typeface.NORMAL), + weight.value, + false + ) + Build.VERSION.SDK_INT >= 21 -> Typeface.create("${family.familyName}-$weightName", Typeface.NORMAL) + else -> Typeface.create(family.familyName, if (weight.value > Weight.MEDIUM.value) Typeface.BOLD else Typeface.NORMAL) + } + } + + enum class Family(val familyName: String) { + SANS_SERIF("sans-serif"), + SERIF("serif") + } + + enum class Weight(val value: Int) { + THIN(100), + EXTRA_LIGHT(200), + DEMI_LIGHT(200), + LIGHT(300), + NORMAL(400), + MEDIUM(500), + SEMI_BOLD(600), + BOLD(700), + EXTRA_BOLD(800), + BLACK(900), + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FontDownloaderJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/FontDownloaderJob.kt index c6797ea6ce..33d24d43c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/FontDownloaderJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FontDownloaderJob.kt @@ -8,7 +8,6 @@ import org.thoughtcrime.securesms.jobmanager.Data import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.util.FutureTaskListener -import java.util.Locale import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit @@ -41,10 +40,8 @@ class FontDownloaderJob private constructor(parameters: Parameters) : BaseJob(pa override fun onFailure() = Unit override fun onRun() { - val locale = Locale.getDefault() - val asyncResults = TextFont.values() - .map { Fonts.resolveFont(context, locale, it) } + .map { Fonts.resolveFont(context, it) } .filterIsInstance(Fonts.FontResult.Async::class.java) if (asyncResults.isEmpty()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java index 9bf4636c41..9504a755f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java @@ -41,7 +41,7 @@ public final class ImageEditorModelRenderMediaTransform implements MediaTransfor public @NonNull Media transform(@NonNull Context context, @NonNull Media media) { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Bitmap bitmap = modelToRender.render(context, size, FontTypefaceProvider.INSTANCE); + Bitmap bitmap = modelToRender.render(context, size, new FontTypefaceProvider()); try { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt index 4a30503542..31ed832e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt @@ -16,15 +16,16 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.fonts.Fonts import org.thoughtcrime.securesms.fonts.TextFont +import org.thoughtcrime.securesms.fonts.TextToScript import org.thoughtcrime.securesms.util.FutureTaskListener import org.thoughtcrime.securesms.util.livedata.Store -import java.util.Locale import java.util.concurrent.ExecutionException class TextStoryPostCreationViewModel : ViewModel() { private val store = Store(TextStoryPostCreationState()) private val textFontSubject: Subject = BehaviorSubject.create() + private val temporaryBodySubject: Subject = BehaviorSubject.createDefault("") private val disposables = CompositeDisposable() private val internalThumbnail = MutableLiveData() @@ -38,14 +39,16 @@ class TextStoryPostCreationViewModel : ViewModel() { init { textFontSubject.onNext(store.state.textFont) - textFontSubject + val scriptGuess = temporaryBodySubject.observeOn(Schedulers.io()).map { TextToScript.guessScript(it) } + + Observable.combineLatest(textFontSubject, scriptGuess, ::Pair) .observeOn(Schedulers.io()) .distinctUntilChanged() - .map { Fonts.resolveFont(ApplicationDependencies.getApplication(), Locale.getDefault(), it) } - .switchMap { - when (it) { - is Fonts.FontResult.Async -> asyncFontEmitter(it) - is Fonts.FontResult.Immediate -> Observable.just(it.typeface) + .map { (textFont, script) -> Fonts.resolveFont(ApplicationDependencies.getApplication(), textFont, script) } + .switchMap { result -> + when (result) { + is Fonts.FontResult.Async -> asyncFontEmitter(result) + is Fonts.FontResult.Immediate -> Observable.just(result.typeface) } } .subscribeOn(Schedulers.io()) @@ -142,6 +145,10 @@ class TextStoryPostCreationViewModel : ViewModel() { store.update { it.copy(linkPreviewUri = url) } } + fun setTemporaryBody(temporaryBody: String) { + temporaryBodySubject.onNext(temporaryBody) + } + companion object { private val TAG = Log.tag(TextStoryPostCreationViewModel::class.java) private const val TEXT_STORY_INSTANCE_STATE = "text.story.instance.state" diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt index eb03e49ba7..8555efbba4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt @@ -20,6 +20,7 @@ import androidx.appcompat.widget.AppCompatSeekBar import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.updateLayoutParams +import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doOnTextChanged import androidx.fragment.app.viewModels import androidx.transition.TransitionManager @@ -112,6 +113,9 @@ class TextStoryPostTextEntryFragment : KeyboardEntryDialogFragment( input.doOnTextChanged { _, _, _, _ -> presentHint() } + input.doAfterTextChanged { text -> + viewModel.setTemporaryBody(text?.toString() ?: "") + } input.setText(viewModel.getBody()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index de08ce0344..b2768faedf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -219,7 +219,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu imageEditorHud = view.findViewById(R.id.scribble_hud); imageEditorView = view.findViewById(R.id.image_editor_view); - imageEditorView.setTypefaceProvider(FontTypefaceProvider.INSTANCE); + imageEditorView.setTypefaceProvider(new FontTypefaceProvider()); int width = getResources().getDisplayMetrics().widthPixels; int height = (int) ((16 / 9f) * width); @@ -555,7 +555,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu FaceDetector detector = new AndroidFaceDetector(); Point size = model.getOutputSizeMaxWidth(1000); - Bitmap render = model.render(ApplicationDependencies.getApplication(), size, FontTypefaceProvider.INSTANCE); + Bitmap render = model.render(ApplicationDependencies.getApplication(), size, new FontTypefaceProvider()); try { return new FaceDetectionResult(detector.detect(render), new Point(render.getWidth(), render.getHeight()), inverseCropPosition); } finally { @@ -769,7 +769,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu @WorkerThread public @NonNull Uri renderToSingleUseBlob() { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Bitmap image = imageEditorView.getModel().render(requireContext(), FontTypefaceProvider.INSTANCE); + Bitmap image = imageEditorView.getModel().render(requireContext(), new FontTypefaceProvider()); image.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); image.recycle(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt index 55d725c8a2..c2a129bfd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost import org.thoughtcrime.securesms.fonts.Fonts import org.thoughtcrime.securesms.fonts.TextFont +import org.thoughtcrime.securesms.fonts.TextToScript import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.mediasend.v2.text.TextAlignment @@ -25,7 +26,6 @@ import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryScale import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryTextWatcher import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay import org.thoughtcrime.securesms.util.concurrent.ListenableFuture -import org.thoughtcrime.securesms.util.concurrent.SimpleTask import org.thoughtcrime.securesms.util.visible import java.util.Locale @@ -153,15 +153,10 @@ class StoryTextPostView @JvmOverloads constructor( setTextBackgroundColor(storyTextPost.textBackgroundColor) setTextGravity(TextAlignment.CENTER) - SimpleTask.run( - { - when (val fontResult = Fonts.resolveFont(context, Locale.getDefault(), font)) { - is Fonts.FontResult.Immediate -> fontResult.typeface - is Fonts.FontResult.Async -> fontResult.future.get() - } - }, - { typeface -> setTypeface(typeface) } - ) + when (val fontResult = Fonts.resolveFont(context, font, TextToScript.guessScript(storyTextPost.body))) { + is Fonts.FontResult.Immediate -> setTypeface(fontResult.typeface) + is Fonts.FontResult.Async -> setTypeface(fontResult.future.get()) + } hideCloseButton() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LocaleUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/LocaleUtil.kt new file mode 100644 index 0000000000..0cf2a6dd35 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LocaleUtil.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util + +import androidx.core.os.LocaleListCompat +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.dynamiclanguage.LanguageString +import java.util.Locale + +object LocaleUtil { + + fun getFirstLocale(): Locale { + return getLocaleDefaults().firstOrNull() ?: Locale.getDefault() + } + + /** + * Get a user priority list of locales supported on the device, with the locale set via Signal settings + * as highest priority over system settings. + */ + fun getLocaleDefaults(): List { + val locales: MutableList = mutableListOf() + val signalLocale: Locale? = LanguageString.parseLocale(SignalStore.settings().language) + val localeList: LocaleListCompat = LocaleListCompat.getDefault() + + if (signalLocale != null) { + locales += signalLocale + } + + for (index in 0 until localeList.size()) { + locales += localeList.get(index) + } + + return locales + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java index 4924a3aa2f..bb80fe1426 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java @@ -50,7 +50,7 @@ final class WallpaperCropViewModel extends ViewModel { { SignalExecutors.BOUNDED.execute( () -> { - Bitmap bitmap = model.render(context, size, FontTypefaceProvider.INSTANCE); + Bitmap bitmap = model.render(context, size, new FontTypefaceProvider()); try { ChatWallpaper chatWallpaper = repository.setWallPaper(BitmapUtil.toWebPByteArray(bitmap)); callback.onComplete(chatWallpaper); diff --git a/app/src/test/java/org/thoughtcrime/securesms/fonts/TextToScriptTest.kt b/app/src/test/java/org/thoughtcrime/securesms/fonts/TextToScriptTest.kt new file mode 100644 index 0000000000..de5327b19b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/fonts/TextToScriptTest.kt @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.fonts + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class TextToScriptTest( + private val texts: List, + private val guess: SupportedScript +) { + + @Test + fun guessScript() { + for (text in texts) { + assertEquals("Expecting $guess for $text", guess, TextToScript.guessScript(text)) + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: guessLocale(..) = {1}") + fun data(): Iterable> = arrayListOf( + arrayOf( + listOf("What’s up?", "I really appreciate it.", "How wonderful!", "Let’s grab a bite to eat.", "I’m gonna hit the sack.", "More latin than other Прошу"), + SupportedScript.LATIN + ), + arrayOf( + listOf("Guten Tag", "Sprechen Sie Englisch?", "Gut, danke", "Gibt es ein Restaurant in der Nähe?", "Haben Sie noch Zimmer frei?", "Haben Sie noch Zimmer frei? Прошу"), + SupportedScript.LATIN + ), + arrayOf( + listOf("Bom Dia", "Prazer", "Qual é o seu nome?", "Como vai?", "Que horas são?", "Que horas são? Прошу"), + SupportedScript.LATIN + ), + arrayOf( + listOf("Bună ziua.", "Cum te numeşti?", " Îmi pare rău.", "Unde este toaleta?", "Felicitări!", "Multumesc pentru tot ajutorul Clauz", "Felicitări! Прошу"), + SupportedScript.LATIN + ), + arrayOf( + listOf("добрий день", "добрий день", "привіт", "дякую", "дякую", "будь ласка", "дуже добре", "Вибачте", "Так", "па-па", "Ви говорите англійською?", "Вибачте abc"), + SupportedScript.CYRILLIC + ), + arrayOf( + listOf("Да", "Нет", "Пожалуйста", "Спасибо", "Не за что.", "на здоровье", "Прошу прощения.", "Извините.", "Я не понимаю.", "Я не говорю по-Русски.", "Я не понимаю. abc"), + SupportedScript.CYRILLIC + ), + arrayOf( + listOf("स्वागत", "नमस्ते", "तुम कैसे हो?", "मैं अच्छा हूँ, धन्यवाद। और तुम?", "तुम कहाँ से (आए) हो?", "शुभ रात्रि", "धन्यवाद", "धन्यवाद abc"), + SupportedScript.DEVANAGARI + ), + arrayOf( + listOf("食咗飯未呀?", "唔該", "多謝", "太貴啦", "好味", "我花生過敏", "地鐵站係邊", "廁所係邊", "無問題", "無問題a"), + SupportedScript.UNKNOWN_CJK + ), + arrayOf( + listOf("你好嗎", "很高興認識你", "我不會說漢語", "我需要你的幫助", "我丟了手提包", "我丟了手提包abc"), + SupportedScript.UNKNOWN_CJK + ), + arrayOf( + listOf("你好", "请问", "你会说英语吗", "我听不懂", "洗手间在哪里", "我可以用您的手机吗", "我可以用您的手机吗abcd"), + SupportedScript.UNKNOWN_CJK + ), + + arrayOf( + listOf("はい", "いいえ", "こんばんは", "えいごをはなせますか", "おあいできて うれしいです", "もっと ゆっくりはなしてください", "だいじょうぶです", "わかります", "だいじょうぶです123"), + SupportedScript.JAPANESE + ), + arrayOf( + listOf("ما اسمك؟", "كيف حالك؟", "انا آسف", "أين الحمام؟", "هل يمكنك التحدث بشكل أبطأ من فضلك؟", "مندواعي سروري مقابلتك", "كيفتجري الامور؟", "أنا تائه.", "أنا تائه.12"), + SupportedScript.ARABIC + ), + arrayOf( + listOf("ППППaaaa", ""), + SupportedScript.LATIN + ), + ) + } +}