diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a0f96bc45f..b078083205 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + val contactsFragment = ContactsFragment() + val transaction = activity?.supportFragmentManager?.beginTransaction(); + transaction?.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right); + transaction?.replace(this.id, contactsFragment, "contactsFragment"); + transaction?.addToBackStack(null)?.commit(); + } + } } diff --git a/app/src/main/java/chat/rocket/android/contacts/ContactListFragment.kt b/app/src/main/java/chat/rocket/android/contacts/ContactListFragment.kt new file mode 100644 index 0000000000..61cf88ff4e --- /dev/null +++ b/app/src/main/java/chat/rocket/android/contacts/ContactListFragment.kt @@ -0,0 +1,97 @@ +package chat.rocket.android.contacts + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.ContactsContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import chat.rocket.android.R +import chat.rocket.android.contacts.models.Contact +import chat.rocket.android.main.ui.MainActivity +import timber.log.Timber +import java.util.ArrayList +import kotlin.collections.HashMap + + +/** + * Load a list of contacts in a recycler view + */ +class ContactListFragment : Fragment() { + /** + * The list of contacts to load in the recycler view + */ + private var contactArrayList: ArrayList? = null + + /** + * The mapping of contacts with their registration status + */ + private var contactHashMap: HashMap = HashMap() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val bundle = arguments + if (bundle != null) { + contactArrayList = bundle.getParcelableArrayList("CONTACT_ARRAY_LIST") + contactHashMap= bundle.getSerializable("CONTACT_HASH_MAP") as HashMap + } + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_contacts, container, false) + val context = view.context + + val recyclerView = view.findViewById(R.id.recycler_view) as RecyclerView + val emptyTextView = view.findViewById(R.id.text_no_data_to_display) as TextView + + if (contactArrayList!!.size == 0) { + emptyTextView.visibility = View.VISIBLE + recyclerView.visibility = View.GONE + } else { + emptyTextView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + + recyclerView.setHasFixedSize(true) + recyclerView.layoutManager = LinearLayoutManager(context) + recyclerView.adapter = ContactRecyclerViewAdapter(this.activity as MainActivity, contactArrayList!!, contactHashMap) + } + + return view + } + + companion object { + + /** + * Create a new ContactList fragment that displays the given list of contacts + * + * @param contactArrayList the list of contacts to load in the recycler view + * @param contactHashMap the mapping of contacts with their registration status + * @return the newly created ContactList fragment + */ + fun newInstance( + contactArrayList: ArrayList, + contactHashMap: HashMap + ): ContactListFragment { + val contactListFragment = ContactListFragment() + + val arguments = Bundle() + arguments.putParcelableArrayList("CONTACT_ARRAY_LIST", contactArrayList) + arguments.putSerializable("CONTACT_HASH_MAP", contactHashMap) + + contactListFragment.arguments = arguments + + return contactListFragment + } + } +} diff --git a/app/src/main/java/chat/rocket/android/contacts/ContactRecyclerViewAdapter.kt b/app/src/main/java/chat/rocket/android/contacts/ContactRecyclerViewAdapter.kt new file mode 100644 index 0000000000..39824c1e56 --- /dev/null +++ b/app/src/main/java/chat/rocket/android/contacts/ContactRecyclerViewAdapter.kt @@ -0,0 +1,78 @@ +package chat.rocket.android.contacts + +import android.content.Context +import timber.log.Timber +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import androidx.recyclerview.widget.RecyclerView +import chat.rocket.android.R +import chat.rocket.android.contacts.models.Contact +import chat.rocket.android.main.ui.MainActivity +import java.util.* +import kotlin.collections.HashMap +import chat.rocket.android.util.extensions.showToast + +class ContactRecyclerViewAdapter( + private val context: MainActivity, + private val contactArrayList: ArrayList, + private val contactHashMap: HashMap +) : RecyclerView.Adapter() { + + override fun getItemCount(): Int { + return contactArrayList.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater + .from(parent.context) + .inflate(R.layout.item_contact, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.contact = contactArrayList[position] + holder.status = contactHashMap.get(holder.contact!!.getPhoneNumber()) + try { + holder.contactName.text = holder.contact!!.getName() + if (holder.contact!!.isPhone()) { + holder.contactDetail.text = holder.contact!!.getPhoneNumber() + } else { + holder.contactDetail.text = holder.contact!!.getEmailAddress() + } + } catch (exception: NullPointerException) { + Timber.e("Failed to send resolution. Exception is: $exception") + } + + } + + inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { + var contact: Contact? = null + var status: String? = null + + var contactName: TextView + var contactDetail: TextView + var inviteButton: Button + + init { + this.contactName = view.findViewById(R.id.contact_name) as TextView + this.contactDetail = view.findViewById(R.id.contact_detail) as TextView + this.inviteButton = view.findViewById(R.id.invite_contact) as Button + + this.inviteButton.setOnClickListener { view -> + run { + // Make API call using context.presenter + if(contact!!.isPhone()){ + context.presenter.inviteViaSMS(contact!!.getPhoneNumber()!!); + }else{ + context.presenter.inviteViaEmail(contact!!.getEmailAddress()!!); + } + } + } + } + } +} diff --git a/app/src/main/java/chat/rocket/android/contacts/ContactsFragment.kt b/app/src/main/java/chat/rocket/android/contacts/ContactsFragment.kt new file mode 100644 index 0000000000..345f94a93d --- /dev/null +++ b/app/src/main/java/chat/rocket/android/contacts/ContactsFragment.kt @@ -0,0 +1,299 @@ +package chat.rocket.android.contacts + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.ContactsContract +import android.view.* +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import chat.rocket.android.R +import chat.rocket.android.contacts.models.Contact +import chat.rocket.android.createchannel.ui.CreateChannelFragment +import chat.rocket.android.main.ui.MainActivity +import chat.rocket.android.util.extension.onQueryTextListener +import kotlinx.android.synthetic.main.app_bar.* +import java.util.ArrayList +import kotlin.Comparator +import kotlin.collections.HashMap + +/** + * Load a list of contacts in a recycler view + */ +class ContactsFragment : Fragment() { + /** + * The list of contacts to load in the recycler view + */ + private var contactArrayList: ArrayList = ArrayList() + + /** + * The mapping of contacts with their registration status + */ + private var contactHashMap: HashMap = HashMap() + + private val MY_PERMISSIONS_REQUEST_RW_CONTACTS = 0 + + private var createNewChannelLink: View? = null + private var searchView: SearchView? = null + private var sortView: MenuItem? = null + + private fun getContactList() { + val cr = context!!.contentResolver + + val cur = cr.query(ContactsContract.Contacts.CONTENT_URI, null, null, null, null) + + if ((cur?.count ?: 0) > 0) { + while (cur != null && cur.moveToNext()) { + val id = cur.getString( + cur.getColumnIndex(ContactsContract.Contacts._ID)) + val name = cur.getString(cur.getColumnIndex( + ContactsContract.Contacts.DISPLAY_NAME)) + + if (cur.getInt(cur.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER)) > 0) { + // Has phone numbers + + val pCur = cr.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, + ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?", + arrayOf(id), null) + while (pCur!!.moveToNext()) { + val phoneNo = pCur.getString(pCur.getColumnIndex( + ContactsContract.CommonDataKinds.Phone.NUMBER)) + val contact = Contact() + contact.setName(name) + contact.setPhoneNumber(phoneNo) + contactArrayList.add(contact) + contactHashMap[phoneNo] = "INDETERMINATE" + } + pCur.close() + } + + if (true) { + // No check for having email address + + val eCur = cr.query( + ContactsContract.CommonDataKinds.Email.CONTENT_URI, null, + ContactsContract.CommonDataKinds.Email.CONTACT_ID + " = ?", + arrayOf(id), null) + while (eCur!!.moveToNext()) { + val emailID = eCur.getString(eCur.getColumnIndex( + ContactsContract.CommonDataKinds.Email.DATA)) + val contact = Contact() + contact.setName(name) + contact.setEmailAddress(emailID) + contactArrayList.add(contact) + contactHashMap[emailID] = "INDETERMINATE" + } + eCur.close() + } + } + } + cur?.close() + contactArrayList.sortWith(Comparator { o1, o2 -> + o1.getName()!!.compareTo(o2.getName()!!) + }) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.chatrooms, menu) + + sortView = menu.findItem(R.id.action_sort) + sortView!!.isVisible = false + + val searchItem = menu.findItem(R.id.action_search) + searchView = searchItem?.actionView as? SearchView + searchView?.setIconifiedByDefault(false) + searchView?.maxWidth = Integer.MAX_VALUE + searchView?.onQueryTextListener { queryContacts(it) } + + 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() + queryContacts("") + return true + } + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + return true + } + } + searchItem?.setOnActionExpandListener(expandListener) + } + + fun containsIgnoreCase(src: String, what: String): Boolean { + val length = what.length + if (length == 0) + return true // Empty string is contained + + val firstLo = Character.toLowerCase(what[0]) + val firstUp = Character.toUpperCase(what[0]) + + for (i in src.length - length downTo 0) { + // Quick check before calling the more expensive regionMatches() method: + val ch = src[i] + if (ch != firstLo && ch != firstUp) + continue + + if (src.regionMatches(i, what, 0, length, ignoreCase = true)) + return true + } + + return false + } + + fun queryContacts(query: String) { + if (query.isBlank() or query.isEmpty()) { + setupFrameLayout(contactArrayList) + } else { + var filteredContactArrayList: ArrayList = ArrayList() + for (contact in contactArrayList) { + if (containsIgnoreCase(contact.getName()!!, query) + || (contact.isPhone() && containsIgnoreCase(contact.getPhoneNumber()!!, query)) + || (!contact.isPhone() && containsIgnoreCase(contact.getEmailAddress()!!, query)) + ) { + filteredContactArrayList.add(contact) + } + } + setupFrameLayout(filteredContactArrayList) + } + } + + private fun populateContacts(actualContacts: Boolean) { + if (actualContacts) { + getContactList() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + when (requestCode) { + MY_PERMISSIONS_REQUEST_RW_CONTACTS -> { + if ( + grantResults.isNotEmpty() + && grantResults[0] == PackageManager.PERMISSION_GRANTED + && grantResults[1] == PackageManager.PERMISSION_GRANTED + ) { + // Permission granted + populateContacts(true) + } else { + populateContacts(false) + } + return + } + else -> { + // Ignore all other requests. + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + + if ( + ContextCompat.checkSelfPermission(context!!, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED + && ContextCompat.checkSelfPermission(context!!, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED + ) { + populateContacts(true) + } else { + ActivityCompat.requestPermissions( + this.activity as Activity, + arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ), + MY_PERMISSIONS_REQUEST_RW_CONTACTS + ) + } + + // Filter before sending to FrameLayout + setupFrameLayout(contactArrayList) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupToolbar() + } + + fun setupToolbar(){ + (activity as AppCompatActivity).supportActionBar?.title = getString(R.string.title_contacts) + (activity as MainActivity).toolbar.setNavigationIcon(R.drawable.ic_arrow_back_white_24dp) + (activity as MainActivity).toolbar.setNavigationOnClickListener { activity?.onBackPressed() } + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + } + + fun setupFrameLayout(filteredContactArrayList: ArrayList) { + try { + val contactListFragment = ContactListFragment.newInstance( + filteredContactArrayList, + contactHashMap + ) + val fragmentTransaction = childFragmentManager.beginTransaction() + fragmentTransaction.replace( + R.id.contacts_area, + contactListFragment, + "CONTACT_LIST_FRAGMENT" + ) + fragmentTransaction.commit() + } catch (exception: IllegalStateException) { + //This is one bad user who clicks too fast + } catch (exception: NullPointerException) { + } + + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_contact_parent, container, false) + + createNewChannelLink = view.findViewById(R.id.create_new_channel_button) + createNewChannelLink!!.setOnClickListener { + val createChannelFragment = CreateChannelFragment() + val transaction = activity?.supportFragmentManager?.beginTransaction(); + transaction?.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right); + transaction?.replace(this.id, createChannelFragment, "createChannelFragment"); + transaction?.addToBackStack(null)?.commit(); + } + + return view + } + + companion object { + + /** + * Create a new ContactList fragment that displays the given list of contacts + * + * @param contactArrayList the list of contacts to load in the recycler view + * @param contactHashMap the mapping of contacts with their registration status + * @return the newly created ContactList fragment + */ + fun newInstance( + contactArrayList: ArrayList, + contactHashMap: HashMap + ): ContactsFragment { + val contactsFragment = ContactsFragment() + + val arguments = Bundle() + arguments.putParcelableArrayList("CONTACT_ARRAY_LIST", contactArrayList) + arguments.putSerializable("CONTACT_HASH_MAP", contactHashMap) + + contactsFragment.arguments = arguments + + return contactsFragment + } + } + +} diff --git a/app/src/main/java/chat/rocket/android/contacts/models/Contact.kt b/app/src/main/java/chat/rocket/android/contacts/models/Contact.kt new file mode 100644 index 0000000000..d567183c9e --- /dev/null +++ b/app/src/main/java/chat/rocket/android/contacts/models/Contact.kt @@ -0,0 +1,70 @@ +package chat.rocket.android.contacts.models + +import android.os.Parcel +import android.os.Parcelable + + +class Contact() : Parcelable { + private var id: Int = 0 + private var name: String? = null + private var phoneNumber: String? = null + private var emailAddress: String? = null + private var isPhone: Boolean = true + + fun getName(): String? { + return name + } + + fun setName(name: String) { + this.name = name + } + + fun getPhoneNumber(): String? { + return phoneNumber + } + + fun setPhoneNumber(phoneNumber: String) { + this.phoneNumber = phoneNumber + } + + fun getEmailAddress(): String? { + return emailAddress + } + + fun setEmailAddress(emailAddress: String) { + this.emailAddress = emailAddress + this.isPhone = false + } + + fun isPhone(): Boolean { + return this.isPhone + } + + constructor(parcel: Parcel) : this() { + this.id = parcel.readInt() + this.name = parcel.readString() + this.phoneNumber = parcel.readString() + } + + + override fun writeToParcel(dest: Parcel?, flags: Int) { + dest?.writeInt(id) + dest?.writeString(name) + dest?.writeString(phoneNumber) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): Contact { + return Contact(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/chat/rocket/android/main/presentation/MainPresenter.kt b/app/src/main/java/chat/rocket/android/main/presentation/MainPresenter.kt index e299409ed9..b50e376eb1 100644 --- a/app/src/main/java/chat/rocket/android/main/presentation/MainPresenter.kt +++ b/app/src/main/java/chat/rocket/android/main/presentation/MainPresenter.kt @@ -36,10 +36,15 @@ import chat.rocket.common.util.ifNull import chat.rocket.core.RocketChatClient import chat.rocket.core.internal.rest.getCustomEmojis import chat.rocket.core.internal.rest.me +import chat.rocket.core.internal.rest.unregisterPushToken +import chat.rocket.core.internal.rest.inviteViaEmail +import chat.rocket.core.internal.rest.inviteViaSMS import chat.rocket.core.model.Myself import kotlinx.coroutines.experimental.channels.Channel import timber.log.Timber import javax.inject.Inject +import chat.rocket.android.util.extensions.showToast + class MainPresenter @Inject constructor( private val view: MainView, @@ -209,6 +214,60 @@ class MainPresenter @Inject constructor( } } + fun inviteViaEmail(email:String) { + launchUI(strategy) { + try { + val result:Boolean = retryIO("inviteViaEmail") { client.inviteViaEmail(email) } + if (result) { + view.showMessage("Invitation Email Sent") + } else{ + view.showMessage("Failed to send Invitation Email") + } + } catch (ex: Exception) { + when (ex) { + is RocketChatAuthException -> { + logout() + } + else -> { + Timber.d(ex, "Error while inviting via email") + ex.message?.let { + view.showMessage(it) + }.ifNull { + view.showGenericErrorMessage() + } + } + } + } + } + } + + fun inviteViaSMS(phone:String) { + launchUI(strategy) { + try { + val result:Boolean = retryIO("inviteViaSMS") { client.inviteViaSMS(phone) } + if (result) { + view.showMessage("Invitation SMS Sent") + } else{ + view.showMessage("Failed to send Invitation SMS") + } + } catch (ex: Exception) { + when (ex) { + is RocketChatAuthException -> { + logout() + } + else -> { + Timber.d(ex, "Error while inviting via SMS") + ex.message?.let { + view.showMessage(it) + }.ifNull { + view.showGenericErrorMessage() + } + } + } + } + } + } + private suspend fun saveAccount(uiModel: NavHeaderUiModel) { val icon = settings.favicon()?.let { currentServer.serverLogoUrl(it) diff --git a/app/src/main/java/chat/rocket/android/main/ui/MainActivity.kt b/app/src/main/java/chat/rocket/android/main/ui/MainActivity.kt index 935a918825..2daa5e58c8 100644 --- a/app/src/main/java/chat/rocket/android/main/ui/MainActivity.kt +++ b/app/src/main/java/chat/rocket/android/main/ui/MainActivity.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.app.AlertDialog import android.app.ProgressDialog import android.os.Bundle +import android.text.Layout import androidx.annotation.IdRes import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GravityCompat diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/app/src/main/res/drawable/ic_add_white_24dp.xml new file mode 100644 index 0000000000..b9b8eca8b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_new_chat.xml b/app/src/main/res/layout/activity_new_chat.xml new file mode 100644 index 0000000000..88d85e04f8 --- /dev/null +++ b/app/src/main/res/layout/activity_new_chat.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_chat_rooms.xml b/app/src/main/res/layout/fragment_chat_rooms.xml index 1a82b796a5..8045f97aa9 100644 --- a/app/src/main/res/layout/fragment_chat_rooms.xml +++ b/app/src/main/res/layout/fragment_chat_rooms.xml @@ -56,4 +56,15 @@ android:textSize="20sp" android:visibility="gone" tools:visibility="visible" /> + + diff --git a/app/src/main/res/layout/fragment_contact_parent.xml b/app/src/main/res/layout/fragment_contact_parent.xml new file mode 100644 index 0000000000..ca48795778 --- /dev/null +++ b/app/src/main/res/layout/fragment_contact_parent.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_contacts.xml b/app/src/main/res/layout/fragment_contacts.xml new file mode 100644 index 0000000000..01e217940e --- /dev/null +++ b/app/src/main/res/layout/fragment_contacts.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_contact.xml b/app/src/main/res/layout/item_contact.xml new file mode 100644 index 0000000000..b786030e11 --- /dev/null +++ b/app/src/main/res/layout/item_contact.xml @@ -0,0 +1,74 @@ + + + + + + + + +