Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion .github/workflows/preview-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ jobs:
bash ./gradlew :app:testFullStandardDebugUnitTest \
--tests '*Independent*Test' \
--tests '*SmallTsu*' \
--tests '*KeyboardFontApplicatorTest' \
--tests '*BundledEmojiCategoryTest' \
--tests '*VibrationTimingPolicyTest' \
--tests '*VibrationShortcutTest' \
--tests '*ShortcutActiveStateResolverTest' \
Expand Down Expand Up @@ -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' &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1946,4 +1946,8 @@ ABC abc 0123456789 ABC 12345
<string name="input_character_pop_distance">発生位置の基準距離(dp・0でキー中心)</string>
<string name="input_character_pop_scale_easing">拡大の補間関数</string>
<string name="input_character_pop_fade_easing">フェードの補間関数</string>
<string name="emoji_rendering_style_title">絵文字の表示</string>
<string name="emoji_rendering_style_summary">Sumire内の絵文字表示を変更します。入力先アプリの表示は変わりません。</string>
<string name="emoji_rendering_style_device">端末標準</string>
<string name="emoji_rendering_style_google">Google絵文字</string>
</resources>
8 changes: 8 additions & 0 deletions app/src/main/res/values/arrays.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,12 @@
<item>legacy</item>
<item>stable</item>
</string-array>
<string-array name="emoji_rendering_style_entries">
<item>@string/emoji_rendering_style_device</item>
<item>@string/emoji_rendering_style_google</item>
</string-array>
<string-array name="emoji_rendering_style_values">
<item>device</item>
<item>google</item>
</string-array>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1948,4 +1948,8 @@ ABC abc 0123456789 ABC 12345
<string name="input_character_pop_distance">Pop position distance (dp; 0 = key center)</string>
<string name="input_character_pop_scale_easing">Pop scale interpolation</string>
<string name="input_character_pop_fade_easing">Pop fade interpolation</string>
<string name="emoji_rendering_style_title">Emoji appearance</string>
<string name="emoji_rendering_style_summary">Changes emoji in Sumire only. Other apps keep their own appearance.</string>
<string name="emoji_rendering_style_device">Device default</string>
<string name="emoji_rendering_style_google">Google emoji</string>
</resources>
10 changes: 10 additions & 0 deletions app/src/main/res/xml/pref_clipboard_shortcut.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@
android:summary="@string/symbol_mode_summary"
android:title="@string/symbol_mode_title" />

<ListPreference
android:defaultValue="device"
android:dialogTitle="@string/emoji_rendering_style_title"
android:entries="@array/emoji_rendering_style_entries"
android:entryValues="@array/emoji_rendering_style_values"
android:key="emoji_rendering_style_preference"
android:summary="@string/emoji_rendering_style_summary"
android:title="@string/emoji_rendering_style_title"
app:useSimpleSummaryProvider="true" />

<ListPreference
android:defaultValue="default"
android:dialogTitle="@string/default_emoji_skin_tone_dialog_title"
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/res/xml/pref_common_legacy.xml
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,16 @@
android:summary="@string/symbol_mode_summary"
android:title="@string/symbol_mode_title" />

<ListPreference
android:defaultValue="device"
android:dialogTitle="@string/emoji_rendering_style_title"
android:entries="@array/emoji_rendering_style_entries"
android:entryValues="@array/emoji_rendering_style_values"
android:key="emoji_rendering_style_preference"
android:summary="@string/emoji_rendering_style_summary"
android:title="@string/emoji_rendering_style_title"
app:useSimpleSummaryProvider="true" />

<ListPreference
android:defaultValue="default"
android:dialogTitle="@string/default_emoji_skin_tone_dialog_title"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>()
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)
}
}
}
106 changes: 106 additions & 0 deletions docs/google-emoji-rendering.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions scripts/preview/emoji_device_tests.sh
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions scripts/preview/verify_apk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading