From d34a3dc99b4e6fa66ee089fb0fda0ceac9c8617c Mon Sep 17 00:00:00 2001 From: kkrix3 <254672938+kkrix3@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:00:16 +0900 Subject: [PATCH 1/6] Add local Google emoji rendering with a pinned bundled font --- .../setting_activity/AppPreference.kt | 7 ++ .../ui/opensource/OpenSourceFragment.kt | 3 + .../ui/setting/SettingDestination.kt | 1 + app/src/main/res/values-ja/strings.xml | 4 + app/src/main/res/values/arrays.xml | 8 ++ app/src/main/res/values/strings.xml | 4 + .../main/res/xml/pref_clipboard_shortcut.xml | 10 ++ app/src/main/res/xml/pref_common_legacy.xml | 10 ++ symbol_keyboard/build.gradle | 4 + symbol_keyboard/google-emoji.gradle | 48 +++++++++ .../assets/licenses/noto-color-emoji.html | 97 +++++++++++++++++++ .../CustomSymbolKeyboardView.kt | 31 ++++++ .../symbol_keyboard/EmojiRenderingStyle.kt | 17 ++++ .../symbol_keyboard/GoogleEmojiFont.kt | 14 +++ .../symbol_keyboard/SymbolAdapter.kt | 31 +++--- 15 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 symbol_keyboard/google-emoji.gradle create mode 100644 symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html create mode 100644 symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/EmojiRenderingStyle.kt create mode 100644 symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/GoogleEmojiFont.kt 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" /> + + + + 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/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.