diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f535093..7edbfd93 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ appcompat = "1.7.1" androidxBiometric = "1.4.0-alpha07" room = "2.8.4" sqlite = "2.6.2" +apacheCSV = "1.14.1" json = "20251224" junit = "4.13.2" truth = "1.4.5" @@ -94,6 +95,9 @@ koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = # Json json = { module = "org.json:json", version.ref = "json" } +# CSV +apache-csv = { module = "org.apache.commons:commons-csv", version.ref = "apacheCSV" } + # Testing kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } kotlin-test-annotations-common = { module = "org.jetbrains.kotlin:kotlin-test-annotations-common", version.ref = "kotlin" } diff --git a/password_manager/build.gradle.kts b/password_manager/build.gradle.kts index 47eade7e..e783cc4a 100644 --- a/password_manager/build.gradle.kts +++ b/password_manager/build.gradle.kts @@ -70,6 +70,9 @@ dependencies { implementation(libs.androidx.navigation3.ui) implementation(libs.kotlinx.serialization.json) + // CSV + implementation(libs.apache.csv) + // Concurrency (Coroutines Bundle) implementation(libs.coroutines.core) implementation(libs.coroutines.android) diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/di/PasswordManagerModule.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/di/PasswordManagerModule.kt index 835eabda..6d98bb1a 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/di/PasswordManagerModule.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/di/PasswordManagerModule.kt @@ -52,7 +52,11 @@ val passwordManagerModule = module { } viewModel { - PasswordManagerViewModel(get()) + PasswordManagerViewModel( + retrieveAllPasswordUseCase = get(), + importPasswordCSVUseCase = get(), + exportPasswordCSVUseCase = get() + ) } viewModel { diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ExportPasswordCSVUseCase.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ExportPasswordCSVUseCase.kt index f8528ada..76fdab35 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ExportPasswordCSVUseCase.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ExportPasswordCSVUseCase.kt @@ -3,23 +3,38 @@ package com.jeeldobariya.passcodes.password_manager.domain.usecases import android.content.Context import android.net.Uri import com.jeeldobariya.passcodes.password_manager.data.repository.PasswordRepository -import com.jeeldobariya.passcodes.password_manager.domain.modals.PasswordModal -import com.jeeldobariya.passcodes.password_manager.domain.utils.GOGGLE_IMPORT_EXPORT_CSV_HEADER +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVPrinter +import java.io.OutputStreamWriter class ExportPasswordCSVUseCase( val context: Context, val passwordRepository: PasswordRepository ) { suspend operator fun invoke(exportFileUri: Uri) { - context.contentResolver.openOutputStream(exportFileUri)?.bufferedWriter().use { writer -> - requireNotNull(writer) + val outputStream = context.contentResolver.openOutputStream(exportFileUri) + ?: throw Exception("Failed to open output stream") - writer.write(GOGGLE_IMPORT_EXPORT_CSV_HEADER) - writer.newLine() + val csvFormat = CSVFormat.DEFAULT.builder() + .setHeader("name", "url", "username", "password", "notes") + .get() - passwordRepository.getAllPasswords().forEach { password: PasswordModal -> - writer.write("${password.domain.trim()},https://local.${password.domain.trim()},${password.username.trim()},${password.password.trim()},${password.notes.trim()}") - writer.newLine() + OutputStreamWriter(outputStream).use { writer -> + CSVPrinter(writer, csvFormat).use { csvPrinter -> + val passwords = passwordRepository.getAllPasswords() + + for (password in passwords) { + val domainClean = password.domain.trim() + + val url = "https://local.$domainClean" + val username = password.username.trim() + val passwordString = password.password + val notes = password.notes.trim() + + csvPrinter.printRecord(domainClean, url, username, passwordString, notes) + } + + csvPrinter.flush() } } } diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ImportPasswordCSVUseCase.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ImportPasswordCSVUseCase.kt index 9434448b..e3036c7e 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ImportPasswordCSVUseCase.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/usecases/ImportPasswordCSVUseCase.kt @@ -4,60 +4,75 @@ import android.content.Context import android.net.Uri import com.jeeldobariya.passcodes.password_manager.data.repository.PasswordRepository import com.jeeldobariya.passcodes.password_manager.domain.modals.PasswordModal -import com.jeeldobariya.passcodes.password_manager.domain.utils.GOGGLE_IMPORT_EXPORT_CSV_HEADER +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVParser +import java.io.InputStreamReader class ImportPasswordCSVUseCase( val context: Context, val passwordRepository: PasswordRepository ) { suspend operator fun invoke(importFileUri: Uri) { - context.contentResolver.openInputStream(importFileUri)?.bufferedReader().use { reader -> - requireNotNull(reader) + val inputStream = context.contentResolver.openInputStream(importFileUri) + ?: throw Exception("Failed to open file stream") - val header = reader.readLine() - if (header != GOGGLE_IMPORT_EXPORT_CSV_HEADER) { - throw Exception("The given csv file has incorrect header format. Correct Format is [$GOGGLE_IMPORT_EXPORT_CSV_HEADER]") + val csvFormat = CSVFormat.DEFAULT.builder() + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .get() + + InputStreamReader(inputStream).use { reader -> + val parser: CSVParser = csvFormat.parse(reader) + + // 1. Strict Header Validation + // Google expects keys: "url", "username", "password", "notes" (sometimes "name") + val headerMap = parser.headerMap + if (!headerMap.containsKey("url") || !headerMap.containsKey("username") || !headerMap.containsKey("password")) { + throw Exception("The given CSV file has an incorrect header format. Missing required columns.") } - var line: String? = reader.readLine() - while (line != null) { - val cols = line.split(",") + // 2. Safely process row entries + for (record in parser) { + val url = record.get("url")?.trim().orEmpty() + val name = + if (headerMap.containsKey("name")) record.get("name")?.trim().orEmpty() else "" + val username = record.get("username")?.trim().orEmpty() + val passwordString = record.get("password") + .orEmpty() // Passwords shouldn't be trimmed to preserve spacing intent + val notes = if (headerMap.containsKey("notes")) record.get("notes")?.trim() + .orEmpty() else "" - val chosenDomain: String = if (!cols[0].isBlank()) { - cols[0].trim() - } else cols[1].trim() + val chosenDomain = name.ifBlank { url } - // Skip the entity/row of csv - // If, - // It lacks value for either, [domain, username or password]!! - if (chosenDomain.isBlank() || cols[2].isBlank() || cols[3].isEmpty()) { - line = reader.readLine() + // Skip the row if it lacks domain, username, or password + if (chosenDomain.isBlank() || username.isBlank() || passwordString.isEmpty()) { continue } - val password: PasswordModal? = passwordRepository.getPasswordByUsernameAndDomain( - username = cols[2].trim(), - domain = chosenDomain - ) + // 3. Database Sync Strategy (Check for duplicate) + val existingPassword: PasswordModal? = + passwordRepository.getPasswordByUsernameAndDomain( + username = username, + domain = chosenDomain + ) - if (password != null) { + if (existingPassword != null) { passwordRepository.updatePassword( - id = password.id, - domain = password.domain, - username = password.username, - password = cols[3].trim(), - notes = cols[4].trim() + id = existingPassword.id, + domain = existingPassword.domain, + username = existingPassword.username, + password = passwordString, + notes = notes ) } else { passwordRepository.savePasswordEntity( domain = chosenDomain, - username = cols[2].trim(), - password = cols[3].trim(), - notes = cols[4].trim(), + username = username, + password = passwordString, + notes = notes ) } - - line = reader.readLine() } } } diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/utils/Constants.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/utils/Constants.kt deleted file mode 100644 index f6dd0a27..00000000 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/domain/utils/Constants.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.jeeldobariya.passcodes.password_manager.domain.utils - -const val GOGGLE_IMPORT_EXPORT_CSV_HEADER = "name,url,username,password,note" diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/oldui/PasswordManagerActivity.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/oldui/PasswordManagerActivity.kt index 37b6f822..6c534cc5 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/oldui/PasswordManagerActivity.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/oldui/PasswordManagerActivity.kt @@ -10,19 +10,19 @@ import androidx.lifecycle.lifecycleScope import com.jeeldobariya.passcodes.core.datastore.appDatastore import com.jeeldobariya.passcodes.password_manager.R import com.jeeldobariya.passcodes.password_manager.databinding.ActivityPasswordManagerBinding -import com.jeeldobariya.passcodes.password_manager.domain.usecases.ExportPasswordCSVUseCase -import com.jeeldobariya.passcodes.password_manager.domain.usecases.ImportPasswordCSVUseCase +import com.jeeldobariya.passcodes.password_manager.presentation.password_manager.PasswordManagerAction +import com.jeeldobariya.passcodes.password_manager.presentation.password_manager.PasswordManagerViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import kotlin.getValue class PasswordManagerActivity : AppCompatActivity() { - private val importPasswordUseCase: ImportPasswordCSVUseCase by inject() - private val exportPasswordUseCase: ExportPasswordCSVUseCase by inject() + private val viewModel: PasswordManagerViewModel by viewModel() private lateinit var binding: ActivityPasswordManagerBinding @@ -33,9 +33,7 @@ class PasswordManagerActivity : AppCompatActivity() { Toast.makeText(this@PasswordManagerActivity, "Exporting...", Toast.LENGTH_SHORT).show() - lifecycleScope.launch(Dispatchers.IO) { - exportPasswordUseCase(uri) - } + viewModel.onAction(PasswordManagerAction.OnExportGooglePassword(uri)) } else { Toast.makeText(this@PasswordManagerActivity, "Something went wrong...", Toast.LENGTH_SHORT).show() } @@ -48,9 +46,7 @@ class PasswordManagerActivity : AppCompatActivity() { Toast.makeText(this@PasswordManagerActivity, "Importing...", Toast.LENGTH_SHORT).show() - lifecycleScope.launch(Dispatchers.IO) { - importPasswordUseCase(uri) - } + viewModel.onAction(PasswordManagerAction.OnImportGooglePassword(uri)) } else { Toast.makeText(this@PasswordManagerActivity, "Something went wrong...", Toast.LENGTH_SHORT).show() } diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/ClassicalPasswordManagerScreen.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/ClassicalPasswordManagerScreen.kt index ecbb314b..bb30dd4c 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/ClassicalPasswordManagerScreen.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/ClassicalPasswordManagerScreen.kt @@ -1,5 +1,9 @@ package com.jeeldobariya.passcodes.password_manager.presentation.password_manager +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -7,11 +11,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -20,16 +26,71 @@ import androidx.compose.ui.unit.sp import com.jeeldobariya.passcodes.core.navigation.Route import com.jeeldobariya.passcodes.design_system.theme.PasscodesTheme import com.jeeldobariya.passcodes.password_manager.R +import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ClassicalPasswordManagerScreen(navigateTo: (Route) -> Unit) { - ClassicalPasswordManagerScreenContent(navigateTo) +fun ClassicalPasswordManagerScreen(navigateTo: (Route) -> Unit, viewModel: PasswordManagerViewModel = koinViewModel()) { + val context = LocalContext.current + + // 1. Setup the Import File Picker Launcher + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val uri = result.data?.data + requireNotNull(uri) + + if (result.resultCode == android.app.Activity.RESULT_OK) { + Toast.makeText(context, "Importing...", Toast.LENGTH_SHORT).show() + viewModel.onAction(PasswordManagerAction.OnImportGooglePassword(uri)) + } else { + Toast.makeText(context, "Something went wrong...", Toast.LENGTH_SHORT).show() + } + } + + // 2. Setup the Export Document Picker Launcher + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val uri = result.data?.data + requireNotNull(uri) + + if (result.resultCode == android.app.Activity.RESULT_OK) { + Toast.makeText(context, "Exporting...", Toast.LENGTH_SHORT).show() + viewModel.onAction(PasswordManagerAction.OnExportGooglePassword(uri)) + } else { + Toast.makeText(context, "Something went wrong...", Toast.LENGTH_SHORT).show() + } + } + + ClassicalPasswordManagerScreenContent( + navigateTo = navigateTo, + onImportClicked = { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + setType("text/comma-separated-values") + putExtra(Intent.EXTRA_TITLE, "passwords.csv") + } + + importLauncher.launch(intent) + }, + onExportClicked = { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + setType("text/comma-separated-values") + putExtra(Intent.EXTRA_TITLE, "passwords.csv") + } + + exportLauncher.launch(intent) + } + ) } @Composable private fun ClassicalPasswordManagerScreenContent( - navigateTo: (Route) -> Unit + navigateTo: (Route) -> Unit, + onImportClicked: () -> Unit, + onExportClicked: () -> Unit ) { Scaffold( modifier = Modifier.fillMaxSize() @@ -57,6 +118,17 @@ private fun ClassicalPasswordManagerScreenContent( }) { Text(stringResource(R.string.load_password_button_text)) } + + OutlinedButton(onClick = { + onImportClicked() + }) { + Text(stringResource(R.string.import_password_button_text)) + } + OutlinedButton(onClick = { + onExportClicked() + }) { + Text(stringResource(R.string.export_password_button_text)) + } } } } @@ -65,6 +137,6 @@ private fun ClassicalPasswordManagerScreenContent( @Composable private fun ClassicalPasswordManagerScreenPreview() { PasscodesTheme { - ClassicalPasswordManagerScreenContent(navigateTo = {}) + ClassicalPasswordManagerScreenContent(navigateTo = {}, {}, {}) } } diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerAction.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerAction.kt index c793e39e..bc99584a 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerAction.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerAction.kt @@ -1,5 +1,9 @@ package com.jeeldobariya.passcodes.password_manager.presentation.password_manager +import android.net.Uri + sealed interface PasswordManagerAction { data object RefreshPassword : PasswordManagerAction + data class OnImportGooglePassword(val fileUri: Uri) : PasswordManagerAction + data class OnExportGooglePassword(val fileUri: Uri) : PasswordManagerAction } diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerViewModel.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerViewModel.kt index 304a1fcb..c597009d 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerViewModel.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/password_manager/PasswordManagerViewModel.kt @@ -2,14 +2,19 @@ package com.jeeldobariya.passcodes.password_manager.presentation.password_manage import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.jeeldobariya.passcodes.password_manager.domain.usecases.ExportPasswordCSVUseCase +import com.jeeldobariya.passcodes.password_manager.domain.usecases.ImportPasswordCSVUseCase import com.jeeldobariya.passcodes.password_manager.domain.usecases.RetrieveAllPasswordUseCase +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class PasswordManagerViewModel( - var retrieveAllPasswordUseCase: RetrieveAllPasswordUseCase + var retrieveAllPasswordUseCase: RetrieveAllPasswordUseCase, + val importPasswordCSVUseCase: ImportPasswordCSVUseCase, + val exportPasswordCSVUseCase: ExportPasswordCSVUseCase, ) : ViewModel() { private val _state = MutableStateFlow(PasswordManagerState()) @@ -20,6 +25,18 @@ class PasswordManagerViewModel( PasswordManagerAction.RefreshPassword -> { refreshData() } + + is PasswordManagerAction.OnImportGooglePassword -> { + viewModelScope.launch(Dispatchers.IO) { + importPasswordCSVUseCase(action.fileUri) + } + } + + is PasswordManagerAction.OnExportGooglePassword -> { + viewModelScope.launch(Dispatchers.IO) { + exportPasswordCSVUseCase(action.fileUri) + } + } } } diff --git a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/view_password/ClassicalViewPasswordScreen.kt b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/view_password/ClassicalViewPasswordScreen.kt index 0f1b7857..6097be6e 100644 --- a/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/view_password/ClassicalViewPasswordScreen.kt +++ b/password_manager/src/main/kotlin/com/jeeldobariya/passcodes/password_manager/presentation/view_password/ClassicalViewPasswordScreen.kt @@ -84,12 +84,6 @@ private fun ClassicalViewPasswordScreenContent( ) { Text("Delete Password") } - - Button(onClick = { - /* TODO: Navigate Back */ - }) { - Text(" Navigate Back ") - } } } }