Skip to content

Commit

Permalink
Users can export unencrypted data to JSON or CSV (#41)
Browse files Browse the repository at this point in the history
  • Loading branch information
SaintPatrck authored Apr 22, 2024
1 parent 066d5c5 commit 66d834c
Show file tree
Hide file tree
Showing 31 changed files with 894 additions and 145 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.bitwarden.authenticator.data.authenticator.manager

import android.net.Uri

/**
* Manages reading and writing files.
*/
interface FileManager {

/**
* Writes the given [dataString] to disk at the provided [fileUri]
*/
suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.bitwarden.authenticator.data.authenticator.manager

import android.content.Context
import android.net.Uri
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
import kotlinx.coroutines.withContext

class FileManagerImpl(
private val context: Context,
private val dispatcherManager: DispatcherManager,
) : FileManager {

override suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean {
@Suppress("TooGenericExceptionCaught")
return try {
withContext(dispatcherManager.io) {
context
.contentResolver
.openOutputStream(fileUri)
?.use { outputStream ->
outputStream.write(dataString.toByteArray())
}
}
true
} catch (exception: RuntimeException) {
false
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.bitwarden.authenticator.data.authenticator.manager.di

import android.content.Context
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.authenticator.manager.FileManager
import com.bitwarden.authenticator.data.authenticator.manager.FileManagerImpl
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManagerImpl
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
Expand All @@ -18,6 +22,16 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object AuthenticatorManagerModule {

@Provides
@Singleton
fun providerFileManager(
@ApplicationContext context: Context,
dispatcherManager: DispatcherManager,
) : FileManager = FileManagerImpl(
context = context,
dispatcherManager = dispatcherManager,
)

@Provides
@Singleton
fun provideTotpCodeManager(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.bitwarden.authenticator.data.authenticator.manager.model

import kotlinx.serialization.Serializable

/**
* Models exported authenticator data in JSON format.
*
* This model is loosely based off of Bitwarden's exported unencrypted vault data.
*/
@Serializable
data class ExportJsonData(
val encrypted: Boolean,
val items: List<ExportItem>,
) {

/**
* Represents a single exported authenticator item.
*
* This model is loosely based off of Bitwarden's exported Cipher JSON.
*/
@Serializable
data class ExportItem(
val id: String,
val name: String,
val folderId: String?,
val organizationId: String?,
val collectionIds: List<String>?,
val notes: String?,
val type: Int,
val login: ItemLoginData,
val favorite: Boolean,
) {
/**
* Represents the login specific data of an exported item.
*
* This model is loosely based off of Bitwarden's Cipher.Login JSON.
*
* @property totp OTP secret used to generate a verification code.
* @property issuer Optional issuer of the 2fa code.
* @property period Optional refresh period in seconds. Default is 30.
* @property digits Optional number of digits in the verification code. Default is 6
*/
@Serializable
data class ItemLoginData(
val totp: String,
val issuer: String?,
val period: Int,
val digits: Int,
)
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package com.bitwarden.authenticator.data.authenticator.repository

import android.net.Uri
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorData
import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult
import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult
import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult
import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult
import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemRequest
import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemResult
import com.bitwarden.authenticator.data.platform.repository.model.DataState
import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportFormat
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

Expand Down Expand Up @@ -78,4 +81,9 @@ interface AuthenticatorRepository {
itemId: String,
updateItemRequest: UpdateItemRequest,
): UpdateItemResult

/**
* Attempt to get the user's data for export.
*/
suspend fun exportVaultData(format: ExportFormat, fileUri: Uri): ExportDataResult
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.bitwarden.authenticator.data.authenticator.repository

import android.net.Uri
import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.authenticator.manager.FileManager
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
import com.bitwarden.authenticator.data.authenticator.manager.model.ExportJsonData
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorData
import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult
import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult
import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult
import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult
import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemRequest
import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemResult
Expand All @@ -15,6 +19,7 @@ import com.bitwarden.authenticator.data.platform.repository.model.DataState
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.bitwarden.authenticator.data.platform.repository.util.combineDataStates
import com.bitwarden.authenticator.data.platform.repository.util.map
import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportFormat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
Expand All @@ -23,13 +28,16 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject

/**
Expand All @@ -41,6 +49,7 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 5_000L
class AuthenticatorRepositoryImpl @Inject constructor(
private val authenticatorDiskSource: AuthenticatorDiskSource,
private val totpCodeManager: TotpCodeManager,
private val fileManager: FileManager,
dispatcherManager: DispatcherManager,
) : AuthenticatorRepository {

Expand Down Expand Up @@ -214,4 +223,73 @@ class AuthenticatorRepositoryImpl @Inject constructor(
UpdateItemResult.Error(e.message)
}
}

override suspend fun exportVaultData(format: ExportFormat, fileUri: Uri): ExportDataResult {
return when (format) {
ExportFormat.JSON -> encodeVaultDataToJson(fileUri)
ExportFormat.CSV -> encodeVaultDataToCsv(fileUri)
}
}

private suspend fun encodeVaultDataToCsv(fileUri: Uri): ExportDataResult {
val headerLine =
"folder,favorite,type,name,login_uri,login_totp,issuer,period,digits"
val dataLines = authenticatorDiskSource
.getItems()
.firstOrNull()
.orEmpty()
.joinToString("\n") { it.toCsvFormat() }

val csvString = "$headerLine\n$dataLines"

return if (fileManager.stringToUri(fileUri = fileUri, dataString = csvString)) {
ExportDataResult.Success
} else {
ExportDataResult.Error
}
}

private fun AuthenticatorItemEntity.toCsvFormat() =
",,1,$accountName,,$key,$issuer,$period,$digits"

private suspend fun encodeVaultDataToJson(fileUri: Uri): ExportDataResult {
val dataString: String = Json.encodeToString(
ExportJsonData(
encrypted = false,
items = authenticatorDiskSource
.getItems()
.firstOrNull()
.orEmpty()
.map { it.toExportJsonItem() },
)
)

return if (
fileManager.stringToUri(
fileUri = fileUri,
dataString = dataString,
)
) {
ExportDataResult.Success
} else {
ExportDataResult.Error
}
}

private fun AuthenticatorItemEntity.toExportJsonItem() = ExportJsonData.ExportItem(
id = id,
folderId = null,
organizationId = null,
collectionIds = null,
name = accountName,
notes = null,
type = 1,
login = ExportJsonData.ExportItem.ItemLoginData(
totp = key,
issuer = issuer,
period = period,
digits = digits,
),
favorite = false,
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.bitwarden.authenticator.data.authenticator.repository.di

import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource
import com.bitwarden.authenticator.data.authenticator.manager.FileManager
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepositoryImpl
Expand All @@ -24,9 +25,11 @@ object AuthenticatorRepositoryModule {
authenticatorDiskSource: AuthenticatorDiskSource,
dispatcherManager: DispatcherManager,
totpCodeManager: TotpCodeManager,
fileManager: FileManager,
): AuthenticatorRepository = AuthenticatorRepositoryImpl(
authenticatorDiskSource,
totpCodeManager,
fileManager,
dispatcherManager,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.bitwarden.authenticator.data.authenticator.repository.model

/**
* Represents the result of a data export operation.
*/
sealed class ExportDataResult {

data object Success : ExportDataResult()

data object Error : ExportDataResult()

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,6 @@ interface SettingsDiskSource {
*/
val appThemeFlow: Flow<AppTheme>

/**
* The currently persisted setting for getting login item icons (or `null` if not set).
*/
var isIconLoadingDisabled: Boolean?

/**
* Emits updates that track [isIconLoadingDisabled].
*/
val isIconLoadingDisabledFlow: Flow<Boolean?>

/**
* Tracks whether user has seen the Welcome tutorial.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale"
private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed"
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid"
private const val ALERT_THRESHOLD_SECONDS_KEY = "$BASE_KEY:alertThresholdSeconds"
private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon"
private const val FIRST_LAUNCH_KEY = "$BASE_KEY:hasSeenWelcomeTutorial"

/**
Expand Down Expand Up @@ -69,17 +68,6 @@ class SettingsDiskSourceImpl(
get() = mutableAppThemeFlow
.onSubscription { emit(appTheme) }

override var isIconLoadingDisabled: Boolean?
get() = getBoolean(key = DISABLE_ICON_LOADING_KEY)
set(value) {
putBoolean(key = DISABLE_ICON_LOADING_KEY, value = value)
mutableIsIconLoadingDisabledFlow.tryEmit(value)
}

override val isIconLoadingDisabledFlow: Flow<Boolean?>
get() = mutableIsIconLoadingDisabledFlow
.onSubscription { emit(getBoolean(DISABLE_ICON_LOADING_KEY)) }

override var hasSeenWelcomeTutorial: Boolean
get() = getBoolean(key = FIRST_LAUNCH_KEY) ?: false
set(value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.bitwarden.authenticator.data.platform.repository

import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

/**
Expand Down Expand Up @@ -35,16 +34,6 @@ interface SettingsRepository {
*/
val authenticatorAlertThresholdSecondsFlow: StateFlow<Int>

/**
* The current setting for getting login item icons.
*/
var isIconLoadingDisabled: Boolean

/**
* Emits updates that track the [isIconLoadingDisabled] value.
*/
val isIconLoadingDisabledFlow: Flow<Boolean>

/**
* Whether the user has seen the Welcome tutorial.
*/
Expand Down
Loading

0 comments on commit 66d834c

Please sign in to comment.