mirror of
https://gitlab.linphone.org/BC/public/linphone-android.git
synced 2026-01-17 11:28:06 +00:00
Added VFS & security settings section
This commit is contained in:
parent
8226f6e1b3
commit
d91093cf01
23 changed files with 494 additions and 38 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
216
app/src/main/java/org/linphone/core/VFS.kt
Normal file
216
app/src/main/java/org/linphone/core/VFS.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
62
app/src/main/res/layout/settings_security.xml
Normal file
62
app/src/main/res/layout/settings_security.xml
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue