Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
3 changes: 3 additions & 0 deletions password_manager/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ val passwordManagerModule = module {
}

viewModel {
PasswordManagerViewModel(get())
PasswordManagerViewModel(
retrieveAllPasswordUseCase = get(),
importPasswordCSVUseCase = get(),
exportPasswordCSVUseCase = get()
)
}

viewModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
}
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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
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
Expand All @@ -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()
Expand Down Expand Up @@ -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))
}
}
}
}
Expand All @@ -65,6 +137,6 @@ private fun ClassicalPasswordManagerScreenContent(
@Composable
private fun ClassicalPasswordManagerScreenPreview() {
PasscodesTheme {
ClassicalPasswordManagerScreenContent(navigateTo = {})
ClassicalPasswordManagerScreenContent(navigateTo = {}, {}, {})
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading