From ce608e927f744bb2d1774af370e13dcb399124ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Fri, 10 May 2024 12:15:12 +0200 Subject: [PATCH] chore: file access crashes [WPB-7368] (#2994) --- .../com/wire/android/ExternalLoggerManager.kt | 8 +- .../avatarpicker/AvatarPickerViewModel.kt | 37 +++++---- .../com/wire/android/util/DeviceUtil.kt | 77 +++++++++++++++++++ .../kotlin/com/wire/android/util/FileUtil.kt | 52 +++---------- app/src/main/res/values/strings.xml | 1 + 5 files changed, 120 insertions(+), 55 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt diff --git a/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt b/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt index 78cc0b1658e..0810621f529 100644 --- a/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt +++ b/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt @@ -29,6 +29,7 @@ import com.datadog.android.rum.tracking.ActivityViewTrackingStrategy import com.datadog.android.rum.tracking.ComponentPredicate import com.wire.android.datastore.GlobalDataStore import com.wire.android.ui.WireActivity +import com.wire.android.util.DeviceUtil import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.sha256 @@ -72,10 +73,15 @@ object ExternalLoggerManager { .useSite(DatadogSite.EU1) .build() + val availableMemorySize = DeviceUtil.getAvailableInternalMemorySize() + val totalMemorySize = DeviceUtil.getTotalInternalMemorySize() + val deviceParams = mapOf("available_memory_size" to availableMemorySize, "total_memory_size" to totalMemorySize) + val credentials = Credentials(clientToken, environmentName, appVariantName, applicationId) val extraInfo = mapOf( "encrypted_proteus_storage_enabled" to runBlocking { globalDataStore.isEncryptedProteusStorageEnabled().first() }, - "git_commit_hash" to context.getGitBuildId() + "git_commit_hash" to context.getGitBuildId(), + "device_params" to deviceParams ) Datadog.initialize(context, credentials, configuration, TrackingConsent.GRANTED) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt index 8bdc8879867..946457b3a97 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import okio.Path +import java.io.FileNotFoundException import javax.inject.Inject @HiltViewModel @@ -113,24 +114,31 @@ class AvatarPickerViewModel @Inject constructor( pictureState = PictureState.Uploading(imgUri) val avatarPath = defaultAvatarPath - val imageDataSize = imgUri.toByteArray(appContext, dispatchers).size.toLong() - when (val result = uploadUserAvatar(avatarPath, imageDataSize)) { - is UploadAvatarResult.Success -> { - dataStore.updateUserAvatarAssetId(result.userAssetId.toString()) - onComplete(dataStore.avatarAssetId.first()) - } - is UploadAvatarResult.Failure -> { - when (result.coreFailure) { - is NetworkFailure.NoNetworkConnection -> showInfoMessage(InfoMessageType.NoNetworkError) - else -> showInfoMessage(InfoMessageType.UploadAvatarError) + try { + val imageDataSize = imgUri.toByteArray(appContext, dispatchers).size.toLong() + + when (val result = uploadUserAvatar(avatarPath, imageDataSize)) { + is UploadAvatarResult.Success -> { + dataStore.updateUserAvatarAssetId(result.userAssetId.toString()) + onComplete(dataStore.avatarAssetId.first()) } - with(initialPictureLoadingState) { - pictureState = when (this) { - is InitialPictureLoadingState.Loaded -> PictureState.Initial(avatarUri) - else -> PictureState.Empty + + is UploadAvatarResult.Failure -> { + when (result.coreFailure) { + is NetworkFailure.NoNetworkConnection -> showInfoMessage(InfoMessageType.NoNetworkError) + else -> showInfoMessage(InfoMessageType.UploadAvatarError) + } + with(initialPictureLoadingState) { + pictureState = when (this) { + is InitialPictureLoadingState.Loaded -> PictureState.Initial(avatarUri) + else -> PictureState.Empty + } } } } + } catch (e: FileNotFoundException) { + appLogger.e("[AvatarPickerViewModel] Could not find a file", e) + showInfoMessage(InfoMessageType.ImageProcessError) } } } @@ -157,5 +165,6 @@ class AvatarPickerViewModel @Inject constructor( sealed class InfoMessageType(override val uiText: UIText) : SnackBarMessage { data object UploadAvatarError : InfoMessageType(UIText.StringResource(R.string.error_uploading_user_avatar)) data object NoNetworkError : InfoMessageType(UIText.StringResource(R.string.error_no_network_message)) + data object ImageProcessError : InfoMessageType(UIText.StringResource(R.string.error_process_user_avatar)) } } diff --git a/app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt b/app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt new file mode 100644 index 00000000000..74ad6cbbffb --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt @@ -0,0 +1,77 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * 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 com.wire.android.util + +import android.os.Environment +import android.os.StatFs + +object DeviceUtil { + private const val BYTES_IN_KILOBYTE = 1024 + private const val BYTES_IN_MEGABYTE = BYTES_IN_KILOBYTE * 1024 + private const val BYTES_IN_GIGABYTE = BYTES_IN_MEGABYTE * 1024 + private const val DIGITS_GROUP_SIZE = 3 // Number of digits between commas in formatted size. + + fun getAvailableInternalMemorySize(): String = try { + val path = Environment.getDataDirectory() + val stat = StatFs(path.path) + val blockSize = stat.blockSizeLong + val availableBlocks = stat.availableBlocksLong + formatSize(availableBlocks * blockSize) + } catch (e: IllegalArgumentException) { + "" + } + + fun getTotalInternalMemorySize(): String = try { + val path = Environment.getDataDirectory() + val stat = StatFs(path.path) + val blockSize = stat.blockSizeLong + val totalBlocks = stat.blockCountLong + formatSize(totalBlocks * blockSize) + } catch (e: IllegalArgumentException) { + "" + } + + private fun formatSize(sizeInBytes: Long): String { + var size = sizeInBytes + var suffix: String? = null + when { + size >= BYTES_IN_GIGABYTE -> { + suffix = "GB" + size /= BYTES_IN_GIGABYTE + } + + size >= BYTES_IN_MEGABYTE -> { + suffix = "MB" + size /= BYTES_IN_MEGABYTE + } + + size >= BYTES_IN_KILOBYTE -> { + suffix = "KB" + size /= BYTES_IN_KILOBYTE + } + } + val resultBuffer = StringBuilder(size.toString()) + var commaOffset = resultBuffer.length - DIGITS_GROUP_SIZE + while (commaOffset > 0) { + resultBuffer.insert(commaOffset, ',') + commaOffset -= DIGITS_GROUP_SIZE + } + suffix?.let { resultBuffer.append(it) } + return resultBuffer.toString() + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index 4a66f7c3b2e..667b5765e5e 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -27,7 +27,6 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.database.Cursor -import android.graphics.drawable.Drawable import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build @@ -42,10 +41,7 @@ import android.provider.MediaStore.MediaColumns.SIZE import android.provider.OpenableColumns import android.provider.Settings import android.webkit.MimeTypeMap -import androidx.annotation.AnyRes -import androidx.annotation.NonNull import androidx.annotation.VisibleForTesting -import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.wire.android.R import com.wire.android.appLogger @@ -62,28 +58,11 @@ import kotlinx.serialization.json.Json import okio.Path import java.io.File import java.io.FileNotFoundException +import java.io.IOException import java.io.InputStream import java.util.Locale import kotlin.time.Duration.Companion.milliseconds -/** - * Gets the uri of any drawable or given resource - * @param context - context - * @param drawableId - drawable res id - * @return - uri - */ -fun getUriFromDrawable( - @NonNull context: Context, - @AnyRes drawableId: Int -): Uri { - return Uri.parse( - ContentResolver.SCHEME_ANDROID_RESOURCE + - "://" + context.resources.getResourcePackageName(drawableId) + - '/' + context.resources.getResourceTypeName(drawableId) + - '/' + context.resources.getResourceEntryName(drawableId) - ) -} - @Suppress("MagicNumber") suspend fun Uri.toByteArray(context: Context, dispatcher: DispatcherProvider = DefaultDispatcherProvider()): ByteArray { return withContext(dispatcher.io()) { @@ -91,21 +70,6 @@ suspend fun Uri.toByteArray(context: Context, dispatcher: DispatcherProvider = D } } -suspend fun Uri.toDrawable(context: Context, dispatcher: DispatcherProvider = DefaultDispatcherProvider()): Drawable? { - val dataUri = this - return withContext(dispatcher.io()) { - try { - context.contentResolver.openInputStream(dataUri).use { inputStream -> - Drawable.createFromStream(inputStream, dataUri.toString()) - } - } catch (e: FileNotFoundException) { - defaultGalleryIcon(context) - } - } -} - -private fun defaultGalleryIcon(context: Context) = ContextCompat.getDrawable(context, R.drawable.ic_gallery) - fun getTempWritableAttachmentUri(context: Context, attachmentPath: Path): Uri { val file = attachmentPath.toFile() file.setWritable(true) @@ -243,9 +207,17 @@ suspend fun Uri.resampleImageAndCopyToTempPath( ImageUtil.resample(originalImage, sizeClass).let { processedImage -> val file = tempCachePath.toFile() - size = processedImage.size.toLong() - file.setWritable(true) - file.outputStream().use { it.write(processedImage) } + try { + size = processedImage.size.toLong() + file.setWritable(true) + file.outputStream().use { it.write(processedImage) } + } catch (e: FileNotFoundException) { + appLogger.e("[ResampleImage] Cannot find file ${file.path}", e) + throw e + } catch (e: IOException) { + appLogger.e("[ResampleImage] I/O error while writing the image", e) + throw e + } } size diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac1b79197a6..4b66ac39640 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -774,6 +774,7 @@ Please check your Internet connection and try again There was an error downloading your profile picture. Please check your Internet connection Picture could not be uploaded + Picture could not be processed Image upload failed Image download failed Notifications could not be updated