Unverified Commit 4e3936ca authored by Filipe de Lima Brito's avatar Filipe de Lima Brito Committed by GitHub

Merge branch 'develop' into new/google-analytics-for-firebase

parents 8ce5d9d3 9e631abd
......@@ -134,6 +134,8 @@ dependencies {
implementation libraries.frescoWebP
implementation libraries.frescoAnimatedWebP
implementation libraries.glide
kapt libraries.kotshiCompiler
implementation libraries.kotshiApi
......
......@@ -18,7 +18,6 @@ import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.GetSettingsInteractor
import chat.rocket.android.server.domain.SITE_URL
import chat.rocket.android.server.domain.TokenRepository
import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.util.setupFabric
import com.facebook.drawee.backends.pipeline.DraweeConfig
import com.facebook.drawee.backends.pipeline.Fresco
......@@ -84,7 +83,6 @@ class RocketChatApplication : Application(), HasActivityInjector, HasServiceInje
context = WeakReference(applicationContext)
AndroidThreeTen.init(this)
EmojiRepository.load(this)
setupFabric(this)
setupFresco()
......
package chat.rocket.android.chatroom.adapter
import android.view.View
import chat.rocket.android.chatroom.uimodel.ActionsAttachmentUiModel
import chat.rocket.android.emoji.EmojiReactionListener
import chat.rocket.core.model.attachment.actions.Action
import chat.rocket.core.model.attachment.actions.ButtonAction
import kotlinx.android.synthetic.main.item_actions_attachment.view.*
import androidx.recyclerview.widget.LinearLayoutManager
import timber.log.Timber
class ActionsAttachmentViewHolder(
itemView: View,
listener: ActionsListener,
reactionListener: EmojiReactionListener? = null,
var actionAttachmentOnClickListener: ActionAttachmentOnClickListener
) : BaseViewHolder<ActionsAttachmentUiModel>(itemView, listener, reactionListener) {
init {
with(itemView) {
setupActionMenu(actions_attachment_container)
}
}
override fun bindViews(data: ActionsAttachmentUiModel) {
val actions = data.actions
val alignment = data.buttonAlignment
Timber.d("no of actions : ${actions.size} : $actions")
with(itemView) {
title.text = data.title ?: ""
actions_list.layoutManager = LinearLayoutManager(itemView.context,
when (alignment) {
"horizontal" -> LinearLayoutManager.HORIZONTAL
else -> LinearLayoutManager.VERTICAL //Default
}, false)
actions_list.adapter = ActionsListAdapter(actions, actionAttachmentOnClickListener)
}
}
}
interface ActionAttachmentOnClickListener {
fun onActionClicked(view: View, action: Action)
}
\ No newline at end of file
package chat.rocket.android.chatroom.adapter
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import chat.rocket.android.R
import chat.rocket.android.util.extensions.inflate
import chat.rocket.core.model.attachment.actions.Action
import chat.rocket.core.model.attachment.actions.ButtonAction
import com.facebook.drawee.backends.pipeline.Fresco
import kotlinx.android.synthetic.main.item_action_button.view.*
import timber.log.Timber
class ActionsListAdapter(actions: List<Action>, var actionAttachmentOnClickListener: ActionAttachmentOnClickListener) : RecyclerView.Adapter<ActionsListAdapter.ViewHolder>() {
var actions: List<Action> = actions
inner class ViewHolder(var layout: View) : RecyclerView.ViewHolder(layout) {
lateinit var action: ButtonAction
private val onClickListener = View.OnClickListener {
actionAttachmentOnClickListener.onActionClicked(it, action)
}
init {
with(itemView) {
action_button.setOnClickListener(onClickListener)
action_image_button.setOnClickListener(onClickListener)
}
}
fun bindAction(action: Action) {
with(itemView) {
Timber.d("action : $action")
this@ViewHolder.action = action as ButtonAction
if (action.imageUrl != null) {
action_button.isVisible = false
action_image_button.isVisible = true
//Image button
val controller = Fresco.newDraweeControllerBuilder().apply {
setUri(action.imageUrl)
autoPlayAnimations = true
oldController = action_image_button.controller
}.build()
action_image_button.controller = controller
} else if (action.text != null) {
action_button.isVisible = true
action_image_button.isVisible = false
this.action_button.setText(action.text)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = parent.inflate(R.layout.item_action_button)
return ViewHolder(view)
}
override fun getItemCount(): Int {
return actions.size
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val action = actions[position]
holder.bindAction(action)
}
}
\ No newline at end of file
package chat.rocket.android.chatroom.adapter
import android.app.AlertDialog
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import chat.rocket.android.R
import chat.rocket.android.chatroom.presentation.ChatRoomPresenter
import chat.rocket.android.chatroom.uimodel.*
import chat.rocket.android.util.extensions.inflate
import chat.rocket.android.emoji.EmojiReactionListener
import chat.rocket.android.util.extensions.openTabbedUrl
import chat.rocket.core.model.attachment.actions.Action
import chat.rocket.core.model.attachment.actions.ButtonAction
import chat.rocket.core.model.Message
import chat.rocket.core.model.isSystemMessage
import timber.log.Timber
import java.security.InvalidParameterException
class ChatRoomAdapter(
private val roomId: String? = null,
private val roomType: String? = null,
private val roomName: String? = null,
private val actionSelectListener: OnActionSelected? = null,
......@@ -72,6 +74,10 @@ class ChatRoomAdapter(
actionSelectListener?.openDirectMessage(roomName, permalink)
}
}
BaseUiModel.ViewType.ACTIONS_ATTACHMENT -> {
val view = parent.inflate(R.layout.item_actions_attachment)
ActionsAttachmentViewHolder(view, actionsListener, reactionListener, actionAttachmentOnClickListener)
}
else -> {
throw InvalidParameterException("TODO - implement for ${viewType.toViewType()}")
}
......@@ -125,6 +131,8 @@ class ChatRoomAdapter(
holder.bind(dataSet[position] as GenericFileAttachmentUiModel)
is MessageReplyViewHolder ->
holder.bind(dataSet[position] as MessageReplyUiModel)
is ActionsAttachmentViewHolder ->
holder.bind(dataSet[position] as ActionsAttachmentUiModel)
}
}
......@@ -203,6 +211,33 @@ class ChatRoomAdapter(
}
}
private val actionAttachmentOnClickListener = object : ActionAttachmentOnClickListener {
override fun onActionClicked(view: View, action: Action) {
val temp = action as ButtonAction
if (temp.url != null && temp.isWebView != null) {
if (temp.isWebView == true) {
//TODO: Open in a configurable sizable webview
Timber.d("Open in a configurable sizable webview")
} else {
//Open in chrome custom tab
temp.url?.let { view.openTabbedUrl(it) }
}
} else if (temp.message != null && temp.isMessageInChatWindow != null) {
if (temp.isMessageInChatWindow == true) {
//Send to chat window
temp.message?.let {
if (roomId != null) {
actionSelectListener?.sendMessage(roomId, it)
}
}
} else {
//TODO: Send to bot but not in chat window
Timber.d("Send to bot but not in chat window")
}
}
}
}
private val actionsListener = object : BaseViewHolder.ActionsListener {
override fun isActionsEnabled(): Boolean = enableActions
......@@ -259,5 +294,6 @@ class ChatRoomAdapter(
fun deleteMessage(roomId: String, id: String)
fun showReactions(id: String)
fun openDirectMessage(roomName: String, message: String)
fun sendMessage(chatRoomId: String, text: String)
}
}
\ No newline at end of file
package chat.rocket.android.chatroom.adapter
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.text.Spannable
import android.text.method.LinkMovementMethod
import android.text.style.ImageSpan
import android.view.View
import androidx.core.view.isVisible
import chat.rocket.android.R
import chat.rocket.android.chatroom.uimodel.MessageUiModel
import chat.rocket.android.emoji.EmojiReactionListener
import chat.rocket.core.model.isSystemMessage
import com.bumptech.glide.load.resource.gif.GifDrawable
import kotlinx.android.synthetic.main.avatar.view.*
import kotlinx.android.synthetic.main.item_message.view.*
......@@ -15,7 +19,7 @@ class MessageViewHolder(
itemView: View,
listener: ActionsListener,
reactionListener: EmojiReactionListener? = null
) : BaseViewHolder<MessageUiModel>(itemView, listener, reactionListener) {
) : BaseViewHolder<MessageUiModel>(itemView, listener, reactionListener), Drawable.Callback {
init {
with(itemView) {
......@@ -26,22 +30,26 @@ class MessageViewHolder(
override fun bindViews(data: MessageUiModel) {
with(itemView) {
day_marker_layout.visibility = if (data.showDayMarker) {
day.text = data.currentDayMarkerText
View.VISIBLE
} else {
View.GONE
}
day_marker_layout.isVisible = data.showDayMarker
if (data.isFirstUnread) {
new_messages_notif.visibility = View.VISIBLE
} else {
new_messages_notif.visibility = View.GONE
}
new_messages_notif.isVisible = data.isFirstUnread
text_message_time.text = data.time
text_sender.text = data.senderName
text_content.text = data.content
if (data.content is Spannable) {
val spans = data.content.getSpans(0, data.content.length, ImageSpan::class.java)
spans.forEach {
if (it.drawable is GifDrawable) {
it.drawable.callback = this@MessageViewHolder
(it.drawable as GifDrawable).start()
}
}
}
text_content.text_content.text = data.content
image_avatar.setImageURI(data.avatar)
text_content.setTextColor(if (data.isTemporary) Color.GRAY else Color.BLACK)
......@@ -64,4 +72,22 @@ class MessageViewHolder(
}
}
}
override fun unscheduleDrawable(who: Drawable?, what: Runnable?) {
with(itemView) {
text_content.removeCallbacks(what)
}
}
override fun invalidateDrawable(p0: Drawable?) {
with(itemView) {
text_content.invalidate()
}
}
override fun scheduleDrawable(who: Drawable?, what: Runnable?, w: Long) {
with(itemView) {
text_content.postDelayed(what, w)
}
}
}
......@@ -148,9 +148,4 @@ interface ChatRoomView : LoadingView, MessageView {
*/
fun onRoomUpdated(userCanPost: Boolean, channelIsBroadcast: Boolean, userCanMod: Boolean)
/**
* Open a DM with the user in the given [chatRoom] and pass the [permalink] for the message
* to reply.
*/
fun openDirectMessage(chatRoom: ChatRoom, permalink: String)
}
......@@ -6,9 +6,13 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Handler
import android.os.SystemClock
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ImageSpan
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.Menu
......@@ -53,11 +57,13 @@ import chat.rocket.android.emoji.EmojiKeyboardPopup
import chat.rocket.android.emoji.EmojiParser
import chat.rocket.android.emoji.EmojiPickerPopup
import chat.rocket.android.emoji.EmojiReactionListener
import chat.rocket.android.emoji.internal.isCustom
import chat.rocket.android.helper.EndlessRecyclerViewScrollListener
import chat.rocket.android.helper.ImageHelper
import chat.rocket.android.helper.KeyboardHelper
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.util.extension.asObservable
import chat.rocket.android.util.extension.launchUI
import chat.rocket.android.util.extensions.circularRevealOrUnreveal
import chat.rocket.android.util.extensions.fadeIn
import chat.rocket.android.util.extensions.fadeOut
......@@ -71,6 +77,7 @@ import chat.rocket.common.model.RoomType
import chat.rocket.common.model.roomTypeOf
import chat.rocket.core.internal.realtime.socket.model.State
import chat.rocket.core.model.ChatRoom
import com.bumptech.glide.load.resource.gif.GifDrawable
import dagger.android.support.AndroidSupportInjection
import io.reactivex.Observable
import io.reactivex.disposables.CompositeDisposable
......@@ -79,6 +86,8 @@ import kotlinx.android.synthetic.main.fragment_chat_room.*
import kotlinx.android.synthetic.main.message_attachment_options.*
import kotlinx.android.synthetic.main.message_composer.*
import kotlinx.android.synthetic.main.message_list.*
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
......@@ -131,7 +140,8 @@ internal const val MENU_ACTION_FAVORITE_MESSAGES = 5
internal const val MENU_ACTION_FILES = 6
class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiReactionListener,
ChatRoomAdapter.OnActionSelected {
ChatRoomAdapter.OnActionSelected, Drawable.Callback {
@Inject
lateinit var presenter: ChatRoomPresenter
@Inject
......@@ -208,10 +218,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
requireNotNull(bundle) { "no arguments supplied when the fragment was instantiated" }
}
adapter = ChatRoomAdapter(
chatRoomType, chatRoomName, this,
reactionListener = this
)
adapter = ChatRoomAdapter(chatRoomId, chatRoomType, chatRoomName, this, reactionListener = this)
}
override fun onCreateView(
......@@ -395,10 +402,6 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
}
}
override fun openDirectMessage(chatRoom: ChatRoom, permalink: String) {
}
private val layoutChangeListener =
View.OnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
val y = oldBottom - bottom
......@@ -641,8 +644,12 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
override fun onEmojiAdded(emoji: Emoji) {
val cursorPosition = text_message.selectionStart
if (cursorPosition > -1) {
text_message.text?.insert(cursorPosition, EmojiParser.parse(emoji.shortname))
text_message.setSelection(cursorPosition + emoji.unicode.length)
context?.let {
val offset = if (!emoji.isCustom()) emoji.unicode.length else emoji.shortname.length
val parsed = if (emoji.isCustom()) emoji.shortname else EmojiParser.parse(it, emoji.shortname)
text_message.text?.insert(cursorPosition, parsed)
text_message.setSelection(cursorPosition + offset)
}
}
}
......@@ -773,9 +780,11 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
button_send.isVisible = false
button_show_attachment_options.alpha = 1f
button_show_attachment_options.isVisible = true
activity?.supportFragmentManager?.addOnBackStackChangedListener {
println("attach")
}
activity?.supportFragmentManager?.registerFragmentLifecycleCallbacks(
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentAttached(
......@@ -791,6 +800,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
},
true
)
subscribeComposeTextMessage()
emojiKeyboardPopup =
EmojiKeyboardPopup(activity!!, activity!!.findViewById(R.id.fragment_container))
......@@ -979,6 +989,18 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
(activity as ChatRoomActivity).showToolbarTitle(toolbarTitle)
}
override fun unscheduleDrawable(who: Drawable?, what: Runnable?) {
text_message?.removeCallbacks(what)
}
override fun invalidateDrawable(who: Drawable?) {
text_message?.invalidate()
}
override fun scheduleDrawable(who: Drawable?, what: Runnable?, `when`: Long) {
text_message?.postDelayed(what, `when`)
}
override fun showMessageInfo(id: String) {
presenter.messageInfo(id)
}
......@@ -1039,4 +1061,8 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
override fun openDirectMessage(roomName: String, message: String) {
presenter.openDirectMessage(roomName, message)
}
override fun sendMessage(chatRoomId: String, text: String) {
presenter.sendMessage(chatRoomId, text, null)
}
}
package chat.rocket.android.chatroom.uimodel
import chat.rocket.android.R
import chat.rocket.core.model.Message
import chat.rocket.core.model.attachment.actions.Action
import chat.rocket.core.model.attachment.actions.ActionsAttachment
data class ActionsAttachmentUiModel(
override val attachmentUrl: String,
val title: String?,
val actions: List<Action>,
val buttonAlignment: String,
override val message: Message,
override val rawData: ActionsAttachment,
override val messageId: String,
override var reactions: List<ReactionUiModel>,
override var nextDownStreamMessage: BaseUiModel<*>? = null,
override var preview: Message? = null,
override var isTemporary: Boolean = false,
override var unread: Boolean? = null,
override var menuItemsToHide: MutableList<Int> = mutableListOf(),
override var currentDayMarkerText: String,
override var showDayMarker: Boolean
) : BaseAttachmentUiModel<ActionsAttachment> {
override val viewType: Int
get() = BaseUiModel.ViewType.ACTIONS_ATTACHMENT.viewType
override val layoutId: Int
get() = R.layout.item_actions_attachment
}
\ No newline at end of file
......@@ -29,7 +29,8 @@ interface BaseUiModel<out T> {
AUTHOR_ATTACHMENT(7),
COLOR_ATTACHMENT(8),
GENERIC_FILE_ATTACHMENT(9),
MESSAGE_REPLY(10)
MESSAGE_REPLY(10),
ACTIONS_ATTACHMENT(11)
}
}
......
......@@ -46,6 +46,7 @@ import chat.rocket.core.model.attachment.GenericFileAttachment
import chat.rocket.core.model.attachment.ImageAttachment
import chat.rocket.core.model.attachment.MessageAttachment
import chat.rocket.core.model.attachment.VideoAttachment
import chat.rocket.core.model.attachment.actions.ActionsAttachment
import chat.rocket.core.model.isSystemMessage
import chat.rocket.core.model.url.Url
import kotlinx.coroutines.experimental.CommonPool
......@@ -305,10 +306,26 @@ class UiModelMapper @Inject constructor(
is MessageAttachment -> mapMessageAttachment(message, attachment)
is AuthorAttachment -> mapAuthorAttachment(message, attachment)
is ColorAttachment -> mapColorAttachment(message, attachment)
is ActionsAttachment -> mapActionsAttachment(message, attachment)
else -> null
}
}
private fun mapActionsAttachment(message: Message, attachment: ActionsAttachment): BaseUiModel<*>? {
return with(attachment) {
val content = stripMessageQuotes(message)
val localDateTime = DateTimeHelper.getLocalDateTime(message.timestamp)
val dayMarkerText = DateTimeHelper.getFormattedDateForMessages(localDateTime, context)
ActionsAttachmentUiModel(attachmentUrl = url, title = title,
actions = actions, buttonAlignment = buttonAlignment, message = message, rawData = attachment,
messageId = message.id, reactions = getReactions(message),
preview = message.copy(message = content.message), unread = message.unread,
showDayMarker = false, currentDayMarkerText = dayMarkerText)
}
}
private fun mapColorAttachment(message: Message, attachment: ColorAttachment): BaseUiModel<*>? {
return with(attachment) {
val content = stripMessageQuotes(message)
......@@ -493,7 +510,7 @@ class UiModelMapper @Inject constructor(
list.add(
ReactionUiModel(messageId = message.id,
shortname = shortname,
unicode = EmojiParser.parse(shortname),
unicode = EmojiParser.parse(context, shortname),
count = count,
usernames = usernames)
)
......
......@@ -7,6 +7,7 @@ import android.graphics.Paint
import android.graphics.RectF
import android.text.Spanned
import android.text.style.ClickableSpan
import android.text.style.ImageSpan
import android.text.style.ReplacementSpan
import android.util.Patterns
import android.view.View
......@@ -25,7 +26,6 @@ import org.commonmark.node.Document
import org.commonmark.node.ListItem
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.Paragraph
import org.commonmark.node.Text
import ru.noties.markwon.Markwon
import ru.noties.markwon.SpannableBuilder
......@@ -60,11 +60,11 @@ class MessageParser @Inject constructor(
}
}
val builder = SpannableBuilder()
val content = EmojiRepository.shortnameToUnicode(text, true)
val content = EmojiRepository.shortnameToUnicode(text)
val parentNode = parser.parse(toLenientMarkdown(content))
parentNode.accept(MarkdownVisitor(configuration, builder))
parentNode.accept(LinkVisitor(builder))
parentNode.accept(EmojiVisitor(configuration, builder))
parentNode.accept(EmojiVisitor(context, configuration, builder))
message.mentions?.let {
parentNode.accept(MentionVisitor(context, builder, mentions, selfUsername))
}
......@@ -126,16 +126,29 @@ class MessageParser @Inject constructor(
}
class EmojiVisitor(
private val context: Context,
configuration: SpannableConfiguration,
private val builder: SpannableBuilder
) : SpannableMarkdownVisitor(configuration, builder) {
private val emojiSize = context.resources.getDimensionPixelSize(R.dimen.radius_mention)
override fun visit(document: Document) {
val spannable = EmojiParser.parse(builder.text())
val spannable = EmojiParser.parse(context, builder.text())
if (spannable is Spanned) {
val spans = spannable.getSpans(0, spannable.length, EmojiTypefaceSpan::class.java)
spans.forEach {
builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), 0)
val emojiOneTypefaceSpans = spannable.getSpans(0, spannable.length,
EmojiTypefaceSpan::class.java)
val emojiImageSpans = spannable.getSpans(0, spannable.length, ImageSpan::class.java)
emojiOneTypefaceSpans.forEach {
builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
emojiImageSpans.forEach {
it.drawable?.setBounds(0, 0, emojiSize, emojiSize)
builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
......
package chat.rocket.android.main.presentation
import android.content.Context
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.db.DatabaseManagerFactory
import chat.rocket.android.emoji.Emoji
import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.emoji.Fitzpatrick
import chat.rocket.android.emoji.internal.EmojiCategory
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.main.uimodel.NavHeaderUiModel
import chat.rocket.android.main.uimodel.NavHeaderUiModelMapper
......@@ -30,6 +35,7 @@ import chat.rocket.common.RocketChatException
import chat.rocket.common.model.UserStatus
import chat.rocket.common.util.ifNull
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.getCustomEmojis
import chat.rocket.core.internal.rest.logout
import chat.rocket.core.internal.rest.me
import chat.rocket.core.internal.rest.unregisterPushToken
......@@ -125,6 +131,38 @@ class MainPresenter @Inject constructor(
}
}
/**
* Load all emojis for the current server. Simple emojis are always the same for every server,
* but custom emojis vary according to the its url.
*/
fun loadEmojis() {
launchUI(strategy) {
EmojiRepository.setCurrentServerUrl(currentServer)
val customEmojiList = mutableListOf<Emoji>()
try {
for (customEmoji in retryIO("getCustomEmojis()") { client.getCustomEmojis() }) {
customEmojiList.add(Emoji(
shortname = ":${customEmoji.name}:",
category = EmojiCategory.CUSTOM.name,
url = "$currentServer/emoji-custom/${customEmoji.name}.${customEmoji.extension}",
count = 0,
fitzpatrick = Fitzpatrick.Default.type,
keywords = customEmoji.aliases,
shortnameAlternates = customEmoji.aliases,
siblings = mutableListOf(),
unicode = "",
isDefault = true
))
}
EmojiRepository.load(view as Context, customEmojis = customEmojiList)
} catch (ex: RocketChatException) {
Timber.e(ex)
EmojiRepository.load(view as Context)
}
}
}
/**
* Logout from current server.
*/
......
......@@ -76,6 +76,7 @@ class MainActivity : AppCompatActivity(), MainView, HasActivityInjector,
presenter.connect()
presenter.loadServerAccounts()
presenter.loadCurrentInfo()
presenter.loadEmojis()
setupToolbar()
setupNavigationView()
}
......
......@@ -66,12 +66,13 @@ var TextView.content: CharSequence?
Markwon.unscheduleDrawables(this)
Markwon.unscheduleTableRows(this)
if (value is Spanned) {
val result = EmojiParser.parse(value.toString()) as Spannable
val context = this.context
val result = EmojiParser.parse(context, value.toString()) as Spannable
val end = if (value.length > result.length) result.length else value.length
TextUtils.copySpansFrom(value, 0, end, Any::class.java, result, 0)
text = result
} else {
val result = EmojiParser.parse(value.toString()) as Spannable
val result = EmojiParser.parse(context, value.toString()) as Spannable
text = result
}
Markwon.scheduleDrawables(this)
......
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:fresco="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/action_button"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="2dp"
android:layout_marginStart="2dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textSize="12sp" />
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/action_image_button"
android:layout_width="match_parent"
android:layout_height="75dp"
android:layout_marginBottom="10dp"
android:layout_marginEnd="2dp"
android:layout_marginStart="2dp"
android:visibility="gone"
fresco:actualImageScaleType="fitStart"
fresco:placeholderImage="@drawable/image_dummy" />
</RelativeLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/actions_attachment_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:paddingBottom="@dimen/message_item_top_and_bottom_padding"
android:paddingEnd="@dimen/screen_edge_left_and_right_padding"
android:paddingStart="@dimen/screen_edge_left_and_right_padding">
<TextView
android:id="@+id/title"
style="@style/Message.TextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:layout_marginStart="56dp"
android:layout_marginTop="2dp"
android:textDirection="locale"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="This is a multiline chat message from Bertie that will take more than just one line of text. I have sure that everything is amazing!" />
<View
android:id="@+id/quote_bar"
android:layout_width="4dp"
android:layout_height="0dp"
android:background="@drawable/quote_vertical_gray_bar"
app:layout_constraintBottom_toTopOf="@id/recycler_view_reactions"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toBottomOf="@id/title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/actions_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="@color/colorAccent"
android:textDirection="locale"
app:layout_constraintEnd_toEndOf="@id/title"
app:layout_constraintStart_toEndOf="@id/quote_bar"
app:layout_constraintTop_toBottomOf="@id/title" />
<include
layout="@layout/layout_reactions"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/quote_bar"
app:layout_constraintTop_toBottomOf="@id/actions_list" />
</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
......@@ -31,6 +31,7 @@
<dimen name="supposed_keyboard_height">252dp</dimen>
<dimen name="picker_popup_height">250dp</dimen>
<dimen name="picker_popup_width">300dp</dimen>
<dimen name="emoji_size">22dp</dimen>
<!--Toolbar-->
<dimen name="toolbar_height">56dp</dimen>
......
......@@ -10,7 +10,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:3.3.0-alpha05'
classpath 'com.android.tools.build:gradle:3.2.0-rc02'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:${versions.dokka}"
classpath 'com.google.gms:google-services:4.0.2'
......
......@@ -5,7 +5,7 @@ ext {
compileSdk : 28,
targetSdk : 28,
minSdk : 21,
buildTools : '28.0.1',
buildTools : '28.0.2',
dokka : '0.9.16',
// For app
......@@ -47,6 +47,7 @@ ext {
frescoImageViewer : '0.5.1',
markwon : '1.1.0',
aVLoadingIndicatorView: '2.1.3',
glide : '4.8.0-SNAPSHOT',
// For wearable
wear : '2.3.0',
......@@ -106,6 +107,8 @@ ext {
kotshiCompiler : "se.ansman.kotshi:compiler:${versions.kotshi}",
frescoImageViewer : "com.github.luciofm:FrescoImageViewer:${versions.frescoImageViewer}",
glide : "com.github.bumptech.glide:glide:${versions.glide}",
glideProcessor : "com.github.bumptech.glide:compiler:${versions.glide}",
markwon : "ru.noties:markwon:${versions.markwon}",
......
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion versions.compileSdk
......@@ -14,6 +15,11 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
}
buildTypes {
......@@ -34,6 +40,10 @@ dependencies {
implementation libraries.constraintlayout
implementation libraries.recyclerview
implementation libraries.material
implementation libraries.glide
kapt libraries.glideProcessor
implementation libraries.room
kapt libraries.roomProcessor
}
kotlin {
......
......@@ -19,3 +19,12 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
# for DexGuard only
-keepresourcexmlelements manifest/application/meta-data@value=GlideModule
package chat.rocket.android.emoji
import android.content.Context
import androidx.appcompat.widget.AppCompatEditText
import android.text.Spanned
import android.text.style.ImageSpan
import android.util.AttributeSet
import android.view.KeyEvent
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.text.getSpans
class ComposerEditText : AppCompatEditText {
var listener: ComposerEditTextListener? = null
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
......@@ -20,6 +24,21 @@ class ComposerEditText : AppCompatEditText {
constructor(context: Context) : this(context, null)
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
text?.getSpans<ImageSpan>()?.forEach {
val s = text?.getSpanStart(it) ?: -1
val e = text?.getSpanEnd(it) ?: -1
val flags = if (selStart in s..e) {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE or Spanned.SPAN_COMPOSING
} else {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
}
text?.setSpan(it, s, e, flags)
}
}
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BACK) {
val state = keyDispatcherState
......
package chat.rocket.android.emoji
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
@Entity
data class Emoji(
val shortname: String,
val shortnameAlternates: List<String>,
val unicode: String,
val keywords: List<String>,
val category: String,
val count: Int = 0,
val siblings: MutableCollection<Emoji> = mutableListOf(),
val fitzpatrick: Fitzpatrick = Fitzpatrick.Default
@PrimaryKey
var shortname: String = "",
var shortnameAlternates: List<String> = listOf(),
var unicode: String = "",
@Ignore val keywords: List<String> = listOf(),
var category: String = "",
var count: Int = 0,
var siblings: MutableList<String> = mutableListOf(), // Siblings are the same emoji with different skin tones.
var fitzpatrick: String = Fitzpatrick.Default.type,
var url: String? = null, // Filled for custom emojis
var isDefault: Boolean = true // Tell if this is the default emoji if it has siblings (usually a yellow-toned one).
)
package chat.rocket.android.emoji
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy.IGNORE
import androidx.room.Query
import androidx.room.Update
@Dao
interface EmojiDao {
@Query("SELECT * FROM emoji")
fun loadAllEmojis(): List<Emoji>
@Query("SELECT * FROM emoji WHERE url IS NULL")
fun loadSimpleEmojis(): List<Emoji>
@Query("SELECT * FROM emoji WHERE url IS NOT NULL")
fun loadAllCustomEmojis(): List<Emoji>
@Query("SELECT * FROM emoji WHERE shortname=:shortname")
fun loadEmojiByShortname(shortname: String): List<Emoji>
@Query("SELECT * FROM emoji WHERE UPPER(category)=UPPER(:category)")
fun loadEmojisByCategory(category: String): List<Emoji>
@Query("SELECT * FROM emoji WHERE UPPER(category)=UPPER(:category) AND url LIKE :url")
fun loadEmojisByCategoryAndUrl(category: String, url: String): List<Emoji>
@Insert(onConflict = IGNORE)
fun insertEmoji(emoji: Emoji)
@Insert(onConflict = IGNORE)
fun insertAllEmojis(vararg emojis: Emoji)
@Update
fun updateEmoji(emoji: Emoji)
@Delete
fun deleteEmoji(emoji: Emoji)
@Query("DELETE FROM emoji")
fun deleteAll()
}
......@@ -22,6 +22,8 @@ import chat.rocket.android.emoji.internal.EmojiCategory
import chat.rocket.android.emoji.internal.EmojiPagerAdapter
import chat.rocket.android.emoji.internal.PREF_EMOJI_SKIN_TONE
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow(context, view) {
......@@ -49,9 +51,11 @@ class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow
}
override fun onViewCreated(view: View) {
launch(UI) {
setupViewPager()
setupBottomBar()
}
}
private fun setupBottomBar() {
searchView.setOnClickListener {
......@@ -81,42 +85,42 @@ class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow
.create()
view.findViewById<TextView>(R.id.default_tone_text).also {
it.text = EmojiParser.parse(it.text)
it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener {
dialog.dismiss()
changeSkinTone(Fitzpatrick.Default)
}
view.findViewById<TextView>(R.id.light_tone_text).also {
it.text = EmojiParser.parse(it.text)
it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener {
dialog.dismiss()
changeSkinTone(Fitzpatrick.LightTone)
}
view.findViewById<TextView>(R.id.medium_light_text).also {
it.text = EmojiParser.parse(it.text)
it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener {
dialog.dismiss()
changeSkinTone(Fitzpatrick.MediumLightTone)
}
view.findViewById<TextView>(R.id.medium_tone_text).also {
it.text = EmojiParser.parse(it.text)
it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener {
dialog.dismiss()
changeSkinTone(Fitzpatrick.MediumTone)
}
view.findViewById<TextView>(R.id.medium_dark_tone_text).also {
it.text = EmojiParser.parse(it.text)
it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener {
dialog.dismiss()
changeSkinTone(Fitzpatrick.MediumDarkTone)
}
view.findViewById<TextView>(R.id.dark_tone_text).also {
it.text = EmojiParser.parse(it.text)
it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener {
dialog.dismiss()
changeSkinTone(Fitzpatrick.DarkTone)
......@@ -148,7 +152,7 @@ class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow
}
}
private fun setupViewPager() {
private suspend fun setupViewPager() {
context.let {
val callback = when (it) {
is EmojiKeyboardListener -> it
......@@ -167,6 +171,7 @@ class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow
callback.onEmojiAdded(emoji)
}
})
viewPager.offscreenPageLimit = EmojiCategory.values().size
viewPager.adapter = adapter
......@@ -183,6 +188,7 @@ class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow
} else {
EmojiCategory.RECENTS.ordinal
}
viewPager.currentItem = currentTab
}
}
......
package chat.rocket.android.emoji
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ImageSpan
import android.util.Log
import chat.rocket.android.emoji.internal.GlideApp
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.gif.GifDrawable
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.Deferred
import kotlinx.coroutines.experimental.async
class EmojiParser {
companion object {
private val regex = ":[\\w]+:".toRegex()
/**
* Parses a text string containing unicode characters and/or shortnames to a rendered
* Spannable.
......@@ -15,10 +29,18 @@ class EmojiParser {
* @param factory Optional. A [Spannable.Factory] instance to reuse when creating [Spannable].
* @return A rendered Spannable containing any supported emoji.
*/
fun parse(text: CharSequence, factory: Spannable.Factory? = null): CharSequence {
val unicodedText = EmojiRepository.shortnameToUnicode(text, true)
val spannable = factory?.newSpannable(unicodedText) ?: SpannableString.valueOf(unicodedText)
val typeface = EmojiRepository.cachedTypeface
fun parse(context: Context, text: CharSequence, factory: Spannable.Factory? = null): CharSequence {
val unicodedText = EmojiRepository.shortnameToUnicode(text)
val spannable = factory?.newSpannable(unicodedText)
?: SpannableString.valueOf(unicodedText)
val typeface = try {
EmojiRepository.cachedTypeface
} catch (ex: UninitializedPropertyAccessException) {
// swallow this exception and create typeface now
Typeface.createFromAsset(context.assets, "fonts/emojione-android.ttf")
}
// Look for groups of emojis, set a EmojiTypefaceSpan with the emojione font.
val length = spannable.length
var inEmoji = false
......@@ -32,6 +54,7 @@ class EmojiParser {
offset += count
continue
}
if (codepoint >= 0x200) {
if (!inEmoji) {
emojiStart = offset
......@@ -44,13 +67,60 @@ class EmojiParser {
}
inEmoji = false
}
offset += count
if (offset >= length && inEmoji) {
spannable.setSpan(EmojiTypefaceSpan("sans-serif", typeface),
emojiStart, offset, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
return spannable
val customEmojis = EmojiRepository.getCustomEmojis()
val px = context.resources.getDimensionPixelSize(R.dimen.custom_emoji_small)
return spannable.also {
regex.findAll(spannable).iterator().forEach { match ->
customEmojis.find { it.shortname.toLowerCase() == match.value.toLowerCase() }?.let {
it.url?.let { url ->
try {
val glideRequest = if (url.endsWith("gif", true)) {
GlideApp.with(context).asGif()
} else {
GlideApp.with(context).asBitmap()
}
val futureTarget = glideRequest
.diskCacheStrategy(DiskCacheStrategy.ALL)
.load(url)
.submit(px, px)
val range = match.range
futureTarget.get()?.let { image ->
if (image is Bitmap) {
spannable.setSpan(ImageSpan(context, image), range.start,
range.endInclusive + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} else if (image is GifDrawable) {
image.setBounds(0, 0, image.intrinsicWidth, image.intrinsicHeight)
spannable.setSpan(ImageSpan(image), range.start,
range.endInclusive + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
} catch (ex: Throwable) {
Log.e("EmojiParser", "", ex)
}
}
}
}
}
}
fun parseAsync(
context: Context,
text: CharSequence,
factory: Spannable.Factory? = null
): Deferred<CharSequence> {
return async(CommonPool) { parse(context, text, factory) }
}
}
}
......@@ -14,6 +14,8 @@ import chat.rocket.android.emoji.internal.EmojiCategory
import chat.rocket.android.emoji.internal.EmojiPagerAdapter
import chat.rocket.android.emoji.internal.PREF_EMOJI_SKIN_TONE
import kotlinx.android.synthetic.main.emoji_picker.*
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
class EmojiPickerPopup(context: Context) : Dialog(context) {
......@@ -27,9 +29,11 @@ class EmojiPickerPopup(context: Context) : Dialog(context) {
setContentView(R.layout.emoji_picker)
tabs.setupWithViewPager(pager_categories)
launch(UI) {
setupViewPager()
setSize()
}
}
private fun setSize() {
val lp = WindowManager.LayoutParams()
......@@ -39,7 +43,7 @@ class EmojiPickerPopup(context: Context) : Dialog(context) {
window.setLayout(dialogWidth, dialogHeight)
}
private fun setupViewPager() {
private suspend fun setupViewPager() {
adapter = EmojiPagerAdapter(object : EmojiKeyboardListener {
override fun onEmojiAdded(emoji: Emoji) {
EmojiRepository.addToRecents(emoji)
......
......@@ -3,12 +3,14 @@ package chat.rocket.android.emoji
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Typeface
import android.os.SystemClock
import chat.rocket.android.emoji.internal.EmojiCategory
import chat.rocket.android.emoji.internal.PREF_EMOJI_RECENTS
import chat.rocket.android.emoji.internal.db.EmojiDatabase
import chat.rocket.android.emoji.internal.isCustom
import com.bumptech.glide.Glide
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.withContext
import kotlinx.coroutines.experimental.yield
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
......@@ -16,25 +18,52 @@ import java.io.InputStream
import java.io.InputStreamReader
import java.util.*
import java.util.regex.Pattern
import kotlin.collections.ArrayList
import kotlin.coroutines.experimental.buildSequence
object EmojiRepository {
private val FITZPATRICK_REGEX = "(.*)_(tone[0-9]):".toRegex(RegexOption.IGNORE_CASE)
private val shortNameToUnicode = HashMap<String, String>()
private val SHORTNAME_PATTERN = Pattern.compile(":([-+\\w]+):")
private val ALL_EMOJIS = mutableListOf<Emoji>()
private var customEmojis = listOf<Emoji>()
private lateinit var preferences: SharedPreferences
internal lateinit var cachedTypeface: Typeface
private lateinit var db: EmojiDatabase
private lateinit var currentServerUrl: String
fun load(context: Context, path: String = "emoji.json") {
preferences = context.getSharedPreferences("emoji", Context.MODE_PRIVATE)
ALL_EMOJIS.clear()
fun setCurrentServerUrl(url: String) {
currentServerUrl = url
}
fun getCurrentServerUrl(): String? {
return if (::currentServerUrl.isInitialized) currentServerUrl else null
}
fun load(context: Context, customEmojis: List<Emoji> = emptyList(), path: String = "emoji.json") {
launch(CommonPool) {
this@EmojiRepository.customEmojis = customEmojis
val allEmojis = mutableListOf<Emoji>()
db = EmojiDatabase.getInstance(context)
cachedTypeface = Typeface.createFromAsset(context.assets, "fonts/emojione-android.ttf")
preferences = context.getSharedPreferences("emoji", Context.MODE_PRIVATE)
val stream = context.assets.open(path)
val emojis = loadEmojis(stream)
emojis.forEach { emoji ->
// Load emojis from emojione ttf file temporarily here. We still need to work on them.
val emojis = loadEmojis(stream).also {
it.addAll(customEmojis)
}.toList()
for (emoji in emojis) {
val unicodeIntList = mutableListOf<Int>()
emoji.category = emoji.category
if (emoji.isCustom()) {
allEmojis.add(emoji)
continue
}
emoji.unicode.split("-").forEach {
val value = it.toInt(16)
if (value >= 0x10000) {
......@@ -45,30 +74,54 @@ object EmojiRepository {
unicodeIntList.add(value)
}
}
val unicodeIntArray = unicodeIntList.toIntArray()
val unicode = String(unicodeIntArray, 0, unicodeIntArray.size)
val emojiWithUnicode = emoji.copy(unicode = unicode)
emoji.unicode = unicode
if (hasFitzpatrick(emoji.shortname)) {
val matchResult = FITZPATRICK_REGEX.find(emoji.shortname)
val prefix = matchResult!!.groupValues[1] + ":"
val fitzpatrick = Fitzpatrick.valueOf(matchResult.groupValues[2])
val defaultEmoji = ALL_EMOJIS.firstOrNull { it.shortname == prefix }
val emojiWithFitzpatrick = emojiWithUnicode.copy(fitzpatrick = fitzpatrick)
if (defaultEmoji != null) {
defaultEmoji.siblings.add(emojiWithFitzpatrick)
val defaultEmoji = allEmojis.firstOrNull { it.shortname == prefix }
emoji.fitzpatrick = fitzpatrick.type
emoji.isDefault = if (defaultEmoji != null) {
defaultEmoji.siblings.add(emoji.shortname)
false
} else {
// This emoji doesn't have a default tone, ie. :man_in_business_suit_levitating_tone1:
// In this case, the default emoji becomes the first toned one.
ALL_EMOJIS.add(emojiWithFitzpatrick)
true
}
} else {
ALL_EMOJIS.add(emojiWithUnicode)
emoji.isDefault = false
}
allEmojis.add(emoji)
shortNameToUnicode.apply {
put(emoji.shortname, unicode)
emoji.shortnameAlternates.forEach { alternate -> put(alternate, unicode) }
}
}
saveEmojisToDatabase(allEmojis.toList())
// Prefetch all custom emojis to make cache.
val px = context.resources.getDimensionPixelSize(R.dimen.custom_emoji_large)
customEmojis.forEach {
val future = Glide.with(context)
.load(it.url)
.submit(px, px)
future.get()
}
}
}
private suspend fun saveEmojisToDatabase(emojis: List<Emoji>) {
withContext(CommonPool) {
db.emojiDao().insertAllEmojis(*emojis.toTypedArray())
}
}
private fun hasFitzpatrick(shortname: String): Boolean {
......@@ -80,22 +133,28 @@ object EmojiRepository {
*
* @return All emojis for all categories.
*/
internal fun getAll() = ALL_EMOJIS
internal suspend fun getAll(): List<Emoji> = withContext(CommonPool) {
return@withContext db.emojiDao().loadAllEmojis()
}
/**
* Get all emojis for a given category.
*
* @param category Emoji category such as: PEOPLE, NATURE, ETC
*
* @return All emoji from specified category
*/
internal fun getEmojisByCategory(category: EmojiCategory): List<Emoji> {
return ALL_EMOJIS.filter { it.category.toLowerCase() == category.name.toLowerCase() }
internal suspend fun getEmojiSequenceByCategory(category: EmojiCategory): Sequence<Emoji> {
val list = withContext(CommonPool) {
db.emojiDao().loadEmojisByCategory(category.name)
}
return buildSequence {
list.forEach {
yield(it)
}
}
}
internal fun getEmojiSequenceByCategory(category: EmojiCategory): Sequence<Emoji> {
val list = ALL_EMOJIS.filter { it.category.toLowerCase() == category.name.toLowerCase() }
return buildSequence{
internal suspend fun getEmojiSequenceByCategoryAndUrl(category: EmojiCategory, url: String): Sequence<Emoji> {
val list = withContext(CommonPool) {
db.emojiDao().loadEmojisByCategoryAndUrl(category.name, "$url%")
}
return buildSequence {
list.forEach {
yield(it)
}
......@@ -109,7 +168,9 @@ object EmojiRepository {
*
* @return Emoji given by shortname or null
*/
internal fun getEmojiByShortname(shortname: String) = ALL_EMOJIS.firstOrNull { it.shortname == shortname }
private suspend fun getEmojiByShortname(shortname: String): Emoji? = withContext(CommonPool) {
return@withContext db.emojiDao().loadAllCustomEmojis().firstOrNull()
}
/**
* Add an emoji to the Recents category.
......@@ -117,40 +178,68 @@ object EmojiRepository {
internal fun addToRecents(emoji: Emoji) {
val emojiShortname = emoji.shortname
val recentsJson = JSONObject(preferences.getString(PREF_EMOJI_RECENTS, "{}"))
if (recentsJson.has(emojiShortname)) {
val useCount = recentsJson.getInt(emojiShortname)
recentsJson.put(emojiShortname, useCount + 1)
} else {
recentsJson.put(emojiShortname, 1)
}
preferences.edit().putString(PREF_EMOJI_RECENTS, recentsJson.toString()).apply()
}
internal suspend fun getCustomEmojisAsync(): List<Emoji> {
return withContext(CommonPool) {
db.emojiDao().loadAllCustomEmojis().also {
this.customEmojis = it
}
}
}
internal fun getCustomEmojis(): List<Emoji> = customEmojis
/**
* Get all recently used emojis ordered by usage count.
*
* @return All recent emojis ordered by usage.
*/
internal fun getRecents(): List<Emoji> {
internal suspend fun getRecents(): List<Emoji> = withContext(CommonPool) {
val list = mutableListOf<Emoji>()
val recentsJson = JSONObject(preferences.getString(PREF_EMOJI_RECENTS, "{}"))
for (shortname in recentsJson.keys()) {
val emoji = getEmojiByShortname(shortname)
emoji?.let {
val allEmojis = db.emojiDao().loadAllEmojis()
val len = recentsJson.length()
val recentShortnames = recentsJson.keys()
for (i in 0 until len) {
val shortname = recentShortnames.next()
allEmojis.firstOrNull {
if (it.shortname == shortname) {
if (it.isCustom()) {
return@firstOrNull getCurrentServerUrl()?.let { url ->
it.url?.startsWith(url)
} ?: false
}
return@firstOrNull true
}
false
}?.let {
val useCount = recentsJson.getInt(it.shortname)
list.add(it.copy(count = useCount))
}
}
list.sortWith(Comparator { o1, o2 ->
o2.count - o1.count
})
return list
return@withContext list
}
/**
* Replace shortnames to unicode characters.
*/
fun shortnameToUnicode(input: CharSequence, removeIfUnsupported: Boolean): String {
fun shortnameToUnicode(input: CharSequence): String {
val matcher = SHORTNAME_PATTERN.matcher(input)
var result: String = input.toString()
......@@ -163,7 +252,7 @@ object EmojiRepository {
return result
}
private fun loadEmojis(stream: InputStream): List<Emoji> {
private fun loadEmojis(stream: InputStream): MutableList<Emoji> {
val emojisJSON = JSONArray(inputStreamToString(stream))
val emojis = ArrayList<Emoji>(emojisJSON.length());
for (i in 0 until emojisJSON.length()) {
......
......@@ -7,12 +7,17 @@ import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.emoji.EmojiTypefaceSpan
import chat.rocket.android.emoji.R
internal enum class EmojiCategory {
enum class EmojiCategory {
RECENTS {
override fun resourceIcon() = R.drawable.ic_emoji_recents
override fun textIcon() = getTextIconFor("\uD83D\uDD58")
},
CUSTOM {
override fun resourceIcon() = R.drawable.ic_emoji_custom
override fun textIcon() = getTextIconFor("\uD83D\uDD58")
},
PEOPLE() {
override fun resourceIcon() = R.drawable.ic_emoji_people
......
package chat.rocket.android.emoji.internal
import android.content.Context
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.engine.cache.ExternalPreferredCacheDiskCacheFactory
import com.bumptech.glide.module.AppGlideModule
@GlideModule
class EmojiGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
builder.setDiskCache(ExternalPreferredCacheDiskCacheFactory(context))
}
}
......@@ -14,7 +14,9 @@ import chat.rocket.android.emoji.EmojiParser
import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.emoji.Fitzpatrick
import chat.rocket.android.emoji.R
import com.bumptech.glide.load.engine.DiskCacheStrategy
import kotlinx.android.synthetic.main.emoji_category_layout.view.*
import kotlinx.android.synthetic.main.emoji_image_row_item.view.*
import kotlinx.android.synthetic.main.emoji_row_item.view.*
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.android.UI
......@@ -43,22 +45,33 @@ internal class EmojiPagerAdapter(private val listener: EmojiKeyboardListener) :
container.addView(view)
launch(UI) {
val currentServerUrl = EmojiRepository.getCurrentServerUrl()
val emojis = if (category != EmojiCategory.RECENTS) {
if (category == EmojiCategory.CUSTOM) {
currentServerUrl?.let { url ->
EmojiRepository.getEmojiSequenceByCategoryAndUrl(category, url)
} ?: emptySequence()
} else {
EmojiRepository.getEmojiSequenceByCategory(category)
}
} else {
sequenceOf(*EmojiRepository.getRecents().toTypedArray())
}
val recentEmojiSize = EmojiRepository.getRecents().size
text_no_recent_emoji.isVisible = category == EmojiCategory.RECENTS && recentEmojiSize == 0
if (adapters[category] == null) {
val adapter = EmojiAdapter(listener = listener)
emoji_recycler_view.adapter = adapter
adapters[category] = adapter
adapter.addEmojisFromSequence(emojis)
}
adapters[category]!!.setFitzpatrick(fitzpatrick)
}
}
return view
}
......@@ -88,20 +101,24 @@ internal class EmojiPagerAdapter(private val listener: EmojiKeyboardListener) :
private val listener: EmojiKeyboardListener
) : RecyclerView.Adapter<EmojiRowViewHolder>() {
private val CUSTOM = 1
private val NORMAL = 2
private val allEmojis = mutableListOf<Emoji>()
private val emojis = mutableListOf<Emoji>()
fun addEmojis(emojis: List<Emoji>) {
this.emojis.clear()
this.emojis.addAll(emojis)
notifyDataSetChanged()
override fun getItemViewType(position: Int): Int {
return if (emojis[position].isCustom()) CUSTOM else NORMAL
}
suspend fun addEmojisFromSequence(emojiSequence: Sequence<Emoji>) {
withContext(CommonPool) {
emojiSequence.forEachIndexed { index, emoji ->
withContext(UI) {
allEmojis.add(emoji)
if (emoji.isDefault) {
emojis.add(emoji)
notifyItemInserted(index)
notifyItemInserted(emojis.size - 1)
}
}
}
}
......@@ -115,12 +132,26 @@ internal class EmojiPagerAdapter(private val listener: EmojiKeyboardListener) :
override fun onBindViewHolder(holder: EmojiRowViewHolder, position: Int) {
val emoji = emojis[position]
holder.bind(
emoji.siblings.find { it.fitzpatrick == fitzpatrick } ?: emoji
if (fitzpatrick != Fitzpatrick.Default) {
emoji.siblings.find {
it.endsWith("${fitzpatrick.type}:")
}?.let { shortname ->
allEmojis.firstOrNull {
it.shortname == shortname
}
} ?: emoji
} else {
emoji
}
)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiRowViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.emoji_row_item, parent, false)
val view = if (viewType == CUSTOM) {
LayoutInflater.from(parent.context).inflate(R.layout.emoji_image_row_item, parent, false)
} else {
LayoutInflater.from(parent.context).inflate(R.layout.emoji_row_item, parent, false)
}
return EmojiRowViewHolder(view, listener)
}
......@@ -134,16 +165,26 @@ internal class EmojiPagerAdapter(private val listener: EmojiKeyboardListener) :
fun bind(emoji: Emoji) {
with(itemView) {
if (emoji.unicode.isNotEmpty()) {
// Handle simple emoji.
val parsedUnicode = unicodeCache[emoji.unicode]
emoji_view.setSpannableFactory(spannableFactory)
emoji_view.text = if (parsedUnicode == null) {
EmojiParser.parse(emoji.unicode, spannableFactory).let {
EmojiParser.parse(itemView.context, emoji.unicode, spannableFactory).let {
unicodeCache[emoji.unicode] = it
it
}
} else {
parsedUnicode
}
} else {
// Handle custom emoji.
GlideApp.with(context)
.load(emoji.url)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(emoji_image_view)
}
itemView.setOnClickListener {
listener.onEmojiAdded(emoji)
}
......
package chat.rocket.android.emoji.internal
import chat.rocket.android.emoji.Emoji
fun Emoji.isCustom(): Boolean = this.url != null
package chat.rocket.android.emoji.internal.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import chat.rocket.android.emoji.Emoji
import chat.rocket.android.emoji.EmojiDao
@Database(entities = [Emoji::class], version = 1)
@TypeConverters(StringListConverter::class)
abstract class EmojiDatabase : RoomDatabase() {
abstract fun emojiDao(): EmojiDao
companion object : SingletonHolder<EmojiDatabase, Context>({
Room.databaseBuilder(it.applicationContext,
EmojiDatabase::class.java, "emoji.db")
.fallbackToDestructiveMigration()
.build()
})
}
open class SingletonHolder<out T, in A>(creator: (A) -> T) {
private var creator: ((A) -> T)? = creator
@kotlin.jvm.Volatile
private var instance: T? = null
fun getInstance(arg: A): T {
val i = instance
if (i != null) {
return i
}
return synchronized(this) {
val i2 = instance
if (i2 != null) {
i2
} else {
val created = creator!!(arg)
instance = created
creator = null
created
}
}
}
}
package chat.rocket.android.emoji.internal.db
import androidx.room.TypeConverter
class StringListConverter {
@TypeConverter
fun fromStringList(list: List<String>?): String {
return list?.joinToString(separator = ",") ?: ""
}
@TypeConverter
fun fromString(value: String?): List<String> {
return value?.split(",") ?: emptyList()
}
}
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center">
<ImageView
android:id="@+id/emoji_image_view"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
tools:src="@tools:sample/avatars" />
</FrameLayout>
......@@ -6,6 +6,7 @@
android:layout_width="48dp"
android:layout_height="48dp"
android:foreground="?selectableItemBackground"
android:gravity="center"
android:textColor="#000000"
android:textSize="26sp"
tools:text="😀" />
......@@ -5,5 +5,6 @@
<dimen name="supposed_keyboard_height">252dp</dimen>
<dimen name="picker_popup_height">250dp</dimen>
<dimen name="picker_popup_width">300dp</dimen>
<dimen name="custom_emoji_large">32dp</dimen>
<dimen name="custom_emoji_small">22dp</dimen>
</resources>
#Wed Aug 01 22:00:00 EDT 2018
#Mon Aug 06 11:30:07 BRT 2018
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment