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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider
import com.getcode.libs.emojis.reactions.EmojiCatalog
import com.getcode.libs.emojis.reactions.EmojiCatalogLoader
import com.getcode.libs.emojis.reactions.EmojiDrawability
import com.getcode.libs.emojis.reactions.deviceGlyphProbe
import com.getcode.libs.emojis.reactions.EmojiPickerModel
import com.getcode.libs.emojis.reactions.RecentReactionsStore
import com.getcode.view.BaseViewModel
Expand Down Expand Up @@ -45,7 +46,7 @@ class EmojiPickerViewModel internal constructor(
dispatchers: DispatcherProvider,
emojiCatalogLoader: EmojiCatalogLoader,
recentReactionsStore: RecentReactionsStore,
) : this(dispatchers, emojiCatalogLoader, recentReactionsStore, EmojiDrawability.PaintGlyphProbe)
) : this(dispatchers, emojiCatalogLoader, recentReactionsStore, deviceGlyphProbe())

data class State(
val searchFieldState: TextFieldState = TextFieldState(),
Expand Down
2 changes: 1 addition & 1 deletion libs/emojis/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
build/
.gradle/
src/main/kotlin/com/getcode/libs/emojis/generated/
src/commonMain/kotlin/com/getcode/libs/emojis/generated/
89 changes: 55 additions & 34 deletions libs/emojis/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,49 +1,70 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask

plugins {
alias(libs.plugins.flipcash.android.library)
id("com.google.devtools.ksp")
id("dagger.hilt.android.plugin")
alias(libs.plugins.flipcash.kmp.app.library)
alias(libs.plugins.kotlin.ksp)
}

android {
namespace = "${Gradle.codeNamespace}.libs.emojis"
}
kotlin {
android {
namespace = "${Gradle.codeNamespace}.libs.emojis"
withDeviceTestBuilder {}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}

// reactions.json is read from the repo's canonical test-vectors/ rather than copied in.
androidComponents {
onVariants { variant ->
val vectors = rootProject.file("test-vectors").path
variant.hostTests.values.forEach { it.sources.resources?.addStaticSourceDirectory(vectors) }
variant.deviceTests.values.forEach { it.sources.assets?.addStaticSourceDirectory(vectors) }
sourceSets {
commonMain.dependencies {
implementation(project(":apps:flipcash:core"))
implementation(libs.bundles.hilt.common)
implementation(libs.bundles.kotlinx.serialization)
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.datastore.preferences.core)
}
androidMain.dependencies {
implementation(libs.hilt.android)
}
// reactions.json is read from the repo's canonical test-vectors/ rather than copied in.
getByName("androidHostTest") {
resources.srcDir(rootProject.file("test-vectors"))
dependencies {
implementation(kotlin("test"))
implementation(libs.bundles.unit.testing)
implementation(libs.robolectric)
}
}
getByName("androidDeviceTest") {
resources.srcDir(rootProject.file("test-vectors"))
dependencies {
implementation(libs.androidx.junit)
implementation(libs.junit)
implementation(libs.androidx.test.runner)
}
}
}
}

dependencies {
implementation(libs.bundles.hilt)
ksp(libs.bundles.hilt.compiler)
implementation(libs.bundles.kotlinx.serialization)
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.datastore)

testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.robolectric)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.runner)
add("kspAndroid", libs.hilt.android.compiler)
add("kspAndroid", libs.hilt.compiler)
add("jvmMainImplementation", libs.dagger)
}

// Define the task to fetch and generate emoji data. KSP reads src/main/kotlin too, so it
// Define the task to fetch and generate emoji data. KSP reads src/commonMain/kotlin too, so it
// has to wait for the generated sources as well as the Kotlin compile.
afterEvaluate {
tasks.matching { it.name.matches(Regex("(compile|ksp).*Kotlin")) }.configureEach {
dependsOn("generateEmojiList")
}
}

tasks.register<GenerateEmojiList>("generateEmojiList") {
val generateEmojiList by tasks.registering(GenerateEmojiList::class) {
emojiUrl.set("https://unicode.org/Public/emoji/16.0/emoji-test.txt")
emojiKeywordsUrl.set("https://raw.githubusercontent.com/unicode-org/cldr-json/refs/heads/main/cldr-json/cldr-annotations-full/annotations/en/annotations.json")
emojiCacheDir.set(layout.projectDirectory)
outputDir.set(layout.projectDirectory.dir("src/main/kotlin/com/getcode/libs/emojis/generated"))
outputDir.set(layout.projectDirectory.dir("src/commonMain/kotlin/com/getcode/libs/emojis/generated"))
}

tasks.withType<KotlinCompilationTask<*>>().configureEach { dependsOn(generateEmojiList) }
tasks.matching { it.name.startsWith("ksp") || it.name.startsWith("lint") || it.name.endsWith("SourcesJar") }
.configureEach { dependsOn(generateEmojiList) }

// :apps:flipcash:core drags in firebase's protolite-well-known-types, which duplicates classes in
// protobuf-javalite; the instrumented test never touches either, so keep the clash off its APK.
configurations.matching { it.name.contains("DeviceTest", ignoreCase = true) }.configureEach {
exclude(group = "com.google.firebase", module = "protolite-well-known-types")
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.getcode.libs.emojis.reactions

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test
Expand All @@ -18,8 +17,8 @@ class EmojiDrawabilityVectorTest {

@Test
fun drawabilityVectors() {
val json = InstrumentationRegistry.getInstrumentation().context.assets
.open("reactions.json").bufferedReader().use { it.readText() }
val json = javaClass.classLoader!!
.getResourceAsStream("reactions.json")!!.bufferedReader().use { it.readText() }
val vectors = JSONObject(json).getJSONArray("drawability")
val failures = (0 until vectors.length()).map { vectors.getJSONObject(it) }.mapNotNull { vector ->
val emoji = vector.getString("emoji")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package com.getcode.libs.emojis.reactions

import androidx.test.core.app.ApplicationProvider
import com.flipcash.app.core.storage.PreferencesFiles
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertTrue

Expand All @@ -13,8 +14,9 @@ class RecentReactionsStoreTest {

// The DataStore file outlives each test in this process, so every test uses owners of its own.
private var owner: String? = null
private val directory = File.createTempFile("recent-reactions", "").apply { delete(); mkdirs() }
private val store = DataStoreRecentReactionsStore(
context = ApplicationProvider.getApplicationContext(),
preferencesFiles = PreferencesFiles { name -> File(directory, "$name.preferences_pb") },
owner = { owner },
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.getcode.libs.emojis.reactions

import android.graphics.Paint

actual fun deviceGlyphProbe(): EmojiDrawability.GlyphProbe = PaintGlyphProbe

/** The real, device-backed probe: [android.graphics.Paint.hasGlyph]. */
object PaintGlyphProbe : EmojiDrawability.GlyphProbe {
private val paint = Paint()

override fun isSingleGlyph(text: String): Boolean = paint.hasGlyph(text)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.getcode.libs.emojis.reactions

/** Answers whether this device draws an emoji as one glyph, matching iOS's `EmojiDrawability`. */
object EmojiDrawability {

/**
* Returns whether this device draws [emoji] as a single glyph.
*
* On Android, `Paint.hasGlyph` asks the platform's font fallback whether a string resolves to
* one glyph (a ligature for a ZWJ/skin-tone sequence, or the base glyph for a plain emoji)
* rather than falling apart into several tofu or component glyphs side by side — the same
* question iOS's `CTLine` run probe answers by counting AppleColorEmoji glyphs.
*/
fun isDrawable(emoji: String, probe: GlyphProbe = deviceGlyphProbe()): Boolean = probe.isSingleGlyph(emoji)

/** Seam so the merge logic that consumes drawability can be tested without a real typeface. */
fun interface GlyphProbe {
fun isSingleGlyph(text: String): Boolean
}
}

/** The probe backed by the running platform's fonts. */
expect fun deviceGlyphProbe(): EmojiDrawability.GlyphProbe
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package com.getcode.libs.emojis.reactions

import android.content.Context
import com.flipcash.app.core.storage.PreferencesFiles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

Expand All @@ -15,9 +14,9 @@ object ReactionsModule {
@Provides
@Singleton
fun providesRecentReactionsStore(
@ApplicationContext context: Context,
preferencesFiles: PreferencesFiles,
owner: RecentReactionsOwner,
): RecentReactionsStore = DataStoreRecentReactionsStore(context, owner)
): RecentReactionsStore = DataStoreRecentReactionsStore(preferencesFiles, owner)

@Provides
@Singleton
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
package com.getcode.libs.emojis.reactions

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.flipcash.app.core.storage.PreferencesFiles
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant

private val Context.recentReactionsDataStore: DataStore<Preferences> by preferencesDataStore(name = "recent_reactions")

/** Per-emoji use counts and last-used times, backing [RecentReactions.rank]. */
interface RecentReactionsStore {

Expand All @@ -39,11 +36,13 @@ fun interface RecentReactionsOwner {

@Singleton
class DataStoreRecentReactionsStore @Inject constructor(
@ApplicationContext context: Context,
preferencesFiles: PreferencesFiles,
private val owner: RecentReactionsOwner,
) : RecentReactionsStore {

private val dataStore = context.recentReactionsDataStore
private val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { preferencesFiles.file(DATASTORE_NAME) },
)

override suspend fun record(emoji: String) {
val owner = owner.current() ?: return
Expand Down Expand Up @@ -86,6 +85,7 @@ class DataStoreRecentReactionsStore @Inject constructor(
}

private companion object {
const val DATASTORE_NAME = "recent_reactions"
const val KEY_PREFIX = "emoji:"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.getcode.libs.emojis.reactions

import java.awt.Font

actual fun deviceGlyphProbe(): EmojiDrawability.GlyphProbe = AwtGlyphProbe

/**
* Desktop probe: asks the logical `Dialog` font, which falls back across the installed fonts,
* whether it can display every code point. AWT has no equivalent of `Paint.hasGlyph`'s
* single-glyph check, so a ZWJ sequence a font would draw as separate components still reads as
* drawable here.
*/
object AwtGlyphProbe : EmojiDrawability.GlyphProbe {
private val font by lazy { Font(Font.DIALOG, Font.PLAIN, 12) }

override fun isSingleGlyph(text: String): Boolean = text.isNotEmpty() && font.canDisplayUpTo(text) == -1
}

This file was deleted.

Loading