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