Commit c319c1a9 authored by Lucio Maciel's avatar Lucio Maciel

Spotlight, loading and empty indicators, fixes on rooms synchronization

and other fixes
parent a8daa6c8
......@@ -16,6 +16,12 @@ android {
versionName "2.4.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
}
signingConfigs {
......
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "06359a8c2943365dd094bc5dff210203",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `username` TEXT, `name` TEXT, `status` TEXT NOT NULL, `utcOffset` REAL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "utcOffset",
"columnName": "utcOffset",
"affinity": "REAL",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_users_username",
"unique": false,
"columnNames": [
"username"
],
"createSql": "CREATE INDEX `index_users_username` ON `${TABLE_NAME}` (`username`)"
}
],
"foreignKeys": []
},
{
"tableName": "chatrooms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `fullname` TEXT, `userId` TEXT, `ownerId` TEXT, `readonly` INTEGER, `isDefault` INTEGER, `favorite` INTEGER, `open` INTEGER NOT NULL, `alert` INTEGER NOT NULL, `unread` INTEGER NOT NULL, `userMentions` INTEGER, `groupMentions` INTEGER, `updatedAt` INTEGER, `timestamp` INTEGER, `lastSeen` INTEGER, `lastMessageText` TEXT, `lastMessageUserId` TEXT, `lastMessageTimestamp` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`ownerId`) REFERENCES `users`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`userId`) REFERENCES `users`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`lastMessageUserId`) REFERENCES `users`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscriptionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullname",
"columnName": "fullname",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "readonly",
"columnName": "readonly",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favorite",
"columnName": "favorite",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "open",
"columnName": "open",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alert",
"columnName": "alert",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userMentions",
"columnName": "userMentions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "groupMentions",
"columnName": "groupMentions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastMessageText",
"columnName": "lastMessageText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastMessageUserId",
"columnName": "lastMessageUserId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastMessageTimestamp",
"columnName": "lastMessageTimestamp",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_chatrooms_userId",
"unique": false,
"columnNames": [
"userId"
],
"createSql": "CREATE INDEX `index_chatrooms_userId` ON `${TABLE_NAME}` (`userId`)"
},
{
"name": "index_chatrooms_ownerId",
"unique": false,
"columnNames": [
"ownerId"
],
"createSql": "CREATE INDEX `index_chatrooms_ownerId` ON `${TABLE_NAME}` (`ownerId`)"
},
{
"name": "index_chatrooms_subscriptionId",
"unique": true,
"columnNames": [
"subscriptionId"
],
"createSql": "CREATE UNIQUE INDEX `index_chatrooms_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)"
},
{
"name": "index_chatrooms_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"createSql": "CREATE INDEX `index_chatrooms_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"lastMessageUserId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"06359a8c2943365dd094bc5dff210203\")"
]
}
}
\ No newline at end of file
package chat.rocket.android.chatrooms.adapter
class LoadingItemHolder : ItemHolder<Unit> {
override val data: Unit
get() = Unit
}
\ No newline at end of file
package chat.rocket.android.chatrooms.adapter
import android.view.View
class LoadingViewHolder(itemView: View) : ViewHolder<ItemHolder<Unit>>(itemView) {
override fun bindViews(data: ItemHolder<Unit>) {
}
}
\ No newline at end of file
......@@ -18,8 +18,11 @@ import chat.rocket.android.util.extensions.avatarUrl
import chat.rocket.android.util.extensions.date
import chat.rocket.android.util.extensions.localDateTime
import chat.rocket.common.model.RoomType
import chat.rocket.common.model.User
import chat.rocket.common.model.roomTypeOf
import chat.rocket.common.model.userStatusOf
import chat.rocket.core.model.Room
import chat.rocket.core.model.SpotlightResult
class RoomUiModelMapper(
private val context: Application,
......@@ -34,7 +37,7 @@ class RoomUiModelMapper(
private val messageUnreadColor = ContextCompat.getColor(context, android.R.color.primary_text_light)
private val messageColor = ContextCompat.getColor(context, R.color.colorSecondaryText)
fun map(rooms: List<ChatRoom>, grouped: Boolean): List<ItemHolder<*>> {
fun map(rooms: List<ChatRoom>, grouped: Boolean = false): List<ItemHolder<*>> {
val list = ArrayList<ItemHolder<*>>(rooms.size + 4)
var lastType: String? = null
rooms.forEach { room ->
......@@ -48,6 +51,47 @@ class RoomUiModelMapper(
return list
}
fun map(spotlight: SpotlightResult): List<ItemHolder<*>> {
val list = ArrayList<ItemHolder<*>>(spotlight.users.size + spotlight.rooms.size)
spotlight.users.filterNot { it.username.isNullOrEmpty() }.forEach { user ->
list.add(RoomItemHolder(mapUser(user)))
}
spotlight.rooms.filterNot { it.name.isNullOrEmpty() }.forEach { room ->
list.add(RoomItemHolder(mapRoom(room)))
}
return list
}
private fun mapUser(user: User): RoomUiModel {
return with(user) {
val name = mapName(user.username!!, user.name, false)
val status = user.status
val avatar = serverUrl.avatarUrl(user.username!!)
RoomUiModel(
id = user.id,
name = name,
type = roomTypeOf(RoomType.DIRECT_MESSAGE),
avatar = avatar,
status = status
)
}
}
private fun mapRoom(room: Room): RoomUiModel {
return with(room) {
RoomUiModel(
id = id,
name = name!!,
type = type,
avatar = serverUrl.avatarUrl(name!!, isGroupOrChannel = true),
lastMessage = mapLastMessage(lastMessage?.sender?.username,
lastMessage?.sender?.name, lastMessage?.message)
)
}
}
fun map(chatRoom: ChatRoom): RoomUiModel {
return with(chatRoom.chatRoom) {
val isUnread = alert || unread > 0
......@@ -89,7 +133,7 @@ class RoomUiModelMapper(
}
}
private fun mapLastMessage(name: String?, fullName: String?, text: String?, unread: Boolean): CharSequence? {
private fun mapLastMessage(name: String?, fullName: String?, text: String?, unread: Boolean = false): CharSequence? {
return if (!settings.showLastMessage()) {
null
} else if (name != null && text != null) {
......
......@@ -6,12 +6,13 @@ import android.view.View
import androidx.core.view.isGone
import androidx.core.view.isVisible
import chat.rocket.android.R
import chat.rocket.android.chatrooms.adapter.model.RoomUiModel
import chat.rocket.common.model.RoomType
import chat.rocket.common.model.UserStatus
import kotlinx.android.synthetic.main.item_chat.view.*
import kotlinx.android.synthetic.main.unread_messages_badge.view.*
class RoomViewHolder(itemView: View, private val listener: (String) -> Unit) : ViewHolder<RoomItemHolder>(itemView) {
class RoomViewHolder(itemView: View, private val listener: (RoomUiModel) -> Unit) : ViewHolder<RoomItemHolder>(itemView) {
private val resources: Resources = itemView.resources
private val channelUnread: Drawable = resources.getDrawable(R.drawable.ic_hashtag_black_12dp)
......@@ -57,7 +58,7 @@ class RoomViewHolder(itemView: View, private val listener: (String) -> Unit) : V
}
setOnClickListener {
listener(room.id)
listener(room)
}
}
}
......
......@@ -3,9 +3,10 @@ package chat.rocket.android.chatrooms.adapter
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import chat.rocket.android.R
import chat.rocket.android.chatrooms.adapter.model.RoomUiModel
import chat.rocket.android.util.extensions.inflate
class RoomsAdapter(private val listener: (String) -> Unit) : RecyclerView.Adapter<ViewHolder<*>>() {
class RoomsAdapter(private val listener: (RoomUiModel) -> Unit) : RecyclerView.Adapter<ViewHolder<*>>() {
init {
setHasStableIds(true)
......@@ -18,14 +19,17 @@ class RoomsAdapter(private val listener: (String) -> Unit) : RecyclerView.Adapte
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<*> {
if (viewType == 0) {
if (viewType == VIEW_TYPE_ROOM) {
val view = parent.inflate(R.layout.item_chat)
return RoomViewHolder(view, listener)
} else if (viewType == 1) {
} else if (viewType == VIEW_TYPE_HEADER) {
val view = parent.inflate(R.layout.item_chatroom_header)
return HeaderViewHolder(view)
} else if (viewType == VIEW_TYPE_LOADING) {
val view = parent.inflate(R.layout.item_loading)
return LoadingViewHolder(view)
}
throw IllegalStateException("View type must be either Room or Header")
throw IllegalStateException("View type must be either Room, Header or Loading")
}
override fun getItemCount() = values.size
......@@ -35,15 +39,17 @@ class RoomsAdapter(private val listener: (String) -> Unit) : RecyclerView.Adapte
return when(item) {
is HeaderItemHolder -> item.data.hashCode().toLong()
is RoomItemHolder -> item.data.id.hashCode().toLong()
else -> throw IllegalStateException("View type must be either Room or Header")
is LoadingItemHolder -> "loading".hashCode().toLong()
else -> throw IllegalStateException("View type must be either Room, Header or Loading")
}
}
override fun getItemViewType(position: Int): Int {
return when(values[position]) {
is RoomItemHolder -> 0
is HeaderItemHolder -> 1
else -> throw IllegalStateException("View type must be either Room or Header")
is RoomItemHolder -> VIEW_TYPE_ROOM
is HeaderItemHolder -> VIEW_TYPE_HEADER
is LoadingItemHolder -> VIEW_TYPE_LOADING
else -> throw IllegalStateException("View type must be either Room, Header or Loading")
}
}
......@@ -55,4 +61,9 @@ class RoomsAdapter(private val listener: (String) -> Unit) : RecyclerView.Adapte
}
}
companion object {
const val VIEW_TYPE_ROOM = 1
const val VIEW_TYPE_HEADER = 2
const val VIEW_TYPE_LOADING = 3
}
}
\ No newline at end of file
......@@ -8,9 +8,9 @@ data class RoomUiModel(
val type: RoomType,
val name: CharSequence,
val avatar: String,
val date: CharSequence?,
val unread: String?,
val alert: Boolean,
val lastMessage: CharSequence?,
val status: UserStatus?
val date: CharSequence? = null,
val unread: String? = null,
val alert: Boolean = false,
val lastMessage: CharSequence? = null,
val status: UserStatus? = null
)
\ No newline at end of file
......@@ -71,7 +71,8 @@ class ChatRoomsFragmentModule {
@Provides
@PerFragment
fun provideFetchChatRoomsInteractor(client: RocketChatClient, dbManager: DatabaseManager): FetchChatRoomsInteractor {
fun provideFetchChatRoomsInteractor(client: RocketChatClient,
dbManager: DatabaseManager): FetchChatRoomsInteractor {
return FetchChatRoomsInteractor(client, dbManager)
}
......
......@@ -3,13 +3,12 @@ package chat.rocket.android.chatrooms.domain
import chat.rocket.android.db.DatabaseManager
import chat.rocket.android.db.model.ChatRoomEntity
import chat.rocket.android.db.model.UserEntity
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.util.retryIO
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.chatRooms
import chat.rocket.core.model.ChatRoom
import chat.rocket.core.model.userId
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.launch
import timber.log.Timber
class FetchChatRoomsInteractor(
......@@ -18,21 +17,15 @@ class FetchChatRoomsInteractor(
) {
suspend fun refreshChatRooms() {
launch(CommonPool) {
try {
val rooms = retryIO("fetch chatRooms", times = 10,
initialDelay = 200, maxDelay = 2000) {
client.chatRooms().update.map { room ->
mapChatRoom(room)
}
}
Timber.d("Refreshing rooms: $rooms")
dbManager.insert(rooms)
} catch (ex: Exception) {
Timber.d(ex, "Error getting chatrooms")
val rooms = retryIO("fetch chatRooms", times = 10,
initialDelay = 200, maxDelay = 2000) {
client.chatRooms().update.map { room ->
mapChatRoom(room)
}
}
Timber.d("Refreshing rooms: $rooms")
dbManager.insert(rooms)
}
private suspend fun mapChatRoom(room: ChatRoom): ChatRoomEntity {
......
......@@ -5,7 +5,7 @@ import chat.rocket.android.db.ChatRoomDao
import chat.rocket.android.db.model.ChatRoom
import javax.inject.Inject
class ChatRoomsRepository @Inject constructor(val dao: ChatRoomDao){
class ChatRoomsRepository @Inject constructor(private val dao: ChatRoomDao){
fun getChatRooms(order: Order): LiveData<List<ChatRoom>> {
return when(order) {
Order.ACTIVITY -> dao.getAll()
......@@ -15,9 +15,9 @@ class ChatRoomsRepository @Inject constructor(val dao: ChatRoomDao){
}
}
fun getChatRooms(query: String): List<ChatRoom> {
TODO()
}
fun search(query: String) = dao.searchSync(query)
fun count() = dao.count()
enum class Order {
ACTIVITY,
......
package chat.rocket.android.chatrooms.presentation
import chat.rocket.android.R
import chat.rocket.android.chatrooms.adapter.model.RoomUiModel
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.db.DatabaseManager
import chat.rocket.android.db.model.ChatRoomEntity
import chat.rocket.android.helper.UserHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.main.presentation.MainNavigator
......@@ -26,6 +29,7 @@ class ChatRoomsPresenter @Inject constructor(
private val strategy: CancelStrategy,
private val navigator: MainNavigator,
@Named("currentServer") private val currentServer: String,
private val dbManager: DatabaseManager,
manager: ConnectionManager,
private val localRepository: LocalRepository,
private val userHelper: UserHelper,
......@@ -34,8 +38,33 @@ class ChatRoomsPresenter @Inject constructor(
private val client = manager.client
private val settings = settingsRepository.get(currentServer)
fun loadChatRoom(chatRoom: chat.rocket.android.db.model.ChatRoom) {
with(chatRoom.chatRoom) {
fun loadChatRoom(chatRoom: RoomUiModel) {
launchUI(strategy) {
view.showLoadingRoom(chatRoom.name)
try {
val room = dbManager.getRoom(chatRoom.id)
if (room != null) {
loadChatRoom(room.chatRoom)
} else {
with(chatRoom) {
val entity = ChatRoomEntity(
id = id,
subscriptionId = "",
type = type.toString(),
name = name.toString(),
open = false
)
loadChatRoom(entity)
}
}
} finally {
view.hideLoadingRoom()
}
}
}
fun loadChatRoom(chatRoom: ChatRoomEntity) {
with(chatRoom) {
val isDirectMessage = roomTypeOf(type) is RoomType.DirectMessage
val roomName = if (settings.useSpecialCharsOnRoom() || (isDirectMessage && settings.useRealName())) {
fullname ?: name
......
......@@ -2,22 +2,9 @@ package chat.rocket.android.chatrooms.presentation
import chat.rocket.android.core.behaviours.LoadingView
import chat.rocket.android.core.behaviours.MessageView
import chat.rocket.core.internal.realtime.socket.model.State
import chat.rocket.core.model.ChatRoom
interface ChatRoomsView : LoadingView, MessageView {
fun showLoadingRoom(name: CharSequence)
/**
* Shows the chat rooms.
*
* @param newDataSet The new data set to show.
*/
suspend fun updateChatRooms(newDataSet: List<ChatRoom>)
/**
* Shows no chat rooms to display.
*/
fun showNoChatRoomsToDisplay()
fun showConnectionState(state: State)
fun hideLoadingRoom()
}
\ No newline at end of file
package chat.rocket.android.chatrooms.ui
import android.app.AlertDialog
import android.app.ProgressDialog
import android.os.Bundle
import android.os.Handler
import android.view.LayoutInflater
......@@ -21,27 +22,25 @@ import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import chat.rocket.android.R
import chat.rocket.android.chatrooms.adapter.RoomsAdapter
import chat.rocket.android.chatrooms.infrastructure.ChatRoomsRepository
import chat.rocket.android.chatrooms.presentation.ChatRoomsPresenter
import chat.rocket.android.chatrooms.presentation.ChatRoomsView
import chat.rocket.android.chatrooms.viewmodel.ChatRoomsViewModel
import chat.rocket.android.chatrooms.viewmodel.ChatRoomsViewModelFactory
import chat.rocket.android.chatrooms.viewmodel.LoadingState
import chat.rocket.android.chatrooms.viewmodel.Query
import chat.rocket.android.db.DatabaseManager
import chat.rocket.android.helper.ChatRoomsSortOrder
import chat.rocket.android.helper.Constants
import chat.rocket.android.helper.SharedPreferenceHelper
import chat.rocket.android.util.extensions.fadeIn
import chat.rocket.android.util.extensions.fadeOut
import chat.rocket.android.util.extensions.inflate
import chat.rocket.android.util.extensions.showToast
import chat.rocket.android.util.extensions.ui
import chat.rocket.android.util.extensions.fadeIn
import chat.rocket.android.util.extensions.fadeOut
import chat.rocket.android.widget.DividerItemDecoration
import chat.rocket.core.internal.realtime.socket.model.State
import chat.rocket.core.model.ChatRoom
import dagger.android.support.AndroidSupportInjection
import kotlinx.android.synthetic.main.fragment_chat_rooms.*
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
import timber.log.Timber
import javax.inject.Inject
......@@ -58,9 +57,12 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
lateinit var viewModel: ChatRoomsViewModel
private var searchView: SearchView? = null
private var sortView: MenuItem? = null
private val handler = Handler()
private var chatRoomId: String? = null
private var progressDialog: ProgressDialog? = null
companion object {
fun newInstance(chatRoomId: String? = null): ChatRoomsFragment {
......@@ -109,16 +111,8 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
private fun subscribeUi() {
ui {
val adapter = RoomsAdapter { roomId ->
launch(UI) {
dbManager.getRoom(roomId)?.let { room ->
ui {
presenter.loadChatRoom(room)
}
}
}
val adapter = RoomsAdapter { room ->
presenter.loadChatRoom(room)
}
recycler_view.layoutManager = LinearLayoutManager(it)
......@@ -132,6 +126,23 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
rooms?.let {
Timber.d("Got items: $it")
adapter.values = it
if (rooms.isNotEmpty()) {
text_no_data_to_display.isVisible = false
}
}
})
viewModel.loadingState.observe(viewLifecycleOwner, Observer { state ->
when(state) {
is LoadingState.Loading -> if (state.count == 0L) showLoading()
is LoadingState.Loaded -> {
hideLoading()
if (state.count == 0L) showNoChatRoomsToDisplay()
}
is LoadingState.Error -> {
hideLoading()
showGenericErrorMessage()
}
}
})
......@@ -147,6 +158,8 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.chatrooms, menu)
sortView = menu.findItem(R.id.action_sort)
val searchItem = menu.findItem(R.id.action_search)
searchView = searchItem?.actionView as? SearchView
searchView?.setIconifiedByDefault(false)
......@@ -160,6 +173,21 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
return queryChatRoomsByName(newText)
}
})
val expandListener = object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
// Simply setting sortView to visible won't work, so we invalidate the options
// to recreate the entire menu...
activity?.invalidateOptionsMenu()
return true
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
sortView?.isVisible = false
return true
}
}
searchItem?.setOnActionExpandListener(expandListener)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
......@@ -209,25 +237,17 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
val sortType = SharedPreferenceHelper.getInt(Constants.CHATROOM_SORT_TYPE_KEY, ChatRoomsSortOrder.ACTIVITY)
val grouped = SharedPreferenceHelper.getBoolean(Constants.CHATROOM_GROUP_BY_TYPE_KEY, false)
val order = when(sortType) {
val query = when(sortType) {
ChatRoomsSortOrder.ALPHABETICAL -> {
if (grouped) {
ChatRoomsRepository.Order.GROUPED_NAME
} else {
ChatRoomsRepository.Order.NAME
}
Query.ByName(grouped)
}
ChatRoomsSortOrder.ACTIVITY -> {
if (grouped) {
ChatRoomsRepository.Order.GROUPED_ACTIVITY
} else {
ChatRoomsRepository.Order.ACTIVITY
}
Query.ByActivity(grouped)
}
else -> ChatRoomsRepository.Order.ACTIVITY
else -> Query.ByActivity()
}
viewModel.setOrdering(order)
viewModel.setQuery(query)
}
private fun invalidateQueryOnSearch() {
......@@ -238,20 +258,16 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
}
}
override suspend fun updateChatRooms(newDataSet: List<ChatRoom>) {}
override fun showNoChatRoomsToDisplay() {
private fun showNoChatRoomsToDisplay() {
ui { text_no_data_to_display.isVisible = true }
}
override fun showLoading() {
ui { view_loading.isVisible = true }
view_loading.isVisible = true
}
override fun hideLoading() {
ui {
view_loading.isVisible = false
}
view_loading.isVisible = false
}
override fun showMessage(resId: Int) {
......@@ -268,7 +284,17 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
override fun showConnectionState(state: State) {
override fun showLoadingRoom(name: CharSequence) {
ui {
progressDialog = ProgressDialog.show(activity, "Rocket.Chat", "Loading room $name")
}
}
override fun hideLoadingRoom() {
progressDialog?.dismiss()
}
private fun showConnectionState(state: State) {
Timber.d("Got new state: $state")
ui {
connection_status_text.fadeIn()
......@@ -298,7 +324,11 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
}
private fun queryChatRoomsByName(name: String?): Boolean {
//presenter.chatRoomsByName(name ?: "")
if (name.isNullOrEmpty()) {
updateSort()
} else {
viewModel.setQuery(Query.Search(name!!))
}
return true
}
}
\ No newline at end of file
......@@ -5,21 +5,30 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import chat.rocket.android.chatrooms.adapter.ItemHolder
import chat.rocket.android.chatrooms.adapter.LoadingItemHolder
import chat.rocket.android.chatrooms.adapter.RoomUiModelMapper
import chat.rocket.android.chatrooms.domain.FetchChatRoomsInteractor
import chat.rocket.android.chatrooms.infrastructure.ChatRoomsRepository
import chat.rocket.android.chatrooms.infrastructure.isGrouped
import chat.rocket.android.db.model.ChatRoom
import chat.rocket.android.server.infraestructure.ConnectionManager
import chat.rocket.android.util.livedata.transform
import chat.rocket.android.util.livedata.wrap
import chat.rocket.android.util.retryIO
import chat.rocket.common.util.ifNull
import chat.rocket.core.internal.realtime.socket.model.State
import chat.rocket.core.internal.rest.spotlight
import chat.rocket.core.model.SpotlightResult
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.isActive
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.newSingleThreadContext
import kotlinx.coroutines.experimental.withContext
import me.henrytao.livedataktx.distinct
import me.henrytao.livedataktx.filter
import me.henrytao.livedataktx.map
import me.henrytao.livedataktx.nonNull
import timber.log.Timber
import java.security.InvalidParameterException
import kotlin.coroutines.experimental.coroutineContext
class ChatRoomsViewModel(
private val connectionManager: ConnectionManager,
......@@ -27,38 +36,65 @@ class ChatRoomsViewModel(
private val repository: ChatRoomsRepository,
private val mapper: RoomUiModelMapper
) : ViewModel() {
private val ordering: MutableLiveData<ChatRoomsRepository.Order> = MutableLiveData()
private val query: MutableLiveData<String> = MutableLiveData()
private val query = MutableLiveData<Query>()
val loadingState = MutableLiveData<LoadingState>()
private val runContext = newSingleThreadContext("chat-rooms-view-model")
private val client = connectionManager.client
private var loaded = false
init {
ordering.value = ChatRoomsRepository.Order.ACTIVITY
}
fun getChatRooms(): LiveData<RoomsModel> {
return Transformations.switchMap(query) { query ->
return@switchMap if (query.isSearch()) {
this@ChatRoomsViewModel.query.wrap(runContext) { _, data: MutableLiveData<RoomsModel> ->
val string = (query as Query.Search).query
fun getChatRooms(): LiveData<List<ItemHolder<*>>> {
return Transformations.switchMap(ordering) { order ->
Timber.d("Querying rooms for order: $order")
repository.getChatRooms(order)
.nonNull()
.distinct()
.transform(runContext) { rooms ->
rooms?.let {
mapper.map(rooms, order.isGrouped())
}
// debounce, to not query while the user is writing
delay(200)
// TODO - find a better way for cancellation checking
if (!coroutineContext.isActive) return@wrap
val rooms = repository.search(string).let { mapper.map(it) }
data.postValue(rooms.toMutableList() + LoadingItemHolder())
if (!coroutineContext.isActive) return@wrap
val spotlight = spotlight(query.query)?.let { mapper.map(it) }
if (!coroutineContext.isActive) return@wrap
spotlight?.let {
data.postValue(rooms.toMutableList() + spotlight)
}.ifNull {
data.postValue(rooms)
}
}
} else {
repository.getChatRooms(query.asSortingOrder())
.nonNull()
.distinct()
.transform(runContext) { rooms ->
val mappedRooms = rooms?.let {
mapper.map(rooms, query.isGrouped())
}
if (loaded && mappedRooms?.isEmpty() == true) {
loadingState.postValue(LoadingState.Loaded(0))
}
mappedRooms
}
}
}
}
fun spotlight(): LiveData<List<ChatRoom>> {
return query.filter { !it.isNullOrEmpty() }.distinct().nonNull().transform {
repository.getChatRooms(it!!)
private suspend fun spotlight(query: String): SpotlightResult? {
return try {
retryIO { client.spotlight(query) }
} catch (ex: Exception) {
ex.printStackTrace()
null
}
}
fun getStatus(): MutableLiveData<State> {
return connectionManager.statusLiveData.nonNull().distinct().map { state ->
if (state is State.Connected) {
// TODO - add a loading status...
fetchRooms()
}
state
......@@ -67,11 +103,69 @@ class ChatRoomsViewModel(
private fun fetchRooms() {
launch {
interactor.refreshChatRooms()
setLoadingState(LoadingState.Loading(repository.count()))
try {
interactor.refreshChatRooms()
setLoadingState(LoadingState.Loaded(repository.count()))
loaded = true
} catch (ex: Exception) {
Timber.d(ex, "Error refreshing chatrooms")
setLoadingState(LoadingState.Error(repository.count()))
}
}
}
fun setOrdering(order: ChatRoomsRepository.Order) {
ordering.value = order
fun setQuery(query: Query) {
this.query.value = query
}
private suspend fun setLoadingState(state: LoadingState) {
withContext(UI) {
loadingState.value = state
}
}
}
typealias RoomsModel = List<ItemHolder<*>>
sealed class LoadingState {
data class Loading(val count: Long) : LoadingState()
data class Loaded(val count: Long) : LoadingState()
data class Error(val count: Long) : LoadingState()
}
sealed class Query {
data class ByActivity(val grouped: Boolean = false) : Query()
data class ByName(val grouped: Boolean = false) : Query()
data class Search(val query: String) : Query()
}
fun Query.isSearch(): Boolean = this is Query.Search
fun Query.isGrouped(): Boolean {
return when(this) {
is Query.Search -> false
is Query.ByName -> grouped
is Query.ByActivity -> grouped
}
}
fun Query.asSortingOrder(): ChatRoomsRepository.Order {
return when(this) {
is Query.ByName -> {
if (grouped) {
ChatRoomsRepository.Order.GROUPED_NAME
} else {
ChatRoomsRepository.Order.NAME
}
}
is Query.ByActivity -> {
if (grouped) {
ChatRoomsRepository.Order.GROUPED_ACTIVITY
} else {
ChatRoomsRepository.Order.ACTIVITY
}
}
else -> throw InvalidParameterException("Should be ByName or ByActivity")
}
}
......@@ -24,6 +24,13 @@ abstract class ChatRoomDao : BaseDao<ChatRoomEntity> {
@Query("$BASE_QUERY")
abstract fun getAllSync(): List<ChatRoom>
@Transaction
@Query("""$BASE_QUERY WHERE chatrooms.name LIKE '%' || :query || '%' OR users.name LIKE '%' || :query || '%'""")
abstract fun searchSync(query: String): List<ChatRoom>
@Query("SELECT COUNT(id) FROM chatrooms")
abstract fun count(): Long
@Transaction
@Query("""
$BASE_QUERY
......@@ -66,6 +73,15 @@ abstract class ChatRoomDao : BaseDao<ChatRoomEntity> {
@Query("DELETE FROM chatrooms WHERE ID = :id")
abstract fun delete(id: String)
@Query("DELETE FROM chatrooms")
abstract fun delete()
@Transaction
open fun cleanInsert(chatRooms: List<ChatRoomEntity>) {
delete()
insert(chatRooms)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertOrReplace(chatRooms: List<ChatRoomEntity>)
......
......@@ -35,6 +35,10 @@ class DatabaseManager(val context: Application,
fun chatRoomDao(): ChatRoomDao = database.chatRoomDao()
fun userDao(): UserDao = database.userDao()
fun logout() {
database.clearAllTables()
}
suspend fun getRoom(id: String) = withContext(dbContext) {
chatRoomDao().get(id)
}
......@@ -297,7 +301,7 @@ class DatabaseManager(val context: Application,
suspend fun insert(rooms: List<ChatRoomEntity>) {
withContext(dbContext) {
chatRoomDao().insert(rooms)
chatRoomDao().cleanInsert(rooms)
}
}
......
......@@ -24,9 +24,9 @@ data class ChatRoomEntity(
var subscriptionId: String,
var type: String,
var name: String,
var fullname: String?,
var userId: String?,
var ownerId: String?,
var fullname: String? = null,
var userId: String? = null,
var ownerId: String? = null,
var readonly: Boolean? = false,
var isDefault: Boolean? = false,
var favorite: Boolean? = false,
......@@ -38,9 +38,9 @@ data class ChatRoomEntity(
var updatedAt: Long? = -1,
var timestamp: Long? = -1,
var lastSeen: Long? = -1,
var lastMessageText: String?,
var lastMessageUserId: String?,
var lastMessageTimestamp: Long?
var lastMessageText: String? = null,
var lastMessageUserId: String? = null,
var lastMessageTimestamp: Long? = null
)
data class ChatRoom(
......
......@@ -18,6 +18,8 @@ interface LocalRepository {
fun clearAllFromServer(server: String)
fun getCurrentUser(url: String): User?
fun saveCurrentUser(url: String, user: User)
fun saveLastChatroomsRefresh(url: String, timestamp: Long)
fun getLastChatroomsRefresh(url: String): Long
companion object {
const val KEY_PUSH_TOKEN = "KEY_PUSH_TOKEN"
......@@ -26,6 +28,7 @@ interface LocalRepository {
const val PERMISSIONS_KEY = "permissions_"
const val USER_KEY = "user_"
const val CURRENT_USERNAME_KEY = "username_"
const val LAST_CHATROOMS_REFRESH = "_chatrooms_refresh"
}
}
......
......@@ -22,6 +22,13 @@ class SharedPreferencesLocalRepository(
save("${url}_${LocalRepository.USER_KEY}", userAdapter.toJson(user))
}
override fun saveLastChatroomsRefresh(url: String, timestamp: Long) {
save("$url${LocalRepository.LAST_CHATROOMS_REFRESH}", timestamp)
}
override fun getLastChatroomsRefresh(url: String) =
getLong("$url${LocalRepository.LAST_CHATROOMS_REFRESH}", 0L)
override fun getBoolean(key: String, defValue: Boolean) = preferences.getBoolean(key, defValue)
override fun getFloat(key: String, defValue: Float) = preferences.getFloat(key, defValue)
......
package chat.rocket.android.main.presentation
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.db.DatabaseManagerFactory
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.main.uimodel.NavHeaderUiModel
import chat.rocket.android.main.uimodel.NavHeaderUiModelMapper
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.domain.GetAccountsInteractor
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.GetSettingsInteractor
import chat.rocket.android.server.domain.PublicSettings
import chat.rocket.android.server.domain.RemoveAccountInteractor
import chat.rocket.android.server.domain.SaveAccountInteractor
import chat.rocket.android.server.domain.TokenRepository
import chat.rocket.android.server.domain.favicon
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
......@@ -23,7 +31,9 @@ import chat.rocket.core.internal.rest.logout
import chat.rocket.core.internal.rest.me
import chat.rocket.core.internal.rest.unregisterPushToken
import chat.rocket.core.model.Myself
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.withContext
import timber.log.Timber
import javax.inject.Inject
......@@ -39,11 +49,13 @@ class MainPresenter @Inject constructor(
private val getAccountsInteractor: GetAccountsInteractor,
private val removeAccountInteractor: RemoveAccountInteractor,
private val factory: RocketChatClientFactory,
dbManagerFactory: DatabaseManagerFactory,
getSettingsInteractor: GetSettingsInteractor,
managerFactory: ConnectionManagerFactory
) : CheckServerPresenter(strategy, factory, view = view) {
private val currentServer = serverInteractor.get()!!
private val manager = managerFactory.create(currentServer)
private val dbManager = dbManagerFactory.create(currentServer)
private val client: RocketChatClient = factory.create(currentServer)
private var settings: PublicSettings = getSettingsInteractor.get(serverInteractor.get()!!)
......@@ -91,6 +103,7 @@ class MainPresenter @Inject constructor(
*/
fun logout() {
launchUI(strategy) {
view.showProgress()
try {
clearTokens()
retryIO("logout") { client.logout() }
......@@ -107,10 +120,13 @@ class MainPresenter @Inject constructor(
disconnect()
removeAccountInteractor.remove(currentServer)
tokenRepository.remove(currentServer)
withContext(CommonPool) { dbManager.logout() }
navigator.toNewServer()
} catch (ex: Exception) {
Timber.d(ex, "Error cleaning up the session...")
}
view.hideProgress()
}
}
......
......@@ -26,4 +26,7 @@ interface MainView : MessageView, VersionCheckView {
fun closeServerSelection()
fun invalidateToken(token: String)
fun showProgress()
fun hideProgress()
}
\ No newline at end of file
......@@ -3,6 +3,7 @@ package chat.rocket.android.main.ui
import DrawableHelper
import android.app.Activity
import android.app.AlertDialog
import android.app.ProgressDialog
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.appcompat.app.AppCompatActivity
......@@ -269,4 +270,14 @@ class MainActivity : AppCompatActivity(), MainView, HasActivityInjector,
fun setCheckedNavDrawerItem(@IdRes item: Int) {
view_navigation.setCheckedItem(item)
}
private var progressDialog : ProgressDialog? = null
override fun showProgress() {
progressDialog = ProgressDialog.show(this, getString(R.string.app_name), getString(R.string.msg_log_out), true, false)
}
override fun hideProgress() {
progressDialog?.dismiss()
progressDialog = null
}
}
\ No newline at end of file
......@@ -2,6 +2,7 @@ package chat.rocket.android.server.infraestructure
import androidx.lifecycle.MutableLiveData
import chat.rocket.android.db.DatabaseManager
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.common.model.BaseRoom
import chat.rocket.common.model.User
import chat.rocket.core.RocketChatClient
......@@ -40,10 +41,8 @@ class ConnectionManager(
private val statusChannel = Channel<State>(Channel.CONFLATED)
private var connectJob: Job? = null
private val roomAndSubscriptionChannels = ArrayList<Channel<StreamMessage<BaseRoom>>>()
private val roomMessagesChannels = LinkedHashMap<String, Channel<Message>>()
private val userDataChannels = ArrayList<Channel<Myself>>()
private val activeUsersChannels = ArrayList<Channel<User>>()
private val subscriptionIdMap = HashMap<String, String>()
private var subscriptionId: String? = null
......@@ -126,9 +125,6 @@ class ConnectionManager(
for (room in client.roomsChannel) {
Timber.d("GOT Room streamed")
roomsActor.send(room)
for (channel in roomAndSubscriptionChannels) {
channel.send(room)
}
}
}
......@@ -137,9 +133,6 @@ class ConnectionManager(
for (subscription in client.subscriptionsChannel) {
Timber.d("GOT Subscription streamed")
roomsActor.send(subscription)
for (channel in roomAndSubscriptionChannels) {
channel.send(subscription)
}
}
}
......@@ -170,9 +163,6 @@ class ConnectionManager(
totalUsers++
//Timber.d("Got activeUsers: $totalUsers")
userActor.send(user)
for (channel in activeUsersChannels) {
channel.send(user)
}
}
}
......@@ -205,20 +195,10 @@ class ConnectionManager(
fun removeStatusChannel(channel: Channel<State>) = statusChannelList.remove(channel)
fun addRoomsAndSubscriptionsChannel(channel: Channel<StreamMessage<BaseRoom>>) =
roomAndSubscriptionChannels.add(channel)
fun removeRoomsAndSubscriptionsChannel(channel: Channel<StreamMessage<BaseRoom>>) =
roomAndSubscriptionChannels.remove(channel)
fun addUserDataChannel(channel: Channel<Myself>) = userDataChannels.add(channel)
fun removeUserDataChannel(channel: Channel<Myself>) = userDataChannels.remove(channel)
fun addActiveUserChannel(channel: Channel<User>) = activeUsersChannels.add(channel)
fun removeActiveUserChannel(channel: Channel<User>) = activeUsersChannels.remove(channel)
fun subscribeRoomMessages(roomId: String, channel: Channel<Message>) {
val oldSub = roomMessagesChannels.put(roomId, channel)
if (oldSub != null) {
......
package chat.rocket.android.server.infraestructure
import chat.rocket.android.db.DatabaseManagerFactory
import chat.rocket.android.infrastructure.LocalRepository
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
......
package chat.rocket.android.util
import chat.rocket.android.BuildConfig
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.MediaType
......@@ -28,6 +29,8 @@ class HttpLoggingInterceptor constructor(private val logger: Logger) : Intercept
@Volatile
internal var level = Level.NONE
private val isDebug = BuildConfig.DEBUG
enum class Level {
/** No logs. */
NONE,
......@@ -140,7 +143,7 @@ class HttpLoggingInterceptor constructor(private val logger: Logger) : Intercept
val name = headers.name(i)
// Skip headers from the request body as they are explicitly logged above.
if (!"Content-Type".equals(name, ignoreCase = true) && !"Content-Length".equals(name, ignoreCase = true)) {
if ("X-Auth-Token".equals(name, ignoreCase = true)) {
if (!isDebug && "X-Auth-Token".equals(name, ignoreCase = true)) {
logger.log("$name: ${skipAuthToken(headers.value(i).length)}")
} else {
logger.log("$name: ${headers.value(i)}")
......
package chat.rocket.android.util
import chat.rocket.common.RocketChatNetworkErrorException
import kotlinx.coroutines.experimental.TimeoutCancellationException
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.isActive
import timber.log.Timber
import kotlin.coroutines.experimental.coroutineContext
const val DEFAULT_RETRY = 3
......@@ -16,14 +19,19 @@ suspend fun <T> retryIO(
{
var currentDelay = initialDelay
repeat(times - 1) { currentTry ->
if (!coroutineContext.isActive) throw TimeoutCancellationException("job canceled")
try {
return block()
} catch (e: RocketChatNetworkErrorException) {
Timber.d(e, "failed call($currentTry): $description")
e.printStackTrace()
}
if (!coroutineContext.isActive) throw TimeoutCancellationException("job canceled")
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
}
if (!coroutineContext.isActive) throw TimeoutCancellationException("job canceled")
return block() // last attempt
}
\ No newline at end of file
package chat.rocket.android.util.livedata
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.CoroutineScope
import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.withContext
import kotlin.coroutines.experimental.CoroutineContext
class WrappedLiveData<Source, Output>(
private val runContext: CoroutineContext = CommonPool,
private val source: LiveData<Source>,
private val transformation: suspend (Source?, MutableLiveData<Output>) -> Unit)
: MutableLiveData<Output>() {
private var job: Job? = null
private val observer = Observer<Source> { source ->
job?.cancel()
job = launch(runContext) {
transformation(source, this@WrappedLiveData)
}
}
override fun onActive() {
source.observeForever(observer)
}
override fun onInactive() {
job?.cancel()
source.removeObserver(observer)
}
}
fun <Source, Output> LiveData<Source>.wrap(
runContext: CoroutineContext = CommonPool,
transformation: suspend (Source?, MutableLiveData<Output>) -> Unit) =
WrappedLiveData(runContext, this, transformation)
\ No newline at end of file
......@@ -2,7 +2,9 @@ package chat.rocket.android.widget
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
......@@ -51,6 +53,9 @@ class DividerItemDecoration() : RecyclerView.ItemDecoration() {
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
if (isLastView(child, parent))
continue
val params = child.layoutParams as RecyclerView.LayoutParams
val top = child.bottom + params.bottomMargin
......@@ -60,4 +65,9 @@ class DividerItemDecoration() : RecyclerView.ItemDecoration() {
divider.draw(c)
}
}
private fun isLastView(view: View, parent: RecyclerView): Boolean {
val position = parent.getChildAdapterPosition(view)
return position == parent.adapter?.itemCount?.minus(1) ?: false
}
}
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="60dp">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
</FrameLayout>
\ No newline at end of file
......@@ -281,4 +281,5 @@
<string name="notif_action_reply_hint">RESPUESTA</string>
<string name="notif_error_sending">La respuesta ha fallado. Inténtalo de nuevo.</string>
<string name="notif_success_sending">Mensaje enviado a %1$s!</string>
<string name="msg_log_out">Saliendo de tu cuenta...</string>
</resources>
......@@ -282,4 +282,5 @@
<string name="notif_action_reply_hint">RÉPONDRE</string>
<string name="notif_error_sending">La réponse a échoué. Veuillez réessayer.</string>
<string name="notif_success_sending">Message envoyé à %1$s!</string>
<string name="msg_log_out">Logging out...</string>
</resources>
......@@ -266,4 +266,5 @@
<string name="notif_action_reply_hint">जवाब</string>
<string name="notif_error_sending">उत्तर विफल हुआ है। कृपया फिर से प्रयास करें।</string>
<string name="notif_success_sending">संदेश भेजा गया %1$s!</string>
<string name="msg_log_out">Logging out...</string>
</resources>
\ No newline at end of file
......@@ -261,4 +261,5 @@
<string name="notif_action_reply_hint">RESPONDER</string>
<string name="notif_error_sending">Falha ao enviar a mensagem.</string>
<string name="notif_success_sending">Mensagem enviada para %1$s!</string>
<string name="msg_log_out">Deslogando...</string>
</resources>
......@@ -258,4 +258,5 @@
<string name="notif_action_reply_hint">ОТВЕТИТЬ</string>
<string name="notif_error_sending">Ошибка ответа. Пожалуйста, попробуйте еще раз.</string>
<string name="notif_success_sending">Сообщение отправлено %1$s!</string>
<string name="msg_log_out">Logging out...</string>
</resources>
......@@ -119,6 +119,7 @@
<string name="msg_are_typing">\u0020are typing…</string>
<string name="msg_several_users_are_typing">Several users are typing…</string>
<string name="msg_no_search_found">No result found</string>
<string name="msg_log_out">Logging out...</string>
<!-- Create channel messages -->
<string name="msg_private_channel">Private</string>
......
......@@ -5,7 +5,7 @@ ext {
targetSdk : 28,
minSdk : 21,
buildTools : '28.0.0-rc2',
kotlin : '1.2.50',
kotlin : '1.2.51',
coroutine : '0.23.1',
dokka : '0.9.16',
......
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