diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt index 5860c4bcf..9bcaaee70 100644 --- a/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt +++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt @@ -48,10 +48,14 @@ class QrCodeViewModel @UiThread constructor() : ViewModel() { if (!isValidUrl) { Log.e("$TAG The content of the QR Code doesn't seem to be a valid web URL") } else { - Log.i("$TAG QR code URL set, restarting the Core") + Log.i( + "$TAG QR code URL set, restarting the Core to apply configuration changes" + ) core.provisioningUri = result coreContext.core.stop() + Log.i("$TAG Core has been stopped, restarting it") coreContext.core.start() + Log.i("$TAG Core has been restarted") } qrCodeFoundEvent.postValue(Event(isValidUrl)) } diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt index e88dd0cb2..39076038f 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt @@ -24,6 +24,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.Animation +import android.view.animation.AnimationUtils import androidx.core.view.doOnPreDraw import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController @@ -51,10 +52,10 @@ class ConversationsFragment : GenericFragment() { } override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { - /*if (findNavController().currentDestination?.id == R.id.newConversationFragment) { + if (findNavController().currentDestination?.id == R.id.startConversationFragment) { // Holds fragment in place while new contact fragment slides over it return AnimationUtils.loadAnimation(activity, R.anim.hold) - }*/ + } return super.onCreateAnimation(transit, enter, nextAnim) } @@ -93,6 +94,13 @@ class ConversationsFragment : GenericFragment() { } } + sharedViewModel.showStartConversationEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i("$TAG Navigating to start conversation fragment") + findNavController().navigate(R.id.action_global_startConversationFragment) + } + } + sharedViewModel.navigateToContactsEvent.observe(viewLifecycleOwner) { it.consume { if (findNavController().currentDestination?.id == R.id.conversationsFragment) { diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt index ace1bfa21..e71ce3bca 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt @@ -32,6 +32,7 @@ import org.linphone.ui.main.chat.adapter.ConversationsListAdapter import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel import org.linphone.ui.main.fragment.AbstractTopBarFragment import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment +import org.linphone.utils.Event import org.linphone.utils.hideKeyboard import org.linphone.utils.showKeyboard @@ -112,9 +113,14 @@ class ConversationsListFragment : AbstractTopBarFragment() { adapter.conversationClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> Log.i("$TAG Show conversation with ID [${model.id}]") + // TODO } } + binding.setOnNewConversationClicked { + sharedViewModel.showStartConversationEvent.value = Event(true) + } + listViewModel.conversations.observe(viewLifecycleOwner) { val currentCount = adapter.itemCount adapter.submitList(it) diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/StartConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/StartConversationFragment.kt new file mode 100644 index 000000000..2ee23cb34 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/StartConversationFragment.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.main.chat.fragment + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.core.view.doOnPreDraw +import androidx.navigation.navGraphViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.StartChatFragmentBinding +import org.linphone.ui.main.chat.viewmodel.StartConversationViewModel +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel +import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.ui.main.history.adapter.ContactsAndSuggestionsListAdapter + +@UiThread +class StartConversationFragment : GenericFragment() { + companion object { + private const val TAG = "[Start Conversation Fragment]" + } + + private lateinit var binding: StartChatFragmentBinding + + private val viewModel: StartConversationViewModel by navGraphViewModels( + R.id.main_nav_graph + ) + + private lateinit var adapter: ContactsAndSuggestionsListAdapter + + private val listener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + val address = model.address + if (address != null) { + coreContext.postOnCoreThread { + // TODO + } + } + } + + @UiThread + override fun onLongPress(model: ContactNumberOrAddressModel) { + } + } + + private var numberOrAddressPickerDialog: Dialog? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = StartChatFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + postponeEnterTransition() + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + + binding.setBackClickListener { + goBack() + } + + adapter = ContactsAndSuggestionsListAdapter(viewLifecycleOwner) + binding.contactsList.setHasFixedSize(true) + binding.contactsList.adapter = adapter + + adapter.contactClickedEvent.observe(viewLifecycleOwner) { + it.consume { model -> + // TODO + } + } + + binding.contactsList.layoutManager = LinearLayoutManager(requireContext()) + + viewModel.contactsList.observe( + viewLifecycleOwner + ) { + Log.i("$TAG Contacts & suggestions list is ready with [${it.size}] items") + val count = adapter.itemCount + adapter.submitList(it) + + if (count == 0 && it.isNotEmpty()) { + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } + } + + viewModel.searchFilter.observe(viewLifecycleOwner) { filter -> + val trimmed = filter.trim() + viewModel.applyFilter(trimmed) + } + + sharedViewModel.defaultAccountChangedEvent.observe(viewLifecycleOwner) { + // Do not consume it! + viewModel.updateGroupChatButtonVisibility() + } + } + + override fun onPause() { + super.onPause() + + numberOrAddressPickerDialog?.dismiss() + numberOrAddressPickerDialog = null + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/StartConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/StartConversationViewModel.kt new file mode 100644 index 000000000..d4ec27fb8 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/StartConversationViewModel.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.main.chat.viewmodel + +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import java.util.ArrayList +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences +import org.linphone.contacts.ContactsManager.ContactsListener +import org.linphone.core.MagicSearch +import org.linphone.core.MagicSearchListenerStub +import org.linphone.core.SearchResult +import org.linphone.core.tools.Log +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.history.model.ContactOrSuggestionModel +import org.linphone.ui.main.model.isInSecureMode +import org.linphone.utils.LinphoneUtils + +class StartConversationViewModel @UiThread constructor() : ViewModel() { + companion object { + private const val TAG = "[Start Conversation ViewModel]" + } + + val searchFilter = MutableLiveData() + + val contactsList = MutableLiveData>() + + val hideGroupChatButton = MutableLiveData() + + val isGroupChatAvailable = MutableLiveData() + + private var currentFilter = "" + private var previousFilter = "NotSet" + private var limitSearchToLinphoneAccounts = true + + private lateinit var magicSearch: MagicSearch + + private val magicSearchListener = object : MagicSearchListenerStub() { + @WorkerThread + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + Log.i("$TAG Magic search contacts available") + processMagicSearchResults(magicSearch.lastSearch) + } + } + + private val contactsListener = object : ContactsListener { + @WorkerThread + override fun onContactsLoaded() { + Log.i("$TAG Contacts have been (re)loaded, updating list") + applyFilter( + currentFilter, + if (limitSearchToLinphoneAccounts) corePreferences.defaultDomain else "", + MagicSearch.Source.Friends.toInt(), + MagicSearch.Aggregation.Friend + ) + } + } + + init { + updateGroupChatButtonVisibility() + + coreContext.postOnCoreThread { core -> + val defaultAccount = core.defaultAccount + limitSearchToLinphoneAccounts = defaultAccount?.isInSecureMode() ?: false + + coreContext.contactsManager.addListener(contactsListener) + magicSearch = core.createMagicSearch() + magicSearch.limitedSearch = false + magicSearch.addListener(magicSearchListener) + } + + applyFilter(currentFilter) + } + + @UiThread + override fun onCleared() { + coreContext.postOnCoreThread { + magicSearch.removeListener(magicSearchListener) + coreContext.contactsManager.removeListener(contactsListener) + } + super.onCleared() + } + + @UiThread + fun clearFilter() { + searchFilter.value = "" + } + + @UiThread + fun updateGroupChatButtonVisibility() { + coreContext.postOnCoreThread { core -> + val hideGroupChat = !LinphoneUtils.isGroupChatAvailable(core) + hideGroupChatButton.postValue(hideGroupChat) + } + } + + @WorkerThread + fun processMagicSearchResults(results: Array) { + Log.i("$TAG Processing [${results.size}] results") + + val contactsList = arrayListOf() + var previousLetter = "" + + for (result in results) { + val address = result.address + if (address != null) { + val friend = coreContext.contactsManager.findContactByAddress(address) + if (friend != null) { + val model = ContactOrSuggestionModel(address, friend) + model.contactAvatarModel = ContactAvatarModel(friend) + + val currentLetter = friend.name?.get(0).toString() + val displayLetter = previousLetter.isEmpty() || currentLetter != previousLetter + if (currentLetter != previousLetter) { + previousLetter = currentLetter + } + model.contactAvatarModel.firstContactStartingByThatLetter.postValue( + displayLetter + ) + + contactsList.add(model) + } + } + } + + val list = arrayListOf() + list.addAll(contactsList) + this.contactsList.postValue(list) + Log.i("$TAG Processed [${results.size}] results, extracted [${list.size}] suggestions") + } + + @UiThread + fun applyFilter(filter: String) { + coreContext.postOnCoreThread { + applyFilter( + filter, + if (limitSearchToLinphoneAccounts) corePreferences.defaultDomain else "", + MagicSearch.Source.Friends.toInt(), + MagicSearch.Aggregation.Friend + ) + } + } + + @WorkerThread + private fun applyFilter( + filter: String, + domain: String, + sources: Int, + aggregation: MagicSearch.Aggregation + ) { + if (previousFilter.isNotEmpty() && ( + previousFilter.length > filter.length || + (previousFilter.length == filter.length && previousFilter != filter) + ) + ) { + magicSearch.resetSearchCache() + } + currentFilter = filter + previousFilter = filter + + Log.i( + "$TAG Asking Magic search for contacts matching filter [$filter], domain [$domain] and in sources [$sources]" + ) + magicSearch.getContactsListAsync( + filter, + domain, + sources, + aggregation + ) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt index 7839f8f03..4eaccb346 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt @@ -94,4 +94,10 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() { val resetMissedCallsCountEvent: MutableLiveData> by lazy { MutableLiveData>() } + + /* Conversation related */ + + val showStartConversationEvent: MutableLiveData> by lazy { + MutableLiveData>() + } } diff --git a/app/src/main/res/layout/start_chat_fragment.xml b/app/src/main/res/layout/start_chat_fragment.xml new file mode 100644 index 000000000..5c5e6ad82 --- /dev/null +++ b/app/src/main/res/layout/start_chat_fragment.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index e8cc973a8..1f296d441 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -180,6 +180,7 @@ android:name="accountIdentity" app:argType="string" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3cf026e9..b1dfec863 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,6 +325,10 @@ Delete conversation Leave the group Yesterday at %s + New conversation + Search contact + Create a group conversation + No contact for the moment… Operation in progress, please wait