diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8cf118cb..d148f636 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { @@ -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) } diff --git a/app/src/main/kotlin/com/jeeldobariya/passcodes/database/PasswordsDao.kt b/app/src/main/kotlin/com/jeeldobariya/passcodes/database/PasswordsDao.kt index 06ab4689..1c7ba6c9 100644 --- a/app/src/main/kotlin/com/jeeldobariya/passcodes/database/PasswordsDao.kt +++ b/app/src/main/kotlin/com/jeeldobariya/passcodes/database/PasswordsDao.kt @@ -30,4 +30,7 @@ interface PasswordsDao { @Delete suspend fun deletePassword(password: Password): Int + + @Query("DELETE FROM passwords") + suspend fun clearAllPasswordData(): Int } diff --git a/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/PasswordManagerActivity.kt b/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/PasswordManagerActivity.kt index 5ff2b07b..393f8957 100644 --- a/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/PasswordManagerActivity.kt +++ b/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/PasswordManagerActivity.kt @@ -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 + private var tmpExportCSVData: String? = null + + private lateinit var importCsvLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { CommonUtils.updateCurrTheme(this) @@ -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) @@ -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) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/SettingsActivity.kt b/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/SettingsActivity.kt index e83f3ef6..53ba7ac2 100644 --- a/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/SettingsActivity.kt +++ b/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/SettingsActivity.kt @@ -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( @@ -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() @@ -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() } + } } } diff --git a/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/ViewPasswordActivity.kt b/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/ViewPasswordActivity.kt index 54d54150..536c3471 100644 --- a/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/ViewPasswordActivity.kt +++ b/app/src/main/kotlin/com/jeeldobariya/passcodes/ui/ViewPasswordActivity.kt @@ -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) diff --git a/app/src/main/kotlin/com/jeeldobariya/passcodes/utils/Controller.kt b/app/src/main/kotlin/com/jeeldobariya/passcodes/utils/Controller.kt index 4e8a4a85..e264fb71 100644 --- a/app/src/main/kotlin/com/jeeldobariya/passcodes/utils/Controller.kt +++ b/app/src/main/kotlin/com/jeeldobariya/passcodes/utils/Controller.kt @@ -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 @@ -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. @@ -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) @@ -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 = 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 + } } diff --git a/app/src/main/res/drawable/ic_passodes.png b/app/src/main/res/drawable/ic_passodes.png new file mode 100644 index 00000000..47255f90 Binary files /dev/null and b/app/src/main/res/drawable/ic_passodes.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 67998b5f..1f595077 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,13 +7,19 @@ tools:context=".ui.MainActivity" android:padding="4sp" > + + - + + + + + + + + + diff --git a/app/src/main/res/layout/activity_view_password.xml b/app/src/main/res/layout/activity_view_password.xml index f1231c0c..272b3a8b 100644 --- a/app/src/main/res/layout/activity_view_password.xml +++ b/app/src/main/res/layout/activity_view_password.xml @@ -87,10 +87,12 @@ android:layout_height="wrap_content" android:text="@string/update_password_button_text" android:textSize="14dp" /> - + Delete Password Import Password Export Password + Clear All Data Check Security Settings Toggle Theme @@ -75,6 +76,7 @@ Restart App Require. This feature is currently under development. + This is preview feature.. Might have Bugs... 404: Not found. Warning: Please fill out the form first. Failed: Please try again. @@ -84,6 +86,9 @@ Something went wrong. Action discarded. Something Went Wrong: Invalid ID!! + Imported %1$d passwords + Failed to import CSV + Passwords exported Copy to Clipboard? diff --git a/build.gradle.kts b/build.gradle.kts index b982f053..92a2fdd2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,8 @@ plugins { - // These plugins are applied to the root project itself, often not needed for a typical Android app. - // If you need to define shared properties or tasks for all subprojects, you can do it here. - id("com.android.application") version "8.11.0" apply false - // id("com.android.library") version "8.11.0" apply false - id("org.jetbrains.kotlin.android") version "2.1.21" apply false - id("com.google.devtools.ksp") version "2.1.21-2.0.2" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.oss.licenses) apply false } // Allprojects block is common for setting up common repositories for all subprojects. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..01765b0b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,50 @@ +[versions] +kotlin = "1.9.0" +material = "1.12.0" +oss-license = "17.2.1" +appcompat = "1.7.0" +room = "2.7.2" +# json = "20250517" +junit = "4.13.2" +truth = "1.4.4" +androidx-test-ext-junit = "1.2.1" +espresso-core = "3.6.1" +coroutines = "1.10.2" +lifecycle = "2.9.2" + +# Plugin versions +agp = "8.11.0" +kotlin-plugin = "2.1.21" +ksp = "2.1.21-2.0.2" +oss-license-plugin = "0.10.6" # latest safe version for oss-licenses-plugin + +[libraries] +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } + +material = { module = "com.google.android.material:material", version.ref = "material" } +oss-license = { module = "com.google.android.gms:play-services-oss-licenses", version.ref = "oss-license" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } + +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } + +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + +lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +# lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } + +# json = { module = "org.json:json", version.ref = "json" } + +junit = { module = "junit:junit", version.ref = "junit" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-plugin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "oss-license-plugin" }