RoomDetail: lazy load EmojiDataSource data (+ async)

This commit is contained in:
ganfra 2021-09-23 13:13:16 +02:00
parent ebd5095662
commit fc5c6b9b00
11 changed files with 194 additions and 105 deletions

View file

@ -17,6 +17,10 @@
package im.vector.app.features.reactions.data
import im.vector.app.InstrumentedTest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.FixMethodOrder
@ -30,64 +34,80 @@ import kotlin.system.measureTimeMillis
@FixMethodOrder(MethodSorters.JVM)
class EmojiDataSourceTest : InstrumentedTest {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@Test
fun checkParsingTime() {
val time = measureTimeMillis {
EmojiDataSource(context().resources)
createEmojiDataSource()
}
assertTrue("Too long to parse", time < 100)
}
@Test
fun checkNumberOfResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Wrong number of emojis", emojiDataSource.rawData.emojis.size >= 500)
assertTrue("Wrong number of categories", emojiDataSource.rawData.categories.size >= 8)
val emojiDataSource = createEmojiDataSource()
val rawData = runBlocking {
emojiDataSource.rawData.await()
}
assertTrue("Wrong number of emojis", rawData.emojis.size >= 500)
assertTrue("Wrong number of categories", rawData.categories.size >= 8)
}
@Test
fun searchTestEmptySearch() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Empty search should return at least 500 results", emojiDataSource.filterWith("").size >= 500)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("")
}
assertTrue("Empty search should return at least 500 results", result.size >= 500)
}
@Test
fun searchTestNoResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Should not have result", emojiDataSource.filterWith("noresult").isEmpty())
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("noresult")
}
assertTrue("Should not have result", result.isEmpty())
}
@Test
fun searchTestOneResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertEquals("Should have 1 result", 1, emojiDataSource.filterWith("france").size)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("france")
}
assertEquals("Should have 1 result", 1, result.size)
}
@Test
fun searchTestManyResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Should have many result", emojiDataSource.filterWith("fra").size > 1)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("fra")
}
assertTrue("Should have many result", result.size > 1)
}
@Test
fun testTada() {
val emojiDataSource = EmojiDataSource(context().resources)
val result = emojiDataSource.filterWith("tada")
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("tada")
}
assertEquals("Should find tada emoji", 1, result.size)
assertEquals("Should find tada emoji", "🎉", result[0].emoji)
}
@Test
fun testQuickReactions() {
val emojiDataSource = EmojiDataSource(context().resources)
assertEquals("Should have 8 quick reactions", 8, emojiDataSource.getQuickReactions().size)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.getQuickReactions()
}
assertEquals("Should have 8 quick reactions", 8, result.size)
}
private fun createEmojiDataSource() = EmojiDataSource(coroutineScope, context().resources)
}

View file

@ -98,6 +98,7 @@ import im.vector.app.features.usercode.UserCodeActivity
import im.vector.app.features.widgets.WidgetActivity
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet
import im.vector.app.features.workers.signout.SignOutBottomSheetDialogFragment
import kotlinx.coroutines.CoroutineScope
@Component(
dependencies = [
@ -129,6 +130,7 @@ interface ScreenComponent {
fun uiStateRepository(): UiStateRepository
fun unrecognizedCertificateDialog(): UnrecognizedCertificateDialog
fun autoAcceptInvites(): AutoAcceptInvites
fun appCoroutineScope(): CoroutineScope
/* ==========================================================================================
* Activities

View file

@ -60,6 +60,7 @@ import im.vector.app.features.reactions.data.EmojiDataSource
import im.vector.app.features.session.SessionListener
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.ui.UiStateRepository
import kotlinx.coroutines.CoroutineScope
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
@ -165,6 +166,8 @@ interface VectorComponent {
fun webRtcCallManager(): WebRtcCallManager
fun appCoroutineScope(): CoroutineScope
fun jitsiActiveConferenceHolder(): JitsiActiveConferenceHolder
@Component.Factory

View file

@ -33,12 +33,18 @@ import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.SharedPrefPinCodeStore
import im.vector.app.features.ui.SharedPreferencesUiStateRepository
import im.vector.app.features.ui.UiStateRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.legacy.LegacySessionImporter
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session
import javax.inject.Singleton
@Module
abstract class VectorModule {
@ -94,6 +100,14 @@ abstract class VectorModule {
fun providesHomeServerHistoryService(matrix: Matrix): HomeServerHistoryService {
return matrix.homeServerHistoryService()
}
@Provides
@JvmStatic
@Singleton
fun providesApplicationCoroutineScope(): CoroutineScope {
return CoroutineScope(SupervisorJob() + Dispatchers.Main)
}
}
@Binds

View file

@ -21,6 +21,11 @@ import androidx.recyclerview.widget.RecyclerView
import im.vector.app.features.autocomplete.AutocompleteClickListener
import im.vector.app.features.autocomplete.RecyclerViewPresenter
import im.vector.app.features.reactions.data.EmojiDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import javax.inject.Inject
class AutocompleteEmojiPresenter @Inject constructor(context: Context,
@ -28,11 +33,14 @@ class AutocompleteEmojiPresenter @Inject constructor(context: Context,
private val controller: AutocompleteEmojiController) :
RecyclerViewPresenter<String>(context), AutocompleteClickListener<String> {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
init {
controller.listener = this
}
fun clear() {
coroutineScope.coroutineContext.cancelChildren()
controller.listener = null
}
@ -45,12 +53,14 @@ class AutocompleteEmojiPresenter @Inject constructor(context: Context,
}
override fun onQuery(query: CharSequence?) {
val data = if (query.isNullOrBlank()) {
// Return common emojis
emojiDataSource.getQuickReactions()
} else {
emojiDataSource.filterWith(query.toString())
coroutineScope.launch {
val data = if (query.isNullOrBlank()) {
// Return common emojis
emojiDataSource.getQuickReactions()
} else {
emojiDataSource.filterWith(query.toString())
}
controller.setData(data)
}
controller.setData(data)
}
}

View file

@ -41,15 +41,15 @@ class EmojiChooserFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = activityViewModelProvider.get(EmojiChooserViewModel::class.java)
emojiRecyclerAdapter.reactionClickListener = this
emojiRecyclerAdapter.interactionListener = this
views.emojiRecyclerView.adapter = emojiRecyclerAdapter
viewModel.moveToSection.observe(viewLifecycleOwner) { section ->
emojiRecyclerAdapter.scrollToSection(section)
}
viewModel.emojiData.observe(viewLifecycleOwner) {
emojiRecyclerAdapter.update(it)
}
}
override fun getCoroutineScope() = lifecycleScope

View file

@ -17,11 +17,16 @@ package im.vector.app.features.reactions
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import im.vector.app.core.utils.LiveEvent
import im.vector.app.features.reactions.data.EmojiData
import im.vector.app.features.reactions.data.EmojiDataSource
import kotlinx.coroutines.launch
import javax.inject.Inject
class EmojiChooserViewModel @Inject constructor() : ViewModel() {
class EmojiChooserViewModel @Inject constructor(private val emojiDataSource: EmojiDataSource) : ViewModel() {
val emojiData: MutableLiveData<EmojiData> = MutableLiveData()
val navigateEvent: MutableLiveData<LiveEvent<String>> = MutableLiveData()
var selectedReaction: String? = null
var eventId: String? = null
@ -29,6 +34,17 @@ class EmojiChooserViewModel @Inject constructor() : ViewModel() {
val currentSection: MutableLiveData<Int> = MutableLiveData()
val moveToSection: MutableLiveData<Int> = MutableLiveData()
init {
loadEmojiData()
}
private fun loadEmojiData() {
viewModelScope.launch {
val rawData = emojiDataSource.rawData.await()
emojiData.postValue(rawData)
}
}
fun onReactionSelected(reaction: String) {
selectedReaction = reaction
navigateEvent.value = LiveEvent(NAVIGATE_FINISH)

View file

@ -25,6 +25,7 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.widget.SearchView
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.viewModel
import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxbinding3.widget.queryTextChanges
@ -36,6 +37,7 @@ import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityEmojiReactionPickerBinding
import im.vector.app.features.reactions.data.EmojiDataSource
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
@ -91,17 +93,19 @@ class EmojiReactionPickerActivity : VectorBaseActivity<ActivityEmojiReactionPick
viewModel = viewModelProvider.get(EmojiChooserViewModel::class.java)
viewModel.eventId = intent.getStringExtra(EXTRA_EVENT_ID)
emojiDataSource.rawData.categories.forEach { category ->
val s = category.emojis[0]
views.tabs.newTab()
.also { tab ->
tab.text = emojiDataSource.rawData.emojis[s]!!.emoji
tab.contentDescription = category.name
}
.also { tab ->
views.tabs.addTab(tab)
}
lifecycleScope.launch {
val rawData = emojiDataSource.rawData.await()
rawData.categories.forEach { category ->
val s = category.emojis[0]
views.tabs.newTab()
.also { tab ->
tab.text = rawData.emojis[s]!!.emoji
tab.contentDescription = category.name
}
.also { tab ->
views.tabs.addTab(tab)
}
}
}
views.tabs.addOnTabSelectedListener(tabLayoutSelectionListener)

View file

@ -15,6 +15,7 @@
*/
package im.vector.app.features.reactions
import android.annotation.SuppressLint
import android.os.Build
import android.os.Trace
import android.text.Layout
@ -30,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import im.vector.app.R
import im.vector.app.features.reactions.data.EmojiData
import im.vector.app.features.reactions.data.EmojiDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -43,13 +45,13 @@ import kotlin.math.abs
* TODO: Performances
* TODO: Scroll to section - Find a way to snap section to the top
*/
class EmojiRecyclerAdapter @Inject constructor(
private val dataSource: EmojiDataSource
) :
class EmojiRecyclerAdapter @Inject constructor() :
RecyclerView.Adapter<EmojiRecyclerAdapter.ViewHolder>() {
var reactionClickListener: ReactionClickListener? = null
var interactionListener: InteractionListener? = null
private var rawData: EmojiData = EmojiData(emptyList(), emptyMap(), emptyMap())
private var mRecyclerView: RecyclerView? = null
private var currentFirstVisibleSection = 0
@ -61,6 +63,12 @@ class EmojiRecyclerAdapter @Inject constructor(
UNKNOWN
}
@SuppressLint("NotifyDataSetChanged")
fun update(emojiData: EmojiData){
rawData = emojiData
notifyDataSetChanged()
}
private var scrollState = ScrollState.UNKNOWN
private var isFastScroll = false
@ -71,10 +79,10 @@ class EmojiRecyclerAdapter @Inject constructor(
if (itemPosition != RecyclerView.NO_POSITION) {
val sectionNumber = getSectionForAbsoluteIndex(itemPosition)
if (!isSection(itemPosition)) {
val sectionMojis = dataSource.rawData.categories[sectionNumber].emojis
val sectionMojis = rawData.categories[sectionNumber].emojis
val sectionOffset = getSectionOffset(sectionNumber)
val emoji = sectionMojis[itemPosition - sectionOffset]
val item = dataSource.rawData.emojis.getValue(emoji).emoji
val item = rawData.emojis.getValue(emoji).emoji
reactionClickListener?.onReactionSelected(item)
}
}
@ -115,7 +123,7 @@ class EmojiRecyclerAdapter @Inject constructor(
}
fun scrollToSection(section: Int) {
if (section < 0 || section >= dataSource.rawData.categories.size) {
if (section < 0 || section >= rawData.categories.size) {
// ignore
return
}
@ -149,7 +157,7 @@ class EmojiRecyclerAdapter @Inject constructor(
private fun isSection(position: Int): Boolean {
var sectionOffset = 1
var lastItemInSection: Int
dataSource.rawData.categories.forEach { category ->
rawData.categories.forEach { category ->
lastItemInSection = sectionOffset + category.emojis.size - 1
if (position == sectionOffset - 1) return true
sectionOffset = lastItemInSection + 2
@ -161,7 +169,7 @@ class EmojiRecyclerAdapter @Inject constructor(
var sectionOffset = 1
var lastItemInSection: Int
var index = 0
dataSource.rawData.categories.forEach { category ->
rawData.categories.forEach { category ->
lastItemInSection = sectionOffset + category.emojis.size - 1
if (position <= lastItemInSection) return index
sectionOffset = lastItemInSection + 2
@ -174,7 +182,7 @@ class EmojiRecyclerAdapter @Inject constructor(
// Todo cache this for fast access
var sectionOffset = 1
var lastItemInSection: Int
dataSource.rawData.categories.forEachIndexed { index, category ->
rawData.categories.forEachIndexed { index, category ->
lastItemInSection = sectionOffset + category.emojis.size - 1
if (section == index) return sectionOffset
sectionOffset = lastItemInSection + 2
@ -186,12 +194,12 @@ class EmojiRecyclerAdapter @Inject constructor(
Trace.beginSection("MyAdapter.onBindViewHolder")
val sectionNumber = getSectionForAbsoluteIndex(position)
if (isSection(position)) {
holder.bind(dataSource.rawData.categories[sectionNumber].name)
holder.bind(rawData.categories[sectionNumber].name)
} else {
val sectionMojis = dataSource.rawData.categories[sectionNumber].emojis
val sectionMojis = rawData.categories[sectionNumber].emojis
val sectionOffset = getSectionOffset(sectionNumber)
val emoji = sectionMojis[position - sectionOffset]
val item = dataSource.rawData.emojis[emoji]!!.emoji
val item = rawData.emojis[emoji]!!.emoji
(holder as EmojiViewHolder).data = item
if (scrollState != ScrollState.SETTLING || !isFastScroll) {
// Log.i("PERF","Bind with draw at position:$position")
@ -220,7 +228,7 @@ class EmojiRecyclerAdapter @Inject constructor(
super.onViewRecycled(holder)
}
override fun getItemCount() = dataSource.rawData.categories
override fun getItemCount() = rawData.categories
.sumOf { emojiCategory -> 1 /* Section */ + emojiCategory.emojis.size }
abstract class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

View file

@ -15,17 +15,19 @@
*/
package im.vector.app.features.reactions
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.reactions.data.EmojiDataSource
import im.vector.app.features.reactions.data.EmojiItem
import kotlinx.coroutines.launch
data class EmojiSearchResultViewState(
val query: String = "",
@ -58,11 +60,14 @@ class EmojiSearchResultViewModel @AssistedInject constructor(
}
private fun updateQuery(action: EmojiSearchAction.UpdateQuery) {
setState {
copy(
query = action.queryString,
results = dataSource.filterWith(action.queryString)
)
viewModelScope.launch {
val results = dataSource.filterWith(action.queryString)
setState {
copy(
query = action.queryString,
results = results
)
}
}
}
}

View file

@ -20,53 +20,60 @@ import android.graphics.Paint
import androidx.core.graphics.PaintCompat
import com.squareup.moshi.Moshi
import im.vector.app.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EmojiDataSource @Inject constructor(
appScope: CoroutineScope,
resources: Resources
) {
private val paint = Paint()
val rawData = resources.openRawResource(R.raw.emoji_picker_datasource)
.use { input ->
Moshi.Builder()
.build()
.adapter(EmojiData::class.java)
.fromJson(input.bufferedReader().use { it.readText() })
}
?.let { parsedRawData ->
// Add key as a keyword, it will solve the issue that ":tada" is not available in completion
// Only add emojis to emojis/categories that can be rendered by the system
parsedRawData.copy(
emojis = mutableMapOf<String, EmojiItem>().apply {
parsedRawData.emojis.keys.forEach { key ->
val origin = parsedRawData.emojis[key] ?: return@forEach
val rawData = appScope.async(Dispatchers.IO, CoroutineStart.LAZY) {
resources.openRawResource(R.raw.emoji_picker_datasource)
.use { input ->
Moshi.Builder()
.build()
.adapter(EmojiData::class.java)
.fromJson(input.bufferedReader().use { it.readText() })
}
?.let { parsedRawData ->
// Add key as a keyword, it will solve the issue that ":tada" is not available in completion
// Only add emojis to emojis/categories that can be rendered by the system
parsedRawData.copy(
emojis = mutableMapOf<String, EmojiItem>().apply {
parsedRawData.emojis.keys.forEach { key ->
val origin = parsedRawData.emojis[key] ?: return@forEach
// Do not add keys containing '_'
if (isEmojiRenderable(origin.emoji)) {
if (origin.keywords.contains(key) || key.contains("_")) {
put(key, origin)
} else {
put(key, origin.copy(keywords = origin.keywords + key))
}
}
}
},
categories = mutableListOf<EmojiCategory>().apply {
parsedRawData.categories.forEach { entry ->
add(EmojiCategory(entry.id, entry.name, mutableListOf<String>().apply {
entry.emojis.forEach { e ->
if (isEmojiRenderable(parsedRawData.emojis[e]!!.emoji)) {
add(e)
// Do not add keys containing '_'
if (isEmojiRenderable(origin.emoji)) {
if (origin.keywords.contains(key) || key.contains("_")) {
put(key, origin)
} else {
put(key, origin.copy(keywords = origin.keywords + key))
}
}
}))
}
},
categories = mutableListOf<EmojiCategory>().apply {
parsedRawData.categories.forEach { entry ->
add(EmojiCategory(entry.id, entry.name, mutableListOf<String>().apply {
entry.emojis.forEach { e ->
if (isEmojiRenderable(parsedRawData.emojis[e]!!.emoji)) {
add(e)
}
}
}))
}
}
}
)
}
?: EmojiData(emptyList(), emptyMap(), emptyMap())
)
}
?: EmojiData(emptyList(), emptyMap(), emptyMap())
}
private val quickReactions = mutableListOf<EmojiItem>()
@ -74,9 +81,9 @@ class EmojiDataSource @Inject constructor(
return PaintCompat.hasGlyph(paint, emoji)
}
fun filterWith(query: String): List<EmojiItem> {
suspend fun filterWith(query: String): List<EmojiItem> {
val words = query.split("\\s".toRegex())
val rawData = this.rawData.await()
// First add emojis with name matching query, sorted by name
return (rawData.emojis.values
.asSequence()
@ -87,9 +94,9 @@ class EmojiDataSource @Inject constructor(
// Then emojis with keyword matching any of the word in the query, sorted by name
rawData.emojis.values
.filter { emojiItem ->
words.fold(true, { prev, word ->
words.fold(true) { prev, word ->
prev && emojiItem.keywords.any { keyword -> keyword.contains(word, true) }
})
}
}
.sortedBy { it.name })
// and ensure they will not be present twice
@ -97,7 +104,7 @@ class EmojiDataSource @Inject constructor(
.toList()
}
fun getQuickReactions(): List<EmojiItem> {
suspend fun getQuickReactions(): List<EmojiItem> {
if (quickReactions.isEmpty()) {
listOf(
"thumbs-up", // 👍
@ -109,7 +116,7 @@ class EmojiDataSource @Inject constructor(
"rocket", // 🚀
"eyes" // 👀
)
.mapNotNullTo(quickReactions) { rawData.emojis[it] }
.mapNotNullTo(quickReactions) { rawData.await().emojis[it] }
}
return quickReactions