Added VFS & security settings section

This commit is contained in:
Sylvain Berfini 2024-03-13 15:48:51 +01:00
parent 8226f6e1b3
commit d91093cf01
23 changed files with 494 additions and 38 deletions

View file

@ -141,6 +141,8 @@ dependencies {
implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0"
implementation "androidx.window:window:1.2.0"
implementation 'androidx.gridlayout:gridlayout:1.0.0'
// For VFS
implementation "androidx.security:security-crypto-ktx:1.1.0-alpha06"
def nav_version = "2.7.7"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"

View file

@ -38,6 +38,7 @@ import org.linphone.core.CorePreferences
import org.linphone.core.Factory
import org.linphone.core.LogCollectionState
import org.linphone.core.LogLevel
import org.linphone.core.VFS
import org.linphone.core.tools.Log
@MainThread
@ -63,6 +64,11 @@ class LinphoneApplication : Application(), ImageLoaderFactory {
corePreferences = CorePreferences(context)
corePreferences.copyAssetsFromPackage()
if (VFS.isEnabled(context)) {
VFS.setup(context)
}
val config = Factory.instance().createConfigWithFactory(
corePreferences.configPath,
corePreferences.factoryConfigPath
@ -103,6 +109,13 @@ class LinphoneApplication : Application(), ImageLoaderFactory {
}
override fun newImageLoader(): ImageLoader {
// When VFS is enabled, prevent Coil from keeping plain version of files on disk
val diskCachePolicy = if (VFS.isEnabled(applicationContext)) {
CachePolicy.DISABLED
} else {
CachePolicy.ENABLED
}
return ImageLoader.Builder(this)
.crossfade(false)
.components {
@ -122,7 +135,7 @@ class LinphoneApplication : Application(), ImageLoaderFactory {
.build()
}
.networkCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(diskCachePolicy)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
}

View file

@ -157,6 +157,10 @@ class CorePreferences @UiThread constructor(private val context: Context) {
val ringtonesPath: String
get() = context.filesDir.absolutePath + "/share/sounds/linphone/rings/"
@get:AnyThread
val vfsCachePath: String
get() = context.cacheDir.absolutePath + "/evfs/"
@UiThread
fun copyAssetsFromPackage() {
copy("linphonerc_default", configPath)

View file

@ -0,0 +1,216 @@
/*
* 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.core
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Pair
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.MessageDigest
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import org.linphone.core.tools.Log
class VFS {
companion object {
private const val TAG = "[VFS]"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val ALIAS = "vfs"
private const val LINPHONE_VFS_ENCRYPTION_AES256GCM128_SHA256 = 2
private const val VFS_IV = "vfsiv"
private const val VFS_KEY = "vfskey"
private const val ENCRYPTED_SHARED_PREFS_FILE = "encrypted.pref"
@Throws(java.lang.Exception::class)
private fun generateSecretKey() {
val keyGenerator =
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
keyGenerator.init(
KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
keyGenerator.generateKey()
}
@Throws(java.lang.Exception::class)
private fun getSecretKey(): SecretKey? {
val ks = KeyStore.getInstance(ANDROID_KEY_STORE)
ks.load(null)
val entry = ks.getEntry(ALIAS, null) as KeyStore.SecretKeyEntry
return entry.secretKey
}
@Throws(java.lang.Exception::class)
fun generateToken(): String {
return sha512(UUID.randomUUID().toString())
}
@Throws(java.lang.Exception::class)
private fun encryptData(textToEncrypt: String): Pair<ByteArray, ByteArray> {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
val iv = cipher.iv
return Pair<ByteArray, ByteArray>(
iv,
cipher.doFinal(textToEncrypt.toByteArray(StandardCharsets.UTF_8))
)
}
@Throws(java.lang.Exception::class)
private fun decryptData(encrypted: String?, encryptionIv: ByteArray): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, encryptionIv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
val encryptedData = Base64.decode(encrypted, Base64.DEFAULT)
return String(cipher.doFinal(encryptedData), StandardCharsets.UTF_8)
}
@Throws(java.lang.Exception::class)
fun encryptToken(string_to_encrypt: String): Pair<String?, String?> {
val encryptedData = encryptData(string_to_encrypt)
return Pair<String?, String?>(
Base64.encodeToString(encryptedData.first, Base64.DEFAULT),
Base64.encodeToString(encryptedData.second, Base64.DEFAULT)
)
}
@Throws(java.lang.Exception::class)
fun sha512(input: String): String {
val md = MessageDigest.getInstance("SHA-512")
val messageDigest = md.digest(input.toByteArray())
val no = BigInteger(1, messageDigest)
var hashtext = no.toString(16)
while (hashtext.length < 32) {
hashtext = "0$hashtext"
}
return hashtext
}
@Throws(java.lang.Exception::class)
fun getVfsKey(sharedPreferences: SharedPreferences): String {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
keyStore.load(null)
return decryptData(
sharedPreferences.getString(VFS_KEY, null),
Base64.decode(sharedPreferences.getString(VFS_IV, null), Base64.DEFAULT)
)
}
fun getEncryptedSharedPreferences(context: Context): SharedPreferences? {
val masterKey: MasterKey = MasterKey.Builder(
context,
MasterKey.DEFAULT_MASTER_KEY_ALIAS
).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return try {
EncryptedSharedPreferences.create(
context,
ENCRYPTED_SHARED_PREFS_FILE,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (kse: KeyStoreException) {
Log.e("[VFS] Keystore exception: $kse")
null
} catch (e: Exception) {
Log.e("[VFS] Exception: $e")
null
}
}
fun isEnabled(context: Context): Boolean {
val preferences = getEncryptedSharedPreferences(context)
if (preferences == null) {
Log.e("$TAG Failed to get encrypted shared preferences!")
return false
}
return preferences.getBoolean("vfs_enabled", false)
}
fun enable(context: Context): Boolean {
val preferences = getEncryptedSharedPreferences(context)
if (preferences == null) {
Log.e("$TAG Failed to get encrypted shared preferences, VFS won't be enabled!")
return false
}
if (preferences.getBoolean("vfs_enabled", false)) {
Log.w("$TAG VFS is already enabled, skipping...")
return false
}
preferences.edit().putBoolean("vfs_enabled", true).apply()
return true
}
fun setup(context: Context) {
// Use Android logger as our isn't ready yet
try {
android.util.Log.i(TAG, "$TAG Initializing...")
val preferences = getEncryptedSharedPreferences(context)
if (preferences == null) {
Log.e("$TAG Failed to get encrypted shared preferences, can't initialize VFS!")
return
}
if (preferences.getString(VFS_IV, null) == null) {
android.util.Log.i(TAG, "$TAG No initialization vector found, generating it")
generateSecretKey()
encryptToken(generateToken()).let { data ->
preferences
.edit()
.putString(VFS_IV, data.first)
.putString(VFS_KEY, data.second)
.commit()
}
}
Factory.instance().setVfsEncryption(
LINPHONE_VFS_ENCRYPTION_AES256GCM128_SHA256,
getVfsKey(preferences).toByteArray().copyOfRange(0, 32),
32
)
android.util.Log.i(TAG, "$TAG Initialized")
} catch (e: Exception) {
android.util.Log.wtf(TAG, "$TAG Unable to activate VFS encryption: $e")
}
}
}
}

View file

@ -6,6 +6,10 @@ import android.net.Uri
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.tools.Log
import org.linphone.utils.FileUtils
@ -15,6 +19,7 @@ class FileModel @AnyThread constructor(
val file: String,
val fileName: String,
fileSize: Long,
private val isEncrypted: Boolean,
val isWaitingToBeDownloaded: Boolean = false,
private val onClicked: ((model: FileModel) -> Unit)? = null
) {
@ -40,6 +45,8 @@ class FileModel @AnyThread constructor(
val isAudio: Boolean
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
downloadProgress.postValue(-1)
formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize))
@ -70,6 +77,16 @@ class FileModel @AnyThread constructor(
isMedia = isVideoPreview || isImage
}
@AnyThread
fun destroy() {
if (isEncrypted) {
Log.i("$TAG [VFS] Deleting plain file in cache: $file")
scope.launch {
FileUtils.deleteFile(file)
}
}
}
@UiThread
fun onClick() {
onClicked?.invoke(this)

View file

@ -247,6 +247,8 @@ class MessageModel @WorkerThread constructor(
fun destroy() {
scope.cancel()
filesList.value.orEmpty().forEach(FileModel::destroy)
if (::voiceRecordPlayer.isInitialized) {
stopVoiceRecordPlayer()
voiceRecordPlayer.removeListener(playerListener)
@ -308,6 +310,8 @@ class MessageModel @WorkerThread constructor(
val contents = chatMessage.contents
for (content in contents) {
val isFileEncrypted = content.isFileEncrypted
if (content.isIcalendar) {
Log.d("$TAG Found iCal content")
parseConferenceInvite(content)
@ -339,16 +343,24 @@ class MessageModel @WorkerThread constructor(
checkAndRepairFilePathIfNeeded(content)
val path = content.filePath ?: ""
val path = if (isFileEncrypted) {
Log.i(
"$TAG [VFS] Content is encrypted, requesting plain file path for file [${content.filePath}]"
)
content.exportPlainFile()
} else {
content.filePath ?: ""
}
val name = content.name ?: ""
if (path.isNotEmpty()) {
Log.d(
"$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]"
)
val fileSize = content.fileSize.toLong()
when (content.type) {
"image", "video" -> {
val fileModel = FileModel(path, name, content.fileSize.toLong()) { model ->
val fileModel = FileModel(path, name, fileSize, isFileEncrypted) { model ->
onContentClicked?.invoke(model.file)
}
filesPath.add(fileModel)
@ -356,7 +368,7 @@ class MessageModel @WorkerThread constructor(
displayableContentFound = true
}
else -> {
val fileModel = FileModel(path, name, content.fileSize.toLong()) { model ->
val fileModel = FileModel(path, name, fileSize, isFileEncrypted) { model ->
onContentClicked?.invoke(model.file)
}
filesPath.add(fileModel)
@ -376,11 +388,11 @@ class MessageModel @WorkerThread constructor(
if (name.isNotEmpty()) {
val fileModel = if (isOutgoing && chatMessage.isFileTransferInProgress) {
val path = content.filePath ?: ""
FileModel(path, name, content.fileSize.toLong(), false) { model ->
FileModel(path, name, content.fileSize.toLong(), isFileEncrypted, false) { model ->
onContentClicked?.invoke(model.file)
}
} else {
FileModel(name, name, content.fileSize.toLong(), true) { model ->
FileModel(name, name, content.fileSize.toLong(), isFileEncrypted, true) { model ->
downloadContent(model, content)
}
}

View file

@ -44,6 +44,10 @@ class ConversationDocumentsListViewModel @UiThread constructor() : AbstractConve
loadDocumentsList()
}
override fun onCleared() {
documentsList.value.orEmpty().forEach(FileModel::destroy)
}
@WorkerThread
private fun loadDocumentsList() {
operationInProgress.postValue(true)
@ -57,11 +61,19 @@ class ConversationDocumentsListViewModel @UiThread constructor() : AbstractConve
val documents = chatRoom.documentContents
Log.i("$TAG [${documents.size}] documents have been fetched")
for (documentContent in documents) {
val path = documentContent.filePath.orEmpty()
val isEncrypted = documentContent.isFileEncrypted
val path = if (isEncrypted) {
Log.i(
"$TAG [VFS] Content is encrypted, requesting plain file path for file [${documentContent.filePath}]"
)
documentContent.exportPlainFile()
} else {
documentContent.filePath.orEmpty()
}
val name = documentContent.name.orEmpty()
val size = documentContent.size.toLong()
if (path.isNotEmpty() && name.isNotEmpty()) {
val model = FileModel(path, name, size) {
val model = FileModel(path, name, size, isEncrypted) {
openDocumentEvent.postValue(Event(it))
}
list.add(model)

View file

@ -48,6 +48,10 @@ class ConversationMediaListViewModel @UiThread constructor() : AbstractConversat
loadMediaList()
}
override fun onCleared() {
mediaList.value.orEmpty().forEach(FileModel::destroy)
}
@WorkerThread
private fun loadMediaList() {
operationInProgress.postValue(true)
@ -61,11 +65,19 @@ class ConversationMediaListViewModel @UiThread constructor() : AbstractConversat
val media = chatRoom.mediaContents
Log.i("$TAG [${media.size}] media have been fetched")
for (mediaContent in media) {
val path = mediaContent.filePath.orEmpty()
val isEncrypted = mediaContent.isFileEncrypted
val path = if (isEncrypted) {
Log.i(
"$TAG [VFS] Content is encrypted, requesting plain file path for file [${mediaContent.filePath}]"
)
mediaContent.exportPlainFile()
} else {
mediaContent.filePath.orEmpty()
}
val name = mediaContent.name.orEmpty()
val size = mediaContent.size.toLong()
if (path.isNotEmpty() && name.isNotEmpty()) {
val model = FileModel(path, name, size) {
val model = FileModel(path, name, size, isEncrypted) {
openMediaEvent.postValue(Event(it))
}
list.add(model)

View file

@ -314,7 +314,8 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
list.addAll(attachments.value.orEmpty())
val fileName = FileUtils.getNameFromFilePath(file)
val model = FileModel(file, fileName, 0) { model ->
val isEncrypted = true // Really ? //TODO FIXME: is it really encrypted here?
val model = FileModel(file, fileName, 0, isEncrypted) { model ->
removeAttachment(model.file)
}

View file

@ -38,6 +38,7 @@ import org.linphone.core.Conference
import org.linphone.core.FriendList
import org.linphone.core.Player
import org.linphone.core.PlayerListener
import org.linphone.core.VFS
import org.linphone.core.tools.Log
import org.linphone.ui.main.settings.model.CardDavLdapModel
import org.linphone.utils.AppUtils
@ -49,6 +50,7 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
private const val TAG = "[Settings ViewModel]"
}
val expandSecurity = MutableLiveData<Boolean>()
val expandCalls = MutableLiveData<Boolean>()
val expandConversations = MutableLiveData<Boolean>()
val expandContacts = MutableLiveData<Boolean>()
@ -56,6 +58,9 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
val expandNetwork = MutableLiveData<Boolean>()
val expandUserInterface = MutableLiveData<Boolean>()
// Security settings
val isVfsEnabled = MutableLiveData<Boolean>()
// Calls settings
val hideVideoCallSetting = MutableLiveData<Boolean>()
@ -144,6 +149,7 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
}
showContactsSettings.value = true
expandSecurity.value = false
expandCalls.value = false
expandConversations.value = false
expandContacts.value = false
@ -151,6 +157,8 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
expandNetwork.value = false
expandUserInterface.value = false
isVfsEnabled.value = VFS.isEnabled(coreContext.context)
val vibrator = coreContext.context.getSystemService(Vibrator::class.java)
isVibrationAvailable.value = vibrator.hasVibrator()
if (isVibrationAvailable.value == false) {
@ -193,6 +201,30 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
}
}
@UiThread
fun toggleSecurityExpand() {
expandSecurity.value = expandSecurity.value == false
}
@UiThread
fun enableVfs() {
Log.i("$TAG Enabling VFS")
if (VFS.enable(coreContext.context)) {
val enabled = VFS.isEnabled(coreContext.context)
isVfsEnabled.postValue(enabled)
if (enabled) {
Log.i("$TAG VFS has been enabled")
}
} else {
Log.e("$TAG Failed to enable VFS!")
}
}
@UiThread
fun toggleCallsExpand() {
expandCalls.value = expandCalls.value == false
}
@UiThread
fun toggleEchoCanceller() {
val newValue = echoCancellerEnabled.value == false
@ -317,20 +349,6 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
}
}
@UiThread
fun toggleUseWifiOnly() {
val newValue = useWifiOnly.value == false
coreContext.postOnCoreThread { core ->
core.isWifiOnlyEnabled = newValue
useWifiOnly.postValue(newValue)
}
}
@UiThread
fun toggleCallsExpand() {
expandCalls.value = expandCalls.value == false
}
@UiThread
fun toggleConversationsExpand() {
expandConversations.value = expandConversations.value == false
@ -431,6 +449,15 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
expandNetwork.value = expandNetwork.value == false
}
@UiThread
fun toggleUseWifiOnly() {
val newValue = useWifiOnly.value == false
coreContext.postOnCoreThread { core ->
core.isWifiOnlyEnabled = newValue
useWifiOnly.postValue(newValue)
}
}
@UiThread
fun toggleUserInterfaceExpand() {
expandUserInterface.value = expandUserInterface.value == false

View file

@ -31,6 +31,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.core.Account
import org.linphone.core.AuthInfo
@ -39,9 +40,11 @@ import org.linphone.core.Call
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.RegistrationState
import org.linphone.core.VFS
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
class MainViewModel @UiThread constructor() : ViewModel() {
@ -288,6 +291,19 @@ class MainViewModel @UiThread constructor() : ViewModel() {
triggerNativeAddressBookImport()
}
}
if (VFS.isEnabled(coreContext.context)) {
val cache = corePreferences.vfsCachePath
viewModelScope.launch {
val notClearedCount = FileUtils.countFilesInDirectory(cache)
if (notClearedCount > 0) {
Log.w(
"$TAG [VFS] There are [$notClearedCount] plain files not cleared from previous app lifetime, removing them now"
)
FileUtils.clearExistingPlainFiles(cache)
}
}
}
}
@UiThread

View file

@ -259,6 +259,7 @@ private fun loadImageForChatBubble(imageView: ImageView, file: String?, grid: Bo
val radius = imageView.resources.getDimension(
R.dimen.chat_bubble_images_rounded_corner_radius
)
if (FileUtils.isExtensionVideo(file)) {
imageView.load(file) {
placeholder(R.drawable.image_square)

View file

@ -424,6 +424,27 @@ class FileUtils {
return stringBuilder.toString()
}
suspend fun clearExistingPlainFiles(path: String) {
val dir = File(path)
if (dir.exists()) {
for (file in dir.listFiles().orEmpty()) {
Log.w(
"$TAG [VFS] Found forgotten plain file [${file.path}], deleting it now"
)
deleteFile(file.path)
}
}
}
@AnyThread
fun countFilesInDirectory(path: String): Int {
val dir = File(path)
if (dir.exists()) {
return dir.listFiles().orEmpty().size
}
return -1
}
@AnyThread
private fun getFileStorageDir(isPicture: Boolean = false): File {
var path: File? = null

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false" android:color="?attr/color_success_700" />
<item android:state_checked="true" android:color="?attr/color_success_500" />
<item android:color="?attr/color_main2_400"/>
</selector>

View file

@ -81,8 +81,8 @@
android:layout_width="0dp"
android:layout_height="50dp"
android:background="@drawable/edit_text_background"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:paddingStart="15dp"
android:paddingEnd="30dp"
app:layout_constraintTop_toBottomOf="@id/transport_title"
app:layout_constraintStart_toStartOf="@id/transport_title"
app:layout_constraintEnd_toEndOf="@id/transport_title" />
@ -91,7 +91,7 @@
android:id="@+id/transport_spinner_caret"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:layout_marginEnd="@dimen/spinner_caret_end_margin"
android:src="@drawable/caret_down"
android:contentDescription="@null"
app:layout_constraintTop_toTopOf="@id/transport_spinner"

View file

@ -138,8 +138,8 @@
android:layout_height="50dp"
android:layout_marginEnd="10dp"
android:background="@drawable/edit_text_background"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:paddingStart="@dimen/spinner_start_padding"
android:paddingEnd="@dimen/spinner_end_padding"
app:layout_constraintTop_toBottomOf="@id/device_ringtone_title"
app:layout_constraintStart_toStartOf="@id/device_ringtone_title"
app:layout_constraintEnd_toStartOf="@id/device_ringtone_player" />
@ -148,7 +148,7 @@
android:id="@+id/device_ringtone_spinner_caret"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:layout_marginEnd="@dimen/spinner_caret_end_margin"
android:src="@drawable/caret_down"
app:layout_constraintTop_toTopOf="@id/device_ringtone_spinner"
app:layout_constraintBottom_toBottomOf="@id/device_ringtone_spinner"

View file

@ -58,6 +58,35 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.AppCompatTextView
style="@style/section_header_style"
android:onClick="@{() -> viewModel.toggleSecurityExpand()}"
android:id="@+id/security"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="5dp"
android:layout_marginStart="26dp"
android:layout_marginEnd="26dp"
android:layout_marginTop="16dp"
android:text="@string/settings_security_title"
android:drawableEnd="@{viewModel.expandSecurity ? @drawable/caret_up : @drawable/caret_down, default=@drawable/caret_up}"
android:drawableTint="?attr/color_main2_600"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<include
android:id="@+id/security_settings"
layout="@layout/settings_security"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="@{viewModel.expandSecurity ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toBottomOf="@id/security"
bind:viewModel="@{viewModel}"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/section_header_style"
android:onClick="@{() -> viewModel.toggleCallsExpand()}"
@ -73,7 +102,7 @@
android:drawableTint="?attr/color_main2_600"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
app:layout_constraintTop_toBottomOf="@id/security_settings"/>
<include
android:id="@+id/calls_settings"

View file

@ -38,8 +38,8 @@
android:layout_height="50dp"
android:layout_marginEnd="10dp"
android:background="@drawable/edit_text_background"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:paddingStart="@dimen/spinner_start_padding"
android:paddingEnd="@dimen/spinner_end_padding"
app:layout_constraintTop_toBottomOf="@id/layout_title"
app:layout_constraintStart_toStartOf="@id/layout_title"
app:layout_constraintEnd_toEndOf="@id/layout_title" />
@ -48,7 +48,7 @@
android:id="@+id/layout_spinner_caret"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:layout_marginEnd="@dimen/spinner_caret_end_margin"
android:src="@drawable/caret_down"
app:layout_constraintTop_toTopOf="@id/layout_spinner"
app:layout_constraintBottom_toBottomOf="@id/layout_spinner"

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="org.linphone.ui.main.settings.viewmodel.SettingsViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="20dp"
android:background="@drawable/shape_squircle_white_background">
<androidx.appcompat.widget.AppCompatTextView
style="@style/settings_title_style"
android:id="@+id/enable_vfs_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="10dp"
android:text="@string/settings_security_enable_vfs_title"
android:maxLines="2"
android:ellipsize="end"
app:layout_constraintTop_toTopOf="@id/enable_vfs_switch"
app:layout_constraintBottom_toTopOf="@id/enable_vfs_subtitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/enable_vfs_switch"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/settings_subtitle_style"
android:id="@+id/enable_vfs_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="10dp"
android:text="@string/settings_security_enable_vfs_subtitle"
app:layout_constraintTop_toBottomOf="@id/enable_vfs_title"
app:layout_constraintBottom_toBottomOf="@id/enable_vfs_switch"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/enable_vfs_switch"/>
<com.google.android.material.materialswitch.MaterialSwitch
style="@style/material_switch_style"
android:id="@+id/enable_vfs_switch"
android:onClick="@{() -> viewModel.enableVfs()}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginEnd="16dp"
android:enabled="@{!viewModel.isVfsEnabled}"
android:checked="@{viewModel.isVfsEnabled}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -38,8 +38,8 @@
android:layout_height="50dp"
android:layout_marginEnd="10dp"
android:background="@drawable/edit_text_background"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:paddingStart="@dimen/spinner_start_padding"
android:paddingEnd="@dimen/spinner_end_padding"
app:layout_constraintTop_toBottomOf="@id/theme_title"
app:layout_constraintStart_toStartOf="@id/theme_title"
app:layout_constraintEnd_toEndOf="@id/theme_title" />
@ -48,7 +48,7 @@
android:id="@+id/theme_spinner_caret"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:layout_marginEnd="@dimen/spinner_caret_end_margin"
android:src="@drawable/caret_down"
app:layout_constraintTop_toTopOf="@id/theme_spinner"
app:layout_constraintBottom_toBottomOf="@id/theme_spinner"

View file

@ -187,6 +187,9 @@
<string name="help_troubleshooting_debug_logs_upload_error_toast_message">Echec à l\'envoi des journaux</string>
<string name="settings_title">Paramètres</string>
<string name="settings_security_title">Securité</string>
<string name="settings_security_enable_vfs_title">Chiffrer tous les fichiers</string>
<string name="settings_security_enable_vfs_subtitle">Attention, vous ne pourrez pas revenir en arrière !</string>
<string name="settings_calls_title">Appels</string>
<string name="settings_calls_echo_canceller_title">Utiliser l\'annulateur d\'écho</string>
<string name="settings_calls_echo_canceller_subtitle">Évite que de l\'écho soit entendu par votre correspondant</string>

View file

@ -89,4 +89,8 @@
<dimen name="chat_bubble_max_height_long_press">300dp</dimen>
<dimen name="recycler_view_min_height">200dp</dimen>
<dimen name="spinner_start_padding">15dp</dimen>
<dimen name="spinner_end_padding">30dp</dimen>
<dimen name="spinner_caret_end_margin">15dp</dimen>
</resources>

View file

@ -231,6 +231,9 @@
<!-- App & SDK settings -->
<string name="settings_title">Settings</string>
<string name="settings_security_title">Security</string>
<string name="settings_security_enable_vfs_title">Encrypt everything</string>
<string name="settings_security_enable_vfs_subtitle">Warning: once enabled it can\'t be disabled!</string>
<string name="settings_calls_title">Calls</string>
<string name="settings_calls_echo_canceller_title">Use echo canceller</string>
<string name="settings_calls_echo_canceller_subtitle">Prevents echo from being heard by remote end</string>