diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 123242c148..d400b87806 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -134,6 +134,7 @@ dependencies {
implementation(libs.androidx.compose.material3.material3)
implementation(libs.androidx.compose.material.material.icons.extended)
implementation(libs.androidx.compose.ui.ui.tooling.preview.android)
+ debugImplementation(libs.androidx.compose.ui.ui.tooling)
debugImplementation(libs.androidx.compose.ui.ui.test.manifest)
androidTestImplementation(composeBom)
@@ -154,6 +155,8 @@ dependencies {
testImplementation(libs.androidx.test.core)
testImplementation(libs.junit.junit)
testImplementation(libs.org.robolectric.robolectric)
+ testImplementation(composeBom)
+ testImplementation(libs.androidx.compose.ui.ui.test.junit4)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.junit.junit)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 596b2f9ba6..320ae34a5c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,6 +44,12 @@
android:resource="@xml/list_widget_info" />
+
+
+
private lateinit var fileOpenLauncher: ActivityResultLauncher
private val mTasks = TaskHandler()
+ private var dialogState by mutableStateOf(ImportExportDialogState.None)
+
companion object {
private const val TAG = "Catima"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- binding = ImportExportActivityBinding.inflate(layoutInflater)
- setTitle(R.string.importExport)
- setContentView(binding.root)
- Utils.applyWindowInsets(binding.root)
- val toolbar: Toolbar = binding.toolbar
- setSupportActionBar(toolbar)
- enableToolbarBackButton()
+
+ fixedEdgeToEdge()
val fileIntent = intent
if (fileIntent?.type != null) {
- chooseImportType(fileIntent.data)
+ fileIntent.data?.let { uri ->
+ pendingImportUri = uri
+ dialogState = ImportExportDialogState.ImportTypeSelection
+ }
}
// would use ActivityResultContracts.CreateDocument() but mime type cannot be set
@@ -103,155 +96,121 @@ class ImportExportActivity : CatimaAppCompatActivity() {
openFileForImport(result, null)
}
- // Check that there is a file manager available
+ val importOptions = buildImportOptions()
+
+ setContent {
+ CatimaTheme {
+ ImportExportScreen(
+ onBackPressedDispatcher = onBackPressedDispatcher,
+ importOptions = importOptions,
+ dialogState = dialogState,
+ onDialogStateChange = { newState -> dialogState = newState },
+ onExportWithPassword = { password -> startExportFlow(password) },
+ onImportSelected = { option -> handleImportSelection(option) },
+ onImportWithPassword = { _, password ->
+ pendingImportUri?.let { uri ->
+ openFileForImport(uri, password.toCharArray())
+ }
+ },
+ onShareExport = { shareExportedFile() }
+ )
+ }
+ }
+
+ // FIXME: The importer/exporter is currently quite broken
+ // To prevent the screen from turning off during import/export and some devices killing Catima as it's no longer foregrounded, force the screen to stay on here
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+
+ private fun buildImportOptions(): List {
+ val importTypesArray = resources.getStringArray(R.array.import_types_array)
+ return listOf(
+ ImportOption(
+ title = importTypesArray.getOrElse(0) { getString(R.string.importCatima) },
+ message = getString(R.string.importCatimaMessage),
+ dataFormat = DataFormat.Catima
+ ),
+ ImportOption(
+ title = importTypesArray.getOrElse(1) { "Fidme" },
+ message = getString(R.string.importFidmeMessage),
+ dataFormat = DataFormat.Fidme,
+ isBeta = true
+ ),
+ ImportOption(
+ title = importTypesArray.getOrElse(2) { getString(R.string.importLoyaltyCardKeychain) },
+ message = getString(R.string.importLoyaltyCardKeychainMessage),
+ dataFormat = DataFormat.Catima
+ ),
+ ImportOption(
+ title = importTypesArray.getOrElse(3) { getString(R.string.importVoucherVault) },
+ message = getString(R.string.importVoucherVaultMessage),
+ dataFormat = DataFormat.VoucherVault
+ )
+ )
+ }
+
+ private fun startExportFlow(password: String) {
+ exportPassword = password
val intentCreateDocumentAction = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
putExtra(Intent.EXTRA_TITLE, "catima.zip")
}
+ try {
+ fileCreateLauncher.launch(intentCreateDocumentAction)
+ } catch (e: ActivityNotFoundException) {
+ Toast.makeText(
+ applicationContext,
+ R.string.failedOpeningFileManager,
+ Toast.LENGTH_LONG
+ ).show()
+ Log.e(TAG, "No activity found to handle intent", e)
+ }
+ }
- val exportButton: Button = binding.exportButton
- exportButton.setOnClickListener {
- val builder = MaterialAlertDialogBuilder(this@ImportExportActivity)
- builder.setTitle(R.string.exportPassword)
-
- val container = FrameLayout(this@ImportExportActivity)
-
- val textInputLayout = TextInputLayout(this@ImportExportActivity).apply {
- endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
- layoutParams = LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.WRAP_CONTENT
- ).apply {
- setMargins(50, 10, 50, 0)
- }
- }
-
- val input = EditText(this@ImportExportActivity).apply {
- inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
- setHint(R.string.exportPasswordHint)
- }
+ private fun handleImportSelection(option: ImportOption) {
+ currentImportDataFormat = option.dataFormat
- textInputLayout.addView(input)
- container.addView(textInputLayout)
- builder.setView(container)
- builder.setPositiveButton(R.string.ok) { _, _ ->
- exportPassword = input.text.toString()
- try {
- fileCreateLauncher.launch(intentCreateDocumentAction)
- } catch (e: ActivityNotFoundException) {
- Toast.makeText(
- applicationContext,
- R.string.failedOpeningFileManager,
- Toast.LENGTH_LONG
- ).show()
- Log.e(TAG, "No activity found to handle intent", e)
- }
- }
- builder.setNegativeButton(R.string.cancel) { dialogInterface, _ -> dialogInterface.cancel() }
- builder.show()
+ // If we have a pending URI from intent, use it directly
+ pendingImportUri?.let { uri ->
+ openFileForImport(uri, null)
+ return
}
- // Check that there is a file manager available
- val importFilesystem: Button = binding.importOptionFilesystemButton
- importFilesystem.setOnClickListener { chooseImportType(null) }
-
- // FIXME: The importer/exporter is currently quite broken
- // To prevent the screen from turning off during import/export and some devices killing Catima as it's no longer foregrounded, force the screen to stay on here
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ // Otherwise open file picker
+ try {
+ fileOpenLauncher.launch("*/*")
+ } catch (e: ActivityNotFoundException) {
+ Toast.makeText(
+ applicationContext,
+ R.string.failedOpeningFileManager,
+ Toast.LENGTH_LONG
+ ).show()
+ Log.e(TAG, "No activity found to handle intent", e)
+ }
}
private fun openFileForImport(uri: Uri, password: CharArray?) {
+ pendingImportUri = uri
// Running this in a thread prevents Android from throwing a NetworkOnMainThreadException for large files
// FIXME: This is still suboptimal, because showing that the import started is delayed until the network request finishes
Thread {
try {
val reader = contentResolver.openInputStream(uri)
Log.d(TAG, "Starting file import with: $uri")
- startImport(reader, uri, importDataFormat, password, true)
+ startImport(reader, uri, currentImportDataFormat, password, true)
} catch (e: IOException) {
Log.e(TAG, "Failed to import file: $uri", e)
onImportComplete(
ImportExportResult(
ImportExportResultType.GenericFailure,
e.toString()
- ), uri, importDataFormat
+ ), currentImportDataFormat
)
}
}.start()
}
- private fun chooseImportType(fileData: Uri?) {
- val betaImportOptions = mutableListOf()
- betaImportOptions.add("Fidme")
- val importOptions = mutableListOf()
-
- for (importOption in resources.getStringArray(R.array.import_types_array)) {
- var option = importOption
- if (betaImportOptions.contains(importOption)) {
- option = "$importOption (BETA)"
- }
- importOptions.add(option)
- }
-
- val builder = MaterialAlertDialogBuilder(this)
- builder.setTitle(R.string.chooseImportType)
- .setItems(importOptions.toTypedArray()) { _, which ->
- when (which) {
- // Catima
- 0 -> {
- importAlertTitle = getString(R.string.importCatima)
- importAlertMessage = getString(R.string.importCatimaMessage)
- importDataFormat = DataFormat.Catima
- }
- // Fidme
- 1 -> {
- importAlertTitle = getString(R.string.importFidme)
- importAlertMessage = getString(R.string.importFidmeMessage)
- importDataFormat = DataFormat.Fidme
- }
- // Loyalty Card Keychain
- 2 -> {
- importAlertTitle = getString(R.string.importLoyaltyCardKeychain)
- importAlertMessage = getString(R.string.importLoyaltyCardKeychainMessage)
- importDataFormat = DataFormat.Catima
- }
- // Voucher Vault
- 3 -> {
- importAlertTitle = getString(R.string.importVoucherVault)
- importAlertMessage = getString(R.string.importVoucherVaultMessage)
- importDataFormat = DataFormat.VoucherVault
- }
-
- else -> throw IllegalArgumentException("Unknown DataFormat")
- }
-
- if (fileData != null) {
- openFileForImport(fileData, null)
- return@setItems
- }
-
- MaterialAlertDialogBuilder(this)
- .setTitle(importAlertTitle)
- .setMessage(importAlertMessage)
- .setPositiveButton(R.string.ok) { _, _ ->
- try {
- fileOpenLauncher.launch("*/*")
- } catch (e: ActivityNotFoundException) {
- Toast.makeText(
- applicationContext,
- R.string.failedOpeningFileManager,
- Toast.LENGTH_LONG
- ).show()
- Log.e(TAG, "No activity found to handle intent", e)
- }
- }
- .setNegativeButton(R.string.cancel, null)
- .show()
- }
- builder.show()
- }
-
private fun startImport(
target: InputStream?,
targetUri: Uri,
@@ -260,8 +219,8 @@ class ImportExportActivity : CatimaAppCompatActivity() {
closeWhenDone: Boolean
) {
mTasks.flushTaskList(TaskHandler.TYPE.IMPORT, true, false, false)
- val listener = ImportExportTask.TaskCompleteListener { result, dataFormat ->
- onImportComplete(result, targetUri, dataFormat)
+ val listener = ImportExportTask.TaskCompleteListener { result, format ->
+ onImportComplete(result, format)
if (closeWhenDone) {
try {
target?.close()
@@ -285,7 +244,7 @@ class ImportExportActivity : CatimaAppCompatActivity() {
closeWhenDone: Boolean
) {
mTasks.flushTaskList(TaskHandler.TYPE.EXPORT, true, false, false)
- val listener = ImportExportTask.TaskCompleteListener { result, dataFormat ->
+ val listener = ImportExportTask.TaskCompleteListener { result, _ ->
onExportComplete(result, targetUri)
if (closeWhenDone) {
try {
@@ -309,108 +268,38 @@ class ImportExportActivity : CatimaAppCompatActivity() {
super.onDestroy()
}
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- val id = item.itemId
-
- if (id == android.R.id.home) {
- finish()
- return true
- }
-
- return super.onOptionsItemSelected(item)
- }
-
- private fun retryWithPassword(dataFormat: DataFormat, uri: Uri) {
- val builder = MaterialAlertDialogBuilder(this)
- builder.setTitle(R.string.passwordRequired)
-
- val container = FrameLayout(this@ImportExportActivity)
-
- val textInputLayout = TextInputLayout(this@ImportExportActivity).apply {
- endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
- layoutParams = LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.WRAP_CONTENT
- ).apply {
- setMargins(50, 10, 50, 0)
- }
- }
-
- val input = EditText(this@ImportExportActivity).apply {
- inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
- setHint(R.string.exportPasswordHint)
- }
-
- textInputLayout.addView(input)
- container.addView(textInputLayout)
- builder.setView(container)
-
- builder.setPositiveButton(R.string.ok) { _, _ ->
- openFileForImport(uri, input.text.toString().toCharArray())
- }
- builder.setNegativeButton(R.string.cancel) { dialogInterface, _ -> dialogInterface.cancel() }
-
- builder.show()
- }
-
- private fun buildResultDialogMessage(result: ImportExportResult, isImport: Boolean): String {
- val messageId = if (result.resultType() == ImportExportResultType.Success) {
- if (isImport) R.string.importSuccessful else R.string.exportSuccessful
- } else {
- if (isImport) R.string.importFailed else R.string.exportFailed
- }
-
- val messageBuilder = StringBuilder(resources.getString(messageId))
- if (result.developerDetails() != null) {
- messageBuilder.append("\n\n")
- messageBuilder.append(resources.getString(R.string.include_if_asking_support))
- messageBuilder.append("\n\n")
- messageBuilder.append(result.developerDetails())
- }
-
- return messageBuilder.toString()
- }
-
- private fun onImportComplete(result: ImportExportResult, path: Uri, dataFormat: DataFormat?) {
+ private fun onImportComplete(result: ImportExportResult, dataFormat: DataFormat?) {
val resultType = result.resultType()
if (resultType == ImportExportResultType.BadPassword) {
- retryWithPassword(dataFormat!!, path)
+ runOnUiThread {
+ dialogState = ImportExportDialogState.ImportPassword(dataFormat!!)
+ }
return
}
- val builder = MaterialAlertDialogBuilder(this)
- builder.setTitle(if (resultType == ImportExportResultType.Success) R.string.importSuccessfulTitle else R.string.importFailedTitle)
- builder.setMessage(buildResultDialogMessage(result, true))
- builder.setNeutralButton(R.string.ok) { dialog, _ -> dialog.dismiss() }
-
- builder.create().show()
+ runOnUiThread {
+ dialogState = ImportExportDialogState.ImportResult(result, dataFormat)
+ }
}
private fun onExportComplete(result: ImportExportResult, path: Uri) {
- val resultType = result.resultType()
-
- val builder = MaterialAlertDialogBuilder(this)
- builder.setTitle(if (resultType == ImportExportResultType.Success) R.string.exportSuccessfulTitle else R.string.exportFailedTitle)
- builder.setMessage(buildResultDialogMessage(result, false))
- builder.setNeutralButton(R.string.ok) { dialog, _ -> dialog.dismiss() }
-
- if (resultType == ImportExportResultType.Success) {
- val sendLabel = this@ImportExportActivity.resources.getText(R.string.sendLabel)
-
- builder.setPositiveButton(sendLabel) { dialog, _ ->
- val sendIntent = Intent(Intent.ACTION_SEND).apply {
- putExtra(Intent.EXTRA_STREAM, path)
- type = "text/csv"
- // set flag to give temporary permission to external app to use the FileProvider
- flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
- }
+ lastExportUri = path
+ val isSuccess = result.resultType() == ImportExportResultType.Success
+ runOnUiThread {
+ dialogState = ImportExportDialogState.ExportResult(result, canShare = isSuccess)
+ }
+ }
- this@ImportExportActivity.startActivity(Intent.createChooser(sendIntent, sendLabel))
- dialog.dismiss()
+ private fun shareExportedFile() {
+ lastExportUri?.let { uri ->
+ val sendIntent = Intent(Intent.ACTION_SEND).apply {
+ putExtra(Intent.EXTRA_STREAM, uri)
+ type = "text/csv"
+ // set flag to give temporary permission to external app to use the FileProvider
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
+ startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.sendLabel)))
}
-
- builder.create().show()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/protect/card_locker/ImportExportScreen.kt b/app/src/main/java/protect/card_locker/ImportExportScreen.kt
new file mode 100644
index 0000000000..d85952dffc
--- /dev/null
+++ b/app/src/main/java/protect/card_locker/ImportExportScreen.kt
@@ -0,0 +1,504 @@
+package protect.card_locker
+
+import androidx.activity.OnBackPressedDispatcher
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import protect.card_locker.compose.CatimaTopAppBar
+import protect.card_locker.importexport.DataFormat
+import protect.card_locker.importexport.ImportExportResult
+import protect.card_locker.importexport.ImportExportResultType
+
+data class ImportOption(
+ val title: String,
+ val message: String,
+ val dataFormat: DataFormat,
+ val isBeta: Boolean = false
+)
+
+sealed class ImportExportDialogState {
+ data object None : ImportExportDialogState()
+ data object ExportPassword : ImportExportDialogState()
+ data object ImportTypeSelection : ImportExportDialogState()
+ data class ImportConfirmation(val option: ImportOption) : ImportExportDialogState()
+ data class ImportPassword(val dataFormat: DataFormat) : ImportExportDialogState()
+ data class ImportResult(val result: ImportExportResult, val dataFormat: DataFormat?) :
+ ImportExportDialogState()
+
+ data class ExportResult(val result: ImportExportResult, val canShare: Boolean) :
+ ImportExportDialogState()
+}
+
+@Composable
+fun ImportExportScreen(
+ onBackPressedDispatcher: OnBackPressedDispatcher?,
+ importOptions: List,
+ dialogState: ImportExportDialogState,
+ onDialogStateChange: (ImportExportDialogState) -> Unit,
+ onExportWithPassword: (String) -> Unit,
+ onImportSelected: (ImportOption) -> Unit,
+ onImportWithPassword: (DataFormat, String) -> Unit,
+ onShareExport: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ CatimaTopAppBar(
+ title = stringResource(R.string.importExport),
+ onBackPressedDispatcher = onBackPressedDispatcher
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .padding(paddingValues)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(R.string.importExportHelp),
+ fontSize = 16.sp,
+ textAlign = TextAlign.Center
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
+
+ Text(
+ text = stringResource(R.string.exportName),
+ fontSize = 20.sp,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Text(
+ text = stringResource(R.string.exportOptionExplanation),
+ fontSize = 16.sp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ )
+ Button(
+ onClick = { onDialogStateChange(ImportExportDialogState.ExportPassword) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ ) {
+ Text(stringResource(R.string.exportName))
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
+
+ Text(
+ text = stringResource(R.string.importOptionFilesystemTitle),
+ fontSize = 20.sp,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Text(
+ text = stringResource(R.string.importOptionFilesystemExplanation),
+ fontSize = 16.sp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ )
+ Button(
+ onClick = { onDialogStateChange(ImportExportDialogState.ImportTypeSelection) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ ) {
+ Text(stringResource(R.string.importOptionFilesystemButton))
+ }
+ }
+
+ // Dialogs
+ when (dialogState) {
+ is ImportExportDialogState.ExportPassword -> {
+ ExportPasswordDialog(
+ onDismiss = { onDialogStateChange(ImportExportDialogState.None) },
+ onConfirm = { password ->
+ onDialogStateChange(ImportExportDialogState.None)
+ onExportWithPassword(password)
+ }
+ )
+ }
+
+ is ImportExportDialogState.ImportTypeSelection -> {
+ ImportTypeSelectionDialog(
+ importOptions = importOptions,
+ onDismiss = { onDialogStateChange(ImportExportDialogState.None) },
+ onSelect = { option ->
+ onDialogStateChange(ImportExportDialogState.ImportConfirmation(option))
+ }
+ )
+ }
+
+ is ImportExportDialogState.ImportConfirmation -> {
+ ImportConfirmationDialog(
+ option = dialogState.option,
+ onDismiss = { onDialogStateChange(ImportExportDialogState.None) },
+ onConfirm = {
+ onDialogStateChange(ImportExportDialogState.None)
+ onImportSelected(dialogState.option)
+ }
+ )
+ }
+
+ is ImportExportDialogState.ImportPassword -> {
+ ImportPasswordDialog(
+ onDismiss = { onDialogStateChange(ImportExportDialogState.None) },
+ onConfirm = { password ->
+ onDialogStateChange(ImportExportDialogState.None)
+ onImportWithPassword(dialogState.dataFormat, password)
+ }
+ )
+ }
+
+ is ImportExportDialogState.ImportResult -> {
+ ImportResultDialog(
+ result = dialogState.result,
+ onDismiss = { onDialogStateChange(ImportExportDialogState.None) }
+ )
+ }
+
+ is ImportExportDialogState.ExportResult -> {
+ ExportResultDialog(
+ result = dialogState.result,
+ canShare = dialogState.canShare,
+ onDismiss = { onDialogStateChange(ImportExportDialogState.None) },
+ onShare = {
+ onDialogStateChange(ImportExportDialogState.None)
+ onShareExport()
+ }
+ )
+ }
+
+ ImportExportDialogState.None -> { /* No dialog */
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExportPasswordDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit
+) {
+ var password by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.exportPassword)) },
+ text = {
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(stringResource(R.string.exportPasswordHint)) },
+ singleLine = true,
+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { onConfirm(password) }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+}
+
+@Composable
+private fun ImportTypeSelectionDialog(
+ importOptions: List,
+ onDismiss: () -> Unit,
+ onSelect: (ImportOption) -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.chooseImportType)) },
+ text = {
+ Column {
+ importOptions.forEach { option ->
+ val displayTitle = if (option.isBeta) "${option.title} (BETA)" else option.title
+ Text(
+ text = displayTitle,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelect(option) }
+ .padding(vertical = 12.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+}
+
+@Composable
+private fun ImportConfirmationDialog(
+ option: ImportOption,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(option.title) },
+ text = { Text(option.message) },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+}
+
+@Composable
+private fun ImportPasswordDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit
+) {
+ var password by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.passwordRequired)) },
+ text = {
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(stringResource(R.string.exportPasswordHint)) },
+ singleLine = true,
+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { onConfirm(password) }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+}
+
+@Composable
+private fun ImportResultDialog(
+ result: ImportExportResult,
+ onDismiss: () -> Unit
+) {
+ val isSuccess = result.resultType() == ImportExportResultType.Success
+ val titleRes = if (isSuccess) R.string.importSuccessfulTitle else R.string.importFailedTitle
+ val messageRes = if (isSuccess) R.string.importSuccessful else R.string.importFailed
+
+ val message = buildString {
+ append(stringResource(messageRes))
+ if (result.developerDetails() != null) {
+ append("\n\n")
+ append(stringResource(R.string.include_if_asking_support))
+ append("\n\n")
+ append(result.developerDetails())
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(titleRes)) },
+ text = {
+ Text(
+ text = message,
+ modifier = Modifier.verticalScroll(rememberScrollState())
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.ok))
+ }
+ }
+ )
+}
+
+@Composable
+private fun ExportResultDialog(
+ result: ImportExportResult,
+ canShare: Boolean,
+ onDismiss: () -> Unit,
+ onShare: () -> Unit
+) {
+ val isSuccess = result.resultType() == ImportExportResultType.Success
+ val titleRes = if (isSuccess) R.string.exportSuccessfulTitle else R.string.exportFailedTitle
+ val messageRes = if (isSuccess) R.string.exportSuccessful else R.string.exportFailed
+
+ val message = buildString {
+ append(stringResource(messageRes))
+ if (result.developerDetails() != null) {
+ append("\n\n")
+ append(stringResource(R.string.include_if_asking_support))
+ append("\n\n")
+ append(result.developerDetails())
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(titleRes)) },
+ text = {
+ Text(
+ text = message,
+ modifier = Modifier.verticalScroll(rememberScrollState())
+ )
+ },
+ confirmButton = {
+ if (isSuccess && canShare) {
+ TextButton(onClick = onShare) {
+ Text(stringResource(R.string.sendLabel))
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.ok))
+ }
+ }
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ImportExportScreenPreview() {
+ val sampleImportOptions = listOf(
+ ImportOption(
+ title = "Catima",
+ message = "Import from Catima backup",
+ dataFormat = DataFormat.Catima
+ ),
+ ImportOption(
+ title = "Fidme",
+ message = "Import from Fidme",
+ dataFormat = DataFormat.Fidme,
+ isBeta = true
+ ),
+ ImportOption(
+ title = "Loyalty Card Keychain",
+ message = "Import from Loyalty Card Keychain",
+ dataFormat = DataFormat.Catima
+ ),
+ ImportOption(
+ title = "Voucher Vault",
+ message = "Import from Voucher Vault",
+ dataFormat = DataFormat.VoucherVault
+ )
+ )
+
+ ImportExportScreen(
+ onBackPressedDispatcher = null,
+ importOptions = sampleImportOptions,
+ dialogState = ImportExportDialogState.None,
+ onDialogStateChange = {},
+ onExportWithPassword = {},
+ onImportSelected = {},
+ onImportWithPassword = { _, _ -> },
+ onShareExport = {}
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ImportTypeSelectionDialogPreview() {
+ val sampleImportOptions = listOf(
+ ImportOption(
+ title = "Catima",
+ message = "Import from Catima backup",
+ dataFormat = DataFormat.Catima
+ ),
+ ImportOption(
+ title = "Fidme",
+ message = "Import from Fidme",
+ dataFormat = DataFormat.Fidme,
+ isBeta = true
+ )
+ )
+
+ ImportTypeSelectionDialog(
+ importOptions = sampleImportOptions,
+ onDismiss = {},
+ onSelect = {}
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ExportPasswordDialogPreview() {
+ ExportPasswordDialog(
+ onDismiss = {},
+ onConfirm = {}
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/import_export_activity.xml b/app/src/main/res/layout/import_export_activity.xml
deleted file mode 100644
index e24a496607..0000000000
--- a/app/src/main/res/layout/import_export_activity.xml
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/test/java/protect/card_locker/ImportExportActivityTest.kt b/app/src/test/java/protect/card_locker/ImportExportActivityTest.kt
index ecd5cc240b..8cd9196fd8 100644
--- a/app/src/test/java/protect/card_locker/ImportExportActivityTest.kt
+++ b/app/src/test/java/protect/card_locker/ImportExportActivityTest.kt
@@ -1,25 +1,34 @@
package protect.card_locker
-import android.app.Activity
+import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo
import android.content.pm.ResolveInfo
-import android.view.View
-import org.junit.Assert.assertEquals
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
-import org.robolectric.Shadows.shadowOf
+import org.robolectric.Shadows
+import protect.card_locker.compose.theme.CatimaTheme
+import protect.card_locker.importexport.DataFormat
@RunWith(RobolectricTestRunner::class)
class ImportExportActivityTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
private fun registerIntentHandler(handler: String) {
// Add something that will 'handle' the given intent type
- val packageManager = RuntimeEnvironment.application.packageManager
+ val packageManager = RuntimeEnvironment.getApplication().packageManager
val info = ResolveInfo().apply {
isDefault = true
@@ -39,39 +48,129 @@ class ImportExportActivityTest {
intent.type = "*/*"
}
- shadowOf(packageManager).addResolveInfoForIntent(intent, info)
- }
-
- private fun checkVisibility(
- activity: Activity,
- state: Int,
- divider: Int,
- title: Int,
- message: Int,
- button: Int
- ) {
- val dividerView = activity.findViewById(divider)
- val titleView = activity.findViewById(title)
- val messageView = activity.findViewById(message)
- val buttonView = activity.findViewById(button)
-
- assertEquals(state, dividerView.visibility)
- assertEquals(state, titleView.visibility)
- assertEquals(state, messageView.visibility)
- assertEquals(state, buttonView.visibility)
+ Shadows.shadowOf(packageManager).addResolveInfoForIntent(intent, info)
}
@Test
- fun testAllOptionsAvailable() {
+ fun testImportExportScreenDisplaysAllOptions() {
registerIntentHandler(Intent.ACTION_PICK)
registerIntentHandler(Intent.ACTION_GET_CONTENT)
- val activity = Robolectric.setupActivity(ImportExportActivity::class.java)
+ val context = ApplicationProvider.getApplicationContext()
+ val importOptions = listOf(
+ ImportOption(
+ title = context.getString(R.string.importCatima),
+ message = context.getString(R.string.importCatimaMessage),
+ dataFormat = DataFormat.Catima
+ ),
+ ImportOption(
+ title = "Fidme",
+ message = context.getString(R.string.importFidmeMessage),
+ dataFormat = DataFormat.Fidme,
+ isBeta = true
+ )
+ )
+
+ composeTestRule.setContent {
+ CatimaTheme {
+ ImportExportScreen(
+ onBackPressedDispatcher = null,
+ importOptions = importOptions,
+ dialogState = ImportExportDialogState.None,
+ onDialogStateChange = {},
+ onExportWithPassword = {},
+ onImportSelected = {},
+ onImportWithPassword = { _, _ -> },
+ onShareExport = {}
+ )
+ }
+ }
- checkVisibility(
- activity, View.VISIBLE, R.id.dividerImportFilesystem,
- R.id.importOptionFilesystemTitle, R.id.importOptionFilesystemExplanation,
- R.id.importOptionFilesystemButton
+ // Verify export section is displayed (exportName appears as title and button text)
+ composeTestRule
+ .onAllNodesWithText(context.getString(R.string.exportName))
+ .assertCountEquals(2)
+
+ // Verify import section is displayed
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.importOptionFilesystemTitle))
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.importOptionFilesystemButton))
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun testImportTypeSelectionDialogDisplaysOptions() {
+ val context = ApplicationProvider.getApplicationContext()
+ val importOptions = listOf(
+ ImportOption(
+ title = "Catima",
+ message = "Import from Catima",
+ dataFormat = DataFormat.Catima
+ ),
+ ImportOption(
+ title = "Fidme",
+ message = "Import from Fidme",
+ dataFormat = DataFormat.Fidme,
+ isBeta = true
+ )
)
+
+ composeTestRule.setContent {
+ CatimaTheme {
+ ImportExportScreen(
+ onBackPressedDispatcher = null,
+ importOptions = importOptions,
+ dialogState = ImportExportDialogState.ImportTypeSelection,
+ onDialogStateChange = {},
+ onExportWithPassword = {},
+ onImportSelected = {},
+ onImportWithPassword = { _, _ -> },
+ onShareExport = {}
+ )
+ }
+ }
+
+ // Verify import type selection dialog is displayed
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.chooseImportType))
+ .assertIsDisplayed()
+
+ // Verify options are shown
+ composeTestRule
+ .onNodeWithText("Catima")
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText("Fidme (BETA)")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun testExportPasswordDialogDisplayed() {
+ val context = ApplicationProvider.getApplicationContext()
+ val importOptions = emptyList()
+
+ composeTestRule.setContent {
+ CatimaTheme {
+ ImportExportScreen(
+ onBackPressedDispatcher = null,
+ importOptions = importOptions,
+ dialogState = ImportExportDialogState.ExportPassword,
+ onDialogStateChange = {},
+ onExportWithPassword = {},
+ onImportSelected = {},
+ onImportWithPassword = { _, _ -> },
+ onShareExport = {}
+ )
+ }
+ }
+
+ // Verify export password dialog is displayed
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.exportPassword))
+ .assertIsDisplayed()
}
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 79736cba9d..0545fe12b4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,6 +28,7 @@ androidx-compose-foundation-foundation = { group = "androidx.compose.foundation"
androidx-compose-material3-material3 = { group = "androidx.compose.material3", name = "material3"}
androidx-compose-material-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version = "1.7.8" }
androidx-compose-ui-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android" }
+androidx-compose-ui-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }