ContactsController.kt 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964
  1. /*
  2. * Nextcloud Talk application
  3. *
  4. * @author Mario Danic
  5. * @author Marcel Hibbe
  6. * @author Andy Scherzinger
  7. * Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
  8. * Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
  9. * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
  10. *
  11. * This program is free software: you can redistribute it and/or modify
  12. * it under the terms of the GNU General Public License as published by
  13. * the Free Software Foundation, either version 3 of the License, or
  14. * at your option) any later version.
  15. *
  16. * This program is distributed in the hope that it will be useful,
  17. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. * GNU General Public License for more details.
  20. *
  21. * You should have received a copy of the GNU General Public License
  22. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  23. */
  24. package com.nextcloud.talk.controllers
  25. import android.app.SearchManager
  26. import android.content.Context
  27. import android.graphics.PorterDuff
  28. import android.os.Build
  29. import android.os.Bundle
  30. import android.text.InputType
  31. import android.util.Log
  32. import android.view.Menu
  33. import android.view.MenuInflater
  34. import android.view.MenuItem
  35. import android.view.View
  36. import android.view.inputmethod.EditorInfo
  37. import androidx.appcompat.widget.SearchView
  38. import androidx.core.content.res.ResourcesCompat
  39. import androidx.core.view.MenuItemCompat
  40. import androidx.work.Data
  41. import androidx.work.OneTimeWorkRequest
  42. import androidx.work.WorkManager
  43. import autodagger.AutoInjector
  44. import com.bluelinelabs.logansquare.LoganSquare
  45. import com.nextcloud.talk.R
  46. import com.nextcloud.talk.adapters.items.ContactItem
  47. import com.nextcloud.talk.adapters.items.GenericTextHeaderItem
  48. import com.nextcloud.talk.api.NcApi
  49. import com.nextcloud.talk.application.NextcloudTalkApplication
  50. import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
  51. import com.nextcloud.talk.controllers.base.NewBaseController
  52. import com.nextcloud.talk.controllers.bottomsheet.ConversationOperationEnum
  53. import com.nextcloud.talk.controllers.util.viewBinding
  54. import com.nextcloud.talk.databinding.ControllerContactsRvBinding
  55. import com.nextcloud.talk.events.OpenConversationEvent
  56. import com.nextcloud.talk.jobs.AddParticipantsToConversation
  57. import com.nextcloud.talk.models.RetrofitBucket
  58. import com.nextcloud.talk.models.database.CapabilitiesUtil
  59. import com.nextcloud.talk.models.database.UserEntity
  60. import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
  61. import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
  62. import com.nextcloud.talk.models.json.conversations.Conversation
  63. import com.nextcloud.talk.models.json.conversations.RoomOverall
  64. import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
  65. import com.nextcloud.talk.models.json.participants.Participant
  66. import com.nextcloud.talk.ui.dialog.ContactsBottomDialog
  67. import com.nextcloud.talk.utils.ApiUtils
  68. import com.nextcloud.talk.utils.ConductorRemapping
  69. import com.nextcloud.talk.utils.bundle.BundleKeys
  70. import com.nextcloud.talk.utils.database.user.UserUtils
  71. import eu.davidea.flexibleadapter.FlexibleAdapter
  72. import eu.davidea.flexibleadapter.SelectableAdapter
  73. import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager
  74. import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
  75. import io.reactivex.Observer
  76. import io.reactivex.android.schedulers.AndroidSchedulers
  77. import io.reactivex.disposables.Disposable
  78. import io.reactivex.schedulers.Schedulers
  79. import okhttp3.ResponseBody
  80. import org.greenrobot.eventbus.EventBus
  81. import org.greenrobot.eventbus.Subscribe
  82. import org.greenrobot.eventbus.ThreadMode
  83. import org.parceler.Parcels
  84. import java.io.IOException
  85. import java.util.ArrayList
  86. import java.util.Collections
  87. import java.util.HashMap
  88. import java.util.HashSet
  89. import java.util.Locale
  90. import javax.inject.Inject
  91. @AutoInjector(NextcloudTalkApplication::class)
  92. class ContactsController(args: Bundle) :
  93. NewBaseController(R.layout.controller_contacts_rv),
  94. SearchView.OnQueryTextListener,
  95. FlexibleAdapter.OnItemClickListener {
  96. private val binding: ControllerContactsRvBinding by viewBinding(ControllerContactsRvBinding::bind)
  97. @Inject
  98. lateinit var userUtils: UserUtils
  99. @Inject
  100. lateinit var eventBus: EventBus
  101. @Inject
  102. lateinit var ncApi: NcApi
  103. private var credentials: String? = null
  104. private var currentUser: UserEntity? = null
  105. private var contactsQueryDisposable: Disposable? = null
  106. private var cacheQueryDisposable: Disposable? = null
  107. private var adapter: FlexibleAdapter<*>? = null
  108. private var contactItems: MutableList<AbstractFlexibleItem<*>>? = null
  109. private var layoutManager: SmoothScrollLinearLayoutManager? = null
  110. private var searchItem: MenuItem? = null
  111. private var searchView: SearchView? = null
  112. private var isNewConversationView = false
  113. private var isPublicCall = false
  114. private var userHeaderItems: HashMap<String, GenericTextHeaderItem> = HashMap<String, GenericTextHeaderItem>()
  115. private var alreadyFetching = false
  116. private var doneMenuItem: MenuItem? = null
  117. private var selectedUserIds: MutableSet<String> = HashSet()
  118. private var selectedGroupIds: MutableSet<String> = HashSet()
  119. private var selectedCircleIds: MutableSet<String> = HashSet()
  120. private var selectedEmails: MutableSet<String> = HashSet()
  121. private var existingParticipants: List<String>? = null
  122. private var isAddingParticipantsView = false
  123. private var conversationToken: String? = null
  124. private var contactsBottomDialog: ContactsBottomDialog? = null
  125. init {
  126. setHasOptionsMenu(true)
  127. sharedApplication!!.componentApplication.inject(this)
  128. if (args.containsKey(BundleKeys.KEY_NEW_CONVERSATION)) {
  129. isNewConversationView = true
  130. existingParticipants = ArrayList()
  131. } else if (args.containsKey(BundleKeys.KEY_ADD_PARTICIPANTS)) {
  132. isAddingParticipantsView = true
  133. conversationToken = args.getString(BundleKeys.KEY_TOKEN)
  134. existingParticipants = ArrayList()
  135. if (args.containsKey(BundleKeys.KEY_EXISTING_PARTICIPANTS)) {
  136. existingParticipants = args.getStringArrayList(BundleKeys.KEY_EXISTING_PARTICIPANTS)
  137. }
  138. }
  139. selectedUserIds = HashSet()
  140. selectedGroupIds = HashSet()
  141. selectedEmails = HashSet()
  142. selectedCircleIds = HashSet()
  143. }
  144. override fun onAttach(view: View) {
  145. super.onAttach(view)
  146. eventBus.register(this)
  147. if (isNewConversationView) {
  148. toggleNewCallHeaderVisibility(!isPublicCall)
  149. }
  150. if (isAddingParticipantsView) {
  151. binding.joinConversationViaLink.joinConversationViaLinkRelativeLayout.visibility = View.GONE
  152. binding.conversationPrivacyToggle.callHeaderLayout.visibility = View.GONE
  153. } else {
  154. binding.joinConversationViaLink.joinConversationViaLinkRelativeLayout.setOnClickListener {
  155. joinConversationViaLink()
  156. }
  157. binding.conversationPrivacyToggle.callHeaderLayout.setOnClickListener {
  158. toggleCallHeader()
  159. }
  160. }
  161. }
  162. override fun onViewBound(view: View) {
  163. super.onViewBound(view)
  164. currentUser = userUtils.currentUser
  165. if (currentUser != null) {
  166. credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token)
  167. }
  168. if (adapter == null) {
  169. contactItems = ArrayList<AbstractFlexibleItem<*>>()
  170. adapter = FlexibleAdapter(contactItems, activity, false)
  171. if (currentUser != null) {
  172. fetchData()
  173. }
  174. }
  175. setupAdapter()
  176. prepareViews()
  177. }
  178. private fun setupAdapter() {
  179. adapter?.setNotifyChangeOfUnfilteredItems(true)?.mode = SelectableAdapter.Mode.MULTI
  180. adapter?.setStickyHeaderElevation(HEADER_ELEVATION)
  181. ?.setUnlinkAllItemsOnRemoveHeaders(true)
  182. ?.setDisplayHeadersAtStartUp(true)
  183. ?.setStickyHeaders(true)
  184. adapter?.addListener(this)
  185. }
  186. private fun selectionDone() {
  187. if (!isAddingParticipantsView) {
  188. if (!isPublicCall && selectedCircleIds.size + selectedGroupIds.size + selectedUserIds.size == 1) {
  189. val userId: String
  190. var sourceType: String? = null
  191. var roomType = "1"
  192. when {
  193. selectedGroupIds.size == 1 -> {
  194. roomType = "2"
  195. userId = selectedGroupIds.iterator().next()
  196. }
  197. selectedCircleIds.size == 1 -> {
  198. roomType = "2"
  199. sourceType = "circles"
  200. userId = selectedCircleIds.iterator().next()
  201. }
  202. else -> {
  203. userId = selectedUserIds.iterator().next()
  204. }
  205. }
  206. createRoom(roomType, sourceType, userId)
  207. } else {
  208. val bundle = Bundle()
  209. val roomType: Conversation.ConversationType = if (isPublicCall) {
  210. Conversation.ConversationType.ROOM_PUBLIC_CALL
  211. } else {
  212. Conversation.ConversationType.ROOM_GROUP_CALL
  213. }
  214. val userIdsArray = ArrayList(selectedUserIds)
  215. val groupIdsArray = ArrayList(selectedGroupIds)
  216. val emailsArray = ArrayList(selectedEmails)
  217. val circleIdsArray = ArrayList(selectedCircleIds)
  218. bundle.putParcelable(BundleKeys.KEY_CONVERSATION_TYPE, Parcels.wrap(roomType))
  219. bundle.putStringArrayList(BundleKeys.KEY_INVITED_PARTICIPANTS, userIdsArray)
  220. bundle.putStringArrayList(BundleKeys.KEY_INVITED_GROUP, groupIdsArray)
  221. bundle.putStringArrayList(BundleKeys.KEY_INVITED_EMAIL, emailsArray)
  222. bundle.putStringArrayList(BundleKeys.KEY_INVITED_CIRCLE, circleIdsArray)
  223. bundle.putSerializable(BundleKeys.KEY_OPERATION_CODE, ConversationOperationEnum.OPS_CODE_INVITE_USERS)
  224. prepareAndShowBottomSheetWithBundle(bundle)
  225. }
  226. } else {
  227. addParticipantsToConversation()
  228. }
  229. }
  230. private fun createRoom(roomType: String, sourceType: String?, userId: String) {
  231. val apiVersion: Int = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.APIv4, 1))
  232. val retrofitBucket: RetrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
  233. apiVersion,
  234. currentUser!!.baseUrl,
  235. roomType,
  236. sourceType,
  237. userId,
  238. null
  239. )
  240. ncApi.createRoom(
  241. credentials,
  242. retrofitBucket.getUrl(), retrofitBucket.getQueryMap()
  243. )
  244. .subscribeOn(Schedulers.io())
  245. .observeOn(AndroidSchedulers.mainThread())
  246. .subscribe(object : Observer<RoomOverall> {
  247. override fun onSubscribe(d: Disposable) {
  248. // unused atm
  249. }
  250. override fun onNext(roomOverall: RoomOverall) {
  251. val bundle = Bundle()
  252. bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, currentUser)
  253. bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomOverall.getOcs().getData().getToken())
  254. bundle.putString(BundleKeys.KEY_ROOM_ID, roomOverall.getOcs().getData().getRoomId())
  255. // FIXME once APIv2 or later is used only, the createRoom already returns all the data
  256. ncApi.getRoom(
  257. credentials,
  258. ApiUtils.getUrlForRoom(
  259. apiVersion, currentUser!!.baseUrl,
  260. roomOverall.getOcs().getData().getToken()
  261. )
  262. )
  263. .subscribeOn(Schedulers.io())
  264. .observeOn(AndroidSchedulers.mainThread())
  265. .subscribe(object : Observer<RoomOverall> {
  266. override fun onSubscribe(d: Disposable) {
  267. // unused atm
  268. }
  269. override fun onNext(roomOverall: RoomOverall) {
  270. bundle.putParcelable(
  271. BundleKeys.KEY_ACTIVE_CONVERSATION,
  272. Parcels.wrap(roomOverall.getOcs().getData())
  273. )
  274. ConductorRemapping.remapChatController(
  275. router, currentUser!!.id,
  276. roomOverall.getOcs().getData().getToken(), bundle, true
  277. )
  278. }
  279. override fun onError(e: Throwable) {
  280. // unused atm
  281. }
  282. override fun onComplete() {
  283. // unused atm
  284. }
  285. })
  286. }
  287. override fun onError(e: Throwable) {
  288. // unused atm
  289. }
  290. override fun onComplete() {
  291. // unused atm
  292. }
  293. })
  294. }
  295. private fun addParticipantsToConversation() {
  296. val userIdsArray: Array<String> = selectedUserIds.toTypedArray<String>()
  297. val groupIdsArray: Array<String> = selectedGroupIds.toTypedArray<String>()
  298. val emailsArray: Array<String> = selectedEmails.toTypedArray<String>()
  299. val circleIdsArray: Array<String> = selectedCircleIds.toTypedArray<String>()
  300. val data = Data.Builder()
  301. data.putLong(BundleKeys.KEY_INTERNAL_USER_ID, currentUser!!.id)
  302. data.putString(BundleKeys.KEY_TOKEN, conversationToken)
  303. data.putStringArray(BundleKeys.KEY_SELECTED_USERS, userIdsArray)
  304. data.putStringArray(BundleKeys.KEY_SELECTED_GROUPS, groupIdsArray)
  305. data.putStringArray(BundleKeys.KEY_SELECTED_EMAILS, emailsArray)
  306. data.putStringArray(BundleKeys.KEY_SELECTED_CIRCLES, circleIdsArray)
  307. val addParticipantsToConversationWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(
  308. AddParticipantsToConversation::class.java
  309. ).setInputData(data.build()).build()
  310. WorkManager.getInstance().enqueue(addParticipantsToConversationWorker)
  311. router.popCurrentController()
  312. }
  313. private fun initSearchView() {
  314. if (activity != null) {
  315. val searchManager: SearchManager? = activity?.getSystemService(Context.SEARCH_SERVICE) as SearchManager?
  316. if (searchItem != null) {
  317. searchView = MenuItemCompat.getActionView(searchItem) as SearchView
  318. searchView!!.maxWidth = Int.MAX_VALUE
  319. searchView!!.inputType = InputType.TYPE_TEXT_VARIATION_FILTER
  320. var imeOptions: Int = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN
  321. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
  322. appPreferences?.isKeyboardIncognito == true
  323. ) {
  324. imeOptions = imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
  325. }
  326. searchView!!.imeOptions = imeOptions
  327. searchView!!.queryHint = resources!!.getString(R.string.nc_search)
  328. if (searchManager != null) {
  329. searchView!!.setSearchableInfo(searchManager.getSearchableInfo(activity?.componentName))
  330. }
  331. searchView!!.setOnQueryTextListener(this)
  332. }
  333. }
  334. }
  335. override fun onOptionsItemSelected(item: MenuItem): Boolean {
  336. return when (item.itemId) {
  337. R.id.home -> {
  338. router.popCurrentController()
  339. }
  340. R.id.contacts_selection_done -> {
  341. selectionDone()
  342. true
  343. }
  344. else -> {
  345. super.onOptionsItemSelected(item)
  346. }
  347. }
  348. }
  349. override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  350. super.onCreateOptionsMenu(menu, inflater)
  351. inflater.inflate(R.menu.menu_contacts, menu)
  352. searchItem = menu.findItem(R.id.action_search)
  353. doneMenuItem = menu.findItem(R.id.contacts_selection_done)
  354. initSearchView()
  355. }
  356. override fun onPrepareOptionsMenu(menu: Menu) {
  357. super.onPrepareOptionsMenu(menu)
  358. checkAndHandleDoneMenuItem()
  359. if (adapter?.hasFilter() == true) {
  360. searchItem!!.expandActionView()
  361. searchView!!.setQuery(adapter!!.getFilter(String::class.java) as CharSequence, false)
  362. }
  363. }
  364. @Suppress("Detekt.TooGenericExceptionCaught")
  365. private fun fetchData() {
  366. dispose(null)
  367. alreadyFetching = true
  368. userHeaderItems = HashMap<String, GenericTextHeaderItem>()
  369. val query = adapter!!.getFilter(String::class.java) as String?
  370. val retrofitBucket: RetrofitBucket =
  371. ApiUtils.getRetrofitBucketForContactsSearchFor14(currentUser!!.baseUrl, query)
  372. val modifiedQueryMap: HashMap<String, Any?> = HashMap<String, Any?>(retrofitBucket.getQueryMap())
  373. modifiedQueryMap.put("limit", CONTACTS_BATCH_SIZE)
  374. if (isAddingParticipantsView) {
  375. modifiedQueryMap.put("itemId", conversationToken)
  376. }
  377. val shareTypesList: ArrayList<String> = ArrayList()
  378. // users
  379. shareTypesList.add("0")
  380. if (!isAddingParticipantsView) {
  381. // groups
  382. shareTypesList.add("1")
  383. } else if (CapabilitiesUtil.hasSpreedFeatureCapability(currentUser, "invite-groups-and-mails")) {
  384. // groups
  385. shareTypesList.add("1")
  386. // emails
  387. shareTypesList.add("4")
  388. }
  389. if (CapabilitiesUtil.hasSpreedFeatureCapability(currentUser, "circles-support")) {
  390. // circles
  391. shareTypesList.add("7")
  392. }
  393. modifiedQueryMap.put("shareTypes[]", shareTypesList)
  394. ncApi.getContactsWithSearchParam(
  395. credentials,
  396. retrofitBucket.getUrl(), shareTypesList, modifiedQueryMap
  397. )
  398. .subscribeOn(Schedulers.io())
  399. .observeOn(AndroidSchedulers.mainThread())
  400. .retry(RETRIES)
  401. .subscribe(object : Observer<ResponseBody> {
  402. override fun onSubscribe(d: Disposable) {
  403. contactsQueryDisposable = d
  404. }
  405. override fun onNext(responseBody: ResponseBody) {
  406. val newUserItemList = processAutocompleteUserList(responseBody)
  407. userHeaderItems = HashMap<String, GenericTextHeaderItem>()
  408. contactItems!!.addAll(newUserItemList)
  409. sortUserItems(newUserItemList)
  410. if (newUserItemList.size > 0) {
  411. adapter?.updateDataSet(newUserItemList as List<Nothing>?)
  412. } else {
  413. adapter?.filterItems()
  414. }
  415. try {
  416. binding.controllerGenericRv.swipeRefreshLayout.isRefreshing = false
  417. } catch (npe: NullPointerException) {
  418. // view binding can be null
  419. // since this is called asynchronously and UI might have been destroyed in the meantime
  420. Log.i(TAG, "UI destroyed - view binding already gone")
  421. }
  422. }
  423. override fun onError(e: Throwable) {
  424. try {
  425. binding.controllerGenericRv.swipeRefreshLayout.isRefreshing = false
  426. } catch (npe: NullPointerException) {
  427. // view binding can be null
  428. // since this is called asynchronously and UI might have been destroyed in the meantime
  429. Log.i(TAG, "UI destroyed - view binding already gone")
  430. }
  431. dispose(contactsQueryDisposable)
  432. }
  433. override fun onComplete() {
  434. try {
  435. binding.controllerGenericRv.swipeRefreshLayout.isRefreshing = false
  436. } catch (npe: NullPointerException) {
  437. // view binding can be null
  438. // since this is called asynchronously and UI might have been destroyed in the meantime
  439. Log.i(TAG, "UI destroyed - view binding already gone")
  440. }
  441. dispose(contactsQueryDisposable)
  442. alreadyFetching = false
  443. disengageProgressBar()
  444. }
  445. })
  446. }
  447. private fun processAutocompleteUserList(responseBody: ResponseBody): MutableList<AbstractFlexibleItem<*>> {
  448. try {
  449. val autocompleteOverall: AutocompleteOverall = LoganSquare.parse<AutocompleteOverall>(
  450. responseBody.string(),
  451. AutocompleteOverall::class.java
  452. )
  453. val autocompleteUsersList: ArrayList<AutocompleteUser> = ArrayList<AutocompleteUser>()
  454. autocompleteUsersList.addAll(autocompleteOverall.ocs!!.data!!)
  455. return processAutocompleteUserList(autocompleteUsersList)
  456. } catch (ioe: IOException) {
  457. Log.e(TAG, "Parsing response body failed while getting contacts", ioe)
  458. }
  459. return ArrayList<AbstractFlexibleItem<*>>()
  460. }
  461. private fun processAutocompleteUserList(
  462. autocompleteUsersList: ArrayList<AutocompleteUser>
  463. ): MutableList<AbstractFlexibleItem<*>> {
  464. var participant: Participant
  465. val actorTypeConverter = EnumActorTypeConverter()
  466. val newUserItemList: MutableList<AbstractFlexibleItem<*>> = ArrayList<AbstractFlexibleItem<*>>()
  467. for (autocompleteUser in autocompleteUsersList) {
  468. if (autocompleteUser.id != currentUser!!.userId &&
  469. !existingParticipants!!.contains(autocompleteUser.id!!)
  470. ) {
  471. participant = createParticipant(autocompleteUser, actorTypeConverter)
  472. val headerTitle = getHeaderTitle(participant)
  473. var genericTextHeaderItem: GenericTextHeaderItem
  474. if (!userHeaderItems.containsKey(headerTitle)) {
  475. genericTextHeaderItem = GenericTextHeaderItem(headerTitle)
  476. userHeaderItems.put(headerTitle, genericTextHeaderItem)
  477. }
  478. val newContactItem = ContactItem(
  479. participant,
  480. currentUser,
  481. userHeaderItems[headerTitle]
  482. )
  483. if (!contactItems!!.contains(newContactItem)) {
  484. newUserItemList.add(newContactItem)
  485. }
  486. }
  487. }
  488. return newUserItemList
  489. }
  490. private fun getHeaderTitle(participant: Participant): String {
  491. return when {
  492. participant.calculatedActorType == Participant.ActorType.GROUPS -> {
  493. resources!!.getString(R.string.nc_groups)
  494. }
  495. participant.calculatedActorType == Participant.ActorType.CIRCLES -> {
  496. resources!!.getString(R.string.nc_circles)
  497. }
  498. else -> {
  499. participant.displayName!!.substring(0, 1).toUpperCase(Locale.getDefault())
  500. }
  501. }
  502. }
  503. private fun createParticipant(
  504. autocompleteUser: AutocompleteUser,
  505. actorTypeConverter: EnumActorTypeConverter
  506. ): Participant {
  507. val participant = Participant()
  508. participant.actorId = autocompleteUser.id
  509. participant.actorType = actorTypeConverter.getFromString(autocompleteUser.source)
  510. participant.displayName = autocompleteUser.label
  511. participant.source = autocompleteUser.source
  512. return participant
  513. }
  514. private fun sortUserItems(newUserItemList: MutableList<AbstractFlexibleItem<*>>) {
  515. Collections.sort(
  516. newUserItemList,
  517. { o1: AbstractFlexibleItem<*>, o2: AbstractFlexibleItem<*> ->
  518. val firstName: String = if (o1 is ContactItem) {
  519. (o1 as ContactItem).model.displayName!!
  520. } else {
  521. (o1 as GenericTextHeaderItem).model
  522. }
  523. val secondName: String = if (o2 is ContactItem) {
  524. (o2 as ContactItem).model.displayName!!
  525. } else {
  526. (o2 as GenericTextHeaderItem).model
  527. }
  528. if (o1 is ContactItem && o2 is ContactItem) {
  529. val firstSource: String = (o1 as ContactItem).model.source!!
  530. val secondSource: String = (o2 as ContactItem).model.source!!
  531. if (firstSource == secondSource) {
  532. return@sort firstName.compareTo(secondName, ignoreCase = true)
  533. }
  534. // First users
  535. if ("users" == firstSource) {
  536. return@sort -1
  537. } else if ("users" == secondSource) {
  538. return@sort 1
  539. }
  540. // Then groups
  541. if ("groups" == firstSource) {
  542. return@sort -1
  543. } else if ("groups" == secondSource) {
  544. return@sort 1
  545. }
  546. // Then circles
  547. if ("circles" == firstSource) {
  548. return@sort -1
  549. } else if ("circles" == secondSource) {
  550. return@sort 1
  551. }
  552. // Otherwise fall back to name sorting
  553. return@sort firstName.compareTo(secondName, ignoreCase = true)
  554. }
  555. firstName.compareTo(secondName, ignoreCase = true)
  556. }
  557. )
  558. Collections.sort(
  559. contactItems
  560. ) { o1: AbstractFlexibleItem<*>, o2: AbstractFlexibleItem<*> ->
  561. val firstName: String = if (o1 is ContactItem) {
  562. (o1 as ContactItem).model.displayName!!
  563. } else {
  564. (o1 as GenericTextHeaderItem).model
  565. }
  566. val secondName: String = if (o2 is ContactItem) {
  567. (o2 as ContactItem).model.displayName!!
  568. } else {
  569. (o2 as GenericTextHeaderItem).model
  570. }
  571. if (o1 is ContactItem && o2 is ContactItem) {
  572. if ("groups" == (o1 as ContactItem).model.source &&
  573. "groups" == (o2 as ContactItem).model.source
  574. ) {
  575. return@sort firstName.compareTo(secondName, ignoreCase = true)
  576. } else if ("groups" == (o1 as ContactItem).model.source) {
  577. return@sort -1
  578. } else if ("groups" == (o2 as ContactItem).model.source) {
  579. return@sort 1
  580. }
  581. }
  582. firstName.compareTo(secondName, ignoreCase = true)
  583. }
  584. }
  585. private fun prepareViews() {
  586. layoutManager = SmoothScrollLinearLayoutManager(activity)
  587. binding.controllerGenericRv.recyclerView.layoutManager = layoutManager
  588. binding.controllerGenericRv.recyclerView.setHasFixedSize(true)
  589. binding.controllerGenericRv.recyclerView.adapter = adapter
  590. binding.controllerGenericRv.swipeRefreshLayout.setOnRefreshListener { fetchData() }
  591. binding.controllerGenericRv.swipeRefreshLayout.setColorSchemeResources(R.color.colorPrimary)
  592. binding.controllerGenericRv.swipeRefreshLayout
  593. .setProgressBackgroundColorSchemeResource(R.color.refresh_spinner_background)
  594. binding.joinConversationViaLink.joinConversationViaLinkImageView
  595. .background
  596. .setColorFilter(
  597. ResourcesCompat.getColor(resources!!, R.color.colorBackgroundDarker, null),
  598. PorterDuff.Mode.SRC_IN
  599. )
  600. binding.conversationPrivacyToggle.publicCallLink
  601. .background
  602. .setColorFilter(
  603. ResourcesCompat.getColor(resources!!, R.color.colorPrimary, null),
  604. PorterDuff.Mode.SRC_IN
  605. )
  606. disengageProgressBar()
  607. }
  608. private fun disengageProgressBar() {
  609. if (!alreadyFetching) {
  610. binding.loadingContent.visibility = View.GONE
  611. binding.controllerGenericRv.root.visibility = View.VISIBLE
  612. if (isNewConversationView) {
  613. binding.conversationPrivacyToggle.callHeaderLayout.visibility = View.VISIBLE
  614. binding.joinConversationViaLink.joinConversationViaLinkRelativeLayout.visibility = View.VISIBLE
  615. }
  616. }
  617. }
  618. private fun dispose(disposable: Disposable?) {
  619. if (disposable != null && !disposable.isDisposed) {
  620. disposable.dispose()
  621. } else if (disposable == null) {
  622. if (contactsQueryDisposable != null && !contactsQueryDisposable!!.isDisposed) {
  623. contactsQueryDisposable!!.dispose()
  624. contactsQueryDisposable = null
  625. }
  626. if (cacheQueryDisposable != null && !cacheQueryDisposable!!.isDisposed) {
  627. cacheQueryDisposable!!.dispose()
  628. cacheQueryDisposable = null
  629. }
  630. }
  631. }
  632. override fun onSaveViewState(view: View, outState: Bundle) {
  633. adapter?.onSaveInstanceState(outState)
  634. super.onSaveViewState(view, outState)
  635. }
  636. override fun onRestoreViewState(view: View, savedViewState: Bundle) {
  637. super.onRestoreViewState(view, savedViewState)
  638. if (adapter != null) {
  639. adapter?.onRestoreInstanceState(savedViewState)
  640. }
  641. }
  642. override fun onDestroy() {
  643. super.onDestroy()
  644. dispose(null)
  645. }
  646. @Suppress("Detekt.TooGenericExceptionCaught")
  647. override fun onQueryTextChange(newText: String): Boolean {
  648. if (newText != "" && adapter?.hasNewFilter(newText) == true) {
  649. adapter?.setFilter(newText)
  650. fetchData()
  651. } else if (newText == "") {
  652. adapter?.setFilter("")
  653. adapter?.updateDataSet(contactItems as List<Nothing>?)
  654. }
  655. try {
  656. binding.controllerGenericRv.swipeRefreshLayout.isEnabled = !adapter!!.hasFilter()
  657. } catch (npe: NullPointerException) {
  658. // view binding can be null
  659. // since this is called asynchronously and UI might have been destroyed in the meantime
  660. Log.i(TAG, "UI destroyed - view binding already gone")
  661. }
  662. return true
  663. }
  664. override fun onQueryTextSubmit(query: String): Boolean {
  665. return onQueryTextChange(query)
  666. }
  667. private fun checkAndHandleDoneMenuItem() {
  668. if (adapter != null && doneMenuItem != null) {
  669. doneMenuItem!!.isVisible =
  670. selectedCircleIds.size + selectedEmails.size + selectedGroupIds.size + selectedUserIds.size > 0 ||
  671. isPublicCall
  672. } else if (doneMenuItem != null) {
  673. doneMenuItem!!.isVisible = false
  674. }
  675. }
  676. override val title: String
  677. get() = when {
  678. isAddingParticipantsView -> {
  679. resources!!.getString(R.string.nc_add_participants)
  680. }
  681. isNewConversationView -> {
  682. resources!!.getString(R.string.nc_select_participants)
  683. }
  684. else -> {
  685. resources!!.getString(R.string.nc_app_product_name)
  686. }
  687. }
  688. private fun prepareAndShowBottomSheetWithBundle(bundle: Bundle) {
  689. // 11: create conversation-enter name for new conversation
  690. // 10: get&join room when enter link
  691. contactsBottomDialog = ContactsBottomDialog(activity!!, bundle)
  692. contactsBottomDialog?.show()
  693. }
  694. @Subscribe(threadMode = ThreadMode.MAIN)
  695. fun onMessageEvent(openConversationEvent: OpenConversationEvent) {
  696. ConductorRemapping.remapChatController(
  697. router, currentUser!!.id,
  698. openConversationEvent.conversation!!.getToken(),
  699. openConversationEvent.bundle!!, true
  700. )
  701. contactsBottomDialog?.dismiss()
  702. }
  703. override fun onDetach(view: View) {
  704. super.onDetach(view)
  705. eventBus.unregister(this)
  706. }
  707. override fun onItemClick(view: View, position: Int): Boolean {
  708. if (adapter?.getItem(position) is ContactItem) {
  709. if (!isNewConversationView && !isAddingParticipantsView) {
  710. createRoom(adapter?.getItem(position) as ContactItem)
  711. } else {
  712. val participant: Participant = (adapter?.getItem(position) as ContactItem).model
  713. updateSelection((adapter?.getItem(position) as ContactItem))
  714. }
  715. }
  716. return true
  717. }
  718. private fun updateSelection(contactItem: ContactItem) {
  719. contactItem.model.selected = !contactItem.model.selected
  720. updateSelectionLists(contactItem.model)
  721. if (CapabilitiesUtil.hasSpreedFeatureCapability(currentUser, "last-room-activity") &&
  722. !CapabilitiesUtil.hasSpreedFeatureCapability(currentUser, "invite-groups-and-mails") &&
  723. isValidGroupSelection(contactItem, contactItem.model, adapter)
  724. ) {
  725. val currentItems: List<ContactItem> = adapter?.currentItems as List<ContactItem>
  726. var internalParticipant: Participant
  727. for (i in currentItems.indices) {
  728. internalParticipant = currentItems[i].model
  729. if (internalParticipant.calculatedActorId == contactItem.model.calculatedActorId &&
  730. internalParticipant.calculatedActorType == Participant.ActorType.GROUPS &&
  731. internalParticipant.selected
  732. ) {
  733. internalParticipant.selected = false
  734. selectedGroupIds.remove(internalParticipant.calculatedActorId!!)
  735. }
  736. }
  737. }
  738. adapter?.notifyDataSetChanged()
  739. checkAndHandleDoneMenuItem()
  740. }
  741. private fun createRoom(contactItem: ContactItem) {
  742. var roomType = "1"
  743. if ("groups" == contactItem.model.source) {
  744. roomType = "2"
  745. }
  746. val apiVersion: Int = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.APIv4, 1))
  747. val retrofitBucket: RetrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
  748. apiVersion,
  749. currentUser!!.baseUrl,
  750. roomType,
  751. null,
  752. contactItem.model.calculatedActorId,
  753. null
  754. )
  755. ncApi.createRoom(
  756. credentials,
  757. retrofitBucket.getUrl(), retrofitBucket.getQueryMap()
  758. )
  759. .subscribeOn(Schedulers.io())
  760. .observeOn(AndroidSchedulers.mainThread())
  761. .subscribe(object : Observer<RoomOverall> {
  762. override fun onSubscribe(d: Disposable) {
  763. // unused atm
  764. }
  765. override fun onNext(roomOverall: RoomOverall) {
  766. if (activity != null) {
  767. val bundle = Bundle()
  768. bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, currentUser)
  769. bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomOverall.getOcs().getData().getToken())
  770. bundle.putString(BundleKeys.KEY_ROOM_ID, roomOverall.getOcs().getData().getRoomId())
  771. bundle.putParcelable(
  772. BundleKeys.KEY_ACTIVE_CONVERSATION,
  773. Parcels.wrap(roomOverall.getOcs().getData())
  774. )
  775. ConductorRemapping.remapChatController(
  776. router,
  777. currentUser!!.id,
  778. roomOverall.getOcs().getData().getToken(),
  779. bundle,
  780. true
  781. )
  782. }
  783. }
  784. override fun onError(e: Throwable) {
  785. // unused atm
  786. }
  787. override fun onComplete() {
  788. // unused atm
  789. }
  790. })
  791. }
  792. private fun updateSelectionLists(participant: Participant) {
  793. if ("groups" == participant.source) {
  794. if (participant.selected) {
  795. selectedGroupIds.add(participant.calculatedActorId!!)
  796. } else {
  797. selectedGroupIds.remove(participant.calculatedActorId!!)
  798. }
  799. } else if ("emails" == participant.source) {
  800. if (participant.selected) {
  801. selectedEmails.add(participant.calculatedActorId!!)
  802. } else {
  803. selectedEmails.remove(participant.calculatedActorId!!)
  804. }
  805. } else if ("circles" == participant.source) {
  806. if (participant.selected) {
  807. selectedCircleIds.add(participant.calculatedActorId!!)
  808. } else {
  809. selectedCircleIds.remove(participant.calculatedActorId!!)
  810. }
  811. } else {
  812. if (participant.selected) {
  813. selectedUserIds.add(participant.calculatedActorId!!)
  814. } else {
  815. selectedUserIds.remove(participant.calculatedActorId!!)
  816. }
  817. }
  818. }
  819. private fun isValidGroupSelection(
  820. contactItem: ContactItem,
  821. participant: Participant,
  822. adapter: FlexibleAdapter<*>?
  823. ): Boolean {
  824. return "groups" == contactItem.model.source && participant.selected && adapter?.selectedItemCount!! > 1
  825. }
  826. private fun joinConversationViaLink() {
  827. val bundle = Bundle()
  828. bundle.putSerializable(BundleKeys.KEY_OPERATION_CODE, ConversationOperationEnum.OPS_CODE_GET_AND_JOIN_ROOM)
  829. prepareAndShowBottomSheetWithBundle(bundle)
  830. }
  831. private fun toggleCallHeader() {
  832. toggleNewCallHeaderVisibility(isPublicCall)
  833. isPublicCall = !isPublicCall
  834. if (isPublicCall) {
  835. binding.joinConversationViaLink.joinConversationViaLinkRelativeLayout.visibility = View.GONE
  836. updateGroupParticipantSelection()
  837. } else {
  838. binding.joinConversationViaLink.joinConversationViaLinkRelativeLayout.visibility = View.VISIBLE
  839. }
  840. enableContactForNonPublicCall()
  841. checkAndHandleDoneMenuItem()
  842. adapter?.notifyDataSetChanged()
  843. }
  844. private fun updateGroupParticipantSelection() {
  845. val currentItems: List<AbstractFlexibleItem<*>> = adapter?.currentItems as
  846. List<AbstractFlexibleItem<*>>
  847. var internalParticipant: Participant
  848. for (i in currentItems.indices) {
  849. if (currentItems[i] is ContactItem) {
  850. internalParticipant = (currentItems[i] as ContactItem).model
  851. if (internalParticipant.calculatedActorType == Participant.ActorType.GROUPS &&
  852. internalParticipant.selected
  853. ) {
  854. internalParticipant.selected = false
  855. selectedGroupIds.remove(internalParticipant.calculatedActorId)
  856. }
  857. }
  858. }
  859. }
  860. private fun enableContactForNonPublicCall() {
  861. for (i in 0 until adapter!!.itemCount) {
  862. if (adapter?.getItem(i) is ContactItem) {
  863. val contactItem: ContactItem = adapter?.getItem(i) as ContactItem
  864. if ("groups" == contactItem.model.source) {
  865. contactItem.isEnabled = !isPublicCall
  866. }
  867. }
  868. }
  869. }
  870. @Suppress("Detekt.TooGenericExceptionCaught")
  871. private fun toggleNewCallHeaderVisibility(showInitialLayout: Boolean) {
  872. try {
  873. if (showInitialLayout) {
  874. binding.conversationPrivacyToggle.initialRelativeLayout.visibility = View.VISIBLE
  875. binding.conversationPrivacyToggle.secondaryRelativeLayout.visibility = View.GONE
  876. } else {
  877. binding.conversationPrivacyToggle.initialRelativeLayout.visibility = View.GONE
  878. binding.conversationPrivacyToggle.secondaryRelativeLayout.visibility = View.VISIBLE
  879. }
  880. } catch (npe: NullPointerException) {
  881. // view binding can be null
  882. // since this is called asynchronously and UI might have been destroyed in the meantime
  883. Log.i(TAG, "UI destroyed - view binding already gone")
  884. }
  885. }
  886. companion object {
  887. const val TAG = "ContactsController"
  888. const val RETRIES: Long = 3
  889. const val CONTACTS_BATCH_SIZE: Int = 50
  890. const val HEADER_ELEVATION: Int = 5
  891. }
  892. }