Commit 4cc134ea authored by Christophe Deschamps's avatar Christophe Deschamps
Browse files

- Migrate devices storage to VCard

    - Handle remote device provisionning (received by configuration URL)
parent 313d1e4e
Pipeline #41328 failed with stage
in 1 minute and 23 seconds
......@@ -10,6 +10,11 @@ Group changes to describe their impact on the project, as follows:
Fixed for any bug fixes.
Security to invite users to upgrade in case of vulnerabilities.
## [1.2.0]
- Locally defined Devices are now stored inside VCards 4.0 - inside Linphone Friend List (those can be edited / removed)
- Devices can remotely provisionned, (they appear as read only - e.g. not deletable, not editable) inside the app.
- Update to Linphone SDK 5.2
## [1.1.0]
- Update to Linphone SDK 5.0.0
......
......@@ -111,3 +111,13 @@ In order to submit a patch for inclusion in linphone's source code:
2. Fill out and send us an email with the link of pullrequest and the [Contributor Agreement](http://www.belledonne-communications.com/downloads/Belledonne_communications_CA.pdf) for your patch to be included in the git tree.
The goal of this agreement to grant us peaceful exercise of our rights on the linphone source code, while not losing your rights on your contribution.
# Device storage and remote provisionning :
- Devices are now stored in Linphone Friend’s VCards.
- Apps have 2 friends list, one local for devices created locally in the app, one remote sent by the server
- Devices created locally can be edited and removed as before
- Devices received from the server cannot be edited not removed.
- Values allowed for Account type are : device_audio_intercom|device_video_intercom|device_security_camera|device_internal_unit
- Values allowed for actions are : action_open_door| action_open_gate | action_lightup | action_unlock
......@@ -160,7 +160,7 @@ dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation "org.linphone:linphone-sdk-android:5.2+"
implementation "org.linphone:linphone-sdk-android:5.2.0-alpha.121+0eee108"
implementation "org.permissionsdispatcher:permissionsdispatcher:4.8.0"
kapt "org.permissionsdispatcher:permissionsdispatcher-processor:4.8.0"
implementation "com.google.android.material:material:1.5.0"
......@@ -175,7 +175,7 @@ dependencies {
implementation 'com.github.ybq:Android-SpinKit:1.4.0'
implementation "androidx.media:media:1.4.3"
implementation 'com.google.firebase:firebase-crashlytics-ndk:18.2.6'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01'
}
......
......@@ -46,6 +46,7 @@ video_rtp_port=-1
[misc]
max_calls=1
history_max_size=100
store_friends=1
## End of factory rc
......@@ -73,6 +73,7 @@ class LinhomeApplication : Application() {
coreContext.core.ring = context.filesDir.absolutePath+"/bell.wav"
coreContext.core.ringDuringIncomingEarlyMedia = true
coreContext.core.isNativeRingingEnabled = true
coreContext.core.friendsDatabasePath = context.filesDir.absolutePath+"/devices.db"
setDefaultCodecs()
}
......
......@@ -54,4 +54,8 @@ object ActionTypes {
}
}
fun isValid(typeKey: String): Boolean {
return !actionTypesConfig.getString(typeKey, "textkey", "-").equals("-")
}
}
......@@ -39,4 +39,10 @@ object ActionsMethodTypes {
}
}
fun methodTypeIsSupported(typeKey: String): Boolean {
return !actionsMethodTypesConfig.getString( typeKey, "textkey", "-").equals("-")
}
}
......@@ -76,4 +76,8 @@ object DeviceTypes {
}
}
fun deviceTypeSupported(typeKey: String): Boolean {
return !deviceTypesConfig?.getString(typeKey, "textkey", "-").equals("-")
}
}
......@@ -28,15 +28,19 @@ import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.linhome.LinhomeApplication
import org.linhome.customisation.ActionTypes
import org.linhome.customisation.ActionsMethodTypes
import org.linhome.customisation.DeviceTypes
import org.linhome.store.StorageManager
import org.linhome.utils.DialogUtil
import org.linhome.utils.extensions.existsAndIsNotEmpty
import org.linhome.utils.extensions.xDigitsUUID
import org.linphone.core.CallParams
import org.linphone.core.Friend
import org.linphone.core.Vcard
import org.linphone.core.tools.Log
import java.io.File
import java.io.FileInputStream
import java.util.*
@Parcelize
......@@ -46,10 +50,25 @@ data class Device(
var name: String,
var address: String,
var actionsMethodType: String?,
var actions: ArrayList<Action>?
var actions: ArrayList<Action>?,
var isRemotelyProvisionned: Boolean
) :
Parcelable {
val friend: Friend
get() =
LinhomeApplication.coreContext.core.createFriend().let { friend ->
friend.createVcard(name)
friend.vcard?.addExtendedProperty(vcard_device_type_header, type!!)
friend.vcard?.addSipAddress(address)
friend.vcard?.addExtendedProperty(vcard_action_method_type_header,actionsMethodType!!)
actions?.forEach { it ->
friend.vcard?.addExtendedProperty(vcard_actions_list_header,it.type!! + ";" + it.code!!)
}
Log.i("[Device] created vCard for device: $name ${friend.vcard?.asVcard4String()}")
friend
}
var typeIconAsBitmap: Bitmap? = null
constructor(
......@@ -57,11 +76,31 @@ data class Device(
name: String,
address: String,
actionsMethodType: String?,
actions: ArrayList<Action>?
actions: ArrayList<Action>?,
isRemotelyProvisionned: Boolean
) : this(
xDigitsUUID(), type, name, address, actionsMethodType, actions
xDigitsUUID(), type, name, address, actionsMethodType, actions, isRemotelyProvisionned
)
constructor (card:Vcard, isRemotelyProvisionned:Boolean) : this(
card.uid?.let{it}?:xDigitsUUID(),
card.getExtendedPropertiesValuesByName(vcard_device_type_header).component1(),
card.fullName!!,
if (isRemotelyProvisionned) card.sipAddresses.component1()?.asStringUriOnly()!! else card.sipAddresses.component1()?.asString()!!,
if (isRemotelyProvisionned) serverActionMethodsToLocalMethods.get(card.getExtendedPropertiesValuesByName(vcard_action_method_type_header).component1()) else card.getExtendedPropertiesValuesByName(vcard_action_method_type_header).component1()!!,
ArrayList(),
isRemotelyProvisionned)
{
card.getExtendedPropertiesValuesByName(vcard_actions_list_header).forEach { action ->
val components = action.split(";")
if (components.size != 2) {
Log.e("Unable to create action from VCard $action")
} else {
actions?.add(Action(components.component1(), components.component2()))
}
}
}
init {
GlobalScope.launch() {
typeIconAsBitmap = typeIconAsBitmap(type)
......@@ -75,6 +114,7 @@ data class Device(
}
fun supportsVideo(): Boolean {
return type?.let {
DeviceTypes.supportsVideo(it)
......@@ -123,6 +163,13 @@ data class Device(
}
companion object {
const val vcard_device_type_header = "X-LINPHONE-ACCOUNT-TYPE"
const val vcard_actions_list_header = "X-LINPHONE-ACCOUNT-ACTION"
const val vcard_action_method_type_header = "X-LINPHONE-ACCOUNT-DTMF-PROTOCOL"
val serverActionMethodsToLocalMethods = mapOf( "sipinfo" to "method_dtmf_sip_info","rfc2833" to "method_dtmf_rfc_4733","sipmessage" to "method_sip_message") // Server side method names to local app names
fun typeIconAsBitmap(type: String?): Bitmap? {
return type?.let {
val svgFile = File(
......@@ -145,6 +192,54 @@ data class Device(
null
}
}
fun remoteVcardValid(card: Vcard?) : Boolean {
if (card == null) {
Log.e("[Device] vCard validation : card is null")
return false
}
val validType = card.getExtendedPropertiesValuesByName(vcard_device_type_header)
.component1()
?.let { typeKey ->
DeviceTypes.deviceTypeSupported(typeKey)
} ?: false
if (!validType) {
Log.e("[Device] vCard validation : invalid type ${
card.getExtendedPropertiesValuesByName(vcard_device_type_header).component1()
}")
return false
}
val validDtmf = card.getExtendedPropertiesValuesByName(vcard_action_method_type_header)
.component1()?.let { remoteDtmfMethod ->
serverActionMethodsToLocalMethods.get(remoteDtmfMethod)?.let { localDtmfMethod ->
ActionsMethodTypes.methodTypeIsSupported(localDtmfMethod)
}?:false
}?:false
if (!validDtmf) {
Log.e("[Device] vCard validation : invalid dtmf sending method ${
card.getExtendedPropertiesValuesByName(vcard_action_method_type_header)
.component1()
}")
return false
}
var validActions = true
card.getExtendedPropertiesValuesByName(vcard_actions_list_header).forEach { action ->
val components = action.split(";")
if (components.size == 2) {
validActions = validActions && ActionTypes.isValid(components.component1())
} else {
validActions = false
}
if (!validActions) {
Log.e("[Device] vCard validation : invalid action $action")
}
}
return validActions
}
}
fun hasThumbNail(): Boolean {
......
......@@ -20,31 +20,71 @@
package org.linhome.store
import androidx.lifecycle.MutableLiveData
import org.linhome.LinhomeApplication
import org.linhome.entities.Action
import org.linhome.entities.Device
import org.linhome.store.StorageManager.devicesXml
import org.linphone.core.Address
import org.linphone.core.Config
import org.linphone.core.Factory
import org.linhome.linphonecore.extensions.getString
import org.linhome.store.StorageManager.devicesXml
import org.linphone.core.*
import org.linphone.mediastream.Log
object DeviceStore {
private var devicesConfig: Config
val vcard_device_type_header = "X-LINPHONE-ACCOUNT-TYPE"
val vcard_actions_list_header = "X-LINPHONE-ACCOUNT-ACTION"
val vcard_action_method_type_header = "X-LINPHONE-ACCOUNT-DTMF-PROTOCOL"
var devices: ArrayList<Device>
val local_devices_fl_name = "local_devices"
private val coreListener: CoreListenerStub = object : CoreListenerStub() {
override fun onGlobalStateChanged(core: Core, state: GlobalState, message: String) {
Log.i("[Context] Global state changed [$state]")
if (state == GlobalState.On) {
devices = readFromFriends()
}
}
override fun onFriendListCreated(core: Core, friendList: FriendList) {
Log.i("[DeviceStore] friend list created. ${friendList.displayName}")
devices = readFromFriends()
}
}
init {
if (!devicesXml.exists())
devicesXml.createNewFile()
devicesConfig = Factory.instance().createConfig(null)
devicesConfig.loadFromXmlFile(devicesXml.absolutePath)
devices = readFromXml()
if (devicesXml.exists()) {
devicesConfig.loadFromXmlFile(devicesXml.absolutePath)
devices = readFromXml()
saveLocalDevices()
devicesXml.delete()
}
devices = readFromFriends()
LinhomeApplication.coreContext.core.addListener(coreListener)
}
fun readFromFriends(): ArrayList<Device> {
val result = ArrayList<Device>()
LinhomeApplication.coreContext.core.getFriendListByName(local_devices_fl_name)?.friends?.forEach { friend ->
val card = friend.vcard
if (card != null) {
result.add(Device(card, false))
}
}
LinhomeApplication.coreContext.core.config.getString("misc","contacts-vcard-list",null)?.also { url ->
LinhomeApplication.coreContext.core.getFriendListByName(url)?.also { serverFriendList ->
serverFriendList.friends.forEach { friend ->
val card = friend.vcard
if (Device.remoteVcardValid(card)) {
result.add(Device(card!!, true))
} else {
Log.e("[DeviceStore] received invalid or malformed vCard from remote : ${friend.vcard?.asVcard4String()} ")
}
}
}
}
result.sortWith(compareBy({ it.name }, { it.address }))
return result
}
fun readFromXml(): ArrayList<Device> {
......@@ -64,7 +104,8 @@ object DeviceStore {
devicesConfig.getString(it, "name", nonNullDefault = "missing"),
devicesConfig.getString(it, "address", nonNullDefault = "missing"),
devicesConfig.getString(it, "actions_method_type", null),
actions
actions,
false
)
)
}
......@@ -72,30 +113,28 @@ object DeviceStore {
return result
}
fun sync() {
devicesConfig.sectionsNamesList.forEach {
devicesConfig.cleanSection(it)
fun saveLocalDevices() {
val core = LinhomeApplication.coreContext.core
val localDevicesFriendList:FriendList?
core.getFriendListByName(local_devices_fl_name)?.also {
core.removeFriendList(it)
}
localDevicesFriendList = core.createFriendList()
localDevicesFriendList.displayName = local_devices_fl_name
devices.sortWith(compareBy({ it.name }, { it.address }))
devices.forEach { device ->
devicesConfig.setString(device.id, "type", device.type)
devicesConfig.setString(device.id, "name", device.name)
devicesConfig.setString(device.id, "address", device.address)
devicesConfig.setString(device.id, "actions_method_type", device.actionsMethodType)
var actionString = String()
device.actions?.forEach {
val separator = if (actionString.isEmpty()) "" else "|"
actionString += separator + it.type + "," + it.code
val friend = device.friend
if (!device.isRemotelyProvisionned) {
localDevicesFriendList.addFriend (friend)
}
devicesConfig.setString(device.id, "actions", actionString)
}
devicesXml.writeText(devicesConfig.dumpAsXml())
core.addFriendList(localDevicesFriendList)
}
fun persistDevice(device: Device) {
devices.add(device)
sync()
saveLocalDevices()
}
fun removeDevice(device: Device) {
......@@ -104,7 +143,7 @@ object DeviceStore {
it.delete()
}
devices.remove(device)
sync()
saveLocalDevices()
}
fun findDeviceByAddress(address: Address): Device? {
......
......@@ -78,6 +78,11 @@ class SwipeToDeleteCallback(private var adapter: DevicesAdapter) :
if (viewHolder.adapterPosition == -1) {
return
}
val device = adapter.devices.value!![viewHolder.adapterPosition]
if (device.isRemotelyProvisionned)
return
if (!initiated) {
init()
}
......@@ -113,6 +118,8 @@ class SwipeToDeleteCallback(private var adapter: DevicesAdapter) :
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val device = adapter.devices.value!![viewHolder.adapterPosition]
if (device.isRemotelyProvisionned)
return
DialogUtil.confirm(
"delete_device_confirm_message",
{ _: DialogInterface, _: Int ->
......
......@@ -20,6 +20,7 @@
package org.linhome.ui.devices
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
......@@ -35,6 +36,9 @@ import org.linhome.GenericFragment
import org.linhome.LinhomeApplication
import org.linhome.databinding.FragmentDevicesBinding
import org.linhome.entities.Device
import org.linhome.store.DeviceStore
import org.linhome.utils.DialogUtil
import org.linphone.core.Core
class DevicesFragment : GenericFragment() {
......@@ -91,6 +95,29 @@ class DevicesFragment : GenericFragment() {
}
}
binding.root.swiperefresh.isEnabled = false
devicesViewModel.friendListUpdatedOk.observe(viewLifecycleOwner, { updateOk ->
(binding.root.device_list.adapter as RecyclerView.Adapter).notifyDataSetChanged()
binding.root.swiperefresh.isRefreshing = false
if (updateOk != true) {
DialogUtil.error("vcard_sync_failed")
}
})
binding.root.swiperefresh.setOnRefreshListener {
if (LinhomeApplication.coreContext.core.isNetworkReachable != true) {
binding.root.swiperefresh.isRefreshing = false
DialogUtil.error("no_network")
} else if (LinhomeApplication.coreContext.core.callsNb == 0) {
LinhomeApplication.coreContext.core.config?.getString("misc", "contacts-vcard-list", null)?.also { remoteFlName ->
LinhomeApplication.coreContext.core.getFriendListByName(remoteFlName)?.also { serverFriendList ->
serverFriendList.addListener(devicesViewModel.friendListListener)
serverFriendList.synchronizeFriendsFromServer()
}
}
}
}
return binding.root
}
......
......@@ -22,13 +22,54 @@ package org.linhome.ui.devices
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linhome.LinhomeApplication
import org.linhome.entities.Device
import org.linhome.store.DeviceStore
import org.linphone.core.*
import org.linphone.mediastream.Log
class DevicesViewModel : ViewModel() {
val devices = MutableLiveData<ArrayList<Device>>().apply {
var devices = MutableLiveData<ArrayList<Device>>().apply {
value = DeviceStore.devices
}
var selectedDevice = MutableLiveData<Device?>()
var friendListUpdatedOk = MutableLiveData<Boolean>()
val friendListListener: FriendListListenerStub = object : FriendListListenerStub() {
override fun onSyncStatusChanged(
friendList: FriendList,
status: FriendList.SyncStatus?,
message: String
) {
if (status == FriendList.SyncStatus.Successful || status == FriendList.SyncStatus.Failure) {
devices.value = DeviceStore.devices
friendListUpdatedOk.value = status == FriendList.SyncStatus.Successful
}
}
}
private val coreListener: CoreListenerStub = object : CoreListenerStub() {
override fun onGlobalStateChanged(core: Core, state: GlobalState, message: String) {
Log.i("[Context] Global state changed [$state]")
if (state == GlobalState.On) {
DeviceStore.devices = DeviceStore.readFromFriends()
devices.value = DeviceStore.devices
}
}
override fun onFriendListCreated(core: Core, friendList: FriendList) {
Log.i("[DeviceStore] friend list created. ${friendList.displayName}")
DeviceStore.devices = DeviceStore.readFromFriends()
devices.value = DeviceStore.devices
friendListUpdatedOk.value = true
}
}
init {
LinhomeApplication.coreContext.core.addListener(coreListener)
}
override fun onCleared() {
LinhomeApplication.coreContext.core.removeListener(coreListener)
}
}
......@@ -116,7 +116,8 @@ class DeviceEditorViewModel : ViewModelWithTools() {
name.first.value!!,
if (address.first.value!!.startsWith("sip:") || address.first.value!!.startsWith("sips:")) address.first.value!! else "sip:${address.first.value}",
if (actionsMethod.value == 0) null else availableMethodTypes.get(actionsMethod.value!!).backingKey,
ArrayList()
ArrayList(),
false
)
actionsViewModels.forEach {
if (it.notEmpty())
......@@ -147,7 +148,7 @@ class DeviceEditorViewModel : ViewModelWithTools() {
)
}
}
DeviceStore.sync()
DeviceStore.saveLocalDevices()
}
return true
......
......@@ -45,7 +45,7 @@ class DeviceInfoFragment : GenericFragment() {
val binding = FragmentDeviceInfoBinding.inflate(inflater, container, false)
binding.lifecycleOwner = this
binding.device = args.device
mainactivity.toolbarViewModel.rightButtonVisible.value = args.device?.isRemotelyProvisionned != true
return binding.root
}
......@@ -59,7 +59,6 @@ class DeviceInfoFragment : GenericFragment() {
super.onResume()
Theme.setIcon("icons/edit", mainactivity.toolbar_right_button_image)
mainactivity.toolbar_right_button_title.text = Texts.get("edit")
mainactivity.toolbarViewModel.rightButtonVisible.value = true
mainactivity.resumeNavigation()
}
......
......@@ -26,19 +26,27 @@
android:visibility='@{model.devices.size() == 0 ? View.VISIBLE : View.GONE}' />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/device_list"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swiperefresh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility='@{model.devices.size() > 0 ? View.VISIBLE : View.GONE}'
android:layout_height="match_parent"
android:layout_marginStart="57dp"
android:layout_marginEnd="57dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:visibility='@{model.devices.size() > 0 ? View.VISIBLE : View.GONE}'
android:layout_marginTop="7dp"
android:paddingBottom="7dp"
android:paddingBottom="7dp">