Import from LastPass

This commit is contained in:
Rafał Kobyłko 2023-07-02 12:28:11 +02:00
parent 2a13d95833
commit ebb29e4579
15 changed files with 189 additions and 14 deletions

View File

@ -0,0 +1,8 @@
<vector android:height="128dp" android:viewportHeight="512"
android:viewportWidth="512" android:width="128dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#d32d27" android:pathData="M76.8,0L435.2,0A76.8,76.8 0,0 1,512 76.8L512,435.2A76.8,76.8 0,0 1,435.2 512L76.8,512A76.8,76.8 0,0 1,0 435.2L0,76.8A76.8,76.8 0,0 1,76.8 0z"/>
<path android:fillColor="#fff" android:pathData="M108,256m-44,0a44,44 0,1 1,88 0a44,44 0,1 1,-88 0"/>
<path android:fillColor="#fff" android:pathData="M227,256m-44,0a44,44 0,1 1,88 0a44,44 0,1 1,-88 0"/>
<path android:fillColor="#fff" android:pathData="M347,256m-44,0a44,44 0,1 1,88 0a44,44 0,1 1,-88 0"/>
<path android:fillColor="#fff" android:pathData="M438,160L438,160A10,10.24 0,0 1,448 170.24L448,341.76A10,10.24 0,0 1,438 352L438,352A10,10.24 0,0 1,428 341.76L428,170.24A10,10.24 0,0 1,438 160z"/>
</vector>

View File

@ -73,6 +73,7 @@ class Strings(c: Context) {
val externalImportGoogleAuthenticator = c.getString(R.string.externalimport_google_authenticator)
val externalImportAegis = c.getString(R.string.externalimport_aegis)
val externalImportRaivo = c.getString(R.string.externalimport_raivo)
val externalImportLastPass = c.getString(R.string.externalimport_lastpass)
val externalImportNotice = c.getString(R.string.externalimport_description)
val trashTitle = c.getString(R.string.settings__trash)

View File

@ -3,6 +3,7 @@ package com.twofasapp.feature.externalimport.di
import com.twofasapp.di.KoinModule
import com.twofasapp.feature.externalimport.domain.AegisImporter
import com.twofasapp.feature.externalimport.domain.GoogleAuthenticatorImporter
import com.twofasapp.feature.externalimport.domain.LastPassImporter
import com.twofasapp.feature.externalimport.domain.RaivoImporter
import com.twofasapp.feature.externalimport.ui.result.ImportResultViewModel
import com.twofasapp.feature.externalimport.ui.scan.ImportScanViewModel
@ -19,5 +20,6 @@ class ExternalImportModule : KoinModule {
factoryOf(::GoogleAuthenticatorImporter)
factoryOf(::AegisImporter)
factoryOf(::RaivoImporter)
factoryOf(::LastPassImporter)
}
}

View File

@ -0,0 +1,108 @@
package com.twofasapp.feature.externalimport.domain
import android.content.Context
import android.net.Uri
import com.twofasapp.data.services.ServicesRepository
import com.twofasapp.parsers.domain.OtpAuthLink
import com.twofasapp.prefs.model.ServiceDto
import com.twofasapp.serialization.JsonSerializer
import com.twofasapp.services.domain.ConvertOtpLinkToService
import kotlinx.serialization.Serializable
import java.io.BufferedReader
internal class LastPassImporter(
private val context: Context,
private val jsonSerializer: JsonSerializer,
private val convertOtpLinkToService: ConvertOtpLinkToService,
private val servicesRepository: ServicesRepository,
) : ExternalImporter {
@Serializable
data class Model(
val accounts: List<Account>,
)
@Serializable
data class Account(
val issuerName: String,
val userName: String,
val secret: String,
val algorithm: String,
val timeStep: Int,
val digits: Int,
)
override fun isSchemaSupported(content: String): Boolean {
return true
}
override fun read(content: String): ExternalImport {
try {
val fileUri = Uri.parse(content)
val fileDescriptor = context.contentResolver.openAssetFileDescriptor(fileUri, "r")
val size = fileDescriptor?.length ?: 0
if (size > 10 * 1024 * 1024) {
return ExternalImport.ParsingError(RuntimeException("File too big"))
}
val inputStream = context.contentResolver.openInputStream(fileUri)!!
val json = inputStream.bufferedReader(Charsets.UTF_8).use(BufferedReader::readText)
val model = jsonSerializer.deserialize<Model>(json)
fileDescriptor?.close()
inputStream.close()
val totalServices = model.accounts.size
val servicesToImport = mutableListOf<ServiceDto>()
model
.accounts
.filter { it.digits == 6 || it.digits == 7 || it.digits == 8 }
.filter { it.timeStep == 30 || it.timeStep == 60 || it.timeStep == 90 }
.filter {
it.algorithm.equals("SHA1", true) || it.algorithm.equals("SHA224", true) || it.algorithm.equals(
"SHA256",
true
) || it.algorithm.equals("SHA384", true) || it.algorithm.equals("SHA512", true)
}
.filter { servicesRepository.isSecretValid(it.secret) }
.forEach { entry ->
servicesToImport.add(parseService(entry))
}
return ExternalImport.Success(
servicesToImport = servicesToImport,
totalServicesCount = totalServices,
)
} catch (e: Exception) {
e.printStackTrace()
return ExternalImport.ParsingError(e)
}
}
private fun parseService(account: Account): ServiceDto {
val otpLink = OtpAuthLink(
type = "TOTP",
label = account.userName,
secret = account.secret,
issuer = account.issuerName,
params = parseParams(account),
link = null,
)
val parsed = convertOtpLinkToService.execute(otpLink)
return parsed.copy(otpAccount = account.userName)
}
private fun parseParams(account: Account): Map<String, String> {
val params = mutableMapOf<String, String>()
account.algorithm.let { params[OtpAuthLink.ALGORITHM_PARAM] = it }
account.timeStep.let { params[OtpAuthLink.PERIOD_PARAM] = it.toString() }
account.digits.let { params[OtpAuthLink.DIGITS_PARAM] = it.toString() }
return params
}
}

View File

@ -2,8 +2,6 @@ package com.twofasapp.feature.externalimport.domain
import android.content.Context
import android.net.Uri
import com.twofasapp.core.analytics.AnalyticsService
import com.twofasapp.parsers.domain.OtpAuthLink
import com.twofasapp.prefs.model.ServiceDto
import com.twofasapp.serialization.JsonSerializer
import com.twofasapp.services.domain.ConvertOtpLinkToService
@ -14,7 +12,6 @@ internal class RaivoImporter(
private val context: Context,
private val jsonSerializer: JsonSerializer,
private val convertOtpLinkToService: ConvertOtpLinkToService,
private val analyticsService: AnalyticsService,
) : ExternalImporter {
@Serializable
@ -74,7 +71,7 @@ internal class RaivoImporter(
)
} catch (e: Exception) {
e.printStackTrace()
return ExternalImport.ParsingError(e)
}
}

View File

@ -11,22 +11,24 @@ import com.twofasapp.android.navigation.NavNode
import com.twofasapp.android.navigation.withArg
import com.twofasapp.feature.externalimport.navigation.ExternalImportNode.Aegis
import com.twofasapp.feature.externalimport.navigation.ExternalImportNode.GoogleAuthenticator
import com.twofasapp.feature.externalimport.navigation.ExternalImportNode.LastPass
import com.twofasapp.feature.externalimport.navigation.ExternalImportNode.Raivo
import com.twofasapp.feature.externalimport.navigation.ExternalImportNode.Result
import com.twofasapp.feature.externalimport.navigation.ExternalImportNode.Scan
import com.twofasapp.feature.externalimport.navigation.ExternalImportNode.Selector
import com.twofasapp.feature.externalimport.ui.aegis.AegisRoute
import com.twofasapp.feature.externalimport.ui.googleauthenticator.GoogleAuthenticatorRoute
import com.twofasapp.feature.externalimport.ui.lastpass.LastPassRoute
import com.twofasapp.feature.externalimport.ui.raivo.RaivoRoute
import com.twofasapp.feature.externalimport.ui.result.ImportResultRoute
import com.twofasapp.feature.externalimport.ui.scan.ImportScanRoute
import com.twofasapp.feature.externalimport.ui.selector.SelectorRoute
object ExternalImportGraph : com.twofasapp.android.navigation.NavGraph {
object ExternalImportGraph : NavGraph {
override val route: String = "externalimport"
}
enum class ImportType { GoogleAuthenticator, Aegis, Raivo }
enum class ImportType { GoogleAuthenticator, Aegis, Raivo, LastPass }
private object NavArg {
val ImportType = navArgument("importType") { type = NavType.StringType; }
@ -34,13 +36,14 @@ private object NavArg {
val StartFromGallery = navArgument("startFromGallery") { type = NavType.BoolType; }
}
private sealed class ExternalImportNode(override val path: String) : com.twofasapp.android.navigation.NavNode {
override val graph: com.twofasapp.android.navigation.NavGraph = ExternalImportGraph
private sealed class ExternalImportNode(override val path: String) : NavNode {
override val graph: NavGraph = ExternalImportGraph
object Selector : ExternalImportNode("selector")
object GoogleAuthenticator : ExternalImportNode("googleauthenticator")
object Aegis : ExternalImportNode("aegis")
object Raivo : ExternalImportNode("raivo")
object LastPass : ExternalImportNode("lastpass")
object Scan : ExternalImportNode("scan?startFromGallery={${NavArg.StartFromGallery.name}}")
object Result : ExternalImportNode("result/{${NavArg.ImportType.name}}/{${NavArg.ImportContent.name}}")
}
@ -59,6 +62,7 @@ fun NavGraphBuilder.externalImportNavigation(
onGoogleAuthenticatorClick = { navController.navigate(GoogleAuthenticator.route) },
onAegisClick = { navController.navigate(Aegis.route) },
onRaivoClick = { navController.navigate(Raivo.route) },
onLastPassClick = { navController.navigate(LastPass.route) },
)
}
@ -92,6 +96,16 @@ fun NavGraphBuilder.externalImportNavigation(
})
}
composable(route = LastPass.route) {
LastPassRoute(onFilePicked = { content ->
navController.navigate(
Result.route
.withArg(NavArg.ImportType, ImportType.LastPass.name)
.withArg(NavArg.ImportContent, content)
)
})
}
composable(
route = Scan.route,
arguments = listOf(NavArg.StartFromGallery)

View File

@ -44,7 +44,7 @@ internal fun ImportFileScaffold(
Column(
Modifier
.fillMaxWidth()
.padding(padding)
.padding(top = padding.calculateTopPadding())
) {
Column(

View File

@ -50,7 +50,7 @@ internal fun GoogleAuthenticatorRoute(
Column(
Modifier
.fillMaxWidth()
.padding(padding)
.padding(top = padding.calculateTopPadding())
) {
Column(

View File

@ -0,0 +1,28 @@
package com.twofasapp.feature.externalimport.ui.lastpass
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.twofasapp.core.encoding.encodeBase64ToString
import com.twofasapp.feature.externalimport.ui.common.ImportDescription
import com.twofasapp.feature.externalimport.ui.common.ImportFilePickerButton
import com.twofasapp.feature.externalimport.ui.common.ImportFileScaffold
import com.twofasapp.resources.R
@Composable
internal fun LastPassRoute(
onFilePicked: (String) -> Unit,
) {
ImportFileScaffold(
title = stringResource(id = R.string.externalimport_lastpass),
image = painterResource(id = R.drawable.ic_import_lastpass),
description = { ImportDescription(text = stringResource(id = R.string.externalimport__lastpass_msg)) }
) {
ImportFilePickerButton(
text = stringResource(id = R.string.externalimport__choose_json_cta),
fileType = "application/json",
onFilePicked = { onFilePicked(it.toString().encodeBase64ToString()) }
)
}
}

View File

@ -60,7 +60,7 @@ internal fun ImportResultRoute(
Column(
Modifier
.fillMaxWidth()
.padding(padding)
.padding(top = padding.calculateTopPadding())
) {
Column(
@ -78,6 +78,7 @@ internal fun ImportResultRoute(
ImportType.GoogleAuthenticator -> R.drawable.ic_import_ga
ImportType.Aegis -> R.drawable.ic_import_aegis
ImportType.Raivo -> R.drawable.ic_import_raivo
ImportType.LastPass -> R.drawable.ic_import_lastpass
}
),
contentDescription = null,

View File

@ -9,6 +9,7 @@ import com.twofasapp.core.encoding.decodeBase64
import com.twofasapp.feature.externalimport.domain.AegisImporter
import com.twofasapp.feature.externalimport.domain.ExternalImport
import com.twofasapp.feature.externalimport.domain.GoogleAuthenticatorImporter
import com.twofasapp.feature.externalimport.domain.LastPassImporter
import com.twofasapp.feature.externalimport.domain.RaivoImporter
import com.twofasapp.feature.externalimport.navigation.ImportType
import com.twofasapp.resources.R
@ -28,6 +29,7 @@ internal class ImportResultViewModel(
private val googleAuthenticatorImporter: GoogleAuthenticatorImporter,
private val aegisImporter: AegisImporter,
private val raivoImporter: RaivoImporter,
private val lastPassImporter: LastPassImporter,
private val addServiceCase: AddServiceCase,
private val syncBackupDispatcher: SyncBackupWorkDispatcher,
) : BaseViewModel() {
@ -41,6 +43,7 @@ internal class ImportResultViewModel(
ImportType.GoogleAuthenticator -> googleAuthenticatorImporter.read(content.decodeBase64())
ImportType.Aegis -> aegisImporter.read(content.decodeBase64())
ImportType.Raivo -> raivoImporter.read(content.decodeBase64())
ImportType.LastPass -> lastPassImporter.read(content.decodeBase64())
}
when (result) {
@ -95,11 +98,13 @@ internal class ImportResultViewModel(
ImportType.GoogleAuthenticator -> R.string.externalimport__ga_title
ImportType.Aegis -> R.string.externalimport__aegis_title
ImportType.Raivo -> R.string.externalimport__raivo_title
ImportType.LastPass -> R.string.externalimport__lastpass_title
}
private fun getSuccessDescription(type: ImportType) = when (type) {
ImportType.GoogleAuthenticator -> R.string.externalimport__ga_success_msg
ImportType.Aegis -> R.string.externalimport__aegis_success_msg
ImportType.Raivo -> R.string.externalimport__raivo_success_msg
ImportType.LastPass -> R.string.externalimport__lastpass_success_msg
}
}

View File

@ -28,7 +28,7 @@ internal fun ImportScanRoute(
topBar = { TwTopAppBar(stringResource(id = com.twofasapp.resources.R.string.commons__scan_qr_code)) }
) { padding ->
QrScannerScreen(isGalleryEnabled = true, startWithGallery = startFromGallery, modifier = Modifier.padding(padding))
QrScannerScreen(isGalleryEnabled = true, startWithGallery = startFromGallery, modifier = Modifier.padding(top = padding.calculateTopPadding()))
LaunchedEffect(uiState.value.isSuccess) {
if (uiState.value.isSuccess) {

View File

@ -18,11 +18,13 @@ internal fun SelectorRoute(
onGoogleAuthenticatorClick: () -> Unit,
onAegisClick: () -> Unit,
onRaivoClick: () -> Unit,
onLastPassClick: () -> Unit,
) {
SelectorScreen(
onGoogleAuthenticatorClick = onGoogleAuthenticatorClick,
onAegisClick = onAegisClick,
onRaivoClick = onRaivoClick
onRaivoClick = onRaivoClick,
onLastPassClick = onLastPassClick,
)
}
@ -31,13 +33,14 @@ private fun SelectorScreen(
onGoogleAuthenticatorClick: () -> Unit,
onAegisClick: () -> Unit,
onRaivoClick: () -> Unit,
onLastPassClick: () -> Unit,
) {
Scaffold(
topBar = { TwTopAppBar(TwLocale.strings.externalImportTitle) }
) { padding ->
LazyColumn(modifier = Modifier.padding(padding)) {
LazyColumn(modifier = Modifier.padding(top = padding.calculateTopPadding())) {
item {
SettingsHeader(title = TwLocale.strings.externalImportHeader)
}
@ -66,6 +69,14 @@ private fun SelectorScreen(
)
}
item {
SettingsLink(
title = TwLocale.strings.externalImportLastPass,
image = painterResource(id = R.drawable.logo_lastpass),
onClick = onLastPassClick
)
}
item {
SettingsDescription(text = TwLocale.strings.externalImportNotice)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB