ContactsController.kt 40 KB

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