Fixes & improvements

This commit is contained in:
Sylvain Berfini 2023-06-22 17:35:17 +02:00
parent 09ca9b5351
commit a2d038eb46
8 changed files with 515 additions and 30 deletions

View file

@ -56,8 +56,6 @@ dependencies {
implementation 'androidx.navigation:navigation-ui-ktx:2.6.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1-rc01'
implementation 'androidx.core:core-ktx:+'
implementation 'androidx.core:core-ktx:+'
def nav_version = "2.6.0"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- To be able to display contacts list & match calling/called numbers -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application
android:name=".LinphoneApplication"
android:allowBackup="true"

View file

@ -0,0 +1,275 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.linphone.contacts
import android.content.ContentUris
import android.database.Cursor
import android.database.StaleDataException
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import android.util.Patterns
import androidx.loader.app.LoaderManager
import androidx.loader.content.CursorLoader
import androidx.loader.content.Loader
import java.lang.Exception
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.PhoneNumberUtils
class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
companion object {
val projection = arrayOf(
ContactsContract.Data.CONTACT_ID,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
ContactsContract.Data.MIMETYPE,
ContactsContract.Contacts.STARRED,
ContactsContract.Contacts.LOOKUP_KEY,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.Phone.LABEL,
ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
)
const val linphoneMime = "vnd.android.cursor.item/vnd.org.linphone.provider.sip_address"
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
val mimeType = ContactsContract.Data.MIMETYPE
val mimeSelection = "$mimeType = ? OR $mimeType = ? OR $mimeType = ?"
val selection = ContactsContract.Data.IN_DEFAULT_DIRECTORY + " == 1 AND ($mimeSelection)"
val selectionArgs = arrayOf(
linphoneMime,
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
)
return CursorLoader(
coreContext.context,
ContactsContract.Data.CONTENT_URI,
projection,
selection,
selectionArgs,
ContactsContract.Data.CONTACT_ID + " ASC"
)
}
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
if (cursor == null) {
Log.e("[Contacts Loader] Cursor is null!")
return
}
Log.i("[Contacts Loader] Load finished, found ${cursor.count} entries in cursor")
val core = coreContext.core
if (core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off) {
Log.w("[Contacts Loader] Core is being stopped or already destroyed, abort")
return
}
coreContext.postOnCoreThread { core ->
val friends = HashMap<String, Friend>()
try {
// Cursor can be null now that we are on a different dispatcher according to Crashlytics
val friendsPhoneNumbers = arrayListOf<String>()
val friendsAddresses = arrayListOf<Address>()
var previousId = ""
while (cursor != null && !cursor.isClosed && cursor.moveToNext()) {
try {
val id: String =
cursor.getString(
cursor.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
)
val mime: String? =
cursor.getString(
cursor.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)
)
if (previousId.isEmpty() || previousId != id) {
friendsPhoneNumbers.clear()
friendsAddresses.clear()
previousId = id
}
val friend = friends[id] ?: core.createFriend()
friend.refKey = id
if (friend.name.isNullOrEmpty()) {
val displayName: String? =
cursor.getString(
cursor.getColumnIndexOrThrow(
ContactsContract.Data.DISPLAY_NAME_PRIMARY
)
)
friend.name = displayName
friend.photo = Uri.withAppendedPath(
ContentUris.withAppendedId(
ContactsContract.Contacts.CONTENT_URI,
id.toLong()
),
ContactsContract.Contacts.Photo.CONTENT_DIRECTORY
).toString()
val starred =
cursor.getInt(
cursor.getColumnIndexOrThrow(ContactsContract.Contacts.STARRED)
) == 1
friend.starred = starred
val lookupKey =
cursor.getString(
cursor.getColumnIndexOrThrow(
ContactsContract.Contacts.LOOKUP_KEY
)
)
friend.nativeUri =
"${ContactsContract.Contacts.CONTENT_LOOKUP_URI}/$lookupKey"
// Disable short term presence
friend.isSubscribesEnabled = false
friend.incSubscribePolicy = SubscribePolicy.SPDeny
}
when (mime) {
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> {
val data1: String? =
cursor.getString(
cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.Phone.NUMBER
)
)
val data2: String? =
cursor.getString(
cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.Phone.TYPE
)
)
val data3: String? =
cursor.getString(
cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.Phone.LABEL
)
)
val data4: String? =
cursor.getString(
cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
)
)
val label =
PhoneNumberUtils.addressBookLabelTypeToVcardParamString(
data2?.toInt()
?: ContactsContract.CommonDataKinds.BaseTypes.TYPE_CUSTOM,
data3
)
val number =
if (data1.isNullOrEmpty() ||
!Patterns.PHONE.matcher(data1).matches()
) {
data4 ?: data1
} else {
data1
}
if (number != null) {
if (
friendsPhoneNumbers.find {
PhoneNumberUtils.arePhoneNumberWeakEqual(
it,
number
)
} == null
) {
val phoneNumber = Factory.instance()
.createFriendPhoneNumber(number, label)
friend.addPhoneNumberWithLabel(phoneNumber)
friendsPhoneNumbers.add(number)
}
}
}
linphoneMime, ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> {
val sipAddress: String? =
cursor.getString(
cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS
)
)
if (sipAddress != null) {
val address = core.interpretUrl(sipAddress, true)
if (address != null &&
friendsAddresses.find {
it.weakEqual(address)
} == null
) {
friend.addAddress(address)
friendsAddresses.add(address)
}
}
}
}
friends[id] = friend
} catch (e: Exception) {
Log.e("[Contacts Loader] Exception: $e")
}
}
if (core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off) {
Log.w("[Contacts Loader] Core is being stopped or already destroyed, abort")
} else if (friends.isEmpty()) {
Log.w("[Contacts Loader] No friend created!")
} else {
Log.i("[Contacts Loader] ${friends.size} friends created")
val fl = core.defaultFriendList ?: core.createFriendList()
for (friend in fl.friends) {
fl.removeFriend(friend)
}
if (fl != core.defaultFriendList) core.addFriendList(fl)
val friendsList = friends.values
for (friend in friendsList) {
fl.addLocalFriend(friend)
}
friends.clear()
Log.i("[Contacts Loader] Friends added")
fl.updateSubscriptions()
Log.i("[Contacts Loader] Subscription(s) updated")
}
} catch (sde: StaleDataException) {
Log.e("[Contacts Loader] State Data Exception: $sde")
} catch (ise: IllegalStateException) {
Log.e("[Contacts Loader] Illegal State Exception: $ise")
} catch (e: Exception) {
Log.e("[Contacts Loader] Exception: $e")
}
}
}
override fun onLoaderReset(loader: Loader<Cursor>) {
Log.i("[Contacts Loader] Loader reset")
}
}

View file

@ -19,21 +19,29 @@
*/
package org.linphone.ui
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.loader.app.LoaderManager
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.navigation.NavigationBarView
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.contacts.ContactLoader
import org.linphone.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
companion object {
private const val CONTACTS_PERMISSION_REQUEST = 0
}
private lateinit var binding: ActivityMainBinding
private lateinit var viewModel: MainViewModel
@ -46,6 +54,11 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, true)
super.onCreate(savedInstanceState)
if (checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
val manager = LoaderManager.getInstance(this)
manager.restartLoader(0, null, ContactLoader())
}
while (!coreContext.isReady()) {
Thread.sleep(20)
}
@ -75,6 +88,26 @@ class MainActivity : AppCompatActivity() {
.addOnDestinationChangedListener(onNavDestinationChangedListener)
getNavBar()?.setupWithNavController(binding.mainNavHostFragment.findNavController())
if (checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(Manifest.permission.READ_CONTACTS),
CONTACTS_PERMISSION_REQUEST
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CONTACTS_PERMISSION_REQUEST && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
val manager = LoaderManager.getInstance(this)
manager.restartLoader(0, null, ContactLoader())
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun getNavBar(): NavigationBarView? {

View file

@ -92,6 +92,27 @@ class ChatRoomData(val chatRoom: ChatRoom) {
}
init {
coreContext.postOnCoreThread { core ->
chatRoom.addListener(chatRoomListener)
}
}
fun onCleared() {
coreContext.postOnCoreThread { core ->
chatRoom.removeListener(chatRoomListener)
}
}
fun onClicked() {
chatRoomDataListener?.onClicked()
}
fun onLongClicked(): Boolean {
chatRoomDataListener?.onLongClicked()
return true
}
fun update() {
if (chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())) {
val remoteAddress = chatRoom.peerAddress
val friend = chatRoom.core.findFriend(remoteAddress)
@ -122,25 +143,6 @@ class ChatRoomData(val chatRoom: ChatRoom) {
isSecureVerified.postValue(chatRoom.securityLevel == ChatRoom.SecurityLevel.Safe)
isEphemeral.postValue(chatRoom.isEphemeralEnabled)
isMuted.postValue(areNotificationsMuted())
coreContext.postOnCoreThread { core ->
chatRoom.addListener(chatRoomListener)
}
}
fun onCleared() {
coreContext.postOnCoreThread { core ->
chatRoom.removeListener(chatRoomListener)
}
}
fun onClicked() {
chatRoomDataListener?.onClicked()
}
fun onLongClicked(): Boolean {
chatRoomDataListener?.onLongClicked()
return true
}
private fun computeLastMessageImdnIcon(message: ChatMessage) {

View file

@ -68,6 +68,7 @@ class ConversationsListAdapter(
) : RecyclerView.ViewHolder(binding.root) {
fun bind(chatRoomData: ChatRoomData) {
with(binding) {
chatRoomData.update()
data = chatRoomData
lifecycleOwner = viewLifecycleOwner
@ -93,7 +94,7 @@ class ConversationsListAdapter(
private class ConversationDiffCallback : DiffUtil.ItemCallback<ChatRoomData>() {
override fun areItemsTheSame(oldItem: ChatRoomData, newItem: ChatRoomData): Boolean {
return oldItem.id.compareTo(newItem.id) == 0
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ChatRoomData, newItem: ChatRoomData): Boolean {

View file

@ -27,7 +27,9 @@ import org.linphone.core.ChatMessage
import org.linphone.core.ChatRoom
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils
class ConversationsListViewModel : ViewModel() {
val chatRoomsList = MutableLiveData<ArrayList<ChatRoomData>>()
@ -40,25 +42,42 @@ class ConversationsListViewModel : ViewModel() {
chatRoom: ChatRoom,
state: ChatRoom.State?
) {
if (state == ChatRoom.State.Created || state == ChatRoom.State.Instantiated || state == ChatRoom.State.Deleted) {
updateChatRoomsList()
Log.i(
"[Conversations List] Chat room [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]"
)
when (state) {
ChatRoom.State.Created -> {
addChatRoomToList(chatRoom)
}
ChatRoom.State.Deleted -> {
removeChatRoomFromList(chatRoom)
}
else -> {}
}
}
override fun onChatRoomRead(core: Core, chatRoom: ChatRoom) {
updateChatRoomsList()
override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
onChatRoomMessageEvent(chatRoom)
}
override fun onMessagesReceived(
core: Core,
room: ChatRoom,
chatRoom: ChatRoom,
messages: Array<out ChatMessage>
) {
reorderChatRoomsList()
onChatRoomMessageEvent(chatRoom)
}
override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
reorderChatRoomsList()
override fun onChatRoomRead(core: Core, chatRoom: ChatRoom) {
notifyChatRoomUpdate(chatRoom)
}
override fun onChatRoomEphemeralMessageDeleted(core: Core, chatRoom: ChatRoom) {
notifyChatRoomUpdate(chatRoom)
}
override fun onChatRoomSubjectChanged(core: Core, chatRoom: ChatRoom) {
notifyChatRoomUpdate(chatRoom)
}
}
@ -76,7 +95,62 @@ class ConversationsListViewModel : ViewModel() {
super.onCleared()
}
private fun addChatRoomToList(chatRoom: ChatRoom) {
coreContext.postOnCoreThread { core ->
val list = arrayListOf<ChatRoomData>()
val data = ChatRoomData(chatRoom)
list.add(data)
list.addAll(chatRoomsList.value.orEmpty())
chatRoomsList.postValue(list)
}
}
private fun removeChatRoomFromList(chatRoom: ChatRoom) {
coreContext.postOnCoreThread { core ->
val list = arrayListOf<ChatRoomData>()
for (data in chatRoomsList.value.orEmpty()) {
if (LinphoneUtils.getChatRoomId(chatRoom) != LinphoneUtils.getChatRoomId(
data.chatRoom
)
) {
list.add(data)
}
}
chatRoomsList.postValue(list)
}
}
private fun findChatRoomIndex(chatRoom: ChatRoom): Int {
val id = LinphoneUtils.getChatRoomId(chatRoom)
for ((index, data) in chatRoomsList.value.orEmpty().withIndex()) {
if (id == data.id) {
return index
}
}
return -1
}
private fun notifyChatRoomUpdate(chatRoom: ChatRoom) {
when (val index = findChatRoomIndex(chatRoom)) {
-1 -> updateChatRoomsList()
else -> notifyItemChangedEvent.postValue(Event(index))
}
}
private fun onChatRoomMessageEvent(chatRoom: ChatRoom) {
when (findChatRoomIndex(chatRoom)) {
-1 -> updateChatRoomsList()
0 -> notifyItemChangedEvent.postValue(Event(0))
else -> reorderChatRoomsList()
}
}
private fun updateChatRoomsList() {
Log.i("[Conversations List] Updating chat rooms list")
coreContext.postOnCoreThread { core ->
chatRoomsList.value.orEmpty().forEach(ChatRoomData::onCleared)
@ -90,6 +164,7 @@ class ConversationsListViewModel : ViewModel() {
}
private fun reorderChatRoomsList() {
Log.i("[Conversations List] Re-ordering chat rooms list")
coreContext.postOnCoreThread { core ->
val list = arrayListOf<ChatRoomData>()
list.addAll(chatRoomsList.value.orEmpty())

View file

@ -0,0 +1,98 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.linphone.utils
import android.content.res.Resources
import android.provider.ContactsContract
class PhoneNumberUtils {
companion object {
fun addressBookLabelTypeToVcardParamString(type: Int, default: String?): String {
return when (type) {
ContactsContract.CommonDataKinds.Phone.TYPE_ASSISTANT -> "assistant"
ContactsContract.CommonDataKinds.Phone.TYPE_CALLBACK -> "callback"
ContactsContract.CommonDataKinds.Phone.TYPE_CAR -> "car"
ContactsContract.CommonDataKinds.Phone.TYPE_COMPANY_MAIN -> "work,main"
ContactsContract.CommonDataKinds.Phone.TYPE_FAX_HOME -> "home,fax"
ContactsContract.CommonDataKinds.Phone.TYPE_FAX_WORK -> "work,fax"
ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> "home"
ContactsContract.CommonDataKinds.Phone.TYPE_ISDN -> "isdn"
ContactsContract.CommonDataKinds.Phone.TYPE_MAIN -> "main"
ContactsContract.CommonDataKinds.Phone.TYPE_MMS -> "text"
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> "cell"
ContactsContract.CommonDataKinds.Phone.TYPE_OTHER -> "other"
ContactsContract.CommonDataKinds.Phone.TYPE_OTHER_FAX -> "fax"
ContactsContract.CommonDataKinds.Phone.TYPE_PAGER -> "pager"
ContactsContract.CommonDataKinds.Phone.TYPE_RADIO -> "radio"
ContactsContract.CommonDataKinds.Phone.TYPE_TELEX -> "telex"
ContactsContract.CommonDataKinds.Phone.TYPE_TTY_TDD -> "textphone"
ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> "work"
ContactsContract.CommonDataKinds.Phone.TYPE_WORK_MOBILE -> "work,cell"
ContactsContract.CommonDataKinds.Phone.TYPE_WORK_PAGER -> "work,pager"
ContactsContract.CommonDataKinds.BaseTypes.TYPE_CUSTOM -> default ?: "custom"
else -> default ?: type.toString()
}
}
fun vcardParamStringToAddressBookLabel(resources: Resources, label: String): String {
if (label.isEmpty()) return label
val type = labelToType(label)
return ContactsContract.CommonDataKinds.Phone.getTypeLabel(resources, type, label).toString()
}
private fun labelToType(label: String): Int {
return when (label) {
"assistant" -> ContactsContract.CommonDataKinds.Phone.TYPE_ASSISTANT
"callback" -> ContactsContract.CommonDataKinds.Phone.TYPE_CALLBACK
"car" -> ContactsContract.CommonDataKinds.Phone.TYPE_CAR
"work,main" -> ContactsContract.CommonDataKinds.Phone.TYPE_COMPANY_MAIN
"home,fax" -> ContactsContract.CommonDataKinds.Phone.TYPE_FAX_HOME
"work,fax" -> ContactsContract.CommonDataKinds.Phone.TYPE_FAX_WORK
"home" -> ContactsContract.CommonDataKinds.Phone.TYPE_HOME
"isdn" -> ContactsContract.CommonDataKinds.Phone.TYPE_ISDN
"main" -> ContactsContract.CommonDataKinds.Phone.TYPE_MAIN
"text" -> ContactsContract.CommonDataKinds.Phone.TYPE_MMS
"cell" -> ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE
"other" -> ContactsContract.CommonDataKinds.Phone.TYPE_OTHER
"fax" -> ContactsContract.CommonDataKinds.Phone.TYPE_OTHER_FAX
"pager" -> ContactsContract.CommonDataKinds.Phone.TYPE_PAGER
"radio" -> ContactsContract.CommonDataKinds.Phone.TYPE_RADIO
"telex" -> ContactsContract.CommonDataKinds.Phone.TYPE_TELEX
"textphone" -> ContactsContract.CommonDataKinds.Phone.TYPE_TTY_TDD
"work" -> ContactsContract.CommonDataKinds.Phone.TYPE_WORK
"work,cell" -> ContactsContract.CommonDataKinds.Phone.TYPE_WORK_MOBILE
"work,pager" -> ContactsContract.CommonDataKinds.Phone.TYPE_WORK_PAGER
"custom" -> ContactsContract.CommonDataKinds.BaseTypes.TYPE_CUSTOM
else -> ContactsContract.CommonDataKinds.BaseTypes.TYPE_CUSTOM
}
}
fun arePhoneNumberWeakEqual(number1: String, number2: String): Boolean {
return trimPhoneNumber(number1) == trimPhoneNumber(number2)
}
private fun trimPhoneNumber(phoneNumber: String): String {
return phoneNumber.replace(" ", "")
.replace("-", "")
.replace("(", "")
.replace(")", "")
}
}
}