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/main/assets/licenses/noto-color-emoji.html b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
new file mode 100644
index 000000000..17a6d9bf8
--- /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.
+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..54b3e0926 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,6 +25,13 @@ class SymbolAdapter :
private var keyboardFontSnapshot = KeyboardFontApplicator.processSnapshot
private var useKeyboardFont = false
+ private var emojiTypeface: Typeface? = null
+
+ fun setEmojiTypeface(typeface: Typeface?) {
+ if (emojiTypeface === typeface) return
+ emojiTypeface = typeface
+ notifyDataSetChanged()
+ }
fun setKeyboardFont(snapshot: KeyboardFontSnapshot, useForCurrentMode: Boolean) {
keyboardFontSnapshot = snapshot
@@ -95,6 +103,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 +137,18 @@ 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,
+ if (useKeyboardFont) keyboardFontSnapshot
+ else keyboardFontSnapshot.copy(typeface = emojiTypeface)
+ )
+ 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 +168,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
}
}
From 11f45ea0e9f45025372f7fdf3ce850d6509ce87e Mon Sep 17 00:00:00 2001
From: kkrix3 <254672938+kkrix3@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:02:56 +0900
Subject: [PATCH 2/6] Verify emoji font selection, persistence, sequences and
APK packaging
---
.github/workflows/preview-ci.yml | 55 +++++-
.../EmojiRenderingSettingTest.kt | 61 ++++++
docs/google-emoji-rendering.md | 100 ++++++++++
scripts/preview/verify_apk.py | 9 +
.../GoogleEmojiRenderingDeviceTest.kt | 182 ++++++++++++++++++
symbol_keyboard/src/debug/AndroidManifest.xml | 6 +
.../symbol_keyboard/EmojiTestActivity.kt | 30 +++
.../symbol_keyboard/SymbolAdapter.kt | 9 +-
8 files changed, 449 insertions(+), 3 deletions(-)
create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/EmojiRenderingSettingTest.kt
create mode 100644 docs/google-emoji-rendering.md
create mode 100644 symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
create mode 100644 symbol_keyboard/src/debug/AndroidManifest.xml
create mode 100644 symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt
diff --git a/.github/workflows/preview-ci.yml b/.github/workflows/preview-ci.yml
index ba88e8169..9a57913c8 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,60 @@ 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 ./gradlew :symbol_keyboard:connectedDebugAndroidTest --no-daemon --console=plain --max-workers=2
+ - name: Collect render comparisons
+ if: always()
+ run: |
+ mkdir -p emoji-rendering-artifacts
+ adb pull /sdcard/Android/data/com.kazumaproject.symbol_keyboard.test/files/emoji-rendering emoji-rendering-artifacts/ || true
+ - 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/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/EmojiRenderingSettingTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/EmojiRenderingSettingTest.kt
new file mode 100644
index 000000000..4d40f7861
--- /dev/null
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/EmojiRenderingSettingTest.kt
@@ -0,0 +1,61 @@
+package com.kazumaproject.markdownhelperkeyboard.setting_activity
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import androidx.test.core.app.ApplicationProvider
+import com.kazumaproject.markdownhelperkeyboard.R
+import com.kazumaproject.symbol_keyboard.EmojiRenderingStyle
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.xmlpull.v1.XmlPullParser
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class EmojiRenderingSettingTest {
+ private val context = ApplicationProvider.getApplicationContext()
+ 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..333010272
--- /dev/null
+++ b/docs/google-emoji-rendering.md
@@ -0,0 +1,100 @@
+# 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, 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
+colored output for ten simple/VS16/ZWJ/tone/flag/keycap/tag sequences. 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.
+
+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,
+process kill/restart and switching back. 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/verify_apk.py b/scripts/preview/verify_apk.py
index 13d49dd66..98227e287 100644
--- a/scripts/preview/verify_apk.py
+++ b/scripts/preview/verify_apk.py
@@ -56,6 +56,15 @@ 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 '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/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..4580ad928
--- /dev/null
+++ b/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
@@ -0,0 +1,182 @@
+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 androidx.test.core.app.ActivityScenario
+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() }
+
+ @Before fun setup() { prefs.edit().remove(EmojiRenderingStyle.PREFERENCE_KEY).commit() }
+ @After fun cleanup() { prefs.edit().remove(EmojiRenderingStyle.PREFERENCE_KEY).commit() }
+
+ @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)
+ var colored = 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 && maxOf(Color.red(c), Color.green(c), Color.blue(c)) -
+ minOf(Color.red(c), Color.green(c), Color.blue(c)) > 30) colored++
+ }
+ assertTrue("Monochrome/empty glyph: $sample", colored > 30)
+ save(bitmap, "google-$index.png")
+ save(render(sample, Typeface.DEFAULT), "device-$index.png")
+ }
+ }
+
+ @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() {
+ ActivityScenario.launch(EmojiTestActivity::class.java).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 ->
+ 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: ActivityScenario, expected: String) =
+ awaitCell(scenario, expected, false, null)
+ private fun awaitFont(scenario: ActivityScenario, text: String, font: Typeface?) =
+ awaitCell(scenario, text, true, font)
+ private fun awaitCell(scenario: ActivityScenario, 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 = Bitmap.createBitmap(view.width, view.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"
+ }
+}
diff --git a/symbol_keyboard/src/debug/AndroidManifest.xml b/symbol_keyboard/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..dcefbc1cc
--- /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..4061b1bed
--- /dev/null
+++ b/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt
@@ -0,0 +1,30 @@
+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
+ 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/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt
index 54b3e0926..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
@@ -26,16 +26,19 @@ 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()
}
@@ -143,8 +146,10 @@ class SymbolAdapter :
if (!useKeyboardFont && emojiTypeface != null) false else holder.originalEmojiCompatEnabled
KeyboardFontApplicator.apply(
holder.symbolTextView,
- if (useKeyboardFont) keyboardFontSnapshot
- else keyboardFontSnapshot.copy(typeface = emojiTypeface)
+ keyboardFontSnapshot.copy(
+ typeface = if (useKeyboardFont) keyboardFontSnapshot.typeface else emojiTypeface,
+ revision = appearanceRevision,
+ )
)
holder.symbolTextView.text = symbol ?: ""
if (symbol != null) {
From 0aef9f58ef5ee10be55c357e88fb8c24ecd71252 Mon Sep 17 00:00:00 2001
From: kkrix3 <254672938+kkrix3@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:09:41 +0900
Subject: [PATCH 3/6] Check saved Google mode in a fresh process and stabilize
popup captures
---
.github/workflows/preview-ci.yml | 2 +-
docs/google-emoji-rendering.md | 5 ++-
scripts/preview/emoji_device_tests.sh | 14 ++++++++
.../GoogleEmojiRenderingDeviceTest.kt | 36 ++++++++++++++++---
.../assets/licenses/noto-color-emoji.html | 1 -
5 files changed, 51 insertions(+), 7 deletions(-)
create mode 100644 scripts/preview/emoji_device_tests.sh
diff --git a/.github/workflows/preview-ci.yml b/.github/workflows/preview-ci.yml
index 9a57913c8..1b985a6bb 100644
--- a/.github/workflows/preview-ci.yml
+++ b/.github/workflows/preview-ci.yml
@@ -273,7 +273,7 @@ jobs:
ram-size: 2048M
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
- script: bash ./gradlew :symbol_keyboard:connectedDebugAndroidTest --no-daemon --console=plain --max-workers=2
+ script: bash scripts/preview/emoji_device_tests.sh
- name: Collect render comparisons
if: always()
run: |
diff --git a/docs/google-emoji-rendering.md b/docs/google-emoji-rendering.md
index 333010272..9f187c99c 100644
--- a/docs/google-emoji-rendering.md
+++ b/docs/google-emoji-rendering.md
@@ -78,6 +78,9 @@ 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.
@@ -89,7 +92,7 @@ are recorded in [Draft PR #13](https://github.com/kkrix3/JapaneseKeyboard/pull/1
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,
-process kill/restart and switching back. Confirm kana/kanji candidates and custom
+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.
diff --git a/scripts/preview/emoji_device_tests.sh b/scripts/preview/emoji_device_tests.sh
new file mode 100644
index 000000000..53b6463e7
--- /dev/null
+++ b/scripts/preview/emoji_device_tests.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+mkdir -p emoji-rendering-artifacts
+bash ./gradlew :symbol_keyboard:connectedDebugAndroidTest --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/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt b/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
index 4580ad928..3d9a642f0 100644
--- a/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
+++ b/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
@@ -39,8 +39,30 @@ class GoogleEmojiRenderingDeviceTest {
"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() }
- @Before fun setup() { prefs.edit().remove(EmojiRenderingStyle.PREFERENCE_KEY).commit() }
- @After fun cleanup() { prefs.edit().remove(EmojiRenderingStyle.PREFERENCE_KEY).commit() }
+ 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)
+ ActivityScenario.launch(EmojiTestActivity::class.java).use { scenario ->
+ awaitFont(scenario, "👩🏽💻", Typeface.create(GoogleEmojiFont.get(context), Typeface.BOLD))
+ }
+ }
@Test fun bundledColorGlyphsAndSequencesRenderWithoutOemFallback() {
val font = GoogleEmojiFont.get(context)
@@ -88,6 +110,7 @@ class GoogleEmojiRenderingDeviceTest {
}
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)
@@ -171,8 +194,13 @@ class GoogleEmojiRenderingDeviceTest {
}
return result
}
- private fun draw(view: View): Bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
- .also { view.draw(Canvas(it)) }
+ 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) }
}
diff --git a/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
index 17a6d9bf8..e34266679 100644
--- a/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
+++ b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
@@ -94,4 +94,3 @@ Noto Color Emoji
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-
From 34a428091f2f3ffcf53fb4637e66b57854cf008d Mon Sep 17 00:00:00 2001
From: kkrix3 <254672938+kkrix3@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:10:54 +0900
Subject: [PATCH 4/6] Fix native glyph-count API and collect renders before
emulator shutdown
---
.github/workflows/preview-ci.yml | 5 -----
scripts/preview/emoji_device_tests.sh | 3 +++
.../symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt | 2 +-
3 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/preview-ci.yml b/.github/workflows/preview-ci.yml
index 1b985a6bb..d54a24859 100644
--- a/.github/workflows/preview-ci.yml
+++ b/.github/workflows/preview-ci.yml
@@ -274,11 +274,6 @@ jobs:
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: Collect render comparisons
- if: always()
- run: |
- mkdir -p emoji-rendering-artifacts
- adb pull /sdcard/Android/data/com.kazumaproject.symbol_keyboard.test/files/emoji-rendering emoji-rendering-artifacts/ || true
- name: Upload emoji reports and render comparisons
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
diff --git a/scripts/preview/emoji_device_tests.sh b/scripts/preview/emoji_device_tests.sh
index 53b6463e7..5f5845b16 100644
--- a/scripts/preview/emoji_device_tests.sh
+++ b/scripts/preview/emoji_device_tests.sh
@@ -1,6 +1,9 @@
#!/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 --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')"
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
index 3d9a642f0..210440dca 100644
--- a/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
+++ b/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
@@ -85,7 +85,7 @@ class GoogleEmojiRenderingDeviceTest {
@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)
+ 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) }
From b06779ca66f6cd3056dc75451e2e90e929b7e3cd Mon Sep 17 00:00:00 2001
From: kkrix3 <254672938+kkrix3@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:22:41 +0900
Subject: [PATCH 5/6] Make native render checks reflect upstream glyphs and use
lifecycle polling
---
docs/google-emoji-rendering.md | 4 +-
scripts/preview/emoji_device_tests.sh | 4 +-
.../GoogleEmojiRenderingDeviceTest.kt | 56 ++++++++++++++++---
symbol_keyboard/src/debug/AndroidManifest.xml | 2 +-
.../symbol_keyboard/EmojiTestActivity.kt | 6 ++
5 files changed, 60 insertions(+), 12 deletions(-)
diff --git a/docs/google-emoji-rendering.md b/docs/google-emoji-rendering.md
index 9f187c99c..b4cff54b2 100644
--- a/docs/google-emoji-rendering.md
+++ b/docs/google-emoji-rendering.md
@@ -71,7 +71,9 @@ Updating the font requires updating the checksum and rerunning native tests.
restoration, backup, unknown/wrongly typed values, and settings choices/defaults.
`GoogleEmojiRenderingDeviceTest` runs on Android 24 and Android 14/API 34. It checks
-colored output for ten simple/VS16/ZWJ/tone/flag/keycap/tag sequences. On API 34,
+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,
diff --git a/scripts/preview/emoji_device_tests.sh b/scripts/preview/emoji_device_tests.sh
index 5f5845b16..20469988b 100644
--- a/scripts/preview/emoji_device_tests.sh
+++ b/scripts/preview/emoji_device_tests.sh
@@ -4,7 +4,9 @@ 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 --no-daemon --console=plain --max-workers=2
+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"
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
index 210440dca..6b3f8e393 100644
--- a/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
+++ b/symbol_keyboard/src/androidTest/java/com/kazumaproject/symbol_keyboard/GoogleEmojiRenderingDeviceTest.kt
@@ -16,7 +16,7 @@ import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
-import androidx.test.core.app.ActivityScenario
+import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.material.tabs.TabLayout
@@ -59,7 +59,7 @@ class GoogleEmojiRenderingDeviceTest {
prefs.getInt("emoji_restart_test_pid", -1))
assertEquals(EmojiRenderingStyle.GOOGLE, EmojiRenderingStyle.read(prefs))
} else setStyle(EmojiRenderingStyle.GOOGLE)
- ActivityScenario.launch(EmojiTestActivity::class.java).use { scenario ->
+ EmojiActivitySession.launch().use { scenario ->
awaitFont(scenario, "👩🏽💻", Typeface.create(GoogleEmojiFont.get(context), Typeface.BOLD))
}
}
@@ -71,15 +71,19 @@ class GoogleEmojiRenderingDeviceTest {
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("Monochrome/empty glyph: $sample", colored > 30)
- save(bitmap, "google-$index.png")
- save(render(sample, Typeface.DEFAULT), "device-$index.png")
+ 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)
}
}
@@ -93,7 +97,7 @@ class GoogleEmojiRenderingDeviceTest {
}
@Test fun livePreferenceRestartHistoryPopupAndRecycledModes() {
- ActivityScenario.launch(EmojiTestActivity::class.java).use { scenario ->
+ EmojiActivitySession.launch().use { scenario ->
val history = "👩🏽💻"
var original: Typeface? = null
awaitCell(scenario, history)
@@ -162,11 +166,11 @@ class GoogleEmojiRenderingDeviceTest {
private fun popup(keyboard: CustomSymbolKeyboardView): PopupWindow =
CustomSymbolKeyboardView::class.java.getDeclaredField("skinTonePopup").apply { isAccessible = true }
.get(keyboard) as PopupWindow
- private fun awaitCell(scenario: ActivityScenario, expected: String) =
+ private fun awaitCell(scenario: EmojiActivitySession, expected: String) =
awaitCell(scenario, expected, false, null)
- private fun awaitFont(scenario: ActivityScenario, text: String, font: Typeface?) =
+ private fun awaitFont(scenario: EmojiActivitySession, text: String, font: Typeface?) =
awaitCell(scenario, text, true, font)
- private fun awaitCell(scenario: ActivityScenario, expected: String, checkFont: Boolean, font: Typeface?) {
+ private fun awaitCell(scenario: EmojiActivitySession, expected: String, checkFont: Boolean, font: Typeface?) {
val deadline = SystemClock.uptimeMillis() + 15_000
while (SystemClock.uptimeMillis() < deadline) {
var ready = false
@@ -208,3 +212,37 @@ class GoogleEmojiRenderingDeviceTest {
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
index dcefbc1cc..433948e9b 100644
--- a/symbol_keyboard/src/debug/AndroidManifest.xml
+++ b/symbol_keyboard/src/debug/AndroidManifest.xml
@@ -1,6 +1,6 @@
+ android:theme="@style/Theme.Material3.DayNight.NoActionBar" />
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
index 4061b1bed..9f4ba0ace 100644
--- a/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt
+++ b/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt
@@ -14,6 +14,12 @@ import com.kazumaproject.listeners.SymbolRecyclerViewItemClickListener
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 {
From 948f2dbce85081b8ecbe3b08813af3c9c2ff8b42 Mon Sep 17 00:00:00 2001
From: kkrix3 <254672938+kkrix3@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:39:52 +0900
Subject: [PATCH 6/6] Include repository and embedded font copyright in the
bundled OFL notice
---
docs/google-emoji-rendering.md | 3 ++-
scripts/preview/verify_apk.py | 3 ++-
symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html | 1 +
3 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/docs/google-emoji-rendering.md b/docs/google-emoji-rendering.md
index b4cff54b2..5368b1842 100644
--- a/docs/google-emoji-rendering.md
+++ b/docs/google-emoji-rendering.md
@@ -57,7 +57,8 @@ to package a checksum mismatch. No font binary is sent through the GitHub connec
- 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, SIL Open Font License 1.1. Full upstream font license
+- 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.
diff --git a/scripts/preview/verify_apk.py b/scripts/preview/verify_apk.py
index 98227e287..c2e930bca 100644
--- a/scripts/preview/verify_apk.py
+++ b/scripts/preview/verify_apk.py
@@ -61,7 +61,8 @@ def main():
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 'SIL OPEN FONT LICENSE Version 1.1' in notice
+ 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}')
diff --git a/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
index e34266679..2e11ba444 100644
--- a/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
+++ b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
@@ -1,6 +1,7 @@
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.