Create extension function `Context.safeOpenOutputStream`

This commit is contained in:
cketti 2022-03-30 15:38:40 +02:00
parent b9b5cab772
commit 29c7ea11bd
7 changed files with 25 additions and 12 deletions

View File

@ -18,6 +18,7 @@ package im.vector.app.core.extensions
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.style.ImageSpan import android.text.style.ImageSpan
@ -31,6 +32,7 @@ import androidx.datastore.preferences.core.Preferences
import dagger.hilt.EntryPoints import dagger.hilt.EntryPoints
import im.vector.app.core.datastore.dataStoreProvider import im.vector.app.core.datastore.dataStoreProvider
import im.vector.app.core.di.SingletonEntryPoint import im.vector.app.core.di.SingletonEntryPoint
import java.io.OutputStream
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun Context.singletonEntryPoint(): SingletonEntryPoint { fun Context.singletonEntryPoint(): SingletonEntryPoint {
@ -68,3 +70,10 @@ private fun Float.toAndroidAlpha(): Int {
} }
val Context.dataStoreProvider: (String) -> DataStore<Preferences> by dataStoreProvider() val Context.dataStoreProvider: (String) -> DataStore<Preferences> by dataStoreProvider()
/**
* Open Uri in truncate mode to make sure we don't partially overwrite content when we get passed a Uri to an existing file.
*/
fun Context.safeOpenOutputStream(uri: Uri): OutputStream? {
return contentResolver.openOutputStream(uri, "wt")
}

View File

@ -19,6 +19,7 @@ package im.vector.app.features.crypto.keys
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.dispatchers.CoroutineDispatchers
import im.vector.app.core.extensions.safeOpenOutputStream
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject import javax.inject.Inject
@ -34,7 +35,7 @@ class KeysExporter @Inject constructor(
suspend fun export(password: String, uri: Uri) { suspend fun export(password: String, uri: Uri) {
withContext(dispatchers.io) { withContext(dispatchers.io) {
val data = session.cryptoService().exportRoomKeys(password) val data = session.cryptoService().exportRoomKeys(password)
context.contentResolver.openOutputStream(uri, "wt") context.safeOpenOutputStream(uri)
?.use { it.write(data) } ?.use { it.write(data) }
?: throw IllegalStateException("Unable to open file for writing") ?: throw IllegalStateException("Unable to open file for writing")
verifyExportedKeysOutputFileSize(uri, expectedSize = data.size.toLong()) verifyExportedKeysOutputFileSize(uri, expectedSize = data.size.toLong())

View File

@ -30,6 +30,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.safeOpenOutputStream
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.LiveEvent import im.vector.app.core.utils.LiveEvent
import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.copyToClipboard
@ -165,7 +166,7 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment<Fr
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
Try { Try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
requireContext().contentResolver.openOutputStream(uri, "wt") requireContext().safeOpenOutputStream(uri)
?.use { os -> ?.use { os ->
os.write(data.toByteArray()) os.write(data.toByteArray())
os.flush() os.flush()

View File

@ -29,6 +29,7 @@ import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.safeOpenOutputStream
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.core.utils.startSharePlainTextIntent
@ -81,7 +82,7 @@ class BootstrapSaveRecoveryKeyFragment @Inject constructor(
val uri = activityResult.data?.data ?: return@registerStartForActivityResult val uri = activityResult.data?.data ?: return@registerStartForActivityResult
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
sharedViewModel.handle(BootstrapActions.SaveKeyToUri(requireContext().contentResolver!!.openOutputStream(uri, "wt")!!)) sharedViewModel.handle(BootstrapActions.SaveKeyToUri(requireContext().safeOpenOutputStream(uri)!!))
} catch (failure: Throwable) { } catch (failure: Throwable) {
sharedViewModel.handle(BootstrapActions.SaveReqFailed) sharedViewModel.handle(BootstrapActions.SaveReqFailed)
} }

View File

@ -34,6 +34,7 @@ import com.airbnb.mvrx.withState
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.safeOpenOutputStream
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.selectTxtFileToWrite import im.vector.app.core.utils.selectTxtFileToWrite
import im.vector.app.databinding.FragmentDevtoolKeyrequestsBinding import im.vector.app.databinding.FragmentDevtoolKeyrequestsBinding
@ -106,7 +107,7 @@ class KeyRequestsFragment @Inject constructor() : VectorBaseFragment<FragmentDev
when (it) { when (it) {
is KeyRequestEvents.SaveAudit -> { is KeyRequestEvents.SaveAudit -> {
tryOrNull { tryOrNull {
requireContext().contentResolver?.openOutputStream(it.uri, "wt") requireContext().safeOpenOutputStream(it.uri)
?.use { os -> os.write(it.raw.toByteArray()) } ?.use { os -> os.write(it.raw.toByteArray()) }
} }
} }

View File

@ -53,7 +53,7 @@ class KeysExporterTest {
@Test @Test
fun `when exporting then writes exported keys to context output stream`() { fun `when exporting then writes exported keys to context output stream`() {
givenFileDescriptorWithSize(size = A_ROOM_KEYS_EXPORT.size.toLong()) givenFileDescriptorWithSize(size = A_ROOM_KEYS_EXPORT.size.toLong())
val outputStream = context.givenOutputStreamFor(A_URI, mode = "wt") val outputStream = context.givenSafeOutputStreamFor(A_URI)
runTest { keysExporter.export(A_PASSWORD, A_URI) } runTest { keysExporter.export(A_PASSWORD, A_URI) }
@ -63,7 +63,7 @@ class KeysExporterTest {
@Test @Test
fun `given different file size returned for export when exporting then throws UnexpectedExportKeysFileSizeException`() { fun `given different file size returned for export when exporting then throws UnexpectedExportKeysFileSizeException`() {
givenFileDescriptorWithSize(size = 110) givenFileDescriptorWithSize(size = 110)
context.givenOutputStreamFor(A_URI, mode = "wt") context.givenSafeOutputStreamFor(A_URI)
assertFailsWith<UnexpectedExportKeysFileSizeException> { assertFailsWith<UnexpectedExportKeysFileSizeException> {
runTest { keysExporter.export(A_PASSWORD, A_URI) } runTest { keysExporter.export(A_PASSWORD, A_URI) }
@ -72,7 +72,7 @@ class KeysExporterTest {
@Test @Test
fun `given output stream is unavailable for exporting to when exporting then throws IllegalStateException`() { fun `given output stream is unavailable for exporting to when exporting then throws IllegalStateException`() {
context.givenMissingOutputStreamFor(A_URI, mode = "wt") context.givenMissingSafeOutputStreamFor(A_URI)
assertFailsWith<IllegalStateException>(message = "Unable to open file for writing") { assertFailsWith<IllegalStateException>(message = "Unable to open file for writing") {
runTest { keysExporter.export(A_PASSWORD, A_URI) } runTest { keysExporter.export(A_PASSWORD, A_URI) }
@ -82,7 +82,7 @@ class KeysExporterTest {
@Test @Test
fun `given exported file is missing after export when exporting then throws IllegalStateException`() { fun `given exported file is missing after export when exporting then throws IllegalStateException`() {
context.givenFileDescriptor(A_URI, mode = "r") { null } context.givenFileDescriptor(A_URI, mode = "r") { null }
context.givenOutputStreamFor(A_URI, mode = "wt") context.givenSafeOutputStreamFor(A_URI)
assertFailsWith<IllegalStateException>(message = "Exported file not found") { assertFailsWith<IllegalStateException>(message = "Exported file not found") {
runTest { keysExporter.export(A_PASSWORD, A_URI) } runTest { keysExporter.export(A_PASSWORD, A_URI) }

View File

@ -39,13 +39,13 @@ class FakeContext(
every { contentResolver.openFileDescriptor(uri, mode, null) } returns fileDescriptor every { contentResolver.openFileDescriptor(uri, mode, null) } returns fileDescriptor
} }
fun givenOutputStreamFor(uri: Uri, mode: String): OutputStream { fun givenSafeOutputStreamFor(uri: Uri): OutputStream {
val outputStream = mockk<OutputStream>(relaxed = true) val outputStream = mockk<OutputStream>(relaxed = true)
every { contentResolver.openOutputStream(uri, mode) } returns outputStream every { contentResolver.openOutputStream(uri, "wt") } returns outputStream
return outputStream return outputStream
} }
fun givenMissingOutputStreamFor(uri: Uri, mode: String) { fun givenMissingSafeOutputStreamFor(uri: Uri) {
every { contentResolver.openOutputStream(uri, mode) } returns null every { contentResolver.openOutputStream(uri, "wt") } returns null
} }
} }