(R.id.symbol_text)
+ ready = !recycler.isComputingLayout && !recycler.hasPendingAdapterUpdates() &&
+ view?.text?.toString() == expected && (!checkFont || view.typeface == font)
+ }
+ if (ready) return
+ SystemClock.sleep(40)
+ }
+ fail("Timed out waiting for cell $expected and font=$font")
+ }
+ private fun render(text: String, font: Typeface): Bitmap {
+ lateinit var result: Bitmap
+ instrumentation.runOnMainSync {
+ val view = TextView(context).apply {
+ this.text = text; typeface = font; textSize = 64f
+ gravity = Gravity.CENTER; setTextColor(Color.BLACK)
+ }
+ val spec = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY)
+ view.measure(spec, spec); view.layout(0, 0, 320, 320)
+ result = draw(view)
+ }
+ return result
+ }
+ private fun draw(view: View): Bitmap {
+ // A just-added PopupWindow has been measured but may not have had its first traversal.
+ val width = view.width.takeIf { it > 0 } ?: view.measuredWidth
+ val height = view.height.takeIf { it > 0 } ?: view.measuredHeight
+ if (view.width == 0 || view.height == 0) view.layout(0, 0, width, height)
+ return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { view.draw(Canvas(it)) }
+ }
+ private fun save(bitmap: Bitmap, name: String) {
+ File(output, name).outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ }
+ companion object {
+ private const val FONT_SHA256 = "15671215ab769fdc7162a045d56fd7d7e477c51b04e6b3c761d914d8fdd6cc44"
+ }
+}
+
+/** Wait for the specific resumed host/cells, not global queue idleness: the keyboard
+ * owns continuously invalidated interactive views in this isolated library host. */
+private class EmojiActivitySession private constructor() : AutoCloseable {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ fun onActivity(action: (EmojiTestActivity) -> Unit) {
+ instrumentation.runOnMainSync { action(requireNotNull(EmojiTestActivity.current)) }
+ }
+ fun recreate() {
+ val previous = EmojiTestActivity.current
+ onActivity { it.recreate() }
+ await { EmojiTestActivity.current != null && EmojiTestActivity.current !== previous }
+ }
+ override fun close() {
+ onActivity { it.finish() }
+ await { EmojiTestActivity.current == null }
+ }
+ private fun await(condition: () -> Boolean) {
+ val deadline = SystemClock.uptimeMillis() + 15_000
+ while (!condition() && SystemClock.uptimeMillis() < deadline) SystemClock.sleep(40)
+ check(condition()) { "Timed out waiting for the emoji host lifecycle" }
+ }
+ companion object {
+ fun launch(): EmojiActivitySession = EmojiActivitySession().apply {
+ instrumentation.runOnMainSync {
+ instrumentation.targetContext.startActivity(
+ Intent(instrumentation.targetContext, EmojiTestActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ )
+ }
+ await { EmojiTestActivity.current != null }
+ }
+ }
+}
diff --git a/symbol_keyboard/src/debug/AndroidManifest.xml b/symbol_keyboard/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..433948e9b
--- /dev/null
+++ b/symbol_keyboard/src/debug/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt b/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt
new file mode 100644
index 000000000..9f4ba0ace
--- /dev/null
+++ b/symbol_keyboard/src/debug/java/com/kazumaproject/symbol_keyboard/EmojiTestActivity.kt
@@ -0,0 +1,36 @@
+package com.kazumaproject.symbol_keyboard
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.kazumaproject.core.data.clicked_symbol.SymbolMode
+import com.kazumaproject.data.clicked_symbol.ClickedSymbol
+import com.kazumaproject.data.emoji.Emoji
+import com.kazumaproject.data.emoji.EmojiCategory
+import com.kazumaproject.data.emoticon.Emoticon
+import com.kazumaproject.data.emoticon.EmoticonCategory
+import com.kazumaproject.listeners.SymbolRecyclerViewItemClickListener
+
+/** Disposable debug-only host; recreated with the same data and saved preferences. */
+class EmojiTestActivity : AppCompatActivity() {
+ lateinit var keyboard: CustomSymbolKeyboardView
+ var lastClicked: ClickedSymbol? = null
+ companion object { @Volatile var current: EmojiTestActivity? = null }
+ override fun onResume() { super.onResume(); current = this }
+ override fun onDestroy() {
+ if (current === this) current = null
+ super.onDestroy()
+ }
+ override fun onCreate(state: Bundle?) {
+ super.onCreate(state)
+ keyboard = CustomSymbolKeyboardView(this).apply {
+ setLifecycleOwner(this@EmojiTestActivity)
+ setOnSymbolRecyclerViewItemClickListener(object : SymbolRecyclerViewItemClickListener {
+ override fun onClick(symbol: ClickedSymbol) { lastClicked = symbol }
+ })
+ setSymbolLists(listOf(Emoji("👍", EmojiCategory.PEOPLE_BODY)),
+ listOf(Emoticon("(^_^)", EmoticonCategory.SMILE)), emptyList(), emptyList(),
+ listOf(ClickedSymbol(mode = SymbolMode.EMOJI, symbol = "👩🏽💻")))
+ }
+ setContentView(keyboard)
+ }
+}
diff --git a/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
new file mode 100644
index 000000000..2e11ba444
--- /dev/null
+++ b/symbol_keyboard/src/main/assets/licenses/noto-color-emoji.html
@@ -0,0 +1,97 @@
+
+Noto Color Emoji
+Unmodified font, version 2.057. Source.
+Copyright 2013 Google LLC (repository).
Copyright 2022 Google Inc. (font).
Noto is a trademark of Google Inc.
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt
index cd2934bb0..f0b835c4a 100644
--- a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt
+++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt
@@ -2,6 +2,8 @@ package com.kazumaproject.symbol_keyboard
import android.annotation.SuppressLint
import android.content.Context
+import android.content.SharedPreferences
+import androidx.preference.PreferenceManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
@@ -105,6 +107,34 @@ class CustomSymbolKeyboardView @JvmOverloads constructor(
private var clipBoardItems: List = emptyList()
private var currentMode: SymbolMode = SymbolMode.EMOJI
private var keyboardFontSnapshot = KeyboardFontApplicator.processSnapshot
+ private var emojiRenderingStyle = EmojiRenderingStyle.DEVICE
+ private val emojiPreferences by lazy {
+ PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
+ }
+ private val emojiPreferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == null || key == EmojiRenderingStyle.PREFERENCE_KEY) {
+ post { if (isAttachedToWindow) setEmojiRenderingStyle(EmojiRenderingStyle.read(emojiPreferences)) }
+ }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ emojiPreferences.registerOnSharedPreferenceChangeListener(emojiPreferenceListener)
+ setEmojiRenderingStyle(EmojiRenderingStyle.read(emojiPreferences))
+ }
+
+ override fun onDetachedFromWindow() {
+ emojiPreferences.unregisterOnSharedPreferenceChangeListener(emojiPreferenceListener)
+ skinTonePopup?.dismiss()
+ super.onDetachedFromWindow()
+ }
+
+ fun setEmojiRenderingStyle(style: EmojiRenderingStyle) {
+ if (emojiRenderingStyle == style) return
+ emojiRenderingStyle = style
+ skinTonePopup?.dismiss()
+ symbolAdapter.setEmojiTypeface(if (style == EmojiRenderingStyle.GOOGLE) GoogleEmojiFont.get(context) else null)
+ }
override fun setKeyboardFont(snapshot: KeyboardFontSnapshot) {
KeyboardFontApplicator.track(this)
@@ -1033,6 +1063,7 @@ class CustomSymbolKeyboardView @JvmOverloads constructor(
variants.forEach { variant ->
content.addView(
TextView(context).apply {
+ if (emojiRenderingStyle == EmojiRenderingStyle.GOOGLE) typeface = GoogleEmojiFont.get(context)
text = variant
textSize =
if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 30f else 26f
diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/EmojiRenderingStyle.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/EmojiRenderingStyle.kt
new file mode 100644
index 000000000..ec90e77f4
--- /dev/null
+++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/EmojiRenderingStyle.kt
@@ -0,0 +1,17 @@
+package com.kazumaproject.symbol_keyboard
+
+import android.content.SharedPreferences
+
+/** Display policy only: original Unicode and input events remain unchanged. */
+enum class EmojiRenderingStyle(val preferenceValue: String) {
+ DEVICE("device"), GOOGLE("google");
+
+ companion object {
+ const val PREFERENCE_KEY = "emoji_rendering_style_preference"
+ fun fromPreference(value: String?): EmojiRenderingStyle =
+ entries.firstOrNull { it.preferenceValue == value } ?: DEVICE
+ fun read(preferences: SharedPreferences): EmojiRenderingStyle = fromPreference(
+ try { preferences.getString(PREFERENCE_KEY, null) } catch (_: ClassCastException) { null }
+ )
+ }
+}
diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/GoogleEmojiFont.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/GoogleEmojiFont.kt
new file mode 100644
index 000000000..4a94407c1
--- /dev/null
+++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/GoogleEmojiFont.kt
@@ -0,0 +1,14 @@
+package com.kazumaproject.symbol_keyboard
+
+import android.content.Context
+import android.graphics.Typeface
+import androidx.core.content.res.ResourcesCompat
+
+/** One bundled CBDT/CBLC Typeface for list, history and skin-tone choices. */
+object GoogleEmojiFont {
+ private var cached: Typeface? = null
+ @Synchronized
+ fun get(context: Context): Typeface = cached ?: requireNotNull(
+ ResourcesCompat.getFont(context.applicationContext, R.font.noto_color_emoji)
+ ).also { cached = it }
+}
diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt
index 3ee905967..4ca5c3679 100644
--- a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt
+++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/SymbolAdapter.kt
@@ -3,6 +3,7 @@ package com.kazumaproject.symbol_keyboard
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
+import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.RippleDrawable
import android.util.TypedValue
@@ -24,10 +25,20 @@ class SymbolAdapter :
private var keyboardFontSnapshot = KeyboardFontApplicator.processSnapshot
private var useKeyboardFont = false
+ private var emojiTypeface: Typeface? = null
+ private var appearanceRevision = 0L
+
+ fun setEmojiTypeface(typeface: Typeface?) {
+ if (emojiTypeface === typeface) return
+ emojiTypeface = typeface
+ appearanceRevision++
+ notifyDataSetChanged()
+ }
fun setKeyboardFont(snapshot: KeyboardFontSnapshot, useForCurrentMode: Boolean) {
keyboardFontSnapshot = snapshot
useKeyboardFont = useForCurrentMode
+ appearanceRevision++
notifyDataSetChanged()
}
@@ -95,6 +106,7 @@ class SymbolAdapter :
RecyclerView.ViewHolder(itemView) {
val symbolTextView: MaterialTextView = itemView.findViewById(R.id.symbol_text)
val originalTextColors = symbolTextView.textColors
+ val originalEmojiCompatEnabled = symbolTextView.isEmojiCompatEnabled
val skinToneIndicator: View = itemView.findViewById(R.id.skin_tone_indicator)
init {
@@ -128,14 +140,20 @@ class SymbolAdapter :
} catch (e: IndexOutOfBoundsException) {
null
}
+ // Automatic EmojiSpans must not override the explicit Google font. Rebinding
+ // the raw String also clears spans when returning to the original policy.
+ holder.symbolTextView.isEmojiCompatEnabled =
+ if (!useKeyboardFont && emojiTypeface != null) false else holder.originalEmojiCompatEnabled
+ KeyboardFontApplicator.apply(
+ holder.symbolTextView,
+ keyboardFontSnapshot.copy(
+ typeface = if (useKeyboardFont) keyboardFontSnapshot.typeface else emojiTypeface,
+ revision = appearanceRevision,
+ )
+ )
+ holder.symbolTextView.text = symbol ?: ""
if (symbol != null) {
- holder.symbolTextView.text = symbol
holder.symbolTextView.textSize = symbolTextSize
- KeyboardFontApplicator.apply(
- holder.symbolTextView,
- if (useKeyboardFont) keyboardFontSnapshot
- else keyboardFontSnapshot.copy(typeface = null)
- )
// ★追加: テキストカラーの適用
holder.symbolTextView.setTextColor(themeTextColor?.let(android.content.res.ColorStateList::valueOf) ?: holder.originalTextColors)
@@ -155,12 +173,6 @@ class SymbolAdapter :
horizontalMarginPx, verticalMarginPx, horizontalMarginPx, verticalMarginPx
)
} else {
- holder.symbolTextView.text = ""
- KeyboardFontApplicator.apply(
- holder.symbolTextView,
- if (useKeyboardFont) keyboardFontSnapshot
- else keyboardFontSnapshot.copy(typeface = null)
- )
holder.skinToneIndicator.visibility = View.GONE
}
}