Add underlying support for file sends (#646)

This commit is contained in:
David Perez
2024-01-17 14:41:18 -06:00
committed by Álison Fernandes
parent dbbb9f6587
commit cd236f183f
24 changed files with 676 additions and 46 deletions

View File

@@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.vault.datasource.network.api
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.PUT
import retrofit2.http.Url
/**
* Defines raw calls to the Azure API without any authentication applied.
*/
interface AzureApi {
/**
* Attempts to upload an encrypted file to Azure.
*/
@PUT
@Headers("x-ms-blob-type: BlockBlob")
suspend fun uploadAzureBlob(
@Url url: String,
@Header("x-ms-date") date: String,
@Header("x-ms-version") version: String?,
@Body body: RequestBody,
): Result<Unit>
}

View File

@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.vault.datasource.network.api
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.POST
@@ -21,6 +23,12 @@ interface SendsApi {
@POST("sends")
suspend fun createSend(@Body body: SendJsonRequest): Result<SyncResponseJson.Send>
/**
* Create a file send.
*/
@POST("sends/file/v2")
suspend fun createFileSend(@Body body: SendJsonRequest): Result<SendFileResponseJson>
/**
* Updates a send.
*/
@@ -30,6 +38,16 @@ interface SendsApi {
@Body body: SendJsonRequest,
): Result<SyncResponseJson.Send>
/**
* Uploads the file associated with a send.
*/
@POST("sends/{sendId}/file/{fileId}")
suspend fun uploadFile(
@Path("sendId") sendId: String,
@Path("fileId") fileId: String,
@Body body: MultipartBody,
): Result<Unit>
/**
* Deletes a send.
*/

View File

@@ -13,6 +13,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import retrofit2.create
import java.time.Clock
import javax.inject.Singleton
/**
@@ -37,9 +38,17 @@ object VaultNetworkModule {
fun provideSendsService(
retrofits: Retrofits,
json: Json,
clock: Clock,
): SendsService = SendsServiceImpl(
azureApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically
.baseUrl("https://www.bitwaredn.com")
.build()
.create(),
sendsApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
clock = clock,
)
@Provides

View File

@@ -0,0 +1,39 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the JSON response from creating a new file send.
*/
@Serializable
data class SendFileResponseJson(
@SerialName("url")
val url: String,
@SerialName("fileUploadType")
val fileUploadType: FileUploadType,
@SerialName("sendResponse")
val sendResponse: SyncResponseJson.Send,
) {
/**
* Represents the type of file upload that should be used.
*/
@Serializable(FileUploadTypeSerializer::class)
enum class FileUploadType {
@SerialName("0")
DIRECT,
@SerialName("1")
AZURE,
}
}
@Keep
private class FileUploadTypeSerializer :
BaseEnumeratedIntSerializer<SendFileResponseJson.FileUploadType>(
SendFileResponseJson.FileUploadType.entries.toTypedArray(),
)

View File

@@ -16,6 +16,7 @@ import java.time.ZonedDateTime
* @property expirationDate The date in which the send will expire (nullable).
* @property deletionDate The date in which the send will be deleted.
* @property file The file associated with this send (nullable).
* @property fileLength The length of the file in bytes (nullable).
* @property text The text associated with this send (nullable).
* @property password The password protecting this send (nullable).
* @property isDisabled Indicate if this send is disabled.
@@ -46,6 +47,9 @@ data class SendJsonRequest(
@Contextual
val deletionDate: ZonedDateTime,
@SerialName("fileLength")
val fileLength: Int?,
@SerialName("file")
val file: SyncResponseJson.Send.File?,

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
@@ -15,6 +16,21 @@ interface SendsService {
body: SendJsonRequest,
): Result<SyncResponseJson.Send>
/**
* Attempt to create a file send.
*/
suspend fun createFileSend(
body: SendJsonRequest,
): Result<SendFileResponseJson>
/**
* Attempt to upload the given [encryptedFile] associated with the [sendFileResponse].
*/
suspend fun uploadFile(
sendFileResponse: SendFileResponseJson,
encryptedFile: ByteArray,
): Result<SyncResponseJson.Send>
/**
* Attempt to update a send.
*/

View File

@@ -1,23 +1,38 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import com.x8bit.bitwarden.data.vault.datasource.network.api.AzureApi
import com.x8bit.bitwarden.data.vault.datasource.network.api.SendsApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* Default implementation of the [SendsService].
*/
class SendsServiceImpl(
private val azureApi: AzureApi,
private val sendsApi: SendsApi,
private val clock: Clock,
private val json: Json,
) : SendsService {
override suspend fun createSend(body: SendJsonRequest): Result<SyncResponseJson.Send> =
sendsApi.createSend(body = body)
override suspend fun createFileSend(body: SendJsonRequest): Result<SendFileResponseJson> =
sendsApi.createFileSend(body = body)
override suspend fun updateSend(
sendId: String,
body: SendJsonRequest,
@@ -38,6 +53,48 @@ class SendsServiceImpl(
?: throw throwable
}
override suspend fun uploadFile(
sendFileResponse: SendFileResponseJson,
encryptedFile: ByteArray,
): Result<SyncResponseJson.Send> {
val send = sendFileResponse.sendResponse
return when (sendFileResponse.fileUploadType) {
SendFileResponseJson.FileUploadType.DIRECT -> {
sendsApi.uploadFile(
sendId = requireNotNull(send.id),
fileId = requireNotNull(send.file?.id),
body = MultipartBody
.Builder(
boundary = "--BWMobileFormBoundary${clock.instant().toEpochMilli()}",
)
.addPart(
part = MultipartBody.Part.createFormData(
body = encryptedFile.toRequestBody(
contentType = "application/octet-stream".toMediaType(),
),
name = "data",
filename = send.file?.fileName,
),
)
.build(),
)
}
SendFileResponseJson.FileUploadType.AZURE -> {
azureApi.uploadAzureBlob(
url = sendFileResponse.url,
date = DateTimeFormatter
.RFC_1123_DATE_TIME
.format(ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC)),
version = sendFileResponse.url.toUri().getQueryParameter("sv"),
body = encryptedFile.toRequestBody(),
)
}
}
.onFailure { sendsApi.deleteSend(send.id) }
.map { send }
}
override suspend fun deleteSend(sendId: String): Result<Unit> =
sendsApi.deleteSend(sendId = sendId)

View File

@@ -169,6 +169,19 @@ interface VaultSdkSource {
sendView: SendView,
): Result<Send>
/**
* Encrypts a [ByteArray] file buffer for the user with the given [userId], returning an
* encrypted [ByteArray] wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun encryptBuffer(
userId: String,
send: Send,
fileBuffer: ByteArray,
): Result<ByteArray>
/**
* Decrypts a [Send] for the user with the given [userId], returning a [SendView] wrapped in a
* [Result].

View File

@@ -104,6 +104,21 @@ class VaultSdkSourceImpl(
.encrypt(sendView)
}
override suspend fun encryptBuffer(
userId: String,
send: Send,
fileBuffer: ByteArray,
): Result<ByteArray> =
runCatching {
getClient(userId = userId)
.vault()
.sends()
.encryptBuffer(
send = send,
buffer = fileBuffer,
)
}
override suspend fun encryptCipher(
userId: String,
cipherView: CipherView,

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Manages reading files.
*/
@OmitFromCoverage
interface FileManager {
/**
* Reads the [fileUri] into memory and returns the raw [ByteArray]
*/
fun uriToByteArray(fileUri: Uri): ByteArray
}

View File

@@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.vault.manager
import android.content.Context
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import java.io.ByteArrayOutputStream
/**
* The buffer size to be used when reading from an input stream.
*/
private const val BUFFER_SIZE: Int = 1024
/**
* The default implementation of the [FileManager] interface.
*/
@OmitFromCoverage
class FileManagerImpl(
private val context: Context,
) : FileManager {
override fun uriToByteArray(fileUri: Uri): ByteArray =
context
.contentResolver
.openInputStream(fileUri)
?.use { inputStream ->
ByteArrayOutputStream().use { outputStream ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } != -1) {
outputStream.write(buffer, 0, length)
}
outputStream.toByteArray()
}
}
?: byteArrayOf()
}

View File

@@ -1,16 +1,20 @@
package com.x8bit.bitwarden.data.vault.manager.di
import android.content.Context
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@@ -21,6 +25,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object VaultManagerModule {
@Provides
@Singleton
fun provideFileManager(
@ApplicationContext context: Context,
): FileManager = FileManagerImpl(context)
@Provides
@Singleton
fun provideVaultLockManager(

View File

@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.FolderView
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.platform.repository.model.DataState
@@ -159,9 +161,10 @@ interface VaultRepository : VaultLockManager {
): UpdateCipherResult
/**
* Attempt to create a send.
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE].
*/
suspend fun createSend(sendView: SendView): CreateSendResult
suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult
/**
* Attempt to update a send.

View File

@@ -1,10 +1,12 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@@ -18,6 +20,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoading
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
@@ -27,6 +30,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
@@ -80,6 +84,7 @@ class VaultRepositoryImpl(
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val fileManager: FileManager,
private val vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
) : VaultRepository,
@@ -415,14 +420,50 @@ class VaultRepositoryImpl(
)
}
override suspend fun createSend(sendView: SendView): CreateSendResult {
override suspend fun createSend(
sendView: SendView,
fileUri: Uri?,
): CreateSendResult {
val userId = requireNotNull(activeUserId)
return vaultSdkSource
.encryptSend(
userId = userId,
sendView = sendView,
)
.flatMap { send -> sendsService.createSend(body = send.toEncryptedNetworkSend()) }
.flatMap { send ->
when (send.type) {
SendType.TEXT -> {
sendsService.createSend(body = send.toEncryptedNetworkSend())
}
SendType.FILE -> {
val uri = fileUri ?: return@flatMap IllegalArgumentException(
"File URI must be present to create a File Send.",
)
.asFailure()
vaultSdkSource
.encryptBuffer(
userId = userId,
send = send,
fileBuffer = fileManager.uriToByteArray(fileUri = uri),
)
.flatMap { encryptedFile ->
sendsService
.createFileSend(
body = send.toEncryptedNetworkSend(
fileLength = encryptedFile.size,
),
)
.flatMap { sendFileResponse ->
sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedFile,
)
}
}
}
}
}
.onSuccess {
// Save the send immediately, regardless of whether the decrypt succeeds
vaultDiskSource.saveSend(userId = userId, send = it)

View File

@@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepositoryImpl
@@ -32,6 +33,7 @@ object VaultRepositoryModule {
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
fileManager: FileManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
): VaultRepository = VaultRepositoryImpl(
@@ -41,6 +43,7 @@ object VaultRepositoryModule {
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
fileManager = fileManager,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
)

View File

@@ -13,7 +13,7 @@ import java.time.ZonedDateTime
/**
* Converts a Bitwarden SDK [Send] object to a corresponding [SyncResponseJson.Send] object.
*/
fun Send.toEncryptedNetworkSend(): SendJsonRequest =
fun Send.toEncryptedNetworkSend(fileLength: Int? = null): SendJsonRequest =
SendJsonRequest(
type = type.toNetworkSendType(),
name = name,
@@ -22,6 +22,7 @@ fun Send.toEncryptedNetworkSend(): SendJsonRequest =
maxAccessCount = maxAccessCount?.toInt(),
expirationDate = expirationDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
deletionDate = ZonedDateTime.ofInstant(deletionDate, ZoneOffset.UTC),
fileLength = fileLength,
file = file?.toNetworkSendFile(),
text = text?.toNetworkSendText(),
password = password,

View File

@@ -452,7 +452,10 @@ class AddSendViewModel @Inject constructor(
viewModelScope.launch {
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> {
val result = vaultRepo.createSend(content.toSendView(clock))
val result = vaultRepo.createSend(
sendView = content.toSendView(clock),
fileUri = null,
)
sendAction(AddSendAction.Internal.CreateSendResultReceive(result))
}