diff --git a/app/build.gradle b/app/build.gradle
index 82767b7e6..9f1db0714 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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"
diff --git a/app/src/main/java/org/linphone/LinphoneApplication.kt b/app/src/main/java/org/linphone/LinphoneApplication.kt
index 81f84907b..f6135bb34 100644
--- a/app/src/main/java/org/linphone/LinphoneApplication.kt
+++ b/app/src/main/java/org/linphone/LinphoneApplication.kt
@@ -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()
}
diff --git a/app/src/main/java/org/linphone/core/CorePreferences.kt b/app/src/main/java/org/linphone/core/CorePreferences.kt
index 07ff3c064..f73cf7ffe 100644
--- a/app/src/main/java/org/linphone/core/CorePreferences.kt
+++ b/app/src/main/java/org/linphone/core/CorePreferences.kt
@@ -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)
diff --git a/app/src/main/java/org/linphone/core/VFS.kt b/app/src/main/java/org/linphone/core/VFS.kt
new file mode 100644
index 000000000..b67838dad
--- /dev/null
+++ b/app/src/main/java/org/linphone/core/VFS.kt
@@ -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 .
+ */
+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 {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
+ val iv = cipher.iv
+ return Pair(
+ 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 {
+ val encryptedData = encryptData(string_to_encrypt)
+ return Pair(
+ 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")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt
index b9716129b..e2fb7e064 100644
--- a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt
+++ b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt
@@ -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)
diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt
index bfc870f1b..cf09cc08b 100644
--- a/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt
+++ b/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt
@@ -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)
}
}
diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt
index 0141cbf93..6a6836158 100644
--- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt
+++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt
@@ -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)
diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt
index ccfc0c060..872cf9ef3 100644
--- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt
+++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt
@@ -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)
diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt
index 4b736e74b..dfb78f41e 100644
--- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt
+++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt
@@ -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)
}
diff --git a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt
index b7188ab06..69634ce6e 100644
--- a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt
@@ -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()
val expandCalls = MutableLiveData()
val expandConversations = MutableLiveData()
val expandContacts = MutableLiveData()
@@ -56,6 +58,9 @@ class SettingsViewModel @UiThread constructor() : ViewModel() {
val expandNetwork = MutableLiveData()
val expandUserInterface = MutableLiveData()
+ // Security settings
+ val isVfsEnabled = MutableLiveData()
+
// Calls settings
val hideVideoCallSetting = MutableLiveData()
@@ -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
diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt
index b95182eeb..f972279bd 100644
--- a/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt
+++ b/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt
@@ -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
diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt
index 8fcba1ef8..428f84f41 100644
--- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt
+++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt
@@ -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)
diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt
index 214e403be..71612cf53 100644
--- a/app/src/main/java/org/linphone/utils/FileUtils.kt
+++ b/app/src/main/java/org/linphone/utils/FileUtils.kt
@@ -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
diff --git a/app/src/main/res/color/switch_track_color.xml b/app/src/main/res/color/switch_track_color.xml
index 2d08a4407..1a475be6d 100644
--- a/app/src/main/res/color/switch_track_color.xml
+++ b/app/src/main/res/color/switch_track_color.xml
@@ -1,5 +1,6 @@
+
diff --git a/app/src/main/res/layout/account_settings_fragment.xml b/app/src/main/res/layout/account_settings_fragment.xml
index a7b75de8c..08aebda00 100644
--- a/app/src/main/res/layout/account_settings_fragment.xml
+++ b/app/src/main/res/layout/account_settings_fragment.xml
@@ -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"
diff --git a/app/src/main/res/layout/settings_calls.xml b/app/src/main/res/layout/settings_calls.xml
index f02cf528c..6777bce39 100644
--- a/app/src/main/res/layout/settings_calls.xml
+++ b/app/src/main/res/layout/settings_calls.xml
@@ -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"
diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml
index 6b06dcc92..dbbd89f65 100644
--- a/app/src/main/res/layout/settings_fragment.xml
+++ b/app/src/main/res/layout/settings_fragment.xml
@@ -58,6 +58,35 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@id/security_settings"/>
@@ -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"
diff --git a/app/src/main/res/layout/settings_security.xml b/app/src/main/res/layout/settings_security.xml
new file mode 100644
index 000000000..fd7c14737
--- /dev/null
+++ b/app/src/main/res/layout/settings_security.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/settings_user_interface.xml b/app/src/main/res/layout/settings_user_interface.xml
index 85b8aa805..1099c121f 100644
--- a/app/src/main/res/layout/settings_user_interface.xml
+++ b/app/src/main/res/layout/settings_user_interface.xml
@@ -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"
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index aa9c9e253..22125be9c 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -187,6 +187,9 @@
Echec à l\'envoi des journaux
Paramètres
+ Securité
+ Chiffrer tous les fichiers
+ Attention, vous ne pourrez pas revenir en arrière !
Appels
Utiliser l\'annulateur d\'écho
Évite que de l\'écho soit entendu par votre correspondant
diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml
index 009cf9590..ea2831132 100644
--- a/app/src/main/res/values/dimen.xml
+++ b/app/src/main/res/values/dimen.xml
@@ -89,4 +89,8 @@
300dp
200dp
+
+ 15dp
+ 30dp
+ 15dp
\ 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 5fd785c15..2d6f80854 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -231,6 +231,9 @@
Settings
+ Security
+ Encrypt everything
+ Warning: once enabled it can\'t be disabled!
Calls
Use echo canceller
Prevents echo from being heard by remote end