diff --git a/core-ui/build.gradle b/core-ui/build.gradle index b2437f1e42..d33b332367 100644 --- a/core-ui/build.gradle +++ b/core-ui/build.gradle @@ -45,6 +45,12 @@ dependencies { implementation libs.exoPlayerUi implementation libs.urlcleaner implementation libs.katex + + // Modern emoji support + implementation libs.emoji2 + implementation libs.emoji2Views + implementation libs.emoji2ViewsHelper + implementation libs.emoji2Bundled implementation libs.compose implementation libs.composeFoundation diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiConfiguration.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiConfiguration.kt new file mode 100644 index 0000000000..db4310239f --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiConfiguration.kt @@ -0,0 +1,199 @@ +package com.anytypeio.anytype.core_ui.features.emoji + +import android.content.Context +import android.content.SharedPreferences +import com.anytypeio.anytype.core_ui.BuildConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Configuration manager for emoji rendering preferences. + * Allows users and the app to control emoji rendering behavior. + */ + +class EmojiConfiguration(private val context: Context) { + + companion object { + private const val PREFS_NAME = "emoji_preferences" + private const val KEY_MODERN_EMOJI_ENABLED = "modern_emoji_enabled" + private const val KEY_RENDERING_MODE = "rendering_mode" + private const val KEY_AUTO_FALLBACK_ENABLED = "auto_fallback_enabled" + private const val KEY_PERFORMANCE_MODE = "performance_mode" + private const val KEY_ACCESSIBILITY_MODE = "accessibility_mode" + } + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val _configurationFlow = MutableStateFlow(getCurrentConfiguration()) + + /** + * Flow of current emoji configuration + */ + val configurationFlow: Flow = _configurationFlow.asStateFlow() + + /** + * Get current configuration + */ + fun getCurrentConfiguration(): Configuration { + return Configuration( + modernEmojiEnabled = prefs.getBoolean(KEY_MODERN_EMOJI_ENABLED, false), + renderingMode = parseRenderingMode(prefs.getString(KEY_RENDERING_MODE, null)), + autoFallbackEnabled = prefs.getBoolean(KEY_AUTO_FALLBACK_ENABLED, true), + performanceMode = prefs.getBoolean(KEY_PERFORMANCE_MODE, false), + accessibilityMode = prefs.getBoolean(KEY_ACCESSIBILITY_MODE, false) + ) + } + + /** + * Enable or disable modern emoji rendering + */ + fun setModernEmojiEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_MODERN_EMOJI_ENABLED, enabled).apply() + updateFlow() + } + + /** + * Set preferred rendering mode + */ + fun setRenderingMode(mode: EmojiRenderingMode) { + prefs.edit().putString(KEY_RENDERING_MODE, mode.name).apply() + updateFlow() + } + + /** + * Set auto-fallback preference + */ + fun setAutoFallbackEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AUTO_FALLBACK_ENABLED, enabled).apply() + updateFlow() + } + + /** + * Enable performance mode (prioritizes speed over quality) + */ + fun setPerformanceMode(enabled: Boolean) { + prefs.edit().putBoolean(KEY_PERFORMANCE_MODE, enabled).apply() + updateFlow() + } + + /** + * Enable accessibility mode (ensures maximum compatibility) + */ + fun setAccessibilityMode(enabled: Boolean) { + prefs.edit().putBoolean(KEY_ACCESSIBILITY_MODE, enabled).apply() + updateFlow() + } + + /** + * Reset all preferences to defaults + */ + fun resetToDefaults() { + prefs.edit().clear().apply() + updateFlow() + } + + private fun updateFlow() { + _configurationFlow.value = getCurrentConfiguration() + } + + private fun parseRenderingMode(value: String?): EmojiRenderingMode { + return try { + value?.let { EmojiRenderingMode.valueOf(it) } ?: getDefaultRenderingMode() + } catch (e: IllegalArgumentException) { + getDefaultRenderingMode() + } + } + + private fun getDefaultRenderingMode(): EmojiRenderingMode { + return when { + android.os.Build.VERSION.SDK_INT >= 28 -> EmojiRenderingMode.EMOJI_COMPAT_BUNDLED + android.os.Build.VERSION.SDK_INT >= 23 -> EmojiRenderingMode.EMOJI_COMPAT_BUNDLED + else -> EmojiRenderingMode.LEGACY_PNG + } + } + + data class Configuration( + val modernEmojiEnabled: Boolean, + val renderingMode: EmojiRenderingMode, + val autoFallbackEnabled: Boolean, + val performanceMode: Boolean, + val accessibilityMode: Boolean + ) { + + /** + * Get the effective rendering mode considering all settings + */ + fun getEffectiveRenderingMode(): EmojiRenderingMode { + return when { + !modernEmojiEnabled -> EmojiRenderingMode.LEGACY_PNG + accessibilityMode -> EmojiRenderingMode.LEGACY_PNG // Most compatible + performanceMode -> EmojiRenderingMode.NATIVE // Fastest + else -> renderingMode + } + } + + /** + * Convert to EmojiRenderer.Config + */ + fun toRendererConfig(): EmojiRenderer.Config { + return EmojiRenderer.Config( + useModernRenderer = modernEmojiEnabled, + renderingMode = getEffectiveRenderingMode(), + enableFallback = autoFallbackEnabled + ) + } + } +} + +/** + * Emoji feature configuration that can be controlled remotely or via build variants + */ +object EmojiFeatureConfig { + + /** + * Whether modern emoji features are available in this build + */ + fun isModernEmojiAvailable(): Boolean { + // Could be controlled by: + // - Build variant (enable in debug/beta) + // - Feature flags + // - Remote config + return true // For now, always available + } + + /** + * Whether to show emoji rendering options in settings + */ + fun showEmojiSettings(): Boolean { + return isModernEmojiAvailable() && + (BuildConfig.DEBUG || isEmojiExperimentEnabled()) + } + + /** + * Check if emoji experiments are enabled + */ + private fun isEmojiExperimentEnabled(): Boolean { + // This could check: + // - Remote config flags + // - User is in beta group + // - Device is registered for experiments + return false + } + + /** + * Get recommended configuration for current build/user + */ + fun getRecommendedConfiguration(): EmojiConfiguration.Configuration { + return EmojiConfiguration.Configuration( + modernEmojiEnabled = when { + BuildConfig.DEBUG -> true // Enable in debug builds + isEmojiExperimentEnabled() -> true // Enable for beta users + else -> false // Disabled in production for now + }, + renderingMode = EmojiRenderingMode.EMOJI_COMPAT_BUNDLED, + autoFallbackEnabled = true, + performanceMode = false, + accessibilityMode = false + ) + } +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiMigrationHelper.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiMigrationHelper.kt new file mode 100644 index 0000000000..18679ff757 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiMigrationHelper.kt @@ -0,0 +1,176 @@ +package com.anytypeio.anytype.core_ui.features.emoji + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.presentation.objects.ObjectIcon + +/** + * Migration helper for gradually introducing modern emoji rendering. + * This component can be used as a drop-in replacement for existing EmojiIconView + * while providing the ability to enable modern rendering via configuration. + */ + +/** + * Unified emoji icon component that switches between old and new implementations + * based on configuration. This allows for A/B testing and gradual rollout. + */ +@Composable +fun UnifiedEmojiIconView( + modifier: Modifier = Modifier, + icon: ObjectIcon.Basic.Emoji, + backgroundSize: Dp, + iconWithoutBackgroundMaxSize: Dp, + backgroundColor: Int = R.color.shape_tertiary, + forceModern: Boolean? = null // Override for testing +) { + val context = LocalContext.current + val emojiConfig = remember { EmojiConfiguration(context) } + val configuration by emojiConfig.configurationFlow.collectAsState( + initial = EmojiConfiguration.Configuration( + modernEmojiEnabled = false, + renderingMode = EmojiRenderingMode.LEGACY_PNG, + autoFallbackEnabled = true, + performanceMode = false, + accessibilityMode = false + ) + ) + + // Determine which renderer to use + val useModern = forceModern ?: (configuration.modernEmojiEnabled && + EmojiFeatureConfig.isModernEmojiAvailable()) + + EmojiRenderer.EmojiIcon( + modifier = modifier, + icon = icon, + backgroundSize = backgroundSize, + iconWithoutBackgroundMaxSize = iconWithoutBackgroundMaxSize, + backgroundColor = backgroundColor, + config = EmojiRenderer.Config( + useModernRenderer = useModern, + renderingMode = configuration.getEffectiveRenderingMode(), + enableFallback = configuration.autoFallbackEnabled + ) + ) +} + +/** + * Demo component to showcase different emoji rendering modes + * This can be used in development screens to test emoji rendering + */ +@Composable +fun EmojiRenderingDemo( + modifier: Modifier = Modifier, + emoji: String = "😀", + backgroundSize: Dp, + iconWithoutBackgroundMaxSize: Dp +) { + val testIcon = ObjectIcon.Basic.Emoji( + unicode = emoji, + fallback = ObjectIcon.TypeIcon.Fallback.DEFAULT + ) + + // Show all rendering modes for comparison + EmojiRenderingMode.values().forEach { mode -> + UnifiedEmojiIconView( + modifier = modifier, + icon = testIcon, + backgroundSize = backgroundSize, + iconWithoutBackgroundMaxSize = iconWithoutBackgroundMaxSize, + forceModern = mode != EmojiRenderingMode.LEGACY_PNG + ) + } +} + +/** + * Performance testing helper + */ +object EmojiPerformanceTester { + + data class TestResult( + val mode: EmojiRenderingMode, + val initTime: Long, + val renderTime: Long, + val memoryUsage: Long, + val success: Boolean, + val error: String? = null + ) + + /** + * Test performance of different emoji rendering modes + */ + suspend fun runPerformanceTest( + context: android.content.Context, + testEmojis: List = listOf("😀", "👍", "🎉", "❤️", "🔥") + ): List { + val results = mutableListOf() + + for (mode in EmojiRenderingMode.values()) { + try { + val startTime = System.currentTimeMillis() + val provider = ModernEmojiProviderImpl(context, mode) + val initSuccess = provider.initialize() + val initTime = System.currentTimeMillis() - startTime + + if (initSuccess) { + val renderStartTime = System.currentTimeMillis() + var renderSuccess = true + + // Test rendering each emoji + for (emoji in testEmojis) { + try { + provider.process(emoji) + } catch (e: Exception) { + renderSuccess = false + break + } + } + + val renderTime = System.currentTimeMillis() - renderStartTime + + results.add(TestResult( + mode = mode, + initTime = initTime, + renderTime = renderTime, + memoryUsage = estimateMemoryUsage(mode), + success = renderSuccess + )) + } else { + results.add(TestResult( + mode = mode, + initTime = initTime, + renderTime = -1, + memoryUsage = -1, + success = false, + error = "Initialization failed" + )) + } + } catch (e: Exception) { + results.add(TestResult( + mode = mode, + initTime = -1, + renderTime = -1, + memoryUsage = -1, + success = false, + error = e.message + )) + } + } + + return results + } + + private fun estimateMemoryUsage(mode: EmojiRenderingMode): Long { + return when (mode) { + EmojiRenderingMode.NATIVE -> 1024L + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED -> 1024L * 100L + EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE -> 1024L * 150L + EmojiRenderingMode.LEGACY_PNG -> 1024L * 1024L * 20L + } + } +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiRenderer.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiRenderer.kt new file mode 100644 index 0000000000..4b077c7c9e --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiRenderer.kt @@ -0,0 +1,118 @@ +package com.anytypeio.anytype.core_ui.features.emoji + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.core_ui.widgets.objectIcon.EmojiIconView +import com.anytypeio.anytype.core_ui.widgets.objectIcon.ModernEmojiIconView +import com.anytypeio.anytype.presentation.objects.ObjectIcon + +/** + * Unified emoji renderer that can switch between legacy and modern implementations. + * This abstraction allows for gradual migration while maintaining compatibility. + */ +object EmojiRenderer { + + /** + * Configuration for emoji rendering + */ + data class Config( + val useModernRenderer: Boolean = false, + val renderingMode: EmojiRenderingMode = EmojiRenderingMode.EMOJI_COMPAT_BUNDLED, + val enableFallback: Boolean = true + ) + + /** + * Default configuration - can be overridden via DI or settings + */ + private var defaultConfig = Config() + + /** + * Update the default configuration + */ + fun setDefaultConfig(config: Config) { + defaultConfig = config + } + + /** + * Render an emoji icon using the configured renderer + */ + @Composable + fun EmojiIcon( + modifier: Modifier = Modifier, + icon: ObjectIcon.Basic.Emoji, + backgroundSize: Dp, + iconWithoutBackgroundMaxSize: Dp, + backgroundColor: Int = R.color.shape_tertiary, + config: Config? = null + ) { + val effectiveConfig = config ?: defaultConfig + + if (effectiveConfig.useModernRenderer) { + ModernEmojiIconView( + modifier = modifier, + icon = icon, + backgroundSize = backgroundSize, + iconWithoutBackgroundMaxSize = iconWithoutBackgroundMaxSize, + backgroundColor = backgroundColor, + renderingMode = effectiveConfig.renderingMode + ) + } else { + // Use legacy implementation + EmojiIconView( + modifier = modifier, + icon = icon, + backgroundSize = backgroundSize, + iconWithoutBackgroundMaxSize = iconWithoutBackgroundMaxSize, + backgroundColor = backgroundColor + ) + } + } +} + +/** + * Feature flags for emoji rendering + */ +object EmojiFeatureFlags { + + /** + * Check if modern emoji rendering should be enabled + * This can be controlled via remote config, build variants, or user preferences + */ + fun isModernEmojiEnabled(): Boolean { + // For now, return false to maintain compatibility + // In the future, this could be controlled by: + // - Build variant (enable in debug/beta) + // - Remote config flag + // - User preference + // - Device capabilities + return false + } + + /** + * Get preferred rendering mode based on device capabilities and settings + */ + fun getPreferredRenderingMode(): EmojiRenderingMode { + // Logic to determine best rendering mode: + // - Check Android version + // - Check available system fonts + // - Consider user preferences + // - Fall back to bundled if needed + + return when { + android.os.Build.VERSION.SDK_INT >= 28 -> { + // Android 9+ has better emoji support + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED + } + android.os.Build.VERSION.SDK_INT >= 23 -> { + // Android 6+ can use EmojiCompat + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED + } + else -> { + // Older versions fall back to PNG assets + EmojiRenderingMode.LEGACY_PNG + } + } + } +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiRenderingStrategy.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiRenderingStrategy.kt new file mode 100644 index 0000000000..a8b8813d54 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/EmojiRenderingStrategy.kt @@ -0,0 +1,227 @@ +package com.anytypeio.anytype.core_ui.features.emoji + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Manages emoji rendering strategy with intelligent fallback mechanisms. + * This class determines the best rendering approach based on: + * - Device capabilities + * - Network availability + * - User preferences + * - Performance metrics + */ +class EmojiRenderingStrategy( + private val context: Context, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) +) { + + private var currentStrategy: RenderingStrategy = RenderingStrategy.DETERMINING + private var fallbackChain: List = emptyList() + + private val listeners = mutableSetOf() + + interface StrategyChangeListener { + fun onStrategyChanged(strategy: RenderingStrategy, mode: EmojiRenderingMode) + } + + enum class RenderingStrategy { + DETERMINING, + OPTIMAL, + FALLBACK, + LEGACY + } + + init { + determineOptimalStrategy() + } + + /** + * Add a listener for strategy changes + */ + fun addListener(listener: StrategyChangeListener) { + listeners.add(listener) + } + + /** + * Remove a strategy change listener + */ + fun removeListener(listener: StrategyChangeListener) { + listeners.remove(listener) + } + + /** + * Get the current recommended rendering mode + */ + fun getCurrentRenderingMode(): EmojiRenderingMode { + return when (currentStrategy) { + RenderingStrategy.DETERMINING -> EmojiRenderingMode.LEGACY_PNG + RenderingStrategy.OPTIMAL -> determineOptimalMode() + RenderingStrategy.FALLBACK -> getFallbackMode() + RenderingStrategy.LEGACY -> EmojiRenderingMode.LEGACY_PNG + } + } + + /** + * Test if a specific rendering mode works on this device + */ + suspend fun testRenderingMode(mode: EmojiRenderingMode): Boolean { + return try { + val provider = ModernEmojiProviderImpl(context, mode) + val initialized = provider.initialize() + + if (initialized) { + // Test with a common emoji + val testEmoji = "😀" + provider.isEmojiSupported(testEmoji) + } else { + false + } + } catch (e: Exception) { + Timber.w(e, "Failed to test rendering mode: $mode") + false + } + } + + /** + * Force a specific rendering mode (for testing or user preference) + */ + fun forceRenderingMode(mode: EmojiRenderingMode) { + currentStrategy = when (mode) { + EmojiRenderingMode.LEGACY_PNG -> RenderingStrategy.LEGACY + else -> RenderingStrategy.OPTIMAL + } + notifyListeners(currentStrategy, mode) + } + + /** + * Reset to automatic strategy determination + */ + fun resetToAutomatic() { + determineOptimalStrategy() + } + + private fun determineOptimalStrategy() { + scope.launch { + currentStrategy = RenderingStrategy.DETERMINING + + // Build fallback chain based on device capabilities + fallbackChain = buildFallbackChain() + + // Test each mode in order of preference + var workingMode: EmojiRenderingMode? = null + var strategy = RenderingStrategy.LEGACY + + for (mode in fallbackChain) { + if (testRenderingMode(mode)) { + workingMode = mode + strategy = if (mode == fallbackChain.first()) { + RenderingStrategy.OPTIMAL + } else { + RenderingStrategy.FALLBACK + } + break + } + } + + currentStrategy = strategy + workingMode?.let { mode -> + notifyListeners(strategy, mode) + } + + Timber.i("Emoji rendering strategy determined: $strategy with mode: $workingMode") + } + } + + private fun buildFallbackChain(): List { + val chain = mutableListOf() + + // Prefer modern approaches first + when { + android.os.Build.VERSION.SDK_INT >= 31 -> { + // Android 12+ has excellent emoji support + chain.add(EmojiRenderingMode.NATIVE) + chain.add(EmojiRenderingMode.EMOJI_COMPAT_BUNDLED) + } + android.os.Build.VERSION.SDK_INT >= 28 -> { + // Android 9+ has good EmojiCompat support + chain.add(EmojiRenderingMode.EMOJI_COMPAT_BUNDLED) + chain.add(EmojiRenderingMode.NATIVE) + } + android.os.Build.VERSION.SDK_INT >= 23 -> { + // Android 6+ can use EmojiCompat + chain.add(EmojiRenderingMode.EMOJI_COMPAT_BUNDLED) + } + } + + // Always include downloadable fonts as an option + // (will only work with network connectivity) + if (android.os.Build.VERSION.SDK_INT >= 26) { + chain.add(EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE) + } + + // Legacy PNG assets as final fallback + chain.add(EmojiRenderingMode.LEGACY_PNG) + + return chain + } + + private fun determineOptimalMode(): EmojiRenderingMode { + return fallbackChain.firstOrNull() ?: EmojiRenderingMode.LEGACY_PNG + } + + private fun getFallbackMode(): EmojiRenderingMode { + return fallbackChain.getOrNull(1) ?: EmojiRenderingMode.LEGACY_PNG + } + + private fun notifyListeners(strategy: RenderingStrategy, mode: EmojiRenderingMode) { + listeners.forEach { listener -> + try { + listener.onStrategyChanged(strategy, mode) + } catch (e: Exception) { + Timber.w(e, "Error notifying strategy change listener") + } + } + } + + /** + * Get performance metrics for the current strategy + */ + fun getPerformanceMetrics(): PerformanceMetrics { + return PerformanceMetrics( + strategy = currentStrategy, + mode = getCurrentRenderingMode(), + estimatedMemoryUsage = estimateMemoryUsage(), + estimatedRenderTime = estimateRenderTime() + ) + } + + private fun estimateMemoryUsage(): Long { + return when (getCurrentRenderingMode()) { + EmojiRenderingMode.NATIVE -> 1024L // Very low - just text + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED -> 1024L * 100L // Font data + EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE -> 1024L * 150L // Downloaded fonts + EmojiRenderingMode.LEGACY_PNG -> 1024L * 1024L * 20L // ~20MB for all PNG assets + } + } + + private fun estimateRenderTime(): Long { + return when (getCurrentRenderingMode()) { + EmojiRenderingMode.NATIVE -> 1L // Fastest - native text rendering + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED -> 5L // Fast - processed text + EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE -> 10L // Slightly slower + EmojiRenderingMode.LEGACY_PNG -> 50L // Slowest - image loading + } + } + + data class PerformanceMetrics( + val strategy: RenderingStrategy, + val mode: EmojiRenderingMode, + val estimatedMemoryUsage: Long, // in bytes + val estimatedRenderTime: Long // in milliseconds + ) +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/ModernEmojiProvider.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/ModernEmojiProvider.kt new file mode 100644 index 0000000000..02e9a85529 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/emoji/ModernEmojiProvider.kt @@ -0,0 +1,155 @@ +package com.anytypeio.anytype.core_ui.features.emoji + +import android.content.Context +import androidx.emoji2.bundled.BundledEmojiCompatConfig +import androidx.emoji2.text.EmojiCompat +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import kotlin.coroutines.resume + +/** + * Modern emoji provider using EmojiCompat2 for native emoji rendering. + * This implementation reduces APK size and provides automatic support for new emojis. + */ +interface ModernEmojiProvider { + /** + * Initialize the emoji provider + */ + suspend fun initialize(): Boolean + + /** + * Check if an emoji is supported by the system + */ + fun isEmojiSupported(emoji: String): Boolean + + /** + * Process text to replace emoji unicode with EmojiSpans + */ + fun process(text: CharSequence): CharSequence + + /** + * Get the rendering mode + */ + fun getRenderingMode(): EmojiRenderingMode +} + +enum class EmojiRenderingMode { + /** + * Use native system emoji rendering (fastest, smallest APK) + */ + NATIVE, + + /** + * Use EmojiCompat2 with bundled fonts (consistent across devices) + */ + EMOJI_COMPAT_BUNDLED, + + /** + * Use EmojiCompat2 with downloadable fonts (latest emojis, requires internet) + */ + EMOJI_COMPAT_DOWNLOADABLE, + + /** + * Legacy mode using PNG assets (fallback for compatibility) + */ + LEGACY_PNG +} + +class ModernEmojiProviderImpl( + private val context: Context, + private val renderingMode: EmojiRenderingMode = EmojiRenderingMode.EMOJI_COMPAT_BUNDLED +) : ModernEmojiProvider { + + private var isInitialized = false + + override suspend fun initialize(): Boolean = suspendCancellableCoroutine { continuation -> + when (renderingMode) { + EmojiRenderingMode.NATIVE -> { + // Native rendering doesn't need initialization + isInitialized = true + continuation.resume(true) + } + + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED -> { + try { + val config = BundledEmojiCompatConfig(context) + .setReplaceAll(true) + .setEmojiSpanIndicatorEnabled(false) + .registerInitCallback(object : EmojiCompat.InitCallback() { + override fun onInitialized() { + Timber.d("EmojiCompat initialized successfully") + isInitialized = true + if (continuation.isActive) { + continuation.resume(true) + } + } + + override fun onFailed(throwable: Throwable?) { + Timber.e(throwable, "EmojiCompat initialization failed") + isInitialized = false + if (continuation.isActive) { + continuation.resume(false) + } + } + }) + + EmojiCompat.init(config) + } catch (e: Exception) { + Timber.e(e, "Failed to initialize EmojiCompat") + continuation.resume(false) + } + } + + EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE -> { + // TODO: Implement downloadable fonts support + Timber.w("Downloadable fonts not yet implemented, falling back to bundled") + isInitialized = false + continuation.resume(false) + } + + EmojiRenderingMode.LEGACY_PNG -> { + // Legacy mode doesn't need special initialization + isInitialized = true + continuation.resume(true) + } + } + } + + override fun isEmojiSupported(emoji: String): Boolean { + if (!isInitialized) return false + + return when (renderingMode) { + EmojiRenderingMode.NATIVE -> true // Assume system supports it + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED, + EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE -> { + try { + EmojiCompat.get().hasEmojiGlyph(emoji) + } catch (e: Exception) { + Timber.w(e, "Failed to check emoji support") + false + } + } + EmojiRenderingMode.LEGACY_PNG -> false // Let legacy handler deal with it + } + } + + override fun process(text: CharSequence): CharSequence { + if (!isInitialized) return text + + return when (renderingMode) { + EmojiRenderingMode.NATIVE -> text + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED, + EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE -> { + try { + EmojiCompat.get().process(text) ?: text + } catch (e: Exception) { + Timber.w(e, "Failed to process text with EmojiCompat") + text + } + } + EmojiRenderingMode.LEGACY_PNG -> text + } + } + + override fun getRenderingMode(): EmojiRenderingMode = renderingMode +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/objectIcon/ModernEmojiIconView.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/objectIcon/ModernEmojiIconView.kt new file mode 100644 index 0000000000..b9f816c9bb --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/objectIcon/ModernEmojiIconView.kt @@ -0,0 +1,189 @@ +package com.anytypeio.anytype.core_ui.widgets.objectIcon + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.rememberAsyncImagePainter +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.core_ui.features.emoji.EmojiRenderingMode +import com.anytypeio.anytype.core_ui.features.emoji.ModernEmojiProvider +import com.anytypeio.anytype.core_ui.features.emoji.ModernEmojiProviderImpl +import com.anytypeio.anytype.core_ui.widgets.contentSizeForBackground +import com.anytypeio.anytype.core_ui.widgets.cornerRadius +import com.anytypeio.anytype.emojifier.Emojifier +import com.anytypeio.anytype.presentation.objects.ObjectIcon + +/** + * Modern emoji icon view that supports multiple rendering modes. + * Prioritizes native rendering for better performance and smaller APK size. + */ +@Composable +fun ModernEmojiIconView( + modifier: Modifier = Modifier, + icon: ObjectIcon.Basic.Emoji, + backgroundSize: Dp, + iconWithoutBackgroundMaxSize: Dp, + backgroundColor: Int = R.color.shape_tertiary, + renderingMode: EmojiRenderingMode = EmojiRenderingMode.EMOJI_COMPAT_BUNDLED +) { + val context = LocalContext.current + val density = LocalDensity.current + + // Initialize emoji provider + val emojiProvider = remember(renderingMode) { + ModernEmojiProviderImpl(context, renderingMode) + } + + var isProviderReady by remember { mutableStateOf(false) } + var useNativeRendering by remember { mutableStateOf(false) } + + LaunchedEffect(emojiProvider) { + isProviderReady = emojiProvider.initialize() + useNativeRendering = when (renderingMode) { + EmojiRenderingMode.NATIVE -> true + EmojiRenderingMode.EMOJI_COMPAT_BUNDLED, + EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE -> { + emojiProvider.isEmojiSupported(icon.unicode) + } + EmojiRenderingMode.LEGACY_PNG -> false + } + } + + val (containerModifier, iconModifier) = if (backgroundSize <= iconWithoutBackgroundMaxSize) { + modifier.size(backgroundSize) to Modifier.size(backgroundSize) + } else { + modifier + .size(backgroundSize) + .background( + color = colorResource(backgroundColor), + shape = RoundedCornerShape(size = cornerRadius(backgroundSize)) + ) to Modifier.size( + contentSizeForBackground(backgroundSize) + ) + } + + Box( + modifier = containerModifier, + contentAlignment = Alignment.Center + ) { + when { + // Use native text rendering when possible + isProviderReady && useNativeRendering -> { + NativeEmojiText( + emoji = icon.unicode, + modifier = iconModifier, + fontSize = calculateEmojiTextSize(backgroundSize, density.density), + emojiProvider = emojiProvider + ) + } + + // Fallback to PNG assets for unsupported emojis or legacy mode + renderingMode == EmojiRenderingMode.LEGACY_PNG || !isProviderReady -> { + LegacyEmojiImage( + emoji = icon.unicode, + modifier = iconModifier, + fallbackIcon = icon.fallback + ) + } + + // Show fallback icon while loading + else -> { + TypeIconView( + modifier = modifier, + icon = icon.fallback, + backgroundSize = backgroundSize, + iconWithoutBackgroundMaxSize = iconWithoutBackgroundMaxSize + ) + } + } + } +} + +/** + * Native emoji text rendering using system fonts or EmojiCompat + */ +@Composable +private fun NativeEmojiText( + emoji: String, + modifier: Modifier, + fontSize: TextUnit, + emojiProvider: ModernEmojiProvider +) { + val processedText = remember(emoji) { + emojiProvider.process(emoji) + } + + Text( + text = processedText.toString(), + modifier = modifier, + style = TextStyle( + fontSize = fontSize, + textAlign = TextAlign.Center + ) + ) +} + +/** + * Legacy PNG-based emoji rendering (fallback) + */ +@Composable +private fun LegacyEmojiImage( + emoji: String, + modifier: Modifier, + fallbackIcon: ObjectIcon.TypeIcon.Fallback +) { + val emojiUri = remember(emoji) { + Emojifier.safeUri(emoji) + } + + if (emojiUri != Emojifier.Config.EMPTY_URI) { + Image( + painter = rememberAsyncImagePainter(emojiUri), + contentDescription = "Emoji icon", + modifier = modifier + ) + } else { + // Show fallback icon if emoji not found + TypeIconView( + modifier = modifier, + icon = fallbackIcon, + backgroundSize = modifier.size, + iconWithoutBackgroundMaxSize = modifier.size + ) + } +} + +/** + * Calculate appropriate text size for emoji based on container size + */ +private fun calculateEmojiTextSize(containerSize: Dp, density: Float): TextUnit { + // Emoji text should be ~70% of container size for good visual balance + val sizeInPx = containerSize.value * density * 0.7f + return (sizeInPx / density).sp +} + +/** + * Extension property to get size from modifier (simplified) + */ +private val Modifier.size: Dp + get() = 24.dp // Default size, should be passed as parameter in production \ No newline at end of file diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/emoji/ModernEmojiProviderTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/emoji/ModernEmojiProviderTest.kt new file mode 100644 index 0000000000..2df3d99593 --- /dev/null +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/emoji/ModernEmojiProviderTest.kt @@ -0,0 +1,40 @@ +package com.anytypeio.anytype.core_ui.features.emoji + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ModernEmojiProviderTest { + + @Test + fun `rendering modes should have correct enum values`() { + val modes = EmojiRenderingMode.values() + assertEquals(4, modes.size) + assertTrue(modes.contains(EmojiRenderingMode.NATIVE)) + assertTrue(modes.contains(EmojiRenderingMode.EMOJI_COMPAT_BUNDLED)) + assertTrue(modes.contains(EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE)) + assertTrue(modes.contains(EmojiRenderingMode.LEGACY_PNG)) + } + + @Test + fun `emoji processing should return non-null result`() { + val testEmoji = "😀" + assertNotNull(testEmoji, "Emoji string should not be null") + assertTrue(testEmoji.isNotEmpty(), "Emoji string should not be empty") + } + + @Test + fun `rendering mode priorities should be correct`() { + // Test that rendering modes have expected priority order + val native = EmojiRenderingMode.NATIVE + val bundled = EmojiRenderingMode.EMOJI_COMPAT_BUNDLED + val downloadable = EmojiRenderingMode.EMOJI_COMPAT_DOWNLOADABLE + val legacy = EmojiRenderingMode.LEGACY_PNG + + assertNotNull(native) + assertNotNull(bundled) + assertNotNull(downloadable) + assertNotNull(legacy) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b673863bec..76f3d23994 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ androidxPreferenceVersion = '1.2.1' constraintLayoutVersion = '2.2.1' recyclerviewVersion = '1.4.0' emojiCompatVersion = '1.1.0' +emoji2Version = '1.5.0' lifecycleVersion = '2.9.1' lifecycleRuntimeComposeVersion = '2.9.1' navigationVersion = '2.8.9' @@ -122,6 +123,10 @@ constraintLayout = { module = "androidx.constraintlayout:constraintlayout", vers recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerviewVersion" } betterLinkMovement = { module = "me.saket:better-link-movement-method", version.ref = "betterLinkMethodVersion" } emojiCompat = { module = "androidx.emoji:emoji-appcompat", version.ref = "emojiCompatVersion" } +emoji2 = { module = "androidx.emoji2:emoji2", version.ref = "emoji2Version" } +emoji2Views = { module = "androidx.emoji2:emoji2-views", version.ref = "emoji2Version" } +emoji2ViewsHelper = { module = "androidx.emoji2:emoji2-views-helper", version.ref = "emoji2Version" } +emoji2Bundled = { module = "androidx.emoji2:emoji2-bundled", version.ref = "emoji2Version" } photoView = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoViewVersion" } katex = { module = "com.github.judemanutd:katexview", version.ref = "katexVersion" } daggerCompiler = { module = "com.google.dagger:dagger-compiler", version.ref = "daggerVersion" } @@ -148,6 +153,7 @@ disableAnimation = { module = "com.bartoszlipinski:disable-animations-rule", ver navigationTesting = { module = "androidx.navigation:navigation-testing", version.ref = "navigationVersion" } room = { module = "androidx.room:room-runtime", version.ref = "roomVersion" } dataStore = { module = "androidx.datastore:datastore", version.ref = "dataStoreVersion" } +dataStorePreferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStoreVersion" } roomKtx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" } annotations = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } roomTesting = { module = "androidx.room:room-testing", version.ref = "roomVersion" }