diff --git a/.github/workflows/preview-ci.yml b/.github/workflows/preview-ci.yml index ba88e8169..d54a24859 100644 --- a/.github/workflows/preview-ci.yml +++ b/.github/workflows/preview-ci.yml @@ -100,6 +100,8 @@ jobs: bash ./gradlew :app:testFullStandardDebugUnitTest \ --tests '*Independent*Test' \ --tests '*SmallTsu*' \ + --tests '*KeyboardFontApplicatorTest' \ + --tests '*BundledEmojiCategoryTest' \ --tests '*VibrationTimingPolicyTest' \ --tests '*VibrationShortcutTest' \ --tests '*ShortcutActiveStateResolverTest' \ @@ -237,9 +239,55 @@ jobs: app/build/reports/androidTests/connected/ retention-days: 14 + emoji-rendering: + name: Emoji rendering API ${{ matrix.api }} + runs-on: ubuntu-24.04 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + api: [24, 34] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 + with: + distribution: temurin + java-version: '17' + - name: Install Android dependencies + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37.0" "build-tools;36.0.0"' + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Verify bundled emoji font and UI + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d + with: + api-level: ${{ matrix.api }} + arch: x86_64 + target: google_apis + profile: pixel_2 + cores: 2 + ram-size: 2048M + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim + script: bash scripts/preview/emoji_device_tests.sh + - name: Upload emoji reports and render comparisons + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: sumire-emoji-api-${{ matrix.api }} + path: | + symbol_keyboard/build/outputs/androidTest-results/ + symbol_keyboard/build/reports/androidTests/ + emoji-rendering-artifacts/ + retention-days: 14 + sign: name: Sign trusted Full Preview - needs: [verify, device-input] + needs: [verify, device-input, emoji-rendering] # Normal push/PR CI never accesses signing secrets. if: >- github.event_name == 'workflow_dispatch' && diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt index 1f4dce97d..a5d32660a 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt @@ -22,6 +22,7 @@ import com.kazumaproject.custom_keyboard.data.SumireFlickInputMode import com.kazumaproject.custom_keyboard.data.TfbiFlickRotation import com.kazumaproject.custom_keyboard.data.buildEvenCircularRanges import com.kazumaproject.domain.EmojiSkinToneSupport +import com.kazumaproject.symbol_keyboard.EmojiRenderingStyle import com.kazumaproject.markdownhelperkeyboard.converter.engine.PredictionConfig import com.kazumaproject.markdownhelperkeyboard.converter.date.DateCandidateConfig import com.kazumaproject.markdownhelperkeyboard.converter.date.DateCandidateFormat @@ -1852,6 +1853,12 @@ object AppPreference { it.putString(SYMBOL_MODE_PREFERENCE.first, value.name) } + var emoji_rendering_style_preference: EmojiRenderingStyle + get() = EmojiRenderingStyle.read(preferences) + set(value) = preferences.edit { + it.putString(EmojiRenderingStyle.PREFERENCE_KEY, value.preferenceValue) + } + var default_emoji_skin_tone_preference: String get() { val skinTone = preferences.getString( diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt index f04ef2528..2c6e3f48f 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt @@ -280,6 +280,9 @@ class OpenSourceFragment : Fragment() { ).forEach { (name, id) -> entries += LicenseEntry(name) { showDictionaryLicense(name, "licenses/$id.html") } } + entries += LicenseEntry("Noto Color Emoji (Google)") { + showDictionaryLicense("Noto Color Emoji — SIL OFL 1.1", "licenses/noto-color-emoji.html") + } return entries } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt index ed0e27ad8..fdf81152e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt @@ -535,6 +535,7 @@ object SettingDestinations { "shortcut_toolbar_size_setting_fragment_preference", "symbol_mode_preference", "default_emoji_skin_tone_preference", + "emoji_rendering_style_preference", "system_user_dictionary_builder_preference", "external_dictionary_settings_preference", "dictionary_browser_preference", diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b55e12a63..4067de866 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1946,4 +1946,8 @@ ABC abc 0123456789 ABC 12345 発生位置の基準距離(dp・0でキー中心) 拡大の補間関数 フェードの補間関数 + 絵文字の表示 + Sumire内の絵文字表示を変更します。入力先アプリの表示は変わりません。 + 端末標準 + Google絵文字 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 1897ffffd..6669af4c4 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -423,4 +423,12 @@ legacy stable + + @string/emoji_rendering_style_device + @string/emoji_rendering_style_google + + + device + google + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7089db50..390aff338 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1948,4 +1948,8 @@ ABC abc 0123456789 ABC 12345 Pop position distance (dp; 0 = key center) Pop scale interpolation Pop fade interpolation + Emoji appearance + Changes emoji in Sumire only. Other apps keep their own appearance. + Device default + Google emoji diff --git a/app/src/main/res/xml/pref_clipboard_shortcut.xml b/app/src/main/res/xml/pref_clipboard_shortcut.xml index f82eca9d6..9410250c6 100644 --- a/app/src/main/res/xml/pref_clipboard_shortcut.xml +++ b/app/src/main/res/xml/pref_clipboard_shortcut.xml @@ -81,6 +81,16 @@ android:summary="@string/symbol_mode_summary" android:title="@string/symbol_mode_title" /> + + + + () + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + @Before fun setup() { prefs.edit().clear().commit(); AppPreference.init(context) } + @After fun cleanup() { prefs.edit().clear().commit() } + + @Test fun defaultsPersistsRestoresAndSurvivesBackup() { + assertEquals(EmojiRenderingStyle.DEVICE, AppPreference.emoji_rendering_style_preference) + AppPreference.emoji_rendering_style_preference = EmojiRenderingStyle.GOOGLE + AppPreference.init(context) + assertEquals(EmojiRenderingStyle.GOOGLE, AppPreference.emoji_rendering_style_preference) + val backup = AppPreference.exportAllToJson() + AppPreference.emoji_rendering_style_preference = EmojiRenderingStyle.DEVICE + assertEquals(EmojiRenderingStyle.DEVICE, AppPreference.emoji_rendering_style_preference) + AppPreference.importAllFromJson(backup) + assertEquals(EmojiRenderingStyle.GOOGLE, AppPreference.emoji_rendering_style_preference) + } + + @Test fun unknownAndWronglyTypedValuesRecoverToDevice() { + prefs.edit().putString(EmojiRenderingStyle.PREFERENCE_KEY, "unknown").commit() + assertEquals(EmojiRenderingStyle.DEVICE, AppPreference.emoji_rendering_style_preference) + prefs.edit().putInt(EmojiRenderingStyle.PREFERENCE_KEY, 1).commit() + assertEquals(EmojiRenderingStyle.DEVICE, AppPreference.emoji_rendering_style_preference) + } + + @Test fun bothSettingsScreensExposeSameChoicesAndDefault() { + val ns = "http://schemas.android.com/apk/res/android" + for (screen in listOf(R.xml.pref_clipboard_shortcut, R.xml.pref_common_legacy)) { + var found = false + context.resources.getXml(screen).use { xml -> + while (xml.next() != XmlPullParser.END_DOCUMENT) { + if (xml.eventType != XmlPullParser.START_TAG || + xml.getAttributeValue(ns, "key") != EmojiRenderingStyle.PREFERENCE_KEY) continue + found = true + assertEquals("device", xml.getAttributeValue(ns, "defaultValue")) + val values = context.resources.getStringArray(xml.getAttributeResourceValue(ns, "entryValues", 0)) + assertArrayEquals(arrayOf("device", "google"), values) + } + } + assertTrue(found) + } + } +} diff --git a/docs/google-emoji-rendering.md b/docs/google-emoji-rendering.md new file mode 100644 index 000000000..5368b1842 --- /dev/null +++ b/docs/google-emoji-rendering.md @@ -0,0 +1,106 @@ +# Google emoji display in Sumire + +Settings → Clipboard and shortcuts → Symbol keyboard → Emoji appearance: +Device default (initial value), or Google emoji (日本語: 端末標準 / Google絵文字). +Only Sumire's emoji grid, history and skin-tone popup change. Normal conversion +candidates, kaomoji, symbols, clipboard text, OS and destination apps retain their +existing display policies. Category icons remain the existing vector icons. + +## Rendering + +Noto Color Emoji 2.057 is an unmodified CBDT/CBLC bitmap color font with GSUB +sequence shaping. Android shapes the original Unicode, including VS16, ZWJ, +modifiers, regional indicators and tag sequences. No normalization, variation +selector insertion or modified strings are sent to the input connection. + +`GoogleEmojiFont` lazily loads one process-cached Typeface from the APK. +`SymbolAdapter` uses the existing KeyboardFontApplicator with a local appearance +revision so recycled cells can switch between device, Google and custom keyboard +fonts even when the keyboard font's process revision has not changed. +AppCompat emoji processing is temporarily disabled for Google cells, and each +cell is rebound from its original String; automatic EmojiSpans cannot override +the chosen font. Other modes keep their original AppCompat policy. + +CustomSymbolKeyboardView registers a listener to the existing default preferences +while attached, synchronizes on attach, and unregisters on detach. This covers +main, floating and split views without changing IMEService or candidate rendering. +Style changes dismiss open skin-tone popups and rebind visible cells. Popup +choices use the same cached font and retain existing selection/input callbacks. + +## Alternatives + +- EmojiCompat bundled with forced replacement can replace OEM glyphs, but needs + care around its process singleton and AppCompat auto processing. Its default + policy replaces only missing system glyphs and would not solve OEM substitution. +- Downloadable fonts depend on a provider and connection at runtime. +- Explicit bundled Typeface is local to these emoji views, offline at runtime, + and adds no new emoji runtime library or global font configuration. +- CBDT/CBLC supports the existing Android 24 minimum. COLRv1 is smaller but has + narrower compatibility with older Android versions. + +Sources: [Android emoji2 guide](https://developer.android.com/develop/ui/views/text-and-emoji/emoji2), +[Noto Emoji](https://github.com/googlefonts/noto-emoji), +[TextRunShaper](https://developer.android.com/reference/android/graphics/text/TextRunShaper). + +## Font provenance, build and license + +`symbol_keyboard/google-emoji.gradle` downloads the font at **build time**, verifies +SHA-256 and generates a font resource before Android's resource compilation. The +APK includes the font and works without network/provider access. Builds need +access to the pinned URL on the first run; Gradle reuses the generated output on +subsequent unchanged builds. The task supports configuration caching and refuses +to package a checksum mismatch. No font binary is sent through the GitHub connector. + +- Repository commit: `99701e07d3d1fe1bdbcdb14964547f0fd592966f` +- Path: `2D/fonts/NotoColorEmoji.ttf` +- Font version record: `Version 2.057;GOOG;noto-emoji:20260911:fc4ca365e7c20e78278ae702aa20434bfe704c8f` +- SHA-256: `15671215ab769fdc7162a045d56fd7d7e477c51b04e6b3c761d914d8fdd6cc44` +- Font size: 10,730,124 bytes (10.23 MiB), approximately 9.50 MiB compressed. + Full Preview APK inspection reports its actual stored contribution. +- Copyright 2013 Google LLC (repository), Copyright 2022 Google Inc. (font name + table), SIL Open Font License 1.1. Full upstream font license + and copyright are packaged in `assets/licenses/noto-color-emoji.html`, accessible + from Settings → Open source licenses → Noto Color Emoji (Google). The font remains + OFL; no separate font sale or modification is involved. + +Future sequences absent from this pinned font may still fall back to the platform. +Updating the font requires updating the checksum and rerunning native tests. + +## Verification + +`EmojiRenderingSettingTest` covers defaults, preference reinitialization, device +restoration, backup, unknown/wrongly typed values, and settings choices/defaults. + +`GoogleEmojiRenderingDeviceTest` runs on Android 24 and Android 14/API 34. It checks +visible output for ten simple/VS16/ZWJ/tone/flag/keycap/tag sequences, and colored +output for the nine whose upstream glyphs are colored (the family silhouette +is intentionally grayscale in Noto 2.057). On API 34, +TextRunShaper must return one glyph, and the actual shaped glyph font buffer must +hash to the bundled font, distinguishing it from OEM/system fallback. Native UI +tests cover live preference updates, activity recreation with saved Google mode, +history/category strings, all six skin-tone options, selected strings, standard +restoration, popup dismissal, and custom-font/emoji precedence after mode recycling. +The CI artifacts include device/Google render pairs and history/popup bitmaps. +A separate save/force-stop/verify instrumentation sequence checks that Google +selection survives a fresh Android process (including a distinct PID) and is used +by a newly attached keyboard. + +The existing Full Preview CI retains app/core/keyboard regression tests, API 35 +real IME input, release APK generation and package/native/model/alignment checks. +The new APK checks require the pinned Noto font and full license. Final run results +are recorded in [Draft PR #13](https://github.com/kkrix3/JapaneseKeyboard/pull/13). + +## Limits and manual checks + +No docomo physical device is available. Confirm on Android 14 docomo hardware that +Google selection actually changes docomo designs, remains colored and fits cells +and popups. Check light/dark themes, landscape, fast scrolling, floating/split mode, +switching back, and full-IME process restart on the physical device. Confirm kana/kanji candidates and custom +fonts in kaomoji/symbol modes remain unaffected. Commit family/tone/flag sequences +into different apps and confirm the same Unicode; each destination controls its +own glyph display. + +Existing Preview signing accepts only owner-dispatched runs of `preview`, with +the signing enable variable and Environment approval. The feature branch cannot +use that job as it stands. This PR keeps the trust guard and remains Draft without +merging. The unsigned Full Preview artifact is for inspection, not installation. diff --git a/scripts/preview/emoji_device_tests.sh b/scripts/preview/emoji_device_tests.sh new file mode 100644 index 000000000..20469988b --- /dev/null +++ b/scripts/preview/emoji_device_tests.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +mkdir -p emoji-rendering-artifacts +# The emulator action shuts the device down before the following workflow step. +# Capture files here while it is still online, including on a failed test. +trap 'adb pull /sdcard/Android/data/com.kazumaproject.symbol_keyboard.test/files/emoji-rendering emoji-rendering-artifacts/ || true' EXIT +bash ./gradlew :symbol_keyboard:connectedDebugAndroidTest \ + -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true \ + --no-daemon --console=plain --max-workers=2 +# Exercise saved preferences after the instrumentation process exits and is force-stopped. +runner="$(adb shell pm list instrumentation | tr -d '\r' | sed -n 's/^instrumentation:\(com.kazumaproject.symbol_keyboard.test\/[^ ]*\).*/\1/p')" +test -n "$runner" +for phase in save verify; do + adb shell am instrument -w -r \ + -e class com.kazumaproject.symbol_keyboard.GoogleEmojiRenderingDeviceTest#persistedGoogleSelectionSurvivesNewProcess \ + -e emojiRestartPhase "$phase" "$runner" | tee "emoji-rendering-artifacts/restart-$phase.txt" + grep -F 'OK (1 test)' "emoji-rendering-artifacts/restart-$phase.txt" + if [[ "$phase" = save ]]; then adb shell am force-stop com.kazumaproject.symbol_keyboard.test; fi +done diff --git a/scripts/preview/verify_apk.py b/scripts/preview/verify_apk.py index 13d49dd66..c2e930bca 100644 --- a/scripts/preview/verify_apk.py +++ b/scripts/preview/verify_apk.py @@ -56,6 +56,16 @@ def main(): assert not any(re.match(r'META-INF/[^/]+\.(RSA|DSA|EC|SF)$', n) for n in names) assert any(n.endswith('system.compact.kdict') for n in names), 'Missing compiled dictionary' assert any('ggml-model' in n and n.endswith('.gguf') for n in names), 'Missing bundled Zenz model' + font_hash = '15671215ab769fdc7162a045d56fd7d7e477c51b04e6b3c761d914d8fdd6cc44' + emoji_fonts = [n for n in names if n.endswith('.ttf') and + hashlib.sha256(archive.read(n)).hexdigest() == font_hash] + assert len(emoji_fonts) == 1, 'Missing or changed bundled Noto Color Emoji' + notice = archive.read('assets/licenses/noto-color-emoji.html').decode('utf-8') + assert 'Copyright 2013 Google LLC' in notice and 'Copyright 2022 Google Inc.' in notice + assert 'SIL OPEN FONT LICENSE Version 1.1' in notice + font_entry = archive.getinfo(emoji_fonts[0]) + print(f'Noto Color Emoji: {font_entry.file_size} bytes, {font_entry.compress_size} APK bytes, SHA-256={font_hash}') + args.output_dir.mkdir(parents=True, exist_ok=False) target = args.output_dir / 'sumire-preview-full-unsigned.apk' shutil.copyfile(args.apk, target) diff --git a/symbol_keyboard/build.gradle b/symbol_keyboard/build.gradle index 25802b775..cbf7cf05e 100644 --- a/symbol_keyboard/build.gradle +++ b/symbol_keyboard/build.gradle @@ -35,8 +35,10 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation "androidx.paging:paging-runtime-ktx:3.3.6" implementation project(':core') + implementation 'androidx.preference:preference-ktx:1.2.1' testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:core-ktx:1.7.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' @@ -46,3 +48,5 @@ dependencies { // Kotlin Extensions and Coroutines support for Room implementation "androidx.room:room-ktx:2.8.4" } + +apply from: "google-emoji.gradle" diff --git a/symbol_keyboard/google-emoji.gradle b/symbol_keyboard/google-emoji.gradle new file mode 100644 index 000000000..d86c59abc --- /dev/null +++ b/symbol_keyboard/google-emoji.gradle @@ -0,0 +1,48 @@ +// Reproducible build input, packaged in the APK. No runtime downloads. +abstract class PrepareGoogleEmojiFont extends DefaultTask { + @Input abstract Property getSourceUrl() + @Input abstract Property getExpectedSha256() + @OutputDirectory abstract DirectoryProperty getOutputDirectory() + + private static String sha256(File file) { + def digest = java.security.MessageDigest.getInstance('SHA-256') + file.withInputStream { input -> + byte[] buffer = new byte[65536] + int count + while ((count = input.read(buffer)) != -1) digest.update(buffer, 0, count) + } + return digest.digest().encodeHex().toString() + } + + @TaskAction void prepare() { + File font = outputDirectory.file('font/noto_color_emoji.ttf').get().asFile + if (font.isFile() && sha256(font) == expectedSha256.get()) return + font.parentFile.mkdirs() + File partial = new File(font.parentFile, font.name + '.download') + try { + def connection = new URI(sourceUrl.get()).toURL().openConnection() + connection.connectTimeout = 30000 + connection.readTimeout = 60000 + connection.getInputStream().withCloseable { input -> + partial.withOutputStream { output -> output << input } + } + if (sha256(partial) != expectedSha256.get()) { + throw new GradleException('Noto Color Emoji SHA-256 mismatch; refusing to package it') + } + java.nio.file.Files.move(partial.toPath(), font.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } finally { + partial.delete() + } + } +} + +def googleEmojiFont = tasks.register('prepareGoogleEmojiFont', PrepareGoogleEmojiFont) { + sourceUrl.set('https://raw.githubusercontent.com/googlefonts/noto-emoji/99701e07d3d1fe1bdbcdb14964547f0fd592966f/2D/fonts/NotoColorEmoji.ttf') + expectedSha256.set('15671215ab769fdc7162a045d56fd7d7e477c51b04e6b3c761d914d8fdd6cc44') + outputDirectory.set(layout.buildDirectory.dir('generated/googleEmoji/res')) +} + +androidComponents.onVariants(androidComponents.selector().all()) { variant -> + variant.sources.res.addGeneratedSourceDirectory(googleEmojiFont, { task -> task.outputDirectory }) +} diff --git a/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt b/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt new file mode 100644 index 000000000..6b3f8e393 --- /dev/null +++ b/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt @@ -0,0 +1,248 @@ +package com.kazumaproject.symbol_keyboard + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.text.TextRunShaper +import android.os.Build +import android.os.SystemClock +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.PopupWindow +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.material.tabs.TabLayout +import com.kazumaproject.core.ui.font.KeyboardFontSnapshot +import com.kazumaproject.domain.EmojiSkinToneSupport +import org.junit.Assert.* +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.security.MessageDigest + +@RunWith(AndroidJUnit4::class) +class GoogleEmojiRenderingDeviceTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + private val prefs get() = PreferenceManager.getDefaultSharedPreferences(context) + private val samples = listOf("😀", "❤\uFE0F", "☀\uFE0F", "👨‍👩‍👧‍👦", "👩🏽‍💻", "👍🏿", "🇯🇵", + "1\uFE0F\u20E3", "🏳️‍🌈", "🏴\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F") + private val output get() = File(context.getExternalFilesDir(null), "emoji-rendering").apply { mkdirs() } + + private val restartPhase get() = InstrumentationRegistry.getArguments().getString("emojiRestartPhase") + @Before fun setup() { + if (restartPhase == null) prefs.edit().remove(EmojiRenderingStyle.PREFERENCE_KEY).commit() + } + @After fun cleanup() { + if (restartPhase != "save") prefs.edit().remove(EmojiRenderingStyle.PREFERENCE_KEY) + .remove("emoji_restart_test_pid").commit() + } + + @Test fun persistedGoogleSelectionSurvivesNewProcess() { + if (restartPhase == "save") { + setStyle(EmojiRenderingStyle.GOOGLE) + assertTrue(prefs.edit().putInt("emoji_restart_test_pid", android.os.Process.myPid()).commit()) + return + } + if (restartPhase == "verify") { + assertNotEquals("Instrumentation must use a fresh process", android.os.Process.myPid(), + prefs.getInt("emoji_restart_test_pid", -1)) + assertEquals(EmojiRenderingStyle.GOOGLE, EmojiRenderingStyle.read(prefs)) + } else setStyle(EmojiRenderingStyle.GOOGLE) + EmojiActivitySession.launch().use { scenario -> + awaitFont(scenario, "👩🏽‍💻", Typeface.create(GoogleEmojiFont.get(context), Typeface.BOLD)) + } + } + + @Test fun bundledColorGlyphsAndSequencesRenderWithoutOemFallback() { + val font = GoogleEmojiFont.get(context) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = font; textSize = 96f } + for ((index, sample) in samples.withIndex()) { + assertTrue("Missing glyph: $sample", paint.hasGlyph(sample)) + if (Build.VERSION.SDK_INT >= 31) assertBundledSingleGlyph(sample, paint) + val bitmap = render(sample, font) + save(bitmap, "google-$index.png") + save(render(sample, Typeface.DEFAULT), "device-$index.png") + var colored = 0 + var ink = 0 + for (y in 0 until bitmap.height) for (x in 0 until bitmap.width) { + val c = bitmap.getPixel(x, y) + if (Color.alpha(c) > 100) ink++ + if (Color.alpha(c) > 100 && maxOf(Color.red(c), Color.green(c), Color.blue(c)) - + minOf(Color.red(c), Color.green(c), Color.blue(c)) > 30) colored++ + } + assertTrue("Empty glyph: $sample", ink > 30) + // Noto 2.057 deliberately draws this family as a grayscale silhouette. + if (sample != "👨‍👩‍👧‍👦") assertTrue("Unexpected monochrome glyph: $sample", colored > 30) + } + } + + @RequiresApi(31) private fun assertBundledSingleGlyph(text: String, paint: Paint) { + val shaped = TextRunShaper.shapeTextRun(text, 0, text.length, 0, text.length, 0f, 0f, false, paint) + assertEquals("Sequence split: $text", 1, shaped.glyphCount()) + val bytes = shaped.getFont(0).buffer.duplicate().apply { rewind() } + val hash = MessageDigest.getInstance("SHA-256").apply { update(bytes) }.digest() + .joinToString("") { "%02x".format(it.toInt() and 255) } + assertEquals("Used fallback font: $text", FONT_SHA256, hash) + } + + @Test fun livePreferenceRestartHistoryPopupAndRecycledModes() { + EmojiActivitySession.launch().use { scenario -> + val history = "👩🏽‍💻" + var original: Typeface? = null + awaitCell(scenario, history) + scenario.onActivity { original = cell(it).typeface } + setStyle(EmojiRenderingStyle.GOOGLE) + awaitFont(scenario, history, Typeface.create(GoogleEmojiFont.get(context), original?.style ?: 0)) + scenario.recreate() + awaitFont(scenario, history, Typeface.create(GoogleEmojiFont.get(context), original?.style ?: 0)) + scenario.onActivity { activity -> + (cell(activity).parent as View).performClick() + assertEquals(history, activity.lastClicked?.symbol) + activity.keyboard.setKeyboardFont(KeyboardFontSnapshot(Typeface.MONOSPACE, 991)) + activity.keyboard.findViewById(R.id.category_tab_layout).getTabAt(1)!!.select() + } + awaitCell(scenario, "👍") + scenario.onActivity { activity -> + assertFalse((cell(activity) as com.google.android.material.textview.MaterialTextView).isEmojiCompatEnabled) + if (Build.VERSION.SDK_INT >= 31) assertBundledSingleGlyph("👍", cell(activity).paint) + (cell(activity).parent as View).performLongClick() + val popup = popup(activity.keyboard) + assertTrue(popup.isShowing) + val content = popup.contentView as LinearLayout + val expected = EmojiSkinToneSupport.skinToneVariants("👍") + assertEquals(6, content.childCount) + for (i in expected.indices) { + val option = content.getChildAt(i) as TextView + assertEquals(expected[i], option.text.toString()) + assertEquals(GoogleEmojiFont.get(context), option.typeface) + } + save(draw(content), "skin-tones-google.png") + content.getChildAt(5).performClick() + assertEquals(expected[5], activity.lastClicked?.symbol) + assertFalse(popup.isShowing) + } + val dark = EmojiSkinToneSupport.skinToneVariants("👍")[5] + awaitCell(scenario, dark) + setStyle(EmojiRenderingStyle.DEVICE) + awaitFont(scenario, dark, original) + scenario.onActivity { activity -> + (cell(activity).parent as View).performLongClick() + val options = popup(activity.keyboard).contentView as LinearLayout + assertNotEquals(GoogleEmojiFont.get(context), (options.getChildAt(0) as TextView).typeface) + } + setStyle(EmojiRenderingStyle.GOOGLE) + awaitFont(scenario, dark, Typeface.create(GoogleEmojiFont.get(context), original?.style ?: 0)) + scenario.onActivity { activity -> + assertFalse(popup(activity.keyboard).isShowing) + activity.keyboard.findViewById(R.id.mode_tab_layout).getTabAt(1)!!.select() + } + awaitFont(scenario, "(^_^)", Typeface.create(Typeface.MONOSPACE, original?.style ?: 0)) + scenario.onActivity { it.keyboard.findViewById(R.id.mode_tab_layout).getTabAt(0)!!.select() } + awaitFont(scenario, history, Typeface.create(GoogleEmojiFont.get(context), original?.style ?: 0)) + scenario.onActivity { save(draw(it.keyboard), "history-google.png") } + setStyle(EmojiRenderingStyle.DEVICE) + awaitFont(scenario, history, original) + scenario.onActivity { save(draw(it.keyboard), "history-device.png") } + } + } + + private fun setStyle(style: EmojiRenderingStyle) { + prefs.edit().putString(EmojiRenderingStyle.PREFERENCE_KEY, style.preferenceValue).commit() + } + private fun cell(activity: EmojiTestActivity): TextView = activity.keyboard + .findViewById(R.id.symbol_candidate_recycler_view) + .findViewHolderForAdapterPosition(0)!!.itemView.findViewById(R.id.symbol_text) + private fun popup(keyboard: CustomSymbolKeyboardView): PopupWindow = + CustomSymbolKeyboardView::class.java.getDeclaredField("skinTonePopup").apply { isAccessible = true } + .get(keyboard) as PopupWindow + private fun awaitCell(scenario: EmojiActivitySession, expected: String) = + awaitCell(scenario, expected, false, null) + private fun awaitFont(scenario: EmojiActivitySession, text: String, font: Typeface?) = + awaitCell(scenario, text, true, font) + private fun awaitCell(scenario: EmojiActivitySession, expected: String, checkFont: Boolean, font: Typeface?) { + val deadline = SystemClock.uptimeMillis() + 15_000 + while (SystemClock.uptimeMillis() < deadline) { + var ready = false + scenario.onActivity { activity -> + val recycler = activity.keyboard.findViewById(R.id.symbol_candidate_recycler_view) + val view = recycler.findViewHolderForAdapterPosition(0)?.itemView?.findViewById(R.id.symbol_text) + ready = !recycler.isComputingLayout && !recycler.hasPendingAdapterUpdates() && + view?.text?.toString() == expected && (!checkFont || view.typeface == font) + } + if (ready) return + SystemClock.sleep(40) + } + fail("Timed out waiting for cell $expected and font=$font") + } + private fun render(text: String, font: Typeface): Bitmap { + lateinit var result: Bitmap + instrumentation.runOnMainSync { + val view = TextView(context).apply { + this.text = text; typeface = font; textSize = 64f + gravity = Gravity.CENTER; setTextColor(Color.BLACK) + } + val spec = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY) + view.measure(spec, spec); view.layout(0, 0, 320, 320) + result = draw(view) + } + return result + } + private fun draw(view: View): Bitmap { + // A just-added PopupWindow has been measured but may not have had its first traversal. + val width = view.width.takeIf { it > 0 } ?: view.measuredWidth + val height = view.height.takeIf { it > 0 } ?: view.measuredHeight + if (view.width == 0 || view.height == 0) view.layout(0, 0, width, height) + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { view.draw(Canvas(it)) } + } + private fun save(bitmap: Bitmap, name: String) { + File(output, name).outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + companion object { + private const val FONT_SHA256 = "15671215ab769fdc7162a045d56fd7d7e477c51b04e6b3c761d914d8fdd6cc44" + } +} + +/** Wait for the specific resumed host/cells, not global queue idleness: the keyboard + * owns continuously invalidated interactive views in this isolated library host. */ +private class EmojiActivitySession private constructor() : AutoCloseable { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + fun onActivity(action: (EmojiTestActivity) -> Unit) { + instrumentation.runOnMainSync { action(requireNotNull(EmojiTestActivity.current)) } + } + fun recreate() { + val previous = EmojiTestActivity.current + onActivity { it.recreate() } + await { EmojiTestActivity.current != null && EmojiTestActivity.current !== previous } + } + override fun close() { + onActivity { it.finish() } + await { EmojiTestActivity.current == null } + } + private fun await(condition: () -> Boolean) { + val deadline = SystemClock.uptimeMillis() + 15_000 + while (!condition() && SystemClock.uptimeMillis() < deadline) SystemClock.sleep(40) + check(condition()) { "Timed out waiting for the emoji host lifecycle" } + } + companion object { + fun launch(): EmojiActivitySession = EmojiActivitySession().apply { + instrumentation.runOnMainSync { + instrumentation.targetContext.startActivity( + Intent(instrumentation.targetContext, EmojiTestActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ) + } + await { EmojiTestActivity.current != null } + } + } +} diff --git a/symbol_keyboard/src/debug/AndroidManifest.xml b/symbol_keyboard/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..433948e9b --- /dev/null +++ b/symbol_keyboard/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt b/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt new file mode 100644 index 000000000..9f4ba0ace --- /dev/null +++ b/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt @@ -0,0 +1,36 @@ +package com.kazumaproject.symbol_keyboard + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.kazumaproject.core.data.clicked_symbol.SymbolMode +import com.kazumaproject.data.clicked_symbol.ClickedSymbol +import com.kazumaproject.data.emoji.Emoji +import com.kazumaproject.data.emoji.EmojiCategory +import com.kazumaproject.data.emoticon.Emoticon +import com.kazumaproject.data.emoticon.EmoticonCategory +import com.kazumaproject.listeners.SymbolRecyclerViewItemClickListener + +/** Disposable debug-only host; recreated with the same data and saved preferences. */ +class EmojiTestActivity : AppCompatActivity() { + lateinit var keyboard: CustomSymbolKeyboardView + var lastClicked: ClickedSymbol? = null + companion object { @Volatile var current: EmojiTestActivity? = null } + override fun onResume() { super.onResume(); current = this } + override fun onDestroy() { + if (current === this) current = null + super.onDestroy() + } + override fun onCreate(state: Bundle?) { + super.onCreate(state) + keyboard = CustomSymbolKeyboardView(this).apply { + setLifecycleOwner(this@EmojiTestActivity) + setOnSymbolRecyclerViewItemClickListener(object : SymbolRecyclerViewItemClickListener { + override fun onClick(symbol: ClickedSymbol) { lastClicked = symbol } + }) + setSymbolLists(listOf(Emoji("👍", EmojiCategory.PEOPLE_BODY)), + listOf(Emoticon("(^_^)", EmoticonCategory.SMILE)), emptyList(), emptyList(), + listOf(ClickedSymbol(mode = SymbolMode.EMOJI, symbol = "👩🏽‍💻"))) + } + setContentView(keyboard) + } +} diff --git a/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html new file mode 100644 index 000000000..2e11ba444 --- /dev/null +++ b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html @@ -0,0 +1,97 @@ + +

Noto Color Emoji

+

Unmodified font, version 2.057. Source.

+

Copyright 2013 Google LLC (repository).
Copyright 2022 Google Inc. (font).
Noto is a trademark of Google Inc.

+
This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt index cd2934bb0..f0b835c4a 100644 --- a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt +++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt @@ -2,6 +2,8 @@ package com.kazumaproject.symbol_keyboard import android.annotation.SuppressLint import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color @@ -105,6 +107,34 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( private var clipBoardItems: List = emptyList() private var currentMode: SymbolMode = SymbolMode.EMOJI private var keyboardFontSnapshot = KeyboardFontApplicator.processSnapshot + private var emojiRenderingStyle = EmojiRenderingStyle.DEVICE + private val emojiPreferences by lazy { + PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + } + private val emojiPreferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null || key == EmojiRenderingStyle.PREFERENCE_KEY) { + post { if (isAttachedToWindow) setEmojiRenderingStyle(EmojiRenderingStyle.read(emojiPreferences)) } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + emojiPreferences.registerOnSharedPreferenceChangeListener(emojiPreferenceListener) + setEmojiRenderingStyle(EmojiRenderingStyle.read(emojiPreferences)) + } + + override fun onDetachedFromWindow() { + emojiPreferences.unregisterOnSharedPreferenceChangeListener(emojiPreferenceListener) + skinTonePopup?.dismiss() + super.onDetachedFromWindow() + } + + fun setEmojiRenderingStyle(style: EmojiRenderingStyle) { + if (emojiRenderingStyle == style) return + emojiRenderingStyle = style + skinTonePopup?.dismiss() + symbolAdapter.setEmojiTypeface(if (style == EmojiRenderingStyle.GOOGLE) GoogleEmojiFont.get(context) else null) + } override fun setKeyboardFont(snapshot: KeyboardFontSnapshot) { KeyboardFontApplicator.track(this) @@ -1033,6 +1063,7 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( variants.forEach { variant -> content.addView( TextView(context).apply { + if (emojiRenderingStyle == EmojiRenderingStyle.GOOGLE) typeface = GoogleEmojiFont.get(context) text = variant textSize = if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 30f else 26f diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/EmojiRenderingStyle.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/EmojiRenderingStyle.kt new file mode 100644 index 000000000..ec90e77f4 --- /dev/null +++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/EmojiRenderingStyle.kt @@ -0,0 +1,17 @@ +package com.kazumaproject.symbol_keyboard + +import android.content.SharedPreferences + +/** Display policy only: original Unicode and input events remain unchanged. */ +enum class EmojiRenderingStyle(val preferenceValue: String) { + DEVICE("device"), GOOGLE("google"); + + companion object { + const val PREFERENCE_KEY = "emoji_rendering_style_preference" + fun fromPreference(value: String?): EmojiRenderingStyle = + entries.firstOrNull { it.preferenceValue == value } ?: DEVICE + fun read(preferences: SharedPreferences): EmojiRenderingStyle = fromPreference( + try { preferences.getString(PREFERENCE_KEY, null) } catch (_: ClassCastException) { null } + ) + } +} diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/GoogleEmojiFont.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/GoogleEmojiFont.kt new file mode 100644 index 000000000..4a94407c1 --- /dev/null +++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/GoogleEmojiFont.kt @@ -0,0 +1,14 @@ +package com.kazumaproject.symbol_keyboard + +import android.content.Context +import android.graphics.Typeface +import androidx.core.content.res.ResourcesCompat + +/** One bundled CBDT/CBLC Typeface for list, history and skin-tone choices. */ +object GoogleEmojiFont { + private var cached: Typeface? = null + @Synchronized + fun get(context: Context): Typeface = cached ?: requireNotNull( + ResourcesCompat.getFont(context.applicationContext, R.font.noto_color_emoji) + ).also { cached = it } +} diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt index 3ee905967..4ca5c3679 100644 --- a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt +++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt @@ -3,6 +3,7 @@ package com.kazumaproject.symbol_keyboard import android.content.Context import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.graphics.drawable.RippleDrawable import android.util.TypedValue @@ -24,10 +25,20 @@ class SymbolAdapter : private var keyboardFontSnapshot = KeyboardFontApplicator.processSnapshot private var useKeyboardFont = false + private var emojiTypeface: Typeface? = null + private var appearanceRevision = 0L + + fun setEmojiTypeface(typeface: Typeface?) { + if (emojiTypeface === typeface) return + emojiTypeface = typeface + appearanceRevision++ + notifyDataSetChanged() + } fun setKeyboardFont(snapshot: KeyboardFontSnapshot, useForCurrentMode: Boolean) { keyboardFontSnapshot = snapshot useKeyboardFont = useForCurrentMode + appearanceRevision++ notifyDataSetChanged() } @@ -95,6 +106,7 @@ class SymbolAdapter : RecyclerView.ViewHolder(itemView) { val symbolTextView: MaterialTextView = itemView.findViewById(R.id.symbol_text) val originalTextColors = symbolTextView.textColors + val originalEmojiCompatEnabled = symbolTextView.isEmojiCompatEnabled val skinToneIndicator: View = itemView.findViewById(R.id.skin_tone_indicator) init { @@ -128,14 +140,20 @@ class SymbolAdapter : } catch (e: IndexOutOfBoundsException) { null } + // Automatic EmojiSpans must not override the explicit Google font. Rebinding + // the raw String also clears spans when returning to the original policy. + holder.symbolTextView.isEmojiCompatEnabled = + if (!useKeyboardFont && emojiTypeface != null) false else holder.originalEmojiCompatEnabled + KeyboardFontApplicator.apply( + holder.symbolTextView, + keyboardFontSnapshot.copy( + typeface = if (useKeyboardFont) keyboardFontSnapshot.typeface else emojiTypeface, + revision = appearanceRevision, + ) + ) + holder.symbolTextView.text = symbol ?: "" if (symbol != null) { - holder.symbolTextView.text = symbol holder.symbolTextView.textSize = symbolTextSize - KeyboardFontApplicator.apply( - holder.symbolTextView, - if (useKeyboardFont) keyboardFontSnapshot - else keyboardFontSnapshot.copy(typeface = null) - ) // ★追加: テキストカラーの適用 holder.symbolTextView.setTextColor(themeTextColor?.let(android.content.res.ColorStateList::valueOf) ?: holder.originalTextColors) @@ -155,12 +173,6 @@ class SymbolAdapter : horizontalMarginPx, verticalMarginPx, horizontalMarginPx, verticalMarginPx ) } else { - holder.symbolTextView.text = "" - KeyboardFontApplicator.apply( - holder.symbolTextView, - if (useKeyboardFont) keyboardFontSnapshot - else keyboardFontSnapshot.copy(typeface = null) - ) holder.skinToneIndicator.visibility = View.GONE } }