From 1c733e666180b478bacb6cb133b30e5c6b9171e0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Jul 2020 16:54:14 +0200 Subject: [PATCH] Display Contact list (#548) WIP (#548) WIP (#548) WIP (#548) WIP (#548) WIP (#548) --- .../riotx/core/contacts/ContactModel.kt | 58 ++++++ .../riotx/core/contacts/ContactsDataSource.kt | 131 ++++++++++++++ .../im/vector/riotx/core/di/FragmentModule.kt | 6 + .../createdirect/CreateDirectRoomActivity.kt | 12 +- .../riotx/features/home/AvatarRenderer.kt | 18 ++ .../invite/InviteUsersToRoomActivity.kt | 8 +- .../userdirectory/ContactDetailItem.kt | 47 +++++ .../features/userdirectory/ContactItem.kt | 46 +++++ .../userdirectory/KnownUsersFragment.kt | 8 + .../features/userdirectory/PhoneBookAction.kt | 23 +++ .../userdirectory/PhoneBookController.kt | 140 +++++++++++++++ .../userdirectory/PhoneBookFragment.kt | 99 ++++++++++ .../userdirectory/PhoneBookViewModel.kt | 169 ++++++++++++++++++ .../userdirectory/PhoneBookViewState.kt | 35 ++++ .../userdirectory/UserDirectoryAction.kt | 2 + .../UserDirectorySharedAction.kt | 1 + .../main/res/layout/fragment_known_users.xml | 19 +- .../main/res/layout/fragment_phonebook.xml | 109 +++++++++++ .../main/res/layout/item_contact_detail.xml | 46 +++++ .../src/main/res/layout/item_contact_main.xml | 38 ++++ vector/src/main/res/values/strings.xml | 4 + 21 files changed, 1013 insertions(+), 6 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/contacts/ContactModel.kt create mode 100644 vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/userdirectory/ContactDetailItem.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/userdirectory/ContactItem.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookAction.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookController.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookFragment.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewModel.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewState.kt create mode 100644 vector/src/main/res/layout/fragment_phonebook.xml create mode 100644 vector/src/main/res/layout/item_contact_detail.xml create mode 100644 vector/src/main/res/layout/item_contact_main.xml diff --git a/vector/src/main/java/im/vector/riotx/core/contacts/ContactModel.kt b/vector/src/main/java/im/vector/riotx/core/contacts/ContactModel.kt new file mode 100644 index 0000000000..589c3030c3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/contacts/ContactModel.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.core.contacts + +import android.net.Uri + +/* TODO Rename to MxContact? */ + +class ContactModelBuilder( + val id: Long, + val displayName: String) { + + var photoURI: Uri? = null + val msisdns = mutableListOf() + val emails = mutableListOf() + + fun toContactModel(): ContactModel { + return ContactModel( + id = id, + displayName = displayName, + photoURI = photoURI, + msisdns = msisdns, + emails = emails + ) + } +} + +data class ContactModel( + val id: Long, + val displayName: String, + val photoURI: Uri? = null, + val msisdns: List = emptyList(), + val emails: List = emptyList() +) + +data class MappedEmail( + val email: String, + val matrixId: String? +) + +data class MappedMsisdn( + val phoneNumber: String, + val matrixId: String? +) diff --git a/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt b/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt new file mode 100644 index 0000000000..2160216d5d --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.core.contacts + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.ContactsContract +import androidx.annotation.WorkerThread +import javax.inject.Inject + +class ContactsDataSource @Inject constructor( + private val context: Context +) { + + @WorkerThread + fun getContacts(): List { + val result = mutableListOf() + val contentResolver = context.contentResolver + + contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, + null, + /* TODO + arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.Data.PHOTO_URI, + ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Email.ADDRESS + ), + */ + null, + null, + // Sort by Display name + ContactsContract.Data.DISPLAY_NAME + ) + ?.use { cursor -> + if (cursor.count > 0) { + while (cursor.moveToNext()) { + val id = cursor.getLong(ContactsContract.Contacts._ID) ?: continue + val displayName = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME) ?: continue + + val currentContact = ContactModelBuilder( + id = id, + displayName = displayName + ) + + cursor.getString(ContactsContract.Data.PHOTO_URI) + ?.let { Uri.parse(it) } + ?.let { currentContact.photoURI = it } + + // Get the phone numbers + contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + null, + ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?", + arrayOf(id.toString()), + null) + ?.use { innerCursor -> + while (innerCursor.moveToNext()) { + innerCursor.getString(ContactsContract.CommonDataKinds.Phone.NUMBER) + ?.let { + currentContact.msisdns.add( + MappedMsisdn( + phoneNumber = it, + matrixId = null + ) + ) + } + } + } + + // Get Emails + contentResolver.query( + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + null, + ContactsContract.CommonDataKinds.Email.CONTACT_ID + " = ?", + arrayOf(id.toString()), + null) + ?.use { innerCursor -> + while (innerCursor.moveToNext()) { + // This would allow you get several email addresses + // if the email addresses were stored in an array + innerCursor.getString(ContactsContract.CommonDataKinds.Email.DATA) + ?.let { + currentContact.emails.add( + MappedEmail( + email = it, + matrixId = null + ) + ) + } + } + } + + result.add(currentContact.toContactModel()) + } + } + } + + return result + .filter { it.emails.isNotEmpty() || it.msisdns.isNotEmpty() } + } + + private fun Cursor.getString(column: String): String? { + return getColumnIndex(column) + .takeIf { it != -1 } + ?.let { getString(it) } + } + + private fun Cursor.getLong(column: String): Long? { + return getColumnIndex(column) + .takeIf { it != -1 } + ?.let { getLong(it) } + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt index 21cff188d0..0201a44096 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt @@ -103,6 +103,7 @@ import im.vector.riotx.features.share.IncomingShareFragment import im.vector.riotx.features.signout.soft.SoftLogoutFragment import im.vector.riotx.features.terms.ReviewTermsFragment import im.vector.riotx.features.userdirectory.KnownUsersFragment +import im.vector.riotx.features.userdirectory.PhoneBookFragment import im.vector.riotx.features.userdirectory.UserDirectoryFragment import im.vector.riotx.features.widgets.WidgetFragment @@ -528,4 +529,9 @@ interface FragmentModule { @IntoMap @FragmentKey(WidgetFragment::class) fun bindWidgetFragment(fragment: WidgetFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(PhoneBookFragment::class) + fun bindPhoneBookFragment(fragment: PhoneBookFragment): Fragment } diff --git a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt index ef3e9bdeff..973a4b6f16 100644 --- a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt @@ -35,10 +35,12 @@ import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.extensions.addFragmentToBackstack +import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.SimpleFragmentActivity import im.vector.riotx.core.platform.WaitingViewData import im.vector.riotx.features.userdirectory.KnownUsersFragment import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs +import im.vector.riotx.features.userdirectory.PhoneBookViewModel import im.vector.riotx.features.userdirectory.UserDirectoryFragment import im.vector.riotx.features.userdirectory.UserDirectorySharedAction import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel @@ -53,6 +55,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory @Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory + @Inject lateinit var phoneBookViewModelFactory: PhoneBookViewModel.Factory @Inject lateinit var errorFormatter: ErrorFormatter override fun injectWith(injector: ScreenComponent) { @@ -68,12 +71,13 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { .observe() .subscribe { sharedAction -> when (sharedAction) { - UserDirectorySharedAction.OpenUsersDirectory -> + UserDirectorySharedAction.OpenUsersDirectory -> addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java) - UserDirectorySharedAction.Close -> finish() - UserDirectorySharedAction.GoBack -> onBackPressed() + UserDirectorySharedAction.Close -> finish() + UserDirectorySharedAction.GoBack -> onBackPressed() is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) - } + UserDirectorySharedAction.OpenPhoneBook -> TODO() + }.exhaustive } .disposeOnDestroy() if (isFirstCreation()) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt index f917b5a9f9..e0d41ca445 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt @@ -30,6 +30,7 @@ import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.target.Target import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.util.MatrixItem +import im.vector.riotx.core.contacts.ContactModel import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideRequest @@ -63,6 +64,23 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active DrawableImageViewTarget(imageView)) } + @UiThread + fun render(contactModel: ContactModel, imageView: ImageView) { + // Create a Fake MatrixItem, for the placeholder + val matrixItem = MatrixItem.UserItem( + // Need an id starting with @ + id = "@${contactModel.displayName}", + displayName = contactModel.displayName + ) + + val placeholder = getPlaceholderDrawable(imageView.context, matrixItem) + GlideApp.with(imageView) + .load(contactModel.photoURI) + .apply(RequestOptions.circleCropTransform()) + .placeholder(placeholder) + .into(imageView) + } + @UiThread fun render(context: Context, glideRequests: GlideRequests, diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt index 839a0767d8..af0e974c8a 100644 --- a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt @@ -30,11 +30,14 @@ import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.extensions.addFragmentToBackstack +import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.SimpleFragmentActivity import im.vector.riotx.core.platform.WaitingViewData import im.vector.riotx.core.utils.toast import im.vector.riotx.features.userdirectory.KnownUsersFragment import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs +import im.vector.riotx.features.userdirectory.PhoneBookFragment +import im.vector.riotx.features.userdirectory.PhoneBookViewModel import im.vector.riotx.features.userdirectory.UserDirectoryFragment import im.vector.riotx.features.userdirectory.UserDirectorySharedAction import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel @@ -53,6 +56,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory @Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory + @Inject lateinit var phoneBookViewModelFactory: PhoneBookViewModel.Factory @Inject lateinit var errorFormatter: ErrorFormatter override fun injectWith(injector: ScreenComponent) { @@ -74,7 +78,9 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { UserDirectorySharedAction.Close -> finish() UserDirectorySharedAction.GoBack -> onBackPressed() is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) - } + UserDirectorySharedAction.OpenPhoneBook -> + addFragmentToBackstack(R.id.container, PhoneBookFragment::class.java) + }.exhaustive } .disposeOnDestroy() if (isFirstCreation()) { diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/ContactDetailItem.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/ContactDetailItem.kt new file mode 100644 index 0000000000..df29545201 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/ContactDetailItem.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.userdirectory + +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.ClickListener +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.epoxy.onClick +import im.vector.riotx.core.extensions.setTextOrHide + +@EpoxyModelClass(layout = R.layout.item_contact_detail) +abstract class ContactDetailItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var threePid: String + @EpoxyAttribute var matrixId: String? = null + @EpoxyAttribute var clickListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.onClick(clickListener) + holder.nameView.text = threePid + holder.matrixIdView.setTextOrHide(matrixId) + } + + class Holder : VectorEpoxyHolder() { + val nameView by bind(R.id.contactDetailName) + val matrixIdView by bind(R.id.contactDetailMatrixId) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/ContactItem.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/ContactItem.kt new file mode 100644 index 0000000000..67d762b4b2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/ContactItem.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.userdirectory + +import android.widget.ImageView +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.contacts.ContactModel +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.features.home.AvatarRenderer + +@EpoxyModelClass(layout = R.layout.item_contact_main) +abstract class ContactItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var contact: ContactModel + + override fun bind(holder: Holder) { + super.bind(holder) + // If name is empty, use userId as name and force it being centered + holder.nameView.text = contact.displayName + avatarRenderer.render(contact, holder.avatarImageView) + } + + class Holder : VectorEpoxyHolder() { + val nameView by bind(R.id.contactDisplayName) + val avatarImageView by bind(R.id.contactAvatar) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt index 42dd46bd01..d681e5d92f 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt @@ -63,6 +63,7 @@ class KnownUsersFragment @Inject constructor( setupRecyclerView() setupFilterView() setupAddByMatrixIdView() + setupAddFromPhoneBookView() setupCloseView() viewModel.selectSubscribe(this, UserDirectoryViewState::selectedUsers) { renderSelectedUsers(it) @@ -96,6 +97,13 @@ class KnownUsersFragment @Inject constructor( } } + private fun setupAddFromPhoneBookView() { + addFromPhoneBook.debouncedClicks { + // TODO handle Permission first + sharedActionViewModel.post(UserDirectorySharedAction.OpenPhoneBook) + } + } + private fun setupRecyclerView() { knownUsersController.callback = this // Don't activate animation as we might have way to much item animation when filtering diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookAction.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookAction.kt new file mode 100644 index 0000000000..d3d534e694 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookAction.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.userdirectory + +import im.vector.riotx.core.platform.VectorViewModelAction + +sealed class PhoneBookAction : VectorViewModelAction { + data class FilterWith(val filter: String) : PhoneBookAction() +} diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookController.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookController.kt new file mode 100644 index 0000000000..39f00d6557 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookController.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.userdirectory + +import com.airbnb.epoxy.EpoxyController +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.riotx.R +import im.vector.riotx.core.contacts.ContactModel +import im.vector.riotx.core.epoxy.errorWithRetryItem +import im.vector.riotx.core.epoxy.loadingItem +import im.vector.riotx.core.epoxy.noResultItem +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.features.home.AvatarRenderer +import javax.inject.Inject + +class PhoneBookController @Inject constructor( + private val stringProvider: StringProvider, + private val avatarRenderer: AvatarRenderer, + private val errorFormatter: ErrorFormatter) : EpoxyController() { + + private var state: PhoneBookViewState? = null + + var callback: Callback? = null + + init { + requestModelBuild() + } + + fun setData(state: PhoneBookViewState) { + this.state = state + requestModelBuild() + } + + override fun buildModels() { + val currentState = state ?: return + val hasSearch = currentState.searchTerm.isNotBlank() + when (val asyncMappedContacts = currentState.mappedContacts) { + is Uninitialized -> renderEmptyState(false) + is Loading -> renderLoading() + is Success -> renderSuccess(currentState.filteredMappedContacts, hasSearch) + is Fail -> renderFailure(asyncMappedContacts.error) + } + } + + private fun renderLoading() { + loadingItem { + id("loading") + } + } + + private fun renderFailure(failure: Throwable) { + errorWithRetryItem { + id("error") + text(errorFormatter.toHumanReadable(failure)) + } + } + + private fun renderSuccess(mappedContacts: List, + hasSearch: Boolean) { + if (mappedContacts.isEmpty()) { + renderEmptyState(hasSearch) + } else { + renderContacts(mappedContacts) + } + } + + private fun renderContacts(mappedContacts: List) { + for (mappedContact in mappedContacts) { + contactItem { + id(mappedContact.id) + contact(mappedContact) + avatarRenderer(avatarRenderer) + } + mappedContact.emails.forEach { + contactDetailItem { + id("$mappedContact.id${it.email}") + threePid(it.email) + matrixId(it.matrixId) + clickListener { + if (it.matrixId != null) { + callback?.onMatrixIdClick(it.matrixId) + } else { + callback?.onThreePidClick(ThreePid.Email(it.email)) + } + } + } + } + mappedContact.msisdns.forEach { + contactDetailItem { + id("$mappedContact.id${it.phoneNumber}") + threePid(it.phoneNumber) + matrixId(it.matrixId) + clickListener { + if (it.matrixId != null) { + callback?.onMatrixIdClick(it.matrixId) + } else { + callback?.onThreePidClick(ThreePid.Msisdn(it.phoneNumber)) + } + } + } + } + } + } + + private fun renderEmptyState(hasSearch: Boolean) { + val noResultRes = if (hasSearch) { + R.string.no_result_placeholder + } else { + R.string.empty_phone_book + } + noResultItem { + id("noResult") + text(stringProvider.getString(noResultRes)) + } + } + + interface Callback { + fun onMatrixIdClick(matrixId: String) + fun onThreePidClick(threePid: ThreePid) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookFragment.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookFragment.kt new file mode 100644 index 0000000000..9f1f8268c3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookFragment.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.userdirectory + +import android.os.Bundle +import android.view.View +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.matrix.android.api.session.user.model.User +import im.vector.riotx.R +import im.vector.riotx.core.extensions.cleanup +import im.vector.riotx.core.extensions.configureWith +import im.vector.riotx.core.extensions.hideKeyboard +import im.vector.riotx.core.platform.VectorBaseFragment +import kotlinx.android.synthetic.main.fragment_phonebook.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class PhoneBookFragment @Inject constructor( + val phoneBookViewModelFactory: PhoneBookViewModel.Factory, + private val phoneBookController: PhoneBookController +) : VectorBaseFragment(), PhoneBookController.Callback { + + override fun getLayoutResId() = R.layout.fragment_phonebook + private val viewModel: UserDirectoryViewModel by activityViewModel() + + // Use activityViewModel to avoid loading several times the data + private val phoneBookViewModel: PhoneBookViewModel by activityViewModel() + + private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java) + setupRecyclerView() + setupFilterView() + setupCloseView() + } + + private fun setupFilterView() { + phoneBookFilter + .textChanges() + .skipInitialValue() + .debounce(300, TimeUnit.MILLISECONDS) + .subscribe { + phoneBookViewModel.handle(PhoneBookAction.FilterWith(it.toString())) + } + .disposeOnDestroyView() + } + + override fun onDestroyView() { + phoneBookRecyclerView.cleanup() + phoneBookController.callback = null + super.onDestroyView() + } + + private fun setupRecyclerView() { + phoneBookController.callback = this + phoneBookRecyclerView.configureWith(phoneBookController) + } + + private fun setupCloseView() { + phoneBookClose.debouncedClicks { + sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + } + } + + override fun invalidate() = withState(phoneBookViewModel) { + phoneBookController.setData(it) + } + + override fun onMatrixIdClick(matrixId: String) { + view?.hideKeyboard() + viewModel.handle(UserDirectoryAction.SelectUser(User(matrixId))) + sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + } + + override fun onThreePidClick(threePid: ThreePid) { + view?.hideKeyboard() + viewModel.handle(UserDirectoryAction.SelectThreePid(threePid)) + sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewModel.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewModel.kt new file mode 100644 index 0000000000..d894bbe908 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewModel.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.userdirectory + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.identity.FoundThreePid +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.riotx.core.contacts.ContactModel +import im.vector.riotx.core.contacts.ContactsDataSource +import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.platform.EmptyViewEvents +import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.features.createdirect.CreateDirectRoomActivity +import im.vector.riotx.features.invite.InviteUsersToRoomActivity +import kotlinx.coroutines.launch + +private typealias PhoneBookSearch = String + +class PhoneBookViewModel @AssistedInject constructor(@Assisted + initialState: PhoneBookViewState, + private val contactsDataSource: ContactsDataSource, + private val session: Session) + : VectorViewModel(initialState) { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: PhoneBookViewState): PhoneBookViewModel + } + + companion object : MvRxViewModelFactory { + + override fun create(viewModelContext: ViewModelContext, state: PhoneBookViewState): PhoneBookViewModel? { + return when (viewModelContext) { + is FragmentViewModelContext -> (viewModelContext.fragment() as PhoneBookFragment).phoneBookViewModelFactory.create(state) + is ActivityViewModelContext -> { + when (viewModelContext.activity()) { + is CreateDirectRoomActivity -> viewModelContext.activity().phoneBookViewModelFactory.create(state) + is InviteUsersToRoomActivity -> viewModelContext.activity().phoneBookViewModelFactory.create(state) + else -> error("Wrong activity or fragment") + } + } + else -> error("Wrong activity or fragment") + } + } + } + + private var allContacts: List = emptyList() + private var mappedContacts: List = emptyList() + private var foundThreePid: List = emptyList() + + init { + loadContacts() + + selectSubscribe(PhoneBookViewState::searchTerm) { + updateState() + } + } + + private fun loadContacts() { + setState { + copy( + mappedContacts = Loading() + ) + } + + viewModelScope.launch { + allContacts = contactsDataSource.getContacts() + mappedContacts = allContacts + + setState { + copy( + mappedContacts = Success(allContacts) + ) + } + + performLookup(allContacts) + updateState() + } + } + + private fun performLookup(data: List) { + viewModelScope.launch { + val threePids = data.flatMap { contact -> + contact.emails.map { ThreePid.Email(it.email) } + + contact.msisdns.map { ThreePid.Msisdn(it.phoneNumber) } + } + session.identityService().lookUp(threePids, object : MatrixCallback> { + override fun onFailure(failure: Throwable) { + // Ignore? + } + + override fun onSuccess(data: List) { + foundThreePid = data + + mappedContacts = allContacts.map { contactModel -> + contactModel.copy( + emails = contactModel.emails.map { email -> + email.copy( + matrixId = foundThreePid + .firstOrNull { foundThreePid -> foundThreePid.threePid.value == email.email } + ?.matrixId + ) + }, + msisdns = contactModel.msisdns.map { msisdn -> + msisdn.copy( + matrixId = foundThreePid + .firstOrNull { foundThreePid -> foundThreePid.threePid.value == msisdn.phoneNumber } + ?.matrixId + ) + } + ) + } + + updateState() + } + }) + } + } + + private fun updateState() = withState { state -> + val filteredMappedContacts = mappedContacts + .filter { it.displayName.contains(state.searchTerm, true) } + + setState { + copy( + filteredMappedContacts = filteredMappedContacts + ) + } + } + + override fun handle(action: PhoneBookAction) { + when (action) { + is PhoneBookAction.FilterWith -> handleFilterWith(action) + }.exhaustive + } + + private fun handleFilterWith(action: PhoneBookAction.FilterWith) { + setState { + copy( + searchTerm = action.filter + ) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewState.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewState.kt new file mode 100644 index 0000000000..81709f84b4 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/PhoneBookViewState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.userdirectory + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxState +import im.vector.riotx.core.contacts.ContactModel + +data class PhoneBookViewState( + val searchTerm: String = "", + val mappedContacts: Async> = Loading(), + val filteredMappedContacts: List = emptyList() + /* + val knownUsers: Async> = Uninitialized, + val directoryUsers: Async> = Uninitialized, + val selectedUsers: Set = emptySet(), + val createAndInviteState: Async = Uninitialized, + val filterKnownUsersValue: Option = Option.empty() + */ +) : MvRxState diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt index 1df3c02736..3051e14bea 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt @@ -16,6 +16,7 @@ package im.vector.riotx.features.userdirectory +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.user.model.User import im.vector.riotx.core.platform.VectorViewModelAction @@ -24,5 +25,6 @@ sealed class UserDirectoryAction : VectorViewModelAction { data class SearchDirectoryUsers(val value: String) : UserDirectoryAction() object ClearFilterKnownUsers : UserDirectoryAction() data class SelectUser(val user: User) : UserDirectoryAction() + data class SelectThreePid(val threePid: ThreePid) : UserDirectoryAction() data class RemoveSelectedUser(val user: User) : UserDirectoryAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt index 7d1987aa4b..b071b6f7a8 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt @@ -21,6 +21,7 @@ import im.vector.riotx.core.platform.VectorSharedAction sealed class UserDirectorySharedAction : VectorSharedAction { object OpenUsersDirectory : UserDirectorySharedAction() + object OpenPhoneBook : UserDirectorySharedAction() object Close : UserDirectorySharedAction() object GoBack : UserDirectorySharedAction() data class OnMenuItemSelected(val itemId: Int, val selectedUsers: Set) : UserDirectorySharedAction() diff --git a/vector/src/main/res/layout/fragment_known_users.xml b/vector/src/main/res/layout/fragment_known_users.xml index c04cf027a6..16e6858e60 100644 --- a/vector/src/main/res/layout/fragment_known_users.xml +++ b/vector/src/main/res/layout/fragment_known_users.xml @@ -123,6 +123,23 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/knownUsersFilterDivider" /> + + diff --git a/vector/src/main/res/layout/fragment_phonebook.xml b/vector/src/main/res/layout/fragment_phonebook.xml new file mode 100644 index 0000000000..297201e2b1 --- /dev/null +++ b/vector/src/main/res/layout/fragment_phonebook.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_contact_detail.xml b/vector/src/main/res/layout/item_contact_detail.xml new file mode 100644 index 0000000000..6e44797baa --- /dev/null +++ b/vector/src/main/res/layout/item_contact_detail.xml @@ -0,0 +1,46 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_contact_main.xml b/vector/src/main/res/layout/item_contact_main.xml new file mode 100644 index 0000000000..c4482241ae --- /dev/null +++ b/vector/src/main/res/layout/item_contact_main.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 32de32e094..df596ae2d8 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2541,4 +2541,8 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming Waiting for encryption history Save recovery key in + + Add from my phone book + Your phone book is empty + Phone book