This commit is contained in:
Anatoly Rosencrantz
2023-11-02 13:35:34 +02:00
parent 8c04a52568
commit cfbfde14d2
68 changed files with 6591 additions and 27 deletions
+11 -3
View File
@@ -1,5 +1,7 @@
default:
image: ${CI_REGISTRY}/android/shared/docker-android:v1.1.1
tags:
- shared-small
variables:
# Use fastzip to improve cache times
@@ -54,7 +56,7 @@ prepare-build:
expire_in: 1 week
cache: []
tags:
- large
- shared-large
script:
- export ARCHIVES_BASE_NAME=$(./gradlew -Dorg.gradle.jvmargs=-Xmx1024m -q getArchivesName | grep "\[ARCHIVES_NAME\]" | sed 's/\[ARCHIVES_NAME\]//')
- export ARCHIVES_VERSION=$(./gradlew -Dorg.gradle.jvmargs=-Xmx1024m -q getArchivesVersion | grep "\[ARCHIVES_VERSION\]" | sed 's/\[ARCHIVES_VERSION\]//')
@@ -65,6 +67,8 @@ prepare-build:
prepare-gradle-build-scan:
stage: prepare
tags:
- shared-large
script:
- echo "BUILD_SCAN_PUBLISH=true" >> build_scan.env
artifacts:
@@ -77,7 +81,7 @@ prepare-gradle-build-scan:
detekt analysis:
stage: analyze
tags:
- large
- shared-large
script:
- ./gradlew multiModuleDetekt --configuration-cache-problems=warn
allow_failure: true
@@ -179,6 +183,8 @@ dev debug unit test:
upload to firebase:
stage: startReview
tags:
- shared-medium
variables:
PRODUCT_FLAVOR: "dynamic"
script:
@@ -202,7 +208,7 @@ upload to firebase:
- job: "build dynamic debug"
stage: test
tags:
- medium
- shared-medium
variables:
RESULTS_DIR: "$FIREBASE_RESULT_ROOT/$CI_JOB_NAME"
PRODUCT_FLAVOR: "dynamic"
@@ -357,6 +363,8 @@ startReview:
needs:
- job: "prepare-build"
- job: "build dev debug"
tags:
- shared-medium
variables:
PRODUCT_FLAVOR: "dev"
before_script:
+1
View File
@@ -51,6 +51,7 @@ driveModule(
implementation(project(":app-lock"))
implementation(project(":app-ui-settings"))
implementation(project(":drive"))
implementation(project(":verifier"))
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.compose.foundationLayout)
@@ -30,8 +30,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import me.proton.android.drive.BuildConfig
import me.proton.android.drive.lock.data.usecase.BuildAppKeyImpl
import me.proton.android.drive.lock.domain.usecase.BuildAppKey
import me.proton.android.drive.log.DriveLogger
import me.proton.android.drive.notification.AppNotificationBuilderProvider
import me.proton.android.drive.notification.AppNotificationEventHandler
@@ -118,8 +118,8 @@ class UploadFlowTest : BaseTest() {
}
@Test
fun upload4MBFile() {
val file = externalFilesRule.createFile("4MB.txt", 4 * 1024 * 1024)
fun upload6MBFile() {
val file = externalFilesRule.createFile("6MB.txt", 6 * 1024 * 1024)
Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFunction {
Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(Uri.fromFile(file)))
@@ -135,7 +135,7 @@ class UploadFlowTest : BaseTest() {
assertStageUploading()
assertStageUploadedProgress(0)
assertStageUploadedProgress(100)
itemIsDisplayed("4MB.txt")
itemIsDisplayed("6MB.txt")
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ object Config {
const val minSdk = 23
const val targetSdk = 33
const val testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
const val versionName = "1.2.3"
const val versionName = "1.3.1"
const val archivesBaseName = "ProtonDrive-$versionName"
val resourceConfigurations = listOf("en")
}
+1
View File
@@ -22,6 +22,7 @@ plugins {
driveModule(
hilt = true,
) {
api(project(":drive:base:domain"))
api(libs.core.domain)
}
@@ -127,6 +127,8 @@ object Dto {
const val URL_PASSWORD_SALT = "UrlPasswordSalt"
const val URLS_EXPIRED = "UrlsExpired"
const val USED_SPACE = "UsedSpace"
const val VERIFICATION_CODE = "VerificationCode"
const val VERIFIER = "Verifier"
const val VOLUME = "Volume"
const val VOLUMES = "Volumes"
const val VOLUME_ID = "VolumeID"
@@ -26,6 +26,7 @@ object ProtonApiCode {
const val ALREADY_EXISTS = 2500
const val NOT_EXISTS = 2501
const val INSUFFICIENT_QUOTA = 200001
const val ENCRYPTION_VERIFICATION_FAILED = 200501
val Long.isSuccessful: Boolean get() = this == SUCCESS.toLong()
}
@@ -109,6 +109,7 @@ object Column {
const val URL_PASSWORD_SALT = "url_password_salt"
const val USED_SPACE = "used_space"
const val USER_ID = "user_id"
const val VERIFIER_TOKEN = "verifier_token"
const val VOLUME_ID = "volume_id"
const val WORK_ID = "work_id"
const val WORKER_ID = "worker_id"
@@ -23,6 +23,7 @@ import me.proton.core.drive.base.data.api.Dto.ENC_SIGNATURE
import me.proton.core.drive.base.data.api.Dto.HASH
import me.proton.core.drive.base.data.api.Dto.INDEX
import me.proton.core.drive.base.data.api.Dto.SIZE
import me.proton.core.drive.base.data.api.Dto.VERIFIER
@Serializable
data class UploadBlockDto(
@@ -33,5 +34,7 @@ data class UploadBlockDto(
@SerialName(ENC_SIGNATURE)
val encSignature: String,
@SerialName(HASH)
val hash: String
val hash: String,
@SerialName(VERIFIER)
val verifier: VerifierDto? = null,
)
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.block.data.api.entity
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.proton.core.drive.base.data.api.Dto.TOKEN
@Serializable
data class VerifierDto(
@SerialName(TOKEN)
val token: String,
)
@@ -18,6 +18,7 @@
package me.proton.core.drive.block.data.extension
import me.proton.core.drive.block.data.api.entity.UploadBlockDto
import me.proton.core.drive.block.data.api.entity.VerifierDto
import me.proton.core.drive.linkupload.domain.entity.UploadBlock
fun UploadBlock.toUploadBlockDto() =
@@ -26,4 +27,5 @@ fun UploadBlock.toUploadBlockDto() =
size = size.value,
hash = hashSha256,
encSignature = encSignature,
verifier = verifierToken?.let { token -> VerifierDto(token) },
)
@@ -35,7 +35,7 @@ class UseSessionKey @Inject constructor(
) {
suspend operator fun <T> invoke(
contentKey: ContentKey,
checkSignature: Boolean = false,
checkSignature: Boolean,
coroutineContext: CoroutineContext = CryptoScope.EncryptAndDecryptWithIO.coroutineContext,
block: suspend (SessionKey) -> T
) =
@@ -54,4 +54,17 @@ class UseSessionKey @Inject constructor(
}
block(sessionKey)
}
suspend operator fun <T> invoke(
contentKey: ContentKey,
coroutineContext: CoroutineContext = CryptoScope.EncryptAndDecryptWithIO.coroutineContext,
block: suspend (SessionKey) -> T
) =
useSessionKey(
decryptKey = contentKey.decryptKey.keyHolder,
encryptedKeyPacket = contentKey.encryptedKeyPacket,
coroutineContext = coroutineContext,
) { sessionKey ->
block(sessionKey)
}
}
@@ -30,6 +30,24 @@ class DecryptFiles @Inject constructor(
private val useSessionKey: UseSessionKey,
private val decryptFile: DecryptFile
) {
suspend operator fun invoke(
contentKey: ContentKey,
checkSignature: Boolean,
input: List<File>,
output: List<File>,
): Result<List<DecryptedFile>> = coRunCatching {
useSessionKey(contentKey = contentKey, checkSignature = checkSignature) { sessionKey ->
input.mapIndexed { index, file ->
DecryptedFile(
file = decryptFile(sessionKey, file, output[index]).getOrThrow(),
status = VerificationStatus.Unknown,
filename = "",
lastModifiedEpochSeconds = -1,
)
}
}.getOrThrow()
}
suspend operator fun invoke(
contentKey: ContentKey,
input: List<File>,
@@ -32,11 +32,12 @@ class EncryptFiles @Inject constructor(
) {
suspend operator fun invoke(
contentKey: ContentKey,
checkSignature: Boolean = false,
input: List<File>,
output: List<File>,
coroutineContext: CoroutineContext = CryptoScope.EncryptAndDecryptWithIO.coroutineContext,
): Result<List<File>> = coRunCatching {
useSessionKey(contentKey = contentKey) { sessionKey ->
useSessionKey(contentKey = contentKey, checkSignature = checkSignature) { sessionKey ->
input.mapIndexed { index, file ->
encryptFile(sessionKey, file, output[index], coroutineContext).getOrThrow()
}
File diff suppressed because it is too large Load Diff
@@ -192,6 +192,7 @@ import me.proton.drive.android.settings.data.db.entity.UiSettingsEntity
AutoMigration(from = 18, to = 19),
AutoMigration(from = 22, to = 23),
AutoMigration(from = 23, to = 24),
AutoMigration(from = 26, to = 27),
],
exportSchema = true,
)
@@ -250,7 +251,7 @@ abstract class DriveDatabase :
WorkerDatabase {
companion object {
const val VERSION = 26
const val VERSION = 27
private val migrations = listOf(
DriveDatabaseMigrations.MIGRATION_1_2,
@@ -278,6 +279,7 @@ abstract class DriveDatabase :
//AutoMigration(from = 23, to = 24)
DriveDatabaseMigrations.MIGRATION_24_25,
DriveDatabaseMigrations.MIGRATION_25_26,
//AutoMigration(from = 26, to = 27)
)
fun buildDatabase(context: Context): DriveDatabase =
@@ -53,4 +53,5 @@
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
<string name="files_upload_verification_failed">Upload failed: Verification of data failed</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2023 Proton AG.
~ This file is part of Proton Core.
~
~ Proton Core 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.
~
~ Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
-->
<resources>
<string name="verifier_initialize_failed">Verifier initialization failed</string>
<string name="verifier_verify_blocks_failed">Verifier failed verifing blocks</string>
</resources>
@@ -54,12 +54,24 @@ class BuildContentKey @Inject constructor(
userId: UserId,
uploadFile: UploadFileLink,
fileKey: Key.Node,
): Result<ContentKey> = invoke(
userId = userId,
contentKeyPacket = uploadFile.contentKeyPacket,
contentKeyPacketSignature = uploadFile.contentKeyPacketSignature,
fileKey = fileKey,
)
suspend operator fun invoke(
userId: UserId,
contentKeyPacket: String,
contentKeyPacketSignature: String,
fileKey: Key.Node,
): Result<ContentKey> = coRunCatching {
contentKeyFactory.createContentKey(
decryptKey = fileKey,
verifyKey = listOf(fileKey, getAddressKeys(userId, getSignatureAddress(userId))),
contentKeyPacket = uploadFile.contentKeyPacket,
contentKeyPacketSignature = uploadFile.contentKeyPacketSignature
contentKeyPacket = contentKeyPacket,
contentKeyPacketSignature = contentKeyPacketSignature
)
}
}
@@ -56,4 +56,14 @@ abstract class UploadBlockDao : BaseDao<UploadBlockEntity>() {
index: Long,
token: String,
)
@Query("""
UPDATE UploadBlockEntity SET verifier_token = :verifierToken WHERE
upload_link_id = :uploadLinkId AND `index` = :index
""")
abstract fun updateVerifierToken(
uploadLinkId: Long,
index: Long,
verifierToken: String,
)
}
@@ -29,6 +29,7 @@ import me.proton.core.drive.base.data.db.Column.SIZE
import me.proton.core.drive.base.data.db.Column.TOKEN
import me.proton.core.drive.base.data.db.Column.UPLOAD_LINK_ID
import me.proton.core.drive.base.data.db.Column.URL
import me.proton.core.drive.base.data.db.Column.VERIFIER_TOKEN
@Entity(
primaryKeys = [UPLOAD_LINK_ID, INDEX],
@@ -58,4 +59,6 @@ data class UploadBlockEntity(
val url: String,
@ColumnInfo(name = RAW_SIZE, defaultValue = "0")
val rawSize: Long,
@ColumnInfo(name = VERIFIER_TOKEN, defaultValue = "NULL")
val verifierToken: String? = null,
)
@@ -30,4 +30,5 @@ fun UploadBlockEntity.toUploadBlock(uploadBlockFactory: UploadBlockFactory) =
rawSize = rawSize.bytes,
size = size.bytes,
token = uploadToken,
verifierToken = verifierToken,
)
@@ -33,7 +33,8 @@ class UploadBlockFactoryImpl @Inject constructor() : UploadBlockFactory {
encSignature: String,
rawSize: Bytes,
size: Bytes,
token: String
token: String,
verifierToken: String?,
): UploadBlock =
UploadBlock(
index = index,
@@ -44,6 +45,7 @@ class UploadBlockFactoryImpl @Inject constructor() : UploadBlockFactory {
size = size,
token = token,
file = block,
verifierToken = verifierToken,
)
override fun create(
@@ -53,7 +55,8 @@ class UploadBlockFactoryImpl @Inject constructor() : UploadBlockFactory {
encSignature: String,
rawSize: Bytes,
size: Bytes,
token: String
token: String,
verifierToken: String?,
): UploadBlock =
UploadBlock(
index = index,
@@ -64,5 +67,6 @@ class UploadBlockFactoryImpl @Inject constructor() : UploadBlockFactory {
size = size,
token = token,
file = File(requireNotNull(Uri.parse(url)?.path)),
verifierToken = verifierToken,
)
}
@@ -17,6 +17,7 @@
*/
package me.proton.core.drive.linkupload.data.repository
import android.util.Base64
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.proton.core.domain.entity.UserId
@@ -227,6 +228,17 @@ class LinkUploadRepositoryImpl @Inject constructor(
token = token,
)
override suspend fun updateUploadBlockVerifierToken(
uploadFileLinkId: Long,
uploadBlockIndex: Long,
verifierToken: ByteArray
) =
db.uploadBlockDao.updateVerifierToken(
uploadLinkId = uploadFileLinkId,
index = uploadBlockIndex,
verifierToken = Base64.encodeToString(verifierToken, Base64.NO_WRAP),
)
override suspend fun removeUploadBlocks(uploadFileLink: UploadFileLink) =
db.uploadBlockDao.delete(uploadFileLink.id)
@@ -30,4 +30,5 @@ data class UploadBlock(
val size: Bytes,
val token: String,
val file: File,
val verifierToken: String?,
) : Block
@@ -30,6 +30,7 @@ interface UploadBlockFactory {
rawSize: Bytes,
size: Bytes,
token: String,
verifierToken: String?,
): UploadBlock
fun create(
@@ -40,5 +41,6 @@ interface UploadBlockFactory {
rawSize: Bytes,
size: Bytes,
token: String,
verifierToken: String?,
): UploadBlock
}
@@ -110,6 +110,12 @@ interface LinkUploadRepository {
token: String,
)
suspend fun updateUploadBlockVerifierToken(
uploadFileLinkId: Long,
uploadBlockIndex: Long,
verifierToken: ByteArray,
)
suspend fun removeUploadBlocks(uploadFileLink: UploadFileLink)
suspend fun insertUploadBulk(uploadBulk: UploadBulk): UploadBulk
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.linkupload.domain.usecase
import me.proton.core.drive.linkupload.domain.entity.UploadBlock
import me.proton.core.drive.linkupload.domain.repository.LinkUploadRepository
import javax.inject.Inject
class UpdateVerifierToken @Inject constructor(
private val linkUploadRepository: LinkUploadRepository,
private val getUploadBlockAfterOperation: GetUploadBlockAfterOperation,
) {
suspend operator fun invoke(
uploadFileLinkId: Long,
index: Long,
verifierToken: ByteArray,
): Result<UploadBlock> = getUploadBlockAfterOperation(uploadFileLinkId, index) {
linkUploadRepository.updateUploadBlockVerifierToken(uploadFileLinkId, index, verifierToken)
}
}
+2
View File
@@ -33,7 +33,9 @@ driveModule(
implementation(project(":drive:link:presentation"))
implementation(project(":drive:notification:domain"))
implementation(project(":drive:worker:data"))
implementation(project(":verifier:data"))
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.core.crypto)
implementation(libs.core.network)
testImplementation(libs.bundles.test.jvm)
}
@@ -17,6 +17,9 @@
*/
package me.proton.core.drive.upload.data.extension
import me.proton.android.drive.verifier.data.extension.log
import me.proton.android.drive.verifier.domain.exception.VerifierException
import me.proton.core.drive.base.presentation.extension.log
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.isRetryable
@@ -30,5 +33,13 @@ internal val Throwable.isRetryable: Boolean
else -> this.error.isRetryable()
}
}
is VerifierException -> this.cause.isRetryable
else -> false
}
internal fun Throwable.log(tag: String, message: String? = null): Throwable = this.also {
when (this) {
is VerifierException -> this.log(tag, message.orEmpty())
else -> this.log(tag, message)
}
}
@@ -18,15 +18,24 @@
package me.proton.core.drive.upload.data.extension
import android.content.Context
import me.proton.android.drive.verifier.data.extension.log
import me.proton.android.drive.verifier.domain.exception.VerifierException
import me.proton.core.drive.base.presentation.extension.getDefaultMessage
import me.proton.core.drive.base.presentation.extension.log
import me.proton.core.drive.upload.data.exception.UploadCleanupException
import me.proton.core.drive.i18n.R as I18N
internal fun UploadCleanupException.getDefaultMessage(
context: Context,
useExceptionMessage: Boolean,
): String = error.getDefaultMessage(context, useExceptionMessage)
): String = when (error) {
is VerifierException -> context.getString(I18N.string.files_upload_verification_failed)
else -> error.getDefaultMessage(context, useExceptionMessage)
}
internal fun UploadCleanupException.log(tag: String, message: String? = null): UploadCleanupException = also {
message?.let { error.log(tag, message) } ?: error.log(tag)
when (error) {
is VerifierException -> message?.let { error.log(tag, message) } ?: error.log(tag)
else -> message?.let { error.log(tag, message) } ?: error.log(tag)
}
}
@@ -46,6 +46,7 @@ import me.proton.core.drive.linkupload.domain.usecase.GetUploadFileLink
import me.proton.core.drive.linkupload.domain.usecase.UpdateToken
import me.proton.core.drive.upload.data.extension.getSizeData
import me.proton.core.drive.upload.data.extension.isRetryable
import me.proton.core.drive.upload.data.extension.retryOrAbort
import me.proton.core.drive.upload.data.extension.setSize
import me.proton.core.drive.upload.data.worker.WorkerKeys.KEY_BLOCK_INDEX
import me.proton.core.drive.upload.data.worker.WorkerKeys.KEY_BLOCK_TOKEN
@@ -132,11 +133,7 @@ class BlockUploadWorker @AssistedInject constructor(
max retries reached ${!canRetry}
""".trimIndent(),
)
return@coroutineScope if (retryable && canRetry) {
Result.retry()
} else {
Result.success(getSizeData(progress.value))
}
return@coroutineScope retryOrAbort(retryable && canRetry, error, uploadFileLink.name)
}
} finally {
job.cancel()
@@ -46,13 +46,19 @@ internal sealed class FileUploadFlow {
uploadFileLinkId = uploadFileLinkId,
uriString = uriString,
shouldDeleteSource = shouldDeleteSource,
tags = uploadTags
tags = uploadTags,
)
).then(
VerifyBlocksWorker.getWorkRequest(
userId = userId,
uploadFileLinkId = uploadFileLinkId,
tags = uploadTags,
)
).then(
GetBlocksUploadUrlWorker.getWorkRequest(
userId = userId,
uploadFileLinkId = uploadFileLinkId,
tags = uploadTags
tags = uploadTags,
)
).enqueue()
}
@@ -73,6 +79,12 @@ internal sealed class FileUploadFlow {
shouldDeleteSource = shouldDeleteSource,
tags = uploadTags
)
).then(
VerifyBlocksWorker.getWorkRequest(
userId = userId,
uploadFileLinkId = uploadFileLinkId,
tags = uploadTags,
)
).then(
GetBlocksUploadUrlWorker.getWorkRequest(
userId = userId,
@@ -27,6 +27,7 @@ import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import me.proton.android.drive.verifier.domain.usecase.CleanupVerifier
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.data.workmanager.addTags
import me.proton.core.drive.base.domain.entity.Percentage
@@ -60,6 +61,7 @@ class UploadCleanupWorker @AssistedInject constructor(
private val getBlockFolder: GetBlockFolder,
private val removeUploadFile: RemoveUploadFile,
private val announceEvent: AnnounceEvent,
private val cleanupVerifier: CleanupVerifier,
configurationProvider: ConfigurationProvider,
canRun: CanRun,
run: Run,
@@ -95,6 +97,14 @@ class UploadCleanupWorker @AssistedInject constructor(
percentage = Percentage(0)
)
)
uploadFileLink.linkId?.let { linkId ->
cleanupVerifier(
userId = userId,
shareId = uploadFileLink.shareId.id,
linkId = linkId,
revisionId = uploadFileLink.draftRevisionId,
)
}
} finally {
uploadFileLink.deleteOnServer()
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.upload.data.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.data.workmanager.addTags
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
import me.proton.core.drive.linkupload.domain.usecase.GetUploadFileLink
import me.proton.core.drive.upload.data.extension.isRetryable
import me.proton.core.drive.upload.data.extension.log
import me.proton.core.drive.upload.data.extension.logTag
import me.proton.core.drive.upload.data.extension.retryOrAbort
import me.proton.core.drive.upload.domain.usecase.VerifyBlocks
import me.proton.core.drive.worker.domain.usecase.CanRun
import me.proton.core.drive.worker.domain.usecase.Done
import me.proton.core.drive.worker.domain.usecase.Run
import java.util.concurrent.TimeUnit
@HiltWorker
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongParameterList")
class VerifyBlocksWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
workManager: WorkManager,
broadcastMessages: BroadcastMessages,
getUploadFileLink: GetUploadFileLink,
private val verifyBlocks: VerifyBlocks,
configurationProvider: ConfigurationProvider,
canRun: CanRun,
run: Run,
done: Done,
) : UploadCoroutineWorker(
appContext = appContext,
workerParams = workerParams,
workManager = workManager,
broadcastMessages = broadcastMessages,
getUploadFileLink = getUploadFileLink,
configurationProvider = configurationProvider,
canRun = canRun,
run = run,
done = done,
) {
override suspend fun doLimitedRetryUploadWork(uploadFileLink: UploadFileLink): Result {
verifyBlocks(uploadFileLink)
.onFailure { error ->
val retryable = error.isRetryable
val canRetry = canRetry()
error.log(
tag = uploadFileLink.logTag(),
message = """
Verify blocks failed with "${error.message}" retryable $retryable,
max retries reached ${!canRetry}
""".trimIndent()
)
return retryOrAbort(retryable && canRetry, error, uploadFileLink.name)
}
return Result.success()
}
companion object {
fun getWorkRequest(
userId: UserId,
uploadFileLinkId: Long,
tags: List<String> = emptyList(),
): OneTimeWorkRequest =
OneTimeWorkRequest.Builder(VerifyBlocksWorker::class.java)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(
Data.Builder()
.putString(WorkerKeys.KEY_USER_ID, userId.id)
.putLong(WorkerKeys.KEY_UPLOAD_FILE_LINK_ID, uploadFileLinkId)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.addTags(listOf(userId.id) + tags)
.build()
}
}
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.upload.data.worker
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.Operation
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.arch.ResponseSource
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
import me.proton.core.drive.linkupload.domain.usecase.GetUploadFileLink
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.upload.domain.usecase.UpdateRevision
import me.proton.core.drive.worker.domain.usecase.CanRun
import me.proton.core.drive.worker.domain.usecase.Done
import me.proton.core.drive.worker.domain.usecase.Run
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class UpdateRevisionWorkerTest {
private val userId: UserId = UserId("user-id")
private val workManager = mockk<WorkManager>()
private val broadcastMessages = mockk<BroadcastMessages>()
private val getUploadFileLink = mockk<GetUploadFileLink>()
private val updateRevision = mockk<UpdateRevision>()
private val configurationProvider = mockk<ConfigurationProvider>()
private val canRun = mockk<CanRun>()
private val run = mockk<Run>()
private val done = mockk<Done>()
private val uploadFileLink = mockk<UploadFileLink>()
private val operation = mockk<Operation>()
@Before
fun before() {
coEvery { canRun(any(), any()) } returns Result.success(true)
coEvery { getUploadFileLink(any() as Long) } returns DataResult.Success(ResponseSource.Local, uploadFileLink)
coEvery { workManager.enqueue(any() as WorkRequest) } returns operation
coEvery { configurationProvider.useExceptionMessage } returns false
coEvery { broadcastMessages(userId, any(), any(), any()) } returns Unit
}
@Test
fun `when commit a revision receives error with proton code 200501, upload fails and message is shown`() = runTest {
// Given
val uploadFile = "proton_drive.pdf"
coEvery { uploadFileLink.name } returns uploadFile
val errorFromServer = "Upload failed: Verification of data failed"
coEvery { updateRevision(any()) } returns Result.failure(
ApiException(
ApiResult.Error.Http(
httpCode = 422,
message = "Unprocessable Content",
proton = ApiResult.Error.ProtonData(
code = 200501,
error = errorFromServer,
)
)
)
)
// When
val result = updateRevisionWorker(userId).doLimitedRetryWork()
// Then
assertEquals(ListenableWorker.Result.failure(), result)
verify(exactly = 1) {
broadcastMessages(
userId = userId,
message = "Uploading file $uploadFile failed with reason: $errorFromServer",
type = BroadcastMessage.Type.ERROR,
extra = null,
)
}
}
private fun updateRevisionWorker(
userId: UserId,
): UpdateRevisionWorker {
val context = ApplicationProvider.getApplicationContext<Context>()
return TestListenableWorkerBuilder<UpdateRevisionWorker>(context)
.setWorkerFactory(
object : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
) = UpdateRevisionWorker(
appContext = appContext,
workerParams = workerParameters,
workManager = workManager,
broadcastMessages = broadcastMessages,
getUploadFileLink = getUploadFileLink,
updateRevision = updateRevision,
configurationProvider = configurationProvider,
canRun = canRun,
run = run,
done = done,
)
}
)
.setInputData(
Data.Builder()
.putString(WorkerKeys.KEY_USER_ID, userId.id)
.build()
)
.build()
}
}
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.upload.data.worker
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.Operation
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import me.proton.android.drive.verifier.domain.exception.VerifierException
import me.proton.core.crypto.common.pgp.exception.CryptoException
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.arch.ResponseSource
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
import me.proton.core.drive.linkupload.domain.usecase.GetUploadFileLink
import me.proton.core.drive.upload.domain.usecase.VerifyBlocks
import me.proton.core.drive.worker.domain.usecase.CanRun
import me.proton.core.drive.worker.domain.usecase.Done
import me.proton.core.drive.worker.domain.usecase.Run
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.util.kotlin.Logger
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class VerifyBlocksWorkerTest {
private val userId: UserId = UserId("user-id")
private val workManager = mockk<WorkManager>()
private val broadcastMessages = mockk<BroadcastMessages>()
private val getUploadFileLink = mockk<GetUploadFileLink>()
private val verifyBlocks = mockk<VerifyBlocks>()
private val configurationProvider = mockk<ConfigurationProvider>()
private val canRun = mockk<CanRun>()
private val run = mockk<Run>()
private val done = mockk<Done>()
private val uploadFileLink = mockk<UploadFileLink>()
private val operation = mockk<Operation>()
private val logger = mockk<Logger>()
@Before
fun before() {
coEvery { canRun(any(), any()) } returns Result.success(true)
coEvery { getUploadFileLink(any() as Long) } returns DataResult.Success(ResponseSource.Local, uploadFileLink)
coEvery { uploadFileLink.id } returns 123L
coEvery { uploadFileLink.name } returns "secret.jpg"
coEvery { workManager.enqueue(any() as WorkRequest) } returns operation
coEvery { configurationProvider.useExceptionMessage } returns false
coEvery { broadcastMessages(userId, any(), any(), any()) } returns Unit
coEvery { logger.d(any(), any()) } returns Unit
coEvery { logger.e(any(), any(), any()) } returns Unit
}
@Test
fun `when verify blocks fails with VerifyBlock exception, upload fails and it is logged as error`() = runTest {
// Given
val error = VerifierException.VerifyBlock(CryptoException("Invalid key"))
coEvery { verifyBlocks(uploadFileLink) } returns Result.failure(error)
val uploadFileLinkId = uploadFileLink.id
CoreLogger.set(logger)
// When
val result = verifyBlocksWorker(userId).doLimitedRetryWork()
// Then
Assert.assertEquals(ListenableWorker.Result.failure(), result)
verify(exactly = 1) {
logger.e(
tag = "core.drive.upload.$uploadFileLinkId",
e = error,
message = """
Verify blocks failed with "${error.message}" retryable false,
max retries reached false
""".trimIndent(),
)
}
}
private fun verifyBlocksWorker(
userId: UserId,
): VerifyBlocksWorker {
val context = ApplicationProvider.getApplicationContext<Context>()
return TestListenableWorkerBuilder<VerifyBlocksWorker>(context)
.setWorkerFactory(
object : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
) = VerifyBlocksWorker(
appContext = appContext,
workerParams = workerParameters,
workManager = workManager,
broadcastMessages = broadcastMessages,
getUploadFileLink = getUploadFileLink,
verifyBlocks = verifyBlocks,
configurationProvider = configurationProvider,
canRun = canRun,
run = run,
done = done,
)
}
)
.setInputData(
Data.Builder()
.putString(WorkerKeys.KEY_USER_ID, userId.id)
.build()
)
.build()
}
}
+1
View File
@@ -28,6 +28,7 @@ driveModule(
api(project(":drive:link-upload:domain"))
api(project(":drive:notification:domain"))
api(project(":drive:thumbnail:domain"))
api(project(":verifier:domain"))
implementation(project(":drive:crypto:domain"))
}
@@ -197,6 +197,7 @@ class SplitFileToBlocksAndEncrypt @Inject constructor(
rawSize = rawBlock.size,
size = encryptedBlock.size,
token = "",
verifierToken = null,
).also { rawBlock.delete() }
}.getOrThrow()
@@ -231,6 +232,7 @@ class SplitFileToBlocksAndEncrypt @Inject constructor(
rawSize = thumbnail.size.bytes,
size = encryptedUploadThumbnail.size,
token = "",
verifierToken = null,
)
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.upload.domain.usecase
import me.proton.android.drive.verifier.domain.usecase.BuildVerifier
import me.proton.android.drive.verifier.domain.usecase.CleanupVerifier
import me.proton.core.drive.base.domain.usecase.GetSignatureAddress
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.key.domain.entity.Key
import me.proton.core.drive.key.domain.usecase.BuildNodeKey
import me.proton.core.drive.key.domain.usecase.GetNodeKey
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
import me.proton.core.drive.linkupload.domain.usecase.GetUploadBlocks
import me.proton.core.drive.linkupload.domain.usecase.UpdateVerifierToken
import javax.inject.Inject
class VerifyBlocks @Inject constructor(
private val buildVerifier: BuildVerifier,
private val cleanupVerifier: CleanupVerifier,
private val getUploadBlocks: GetUploadBlocks,
private val getSignatureAddress: GetSignatureAddress,
private val getNodeKey: GetNodeKey,
private val buildNodeKey: BuildNodeKey,
private val updateVerifierToken: UpdateVerifierToken,
) {
suspend operator fun invoke(uploadFileLink: UploadFileLink): Result<Unit> = coRunCatching {
val verifier = buildVerifier(
userId = uploadFileLink.userId,
shareId = uploadFileLink.shareId.id,
linkId = requireNotNull(uploadFileLink.linkId),
revisionId = uploadFileLink.draftRevisionId,
fileKey = uploadFileLink.buildFileKey(),
).getOrThrow()
val uploadBlocks = getUploadBlocks(uploadFileLink)
.getOrThrow()
.associateBy { uploadBlock -> uploadBlock.file }
verifier.verifyBlocks(uploadBlocks.keys.toList())
.getOrThrow()
.map { (file, verifierToken) ->
updateVerifierToken(
uploadFileLinkId = uploadFileLink.id,
index = requireNotNull(uploadBlocks[file]).index,
verifierToken = verifierToken,
)
}
cleanupVerifier(
userId = uploadFileLink.userId,
shareId = uploadFileLink.shareId.id,
linkId = requireNotNull(uploadFileLink.linkId),
revisionId = uploadFileLink.draftRevisionId,
)
}
private suspend fun UploadFileLink.buildFileKey(): Key.Node =
buildNodeKey(
userId = userId,
parentKey = getNodeKey(parentLinkId).getOrThrow(),
uploadFileLink = this,
signatureAddress = getSignatureAddress(userId),
).getOrThrow()
}
+2 -1
View File
@@ -231,6 +231,7 @@ androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator",
androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso-contrib"}
androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" }
androidx-compose-ui-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" }
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
junit = { module = "junit:junit", version.ref = "junit" }
mockk-jvm = { module = "io.mockk:mockk", version.ref = "mockk" }
@@ -243,7 +244,7 @@ accompanist = ["accompanist-insets", "accompanist-navigation-animation", "accomp
core = ["core-account", "core-accountManager", "core-auth", "core-challenge", "core-country", "core-crypto", "core-cryptoValidator", "core-data", "core-dataRoom", "core-domain", "core-eventManager", "core-featureFlag", "core-humanVerification", "core-key", "core-keyTransparency", "core-network", "core-observability", "core-payment", "core-payment-iap", "core-plan", "core-report", "core-presentation", "core-presentation-compose", "core-user", "core-userSettings", "core-utilAndroidDagger", "core-utilKotlin"]
core-test = ["core-auth-test", "core-humanVerification-test", "core-report-test"]
test-android = ["junit", "mockk-android", "coroutines-test", "androidx-test-core-ktx", "androidx-test-runner", "androidx-test-rules", "androidx-compose-ui-test", "androidx-compose-ui-test-junit", "androidx-test-uiautomator", "core-test-android-instrumented"]
test-jvm = ["junit", "mockk-jvm", "coroutines-test", "core-test-kotlin", "core-test-quark", "robolectric"]
test-jvm = ["junit", "mockk-jvm", "coroutines-test", "androidx-test-core-ktx", "androidx-work-testing", "core-test-kotlin", "core-test-quark", "robolectric"]
[plugins]
proton-detekt = { id = "me.proton.core.gradle-plugins.detekt", version.ref = "proton-detekt-plugin" }
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
}
android {
namespace = "me.proton.android.drive.verifier"
}
driveModule(includeSubmodules = true)
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2021-2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
}
android {
namespace = "me.proton.android.drive.verifier.data"
}
driveModule(
hilt = true,
serialization = true,
) {
api(project(":verifier:domain"))
implementation(project(":drive:base:data"))
implementation(project(":drive:crypto:domain"))
implementation(project(":drive:i18n"))
implementation(libs.retrofit)
testImplementation(project(":drive:base:data-test"))
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.api
import me.proton.android.drive.verifier.data.api.response.GetVerificationDataResponse
import me.proton.core.network.data.protonApi.BaseRetrofitApi
import retrofit2.http.GET
import retrofit2.http.Path
interface VerifierApi : BaseRetrofitApi {
@GET("drive/shares/@{enc_shareID}/links/@{enc_linkID}/revisions/@{enc_revisionID}/verification")
suspend fun getVerificationData(
@Path("enc_shareID") shareId: String,
@Path("enc_linkID") linkId: String,
@Path("enc_revisionID") revisionId: String,
): GetVerificationDataResponse
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.api
import me.proton.android.drive.verifier.data.api.response.GetVerificationDataResponse
import me.proton.android.drive.verifier.domain.entity.VerificationData
import me.proton.core.domain.entity.UserId
import me.proton.core.network.data.ApiProvider
import me.proton.core.network.domain.ApiException
class VerifierApiDataSource(private val apiProvider: ApiProvider) {
@Throws(ApiException::class)
suspend fun getVerificationData(
userId: UserId,
shareId: String,
linkId: String,
revisionId: String,
): GetVerificationDataResponse =
apiProvider.get<VerifierApi>(userId).invoke { getVerificationData(shareId, linkId, revisionId) }.valueOrThrow
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.api.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.proton.core.drive.base.data.api.Dto.CODE
import me.proton.core.drive.base.data.api.Dto.CONTENT_KEY_PACKET
import me.proton.core.drive.base.data.api.Dto.REVISION
import me.proton.core.drive.base.data.api.Dto.SIGNATURE_ADDRESS
import me.proton.core.drive.base.data.api.Dto.STATE
import me.proton.core.drive.base.data.api.Dto.VERIFICATION_CODE
@Serializable
data class GetVerificationDataResponse(
@SerialName(CODE)
val code: Long,
@SerialName(VERIFICATION_CODE)
val verificationCode: String,
@SerialName(CONTENT_KEY_PACKET)
val contentKeyPacket: String,
)
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.proton.android.drive.verifier.data.repository.VerifierRepositoryImpl
import me.proton.android.drive.verifier.domain.repository.VerifierRepository
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
interface VerifierBindModule {
@Binds
@Singleton
fun bindsRepositoryImpl(impl: VerifierRepositoryImpl): VerifierRepository
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.proton.android.drive.verifier.data.api.VerifierApiDataSource
import me.proton.android.drive.verifier.data.factory.VerifierFactoryImpl
import me.proton.android.drive.verifier.domain.factory.VerifierFactory
import me.proton.core.drive.base.domain.usecase.GetCacheTempFolder
import me.proton.core.drive.crypto.domain.usecase.file.DecryptFiles
import me.proton.core.network.data.ApiProvider
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object VerifierModule {
@Singleton
@Provides
fun provideVerifierApiDataSource(apiProvider: ApiProvider) =
VerifierApiDataSource(apiProvider)
@Singleton
@Provides
fun provideVerifierFactory(
decryptFiles: DecryptFiles,
getCacheTempFolder: GetCacheTempFolder,
): VerifierFactory =
VerifierFactoryImpl(decryptFiles, getCacheTempFolder)
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.entity
import me.proton.android.drive.verifier.domain.exception.VerifierException
import me.proton.android.drive.verifier.data.extension.head
import me.proton.android.drive.verifier.data.extension.xor
import me.proton.android.drive.verifier.domain.entity.Verifier
import me.proton.core.drive.base.domain.extension.bytes
import me.proton.core.drive.crypto.domain.usecase.file.DecryptFiles
import me.proton.core.drive.key.domain.entity.ContentKey
import java.io.File
import java.util.UUID
internal class VerifierImpl constructor(
private val decryptFiles: DecryptFiles,
private val contentKey: ContentKey,
private val verificationCode: ByteArray,
private val tempFolder: File
) : Verifier {
init {
require(verificationCode.size == VERIFICATION_CODE_SIZE) {
"Invalid verification code size"
}
}
override suspend fun verifyBlocks(blocks: List<File>): Result<Map<File, ByteArray>> =
try {
require(blocks.isNotEmpty()) { "Input blocks list is empty" }
require(blocks.all { block -> block.exists() }) { "Input block does not exist" }
verifyByDecryptingBlocks(blocks)
Result.success(
blocks.associateBy(
keySelector = { file -> file }
) { file -> verificationCode.xor(file.head(len = verificationCode.size.bytes))}
)
} catch (t: Throwable) {
Result.failure(VerifierException.VerifyBlock(t))
}
private suspend fun verifyByDecryptingBlocks(blocks: List<File>) {
val output = blocks.map { block ->
File(block.destinationFolder(), UUID.randomUUID().toString()).apply {
parentFile?.mkdirs()
createNewFile()
}
}
val result = decryptFiles(
contentKey = contentKey,
input = blocks,
output = output,
)
output.forEach { file -> file.delete() }
result.getOrThrow()
}
private fun File.destinationFolder() = parent ?: tempFolder.path
companion object {
private const val VERIFICATION_CODE_SIZE = 32
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.extension
import kotlin.experimental.xor
internal fun ByteArray.xor(other: ByteArray): ByteArray {
require(size == other.size) { "Arrays of same size are required" }
val output = ByteArray(size)
for (i in indices) {
output[i] = this[i].xor(other[i])
}
return output
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.extension
import me.proton.core.drive.base.domain.entity.Bytes
import me.proton.core.drive.base.domain.extension.MiB
import java.io.File
internal fun File.head(len: Bytes): ByteArray = inputStream().use { fileInputStream ->
require(len.value in LongRange(1, 1.MiB.value))
val size = len.value.toInt()
val bytes = ByteArray(size)
fileInputStream.read(bytes, 0, size)
bytes
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.extension
import android.content.Context
import me.proton.android.drive.verifier.domain.exception.VerifierException
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.drive.i18n.R as I18N
fun VerifierException.getDefaultMessage(context: Context): String = when (this) {
is VerifierException.Initialize -> context.getString(I18N.string.verifier_initialize_failed)
is VerifierException.VerifyBlock -> context.getString(I18N.string.verifier_verify_blocks_failed)
}
fun VerifierException.log(tag: String, message: String = this.message.orEmpty()): VerifierException = also {
val logToSentry = this is VerifierException.VerifyBlock
val log: (String, Throwable, String) -> Unit = if (logToSentry) CoreLogger::e else CoreLogger::d
log(tag, this, message)
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.factory
import me.proton.android.drive.verifier.data.entity.VerifierImpl
import me.proton.android.drive.verifier.domain.entity.Verifier
import me.proton.android.drive.verifier.domain.factory.VerifierFactory
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.usecase.GetCacheTempFolder
import me.proton.core.drive.crypto.domain.usecase.file.DecryptFiles
import me.proton.core.drive.key.domain.entity.ContentKey
import javax.inject.Inject
class VerifierFactoryImpl @Inject constructor(
private val decryptFiles: DecryptFiles,
private val getCacheTempFolder: GetCacheTempFolder,
) : VerifierFactory {
override suspend fun create(
userId: UserId,
contentKey: ContentKey,
verificationCode: ByteArray,
): Verifier =
VerifierImpl(
decryptFiles = decryptFiles,
contentKey = contentKey,
verificationCode = verificationCode,
tempFolder = getCacheTempFolder(userId),
)
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.repository
import android.util.Base64
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import me.proton.android.drive.verifier.data.api.VerifierApiDataSource
import me.proton.android.drive.verifier.domain.entity.VerificationData
import me.proton.android.drive.verifier.domain.repository.VerifierRepository
import me.proton.core.domain.entity.UserId
import javax.inject.Inject
class VerifierRepositoryImpl @Inject constructor(
private val api: VerifierApiDataSource,
) : VerifierRepository {
internal val verificationDataCache: MutableMap<VerificationDataKey, VerificationData> = mutableMapOf()
private val mutex = Mutex()
override suspend fun getVerificationData(
userId: UserId,
shareId: String,
linkId: String,
revisionId: String,
): VerificationData = getOrFetch(VerificationDataKey(userId, shareId, linkId, revisionId))
override suspend fun removeVerificationData(
userId: UserId,
shareId: String,
linkId: String,
revisionId: String,
) {
remove(
VerificationDataKey(userId, shareId, linkId, revisionId)
)
}
private suspend fun getOrFetch(key: VerificationDataKey): VerificationData = get(key) ?: fetchAndStore(key)
private suspend fun fetchAndStore(key: VerificationDataKey): VerificationData =
api.getVerificationData(key.userId, key.shareId, key.linkId, key.revisionId).let { response ->
VerificationData(
contentKeyPacket = response.contentKeyPacket,
verificationCode = Base64.decode(response.verificationCode, Base64.NO_WRAP),
)
}.also { verificationData ->
put(key, verificationData)
}
internal suspend fun get(key: VerificationDataKey): VerificationData? = mutex.withLock {
verificationDataCache[key]
}
internal suspend fun put(key: VerificationDataKey, verificationData: VerificationData) = mutex.withLock {
verificationDataCache[key] = verificationData
}
internal suspend fun remove(key: VerificationDataKey) = mutex.withLock {
verificationDataCache.remove(key)
}
data class VerificationDataKey(
val userId: UserId,
val shareId: String,
val linkId: String,
val revisionId: String,
)
}
@@ -0,0 +1,180 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.entity
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import me.proton.android.drive.verifier.data.extension.createFile
import me.proton.android.drive.verifier.domain.exception.VerifierException
import me.proton.core.crypto.common.pgp.exception.CryptoException
import me.proton.core.drive.base.domain.extension.bytes
import me.proton.core.drive.base.domain.extension.toHex
import me.proton.core.drive.crypto.domain.usecase.file.DecryptFiles
import me.proton.core.drive.key.domain.entity.ContentKey
import me.proton.core.drive.key.domain.entity.Key
import me.proton.core.drive.key.domain.usecase.BuildContentKey
import me.proton.core.test.kotlin.assertEquals
import me.proton.core.test.kotlin.assertTrue
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class VerifierTest {
private val buildContentKey = mockk<BuildContentKey>()
private val decryptFiles = mockk<DecryptFiles>()
private val fileKey = mockk<Key.Node>()
private val contentKey = mockk<ContentKey>()
private val verificationCode = ByteArray(VERIFICATION_CODE_SIZE) { i -> i.toByte() }
private lateinit var verifier: VerifierImpl
private lateinit var file64B: File
private lateinit var file17B: File
@get: Rule
val temporaryFolder = TemporaryFolder()
@Before
fun before() {
coEvery {
buildContentKey(
userId = any(),
contentKeyPacket = any(),
contentKeyPacketSignature = any(),
fileKey = fileKey,
)
} returns Result.success(contentKey)
coEvery {
decryptFiles(
contentKey = contentKey,
input = any(),
output = any(),
)
} returns Result.success(emptyList())
verifier = VerifierImpl(decryptFiles, contentKey, verificationCode, temporaryFolder.newFolder())
file64B = temporaryFolder.createFile(64.bytes)
file17B = temporaryFolder.createFile(17.bytes)
}
@Test(expected = IllegalArgumentException::class)
fun `VerifierImpl throws IllegalArgumentException if verificationCode with invalid size is given`() = runTest {
// Given
val verificationCode = ByteArray(31) { i -> i.toByte() }
// Then
VerifierImpl(decryptFiles, contentKey, verificationCode, temporaryFolder.newFolder())
}
@Test
fun `verifyBlock when given empty block list throws VerifyBlock with cause IllegalArgumentException`() = runTest {
// Given
val blocks = emptyList<File>()
// When
val exception = verifier.verifyBlocks(blocks).exceptionOrNull()
// Then
assertNotNull(exception)
assertTrue(exception is VerifierException.VerifyBlock) {
"actual: $exception, expected: VerifyBlock"
}
assertTrue(exception?.cause is IllegalArgumentException) {
"actual: ${exception?.cause}, expected: IllegalArgumentException"
}
}
@Test
fun `verifyBlock when given non-existing block file(s) throws VerifyBlock with cause IllegalArgumentException`() = runTest {
// Given
val nonExistingFile = File("", "test.txt")
val blocks = listOf(nonExistingFile)
// When
val exception = verifier.verifyBlocks(blocks).exceptionOrNull()
// Then
assertNotNull(exception)
assertTrue(exception is VerifierException.VerifyBlock) {
"actual: $exception, expected: VerifyBlock"
}
assertTrue(exception?.cause is IllegalArgumentException) {
"actual: ${exception?.cause}, expected: IllegalArgumentException"
}
}
@Test
fun `verifyBlock throws VerifyBlock when decrypt blocks fails`() = runTest {
// Given
coEvery { decryptFiles(contentKey, any(), any()) } returns Result.failure(CryptoException())
// When
val exception = verifier.verifyBlocks(listOf(file64B)).exceptionOrNull()
// Then
assertNotNull(exception)
assertTrue(exception is VerifierException.VerifyBlock) {
"actual: $exception, expected: VerifyBlock"
}
assertTrue(exception?.cause is CryptoException) {
"actual: ${exception?.cause}, expected: CryptoException"
}
}
@Test
fun `successful dual block verification`() = runTest {
// Given
val blocks = listOf(file64B, file17B)
// When
val verifierTokens = verifier.verifyBlocks(blocks).getOrThrow()
// Then
assertEquals(blocks.size, verifierTokens.size) { "Verify blocks result size mismatch" }
assertTrue(verifierTokens.containsKey(file64B)) { "File mismatch" }
assertTrue(verifierTokens.containsKey(file17B)) { "File mismatch" }
val verificationCodeXorHeaderOfFile64B = byteArrayOf(
0x61, 0x60, 0x63, 0x62, 0x65, 0x64, 0x67, 0x66,
0x69, 0x68, 0x6B, 0x6A, 0x6D, 0x6C, 0x6F, 0x6E,
0x71, 0x70, 0x73, 0x72, 0x75, 0x74, 0x77, 0x76,
0x79, 0x78, 0x7B, 0x7A, 0x7D, 0x7C, 0x7F, 0x7E
)
assertTrue(verifierTokens[file64B].contentEquals(verificationCodeXorHeaderOfFile64B)) {
"Verifier token mismatch actual: ${verifierTokens[file64B]?.toHex()}, expected: ${verificationCodeXorHeaderOfFile64B.toHex()}"
}
val verificationCodeXorHeaderOfFile17B = byteArrayOf(
0x61, 0x60, 0x63, 0x62, 0x65, 0x64, 0x67, 0x66,
0x69, 0x68, 0x6B, 0x6A, 0x6D, 0x6C, 0x6F, 0x6E,
0x71, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
)
assertTrue(verifierTokens[file17B].contentEquals(verificationCodeXorHeaderOfFile17B)) {
"Verifier token mismatch actual: ${verifierTokens[file17B]?.toHex()}, expected: ${verificationCodeXorHeaderOfFile17B.toHex()}"
}
}
companion object {
private const val VERIFICATION_CODE_SIZE = 32 // bytes
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.extension
import me.proton.core.test.kotlin.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class ByteArrayParameterizedTest(
private val first: ByteArray,
private val second: ByteArray,
private val firstXorSecond: ByteArray,
) {
@Test
fun `xor of two byte arrays`() {
assertTrue(
first.xor(second).contentEquals(firstXorSecond)
) { "xor failed" }
}
companion object {
@get:Parameterized.Parameters(name = "{0} xor {1} equals {2}")
@get:JvmStatic
val data = listOf(
// Byte array with size 1
arrayOf(
byteArrayOf(0x00),
byteArrayOf(0xFF.toByte()),
byteArrayOf(0xFF.toByte()),
),
arrayOf(
byteArrayOf(0xF0.toByte()),
byteArrayOf(0x0F.toByte()),
byteArrayOf(0xFF.toByte()),
),
// Byte array with size 2
arrayOf(
byteArrayOf(0x00, 0xFF.toByte()),
byteArrayOf(0xFF.toByte(), 0x00),
byteArrayOf(0xFF.toByte(), 0xFF.toByte()),
),
arrayOf(
byteArrayOf(0x00, 0x55.toByte()),
byteArrayOf(0xAA.toByte(), 0x00),
byteArrayOf(0xAA.toByte(), 0x55.toByte()),
),
// Byte array with size 4
arrayOf(
byteArrayOf(0x05.toByte(), 0x06.toByte(), 0x07.toByte(), 0x08.toByte()),
byteArrayOf(0xA3.toByte(), 0xA4.toByte(), 0xA5.toByte(), 0xA6.toByte()),
byteArrayOf(0xA6.toByte(), 0xA2.toByte(), 0xA2.toByte(), 0xAE.toByte()),
),
)
}
}
class ByteArrayTest {
@Test(expected = IllegalArgumentException::class)
fun `different size byte arrays cause IllegalArgumentException`() {
byteArrayOf(0x00).xor(byteArrayOf(0x00, 0x00))
}
@Test
fun `xor of two empty byte array result in empty byte array`() {
assertTrue(
ByteArray(0).xor(ByteArray(0)).contentEquals(ByteArray(0))
) { "xor failed" }
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.extension
import me.proton.core.drive.base.domain.extension.bytes
import me.proton.core.drive.base.domain.extension.size
import me.proton.core.test.kotlin.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class FileTest(
private val fileSize: Int,
) {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `head on different file size`() {
// Given
val fileSizeInBytes = fileSize.bytes
val file = temporaryFolder.createFile(fileSizeInBytes)
check(file.exists()) { "File does not exist" }
check(file.size == fileSizeInBytes) {
"File size mismatch expected: $fileSizeInBytes, actual: ${file.size}"
}
// When
val head = file.head(TARGET_HEAD_SIZE.bytes)
// Then
assertEquals(TARGET_HEAD_SIZE, head.size) {
"Head size mismatch"
}
for (i in 0 until minOf(fileSize, TARGET_HEAD_SIZE)) {
assertEquals('a'.code, head[i].toInt()) { "Byte mismatch" }
}
for (i in fileSize until TARGET_HEAD_SIZE) {
assertEquals(0, head[i].toInt()) { "Byte mismatch" }
}
}
companion object {
private const val TARGET_HEAD_SIZE = 32
@get:Parameterized.Parameters
@get:JvmStatic
val data = listOf(
arrayOf(0),
arrayOf(1),
arrayOf(17),
arrayOf(33),
)
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.extension
import me.proton.core.drive.base.domain.entity.Bytes
import org.junit.rules.TemporaryFolder
import java.io.File
import java.util.UUID
fun TemporaryFolder.createFile(size: Bytes): File =
File(root, "${size.value}_byte(s)_${UUID.randomUUID()}.txt").apply {
if (exists()) { delete() }
createNewFile()
appendText(
(0 until size.value.toInt()).joinToString(separator = "") { "a" }
)
}
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.data.repository
import io.mockk.coEvery
import io.mockk.mockk
import junit.framework.TestCase.assertNull
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.test.runTest
import me.proton.android.drive.verifier.data.api.VerifierApiDataSource
import me.proton.android.drive.verifier.data.api.response.GetVerificationDataResponse
import me.proton.android.drive.verifier.domain.entity.VerificationData
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.test.kotlin.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Base64
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class VerifierRepositoryTest {
private val apiDataSource = mockk<VerifierApiDataSource>()
private val userId = UserId("user-id")
private val shareId = "share-id"
private val linkId = "link-id"
private val revisionId = "revision-id"
private val contentKeyPacket = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
private val verificationCode = Base64.getEncoder().encodeToString(ByteArray(32) { i -> i.toByte() })
private lateinit var repository: VerifierRepositoryImpl
@Before
fun before() {
coEvery {
apiDataSource.getVerificationData(
userId = any(),
shareId = any(),
linkId = any(),
revisionId = any(),
)
} returns GetVerificationDataResponse(
code = 1000,
verificationCode = verificationCode,
contentKeyPacket = contentKeyPacket,
)
repository = VerifierRepositoryImpl(apiDataSource)
}
@Test
fun `successful remove of verification data removes it from repository cache`() = runTest {
// When
repository.getVerificationData(userId, shareId, linkId, revisionId)
repository.removeVerificationData(userId, shareId, linkId, revisionId)
// Then
val key = VerifierRepositoryImpl.VerificationDataKey(userId, shareId, linkId, revisionId)
assertNull(repository.verificationDataCache[key])
}
@Test
fun `successful verification data from verifier api data source and repository cache`() = runTest {
// When
val verificationData = repository.getVerificationData(userId, shareId, linkId, revisionId)
// Then
val expectedVerificationData = VerificationData(
contentKeyPacket = contentKeyPacket,
verificationCode = Base64.getDecoder().decode(verificationCode),
)
assertEquals(expectedVerificationData, verificationData) { "Verification data mismatch from data source" }
val key = VerifierRepositoryImpl.VerificationDataKey(userId, shareId, linkId, revisionId)
assertEquals(expectedVerificationData, repository.verificationDataCache[key]) {
"Verification data mismatch from cache"
}
}
@Test
fun `cached verification data is provided when available`() = runTest {
// Given
repository.getVerificationData(userId, shareId, linkId, revisionId)
coEvery {
apiDataSource.getVerificationData(
userId = any(),
shareId = any(),
linkId = any(),
revisionId = any(),
)
} throws ApiException(ApiResult.Error.Http(httpCode = 500, message = "Internal server error"))
// When
val verificationData = repository.getVerificationData(userId, shareId, linkId, revisionId)
// Then
val expectedVerificationData = VerificationData(contentKeyPacket, Base64.getDecoder().decode(verificationCode))
assertEquals(expectedVerificationData, verificationData) { "Verification data mismatch from data source" }
}
@Test(expected = ApiException::class)
fun `when network error occurs it is propagated`() = runTest {
// Given
coEvery {
apiDataSource.getVerificationData(
userId = any(),
shareId = any(),
linkId = any(),
revisionId = any(),
)
} throws ApiException(ApiResult.Error.Http(httpCode = 500, message = "Internal server error"))
// Then
repository.getVerificationData(userId, shareId, linkId, revisionId)
}
@Test
fun `many concurrent calls to get same verification data leaves repository cache in consistent state`() = runTest {
// When
(0..99).map {
async {
repository.getVerificationData(userId, shareId, linkId, revisionId)
}
}.awaitAll()
// Then
val key = VerifierRepositoryImpl.VerificationDataKey(userId, shareId, linkId, revisionId)
assertEquals(1, repository.verificationDataCache.keys.size) { "Invalid cache size" }
val expectedVerificationData = VerificationData(contentKeyPacket, Base64.getDecoder().decode(verificationCode))
assertEquals(expectedVerificationData, repository.verificationDataCache[key]) {
"Verification data mismatch from cache"
}
}
@Test
fun `many concurrent calls to get different verification data and then to remove it leaves repository cache in consistent state`() = runTest {
// When
(0..99).map { i ->
async {
repository.getVerificationData(userId, shareId, linkId, "${revisionId}_$i")
repository.removeVerificationData(userId, shareId, linkId, "${revisionId}_$i")
}
}.awaitAll()
// Then
assertEquals(0, repository.verificationDataCache.keys.size) { "Invalid cache size" }
}
}
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2021-2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core 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.
*
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
}
android {
namespace = "me.proton.android.drive.verifier.domain"
}
driveModule(
hilt = true,
) {
api(project(":drive:key:domain"))
api(project(":drive:link-upload:domain"))
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.domain.entity
data class VerificationData(
val contentKeyPacket: String,
val verificationCode: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VerificationData
if (contentKeyPacket != other.contentKeyPacket) return false
if (!verificationCode.contentEquals(other.verificationCode)) return false
return true
}
override fun hashCode(): Int {
var result = contentKeyPacket.hashCode()
result = 31 * result + verificationCode.contentHashCode()
return result
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.domain.entity
import java.io.File
interface Verifier {
suspend fun verifyBlocks(blocks: List<File>): Result<Map<File, ByteArray>>
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.domain.exception
sealed class VerifierException(override val cause: Throwable) : Throwable() {
data class VerifyBlock(override val cause: Throwable) : VerifierException(cause)
data class Initialize(override val cause: Throwable) : VerifierException(cause)
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.domain.factory
import me.proton.android.drive.verifier.domain.entity.Verifier
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.key.domain.entity.ContentKey
interface VerifierFactory {
suspend fun create(
userId: UserId,
contentKey: ContentKey,
verificationCode: ByteArray,
): Verifier
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.domain.repository
import me.proton.android.drive.verifier.domain.entity.VerificationData
import me.proton.core.domain.entity.UserId
interface VerifierRepository {
suspend fun getVerificationData(
userId: UserId,
shareId: String,
linkId: String,
revisionId: String,
): VerificationData
suspend fun removeVerificationData(
userId: UserId,
shareId: String,
linkId: String,
revisionId: String,
)
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.domain.usecase
import me.proton.android.drive.verifier.domain.entity.Verifier
import me.proton.android.drive.verifier.domain.exception.VerifierException
import me.proton.android.drive.verifier.domain.factory.VerifierFactory
import me.proton.android.drive.verifier.domain.repository.VerifierRepository
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.key.domain.entity.Key
import me.proton.core.drive.key.domain.usecase.BuildContentKey
import javax.inject.Inject
class BuildVerifier @Inject constructor(
private val repository: VerifierRepository,
private val buildContentKey: BuildContentKey,
private val factory: VerifierFactory,
) {
suspend operator fun invoke(
userId: UserId,
shareId: String,
linkId: String,
revisionId: String,
fileKey: Key.Node,
): Result<Verifier> = try {
val verificationData = repository.getVerificationData(userId, shareId, linkId, revisionId)
val contentKey = buildContentKey(
userId = userId,
contentKeyPacket = verificationData.contentKeyPacket,
contentKeyPacketSignature = "",
fileKey = fileKey
).getOrThrow()
Result.success(factory.create(
userId = userId,
contentKey = contentKey,
verificationCode = verificationData.verificationCode,
))
} catch (t: Throwable) {
Result.failure(VerifierException.Initialize(t))
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive 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.
*
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.verifier.domain.usecase
import me.proton.android.drive.verifier.domain.repository.VerifierRepository
import me.proton.core.domain.entity.UserId
import javax.inject.Inject
class CleanupVerifier @Inject constructor(
private val repository: VerifierRepository,
) {
suspend operator fun invoke(userId: UserId, shareId: String, linkId: String, revisionId: String) =
repository.removeVerificationData(userId, shareId, linkId, revisionId)
}