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
84 changes: 28 additions & 56 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import org.gradle.api.GradleException
import com.android.build.api.dsl.ApplicationExtension

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
// If you use Kotlin Parcelize, uncomment the next line:
// id("kotlin-parcelize")
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.oss.licenses)
}

android {
Expand Down Expand Up @@ -143,54 +141,28 @@ android {
}

dependencies {
val kotlinVersion = "1.9.0"
val materialVersion = "1.12.0"
val ossLicenseVersion = "17.2.1"
val appCompatVersion = "1.7.0"
val roomVersion = "2.7.2"
// val jsonVersion = "20250517"
val junitVersion = "4.13.2"
val truthVersion = "1.4.4"
val androidxTestExtJunitVersion = "1.2.1"
val espressoCoreVersion = "3.6.1"

val coroutinesVersion = "1.10.2"
val lifecycleVersion = "2.9.2"

implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")

implementation("com.google.android.material:material:$materialVersion")
implementation("com.google.android.gms:play-services-oss-licenses:$ossLicenseVersion")
implementation("androidx.appcompat:appcompat:$appCompatVersion")
// viewbinding is often not explicitly needed here if buildFeatures.viewBinding = true
// implementation("androidx.databinding:viewbinding:7.4.1")

implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")

// Kotlin Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")

// Lifecycle components for lifecycleScope
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
// implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") // Good for future ViewModels

// // External JSON library
// implementation("org.json:json:$jsonVersion") // Standard org.json library

// Testing dependencies
testImplementation("junit:junit:$junitVersion")
testImplementation("com.google.truth:truth:$truthVersion")

// Room testing
androidTestImplementation("androidx.room:room-testing:$roomVersion") // Essential for Room testing
androidTestImplementation("androidx.test.ext:junit:$androidxTestExtJunitVersion")
androidTestImplementation("androidx.test.espresso:espresso-core:$espressoCoreVersion")

// Coroutine test utilities (for runTest)
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")

// Assertion library (Google Truth)
androidTestImplementation("com.google.truth:truth:$truthVersion")
implementation(libs.kotlin.stdlib)

implementation(libs.material)
implementation(libs.oss.license)
implementation(libs.appcompat)

implementation(libs.room.ktx)
ksp(libs.room.compiler)

implementation(libs.coroutines.core)
implementation(libs.coroutines.android)

implementation(libs.lifecycle.runtime)
// implementation(libs.lifecycle.viewmodel)

// test
testImplementation(libs.junit)
testImplementation(libs.truth)

androidTestImplementation(libs.room.testing)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.coroutines.test)
androidTestImplementation(libs.truth)
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@ interface PasswordsDao {

@Delete
suspend fun deletePassword(password: Password): Int

@Query("DELETE FROM passwords")
suspend fun clearAllPasswordData(): Int
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,32 @@ package com.jeeldobariya.passcodes.ui
import android.content.Intent
import android.view.View.GONE
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import com.jeeldobariya.passcodes.R
import com.jeeldobariya.passcodes.databinding.ActivityPasswordManagerBinding
import com.jeeldobariya.passcodes.flags.FeatureFlagManager
import com.jeeldobariya.passcodes.utils.CommonUtils
import com.jeeldobariya.passcodes.utils.Controller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext


class PasswordManagerActivity : AppCompatActivity() {

private lateinit var binding: ActivityPasswordManagerBinding // Use late init for binding
private lateinit var binding: ActivityPasswordManagerBinding
private lateinit var controller: Controller

private lateinit var exportCsvLauncher: ActivityResultLauncher<Intent>
private var tmpExportCSVData: String? = null

private lateinit var importCsvLauncher: ActivityResultLauncher<Intent>

override fun onCreate(savedInstanceState: Bundle?) {
CommonUtils.updateCurrTheme(this)
Expand All @@ -27,6 +41,59 @@ class PasswordManagerActivity : AppCompatActivity() {
binding.exportPasswordBtn.visibility = GONE
}

controller = Controller(this) // Initialize the controller here

importCsvLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
val uri = result.data?.data
if (uri != null) {
val CSVData: String? = contentResolver.openInputStream(uri)?.bufferedReader()?.use {
it.readText()
}

lifecycleScope.launch(Dispatchers.IO) {
if (CSVData != null) {
try {
val importCount: Int = controller.importDataFromCsvString(CSVData)

withContext(Dispatchers.Main) {
Toast.makeText(
this@PasswordManagerActivity,
getString(R.string.import_success, importCount),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
this@PasswordManagerActivity,
getString(R.string.import_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
}
}

exportCsvLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
val uri = result.data?.data
if (uri != null && !tmpExportCSVData.isNullOrEmpty()) {
contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(tmpExportCSVData!!.toByteArray())
}
Toast.makeText(this, getString(R.string.export_success), Toast.LENGTH_SHORT).show()
}
}
}

// Add event onclick listener
addOnClickListenerOnButton(binding)

Expand All @@ -47,11 +114,42 @@ class PasswordManagerActivity : AppCompatActivity() {
}

binding.importPasswordBtn.setOnClickListener {
Toast.makeText(this, getString(R.string.future_feat_clause), Toast.LENGTH_SHORT).show()
Toast.makeText(this@PasswordManagerActivity, getString(R.string.preview_feature), Toast.LENGTH_LONG).show()

importCsvFilePicker()
}

binding.exportPasswordBtn.setOnClickListener {
Toast.makeText(this, getString(R.string.future_feat_clause), Toast.LENGTH_SHORT).show()
Toast.makeText(this@PasswordManagerActivity, getString(R.string.preview_feature), Toast.LENGTH_LONG).show()

lifecycleScope.launch(Dispatchers.IO) {
val csvDataExportBlob = controller.exportDataToCsvString()

withContext(Dispatchers.Main) {
tmpExportCSVData = csvDataExportBlob
exportCsvFilePicker()
}
}
}
}

private fun exportCsvFilePicker() {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/comma-separated-values"
putExtra(Intent.EXTRA_TITLE, "passwords.csv")
}

exportCsvLauncher.launch(intent)
}

private fun importCsvFilePicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/comma-separated-values"
putExtra(Intent.EXTRA_TITLE, "passwords.csv")
}

importCsvLauncher.launch(intent)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import com.jeeldobariya.passcodes.R
import com.jeeldobariya.passcodes.databinding.ActivitySettingsBinding
import com.jeeldobariya.passcodes.flags.FeatureFlagManager
import androidx.core.content.edit
import androidx.lifecycle.lifecycleScope
import com.jeeldobariya.passcodes.utils.CommonUtils
import com.jeeldobariya.passcodes.utils.Constant
import com.jeeldobariya.passcodes.utils.Controller
import kotlinx.coroutines.launch

class SettingsActivity : AppCompatActivity() {

private lateinit var binding: ActivitySettingsBinding

private lateinit var controller: Controller

// List of available themes to cycle through
private val THEMES = listOf(
Expand All @@ -42,6 +45,8 @@ class SettingsActivity : AppCompatActivity() {

binding.switchLatestFeatures.isChecked = FeatureFlagManager.get(this).latestFeaturesEnabled

controller = Controller(this) // Initialize the controller here

// Add event onclick listener
addOnClickListenerOnButton()

Expand Down Expand Up @@ -98,5 +103,9 @@ class SettingsActivity : AppCompatActivity() {
FeatureFlagManager.get(this).latestFeaturesEnabled = isChecked
Toast.makeText(this@SettingsActivity, getString(R.string.future_feat_clause) + isChecked.toString(), Toast.LENGTH_SHORT).show()
}

binding.clearAllDataBtn.setOnClickListener { v ->
lifecycleScope.launch { controller.clearAllData() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class ViewPasswordActivity : AppCompatActivity() {
// Added all the onclick event listeners
private fun addOnClickListenerOnButton() {
binding.copyPasswordBtn.setOnClickListener {
Toast.makeText(this, getString(R.string.preview_feature), Toast.LENGTH_SHORT).show()

val confirmDialog = AlertDialog.Builder(this@ViewPasswordActivity)
.setTitle(R.string.copy_password_dialog_title)
.setMessage(R.string.danger_copy_to_clipboard_desc)
Expand Down
68 changes: 62 additions & 6 deletions app/src/main/kotlin/com/jeeldobariya/passcodes/utils/Controller.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import com.jeeldobariya.passcodes.database.MasterDatabase
import com.jeeldobariya.passcodes.database.Password
import com.jeeldobariya.passcodes.database.PasswordsDao
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first

class InvalidInputException(message: String = "Input parameters cannot be blank.") : Exception(message)
class DatabaseOperationException(message: String = "A database operation error occurred.", cause: Throwable? = null) : Exception(message, cause)
class PasswordNotFoundException(message: String = "Password with the given ID was not found.") : Exception(message)

class InvalidImportFormat(message: String = "Given Data Is In Invalid Format") : Exception(message)

class Controller(context: Context) {
private val passwordsDao: PasswordsDao
Expand All @@ -20,6 +21,10 @@ class Controller(context: Context) {
passwordsDao = db.passwordsDao
}

companion object {
const val CSV_HEADER = "name,url,username,password,notes"
}

/**
* Saves a new password entity into the database.
* @return The rowId of the newly inserted row.
Expand Down Expand Up @@ -71,14 +76,10 @@ class Controller(context: Context) {
* Retrieves a password entity by username and domain.
* @return The Password object if found.
* @throws DatabaseOperationException if a database error occurs.
* @throws PasswordNotFoundException if the password is not found.
*/
suspend fun getPasswordByUsernameAndDomain(username: String, domain: String): Password {
suspend fun getPasswordByUsernameAndDomain(username: String, domain: String): Password? {
return try {
passwordsDao.getPasswordByUsernameAndDomain(username, domain)
?: throw PasswordNotFoundException("Password for username '$username' and domain '$domain' not found.")
} catch (e: PasswordNotFoundException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
throw DatabaseOperationException("Error retrieving password by username and domain.", e)
Expand Down Expand Up @@ -133,4 +134,59 @@ class Controller(context: Context) {
throw DatabaseOperationException("Error deleting password.", e)
}
}

suspend fun clearAllData() {
passwordsDao.clearAllPasswordData()
}

suspend fun exportDataToCsvString(): String {
val passwords: List<Password> = getAllPasswords().first()

val rows = passwords.joinToString("\n") { password ->
"${password.domain},https://local.${password.domain},${password.username},${password.password},${password.notes}"
}

return CSV_HEADER + "\n" + rows
}

suspend fun importDataFromCsvString(csvString: String): Int {
val lines = csvString.lines().filter { it.isNotBlank() }

if (lines.isEmpty() || lines[0] != CSV_HEADER) {
throw InvalidImportFormat()
}

var importedPasswordCount = 0

lines.drop(1).forEach { line ->
val cols = line.split(",")

try {
val password: Password? = passwordsDao.getPasswordByUsernameAndDomain(username = cols[2].trim(), domain = cols[0].trim())

if (password != null) {
updatePassword(
id = password.id,
domain = password.domain,
username = password.username,
password = cols[3].trim(),
notes = cols[4].trim()
)
} else {
savePasswordEntity(
domain = cols[0].trim(),
username = cols[2].trim(),
password = cols[3].trim(),
notes = cols[4].trim()
)
}

importedPasswordCount++
} catch (e: InvalidInputException) {
e.printStackTrace()
}
}

return importedPasswordCount
}
}
Binary file added app/src/main/res/drawable/ic_passodes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading