diff --git a/app/build.gradle b/app/build.gradle index 0da694d..4274468 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,9 +6,9 @@ android { applicationId "com.codedead.deadhash" minSdk 30 targetSdk 37 - versionName '1.8.4' + versionName '1.8.5' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 14 + versionCode 16 } buildTypes { release { diff --git a/app/src/main/java/com/codedead/deadhash/gui/MainActivity.java b/app/src/main/java/com/codedead/deadhash/gui/MainActivity.java index 3238ae2..5609419 100644 --- a/app/src/main/java/com/codedead/deadhash/gui/MainActivity.java +++ b/app/src/main/java/com/codedead/deadhash/gui/MainActivity.java @@ -28,6 +28,7 @@ import com.codedead.deadhash.domain.objects.hashgenerator.TextHashGenerator; import com.codedead.deadhash.domain.objects.settings.SettingsContainer; import com.codedead.deadhash.domain.utils.IntentUtils; +import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.navigation.NavigationView; import androidx.core.view.GravityCompat; @@ -56,6 +57,11 @@ import com.codedead.deadhash.domain.objects.hashgenerator.HashData; import com.codedead.deadhash.domain.utils.LocaleHelper; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Random; @@ -93,6 +99,14 @@ public class MainActivity extends AppCompatActivity implements NavigationView.On private Uri fileUri; private ActivityResultLauncher activityResultLauncher; + private ActivityResultLauncher fileTxtExportLauncher; + private ActivityResultLauncher fileCsvExportLauncher; + private ActivityResultLauncher textTxtExportLauncher; + private ActivityResultLauncher textCsvExportLauncher; + + private FloatingActionButton fabExportFile; + private FloatingActionButton fabExportText; + @Override protected void onCreate(final Bundle savedInstanceState) { settingsContainer.loadSettings(getApplicationContext()); @@ -145,6 +159,7 @@ protected void onCreate(final Bundle savedInstanceState) { loadAboutContent(); loadAlertContent(); + registerExportLaunchers(); this.activityResultLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), @@ -160,6 +175,7 @@ protected void onCreate(final Bundle savedInstanceState) { fileDataArrayList.clear(); mAdapterFile.notifyDataSetChanged(); + updateExportVisibility(); } } } else { @@ -206,10 +222,9 @@ private void handleSendFile(final Intent intent) { fileDataArrayList.clear(); mAdapterFile.notifyDataSetChanged(); + updateExportVisibility(); } } - } else { - Toast.makeText(getApplicationContext(), R.string.error_open_file, Toast.LENGTH_SHORT).show(); } } @@ -336,6 +351,9 @@ private void loadFileHashContent(final Bundle savedInstance) { final Button btnGenerate = findViewById(R.id.ButtonGenerateFile); edtFileCompare = findViewById(R.id.Edit_FileCompare); + fabExportFile = findViewById(R.id.FabExportFile); + fabExportFile.setOnClickListener(v -> showExportDialog(true)); + if (savedInstance != null) { if (savedInstance.containsKey("FILE_PATH")) { edtFilePath.setText(savedInstance.getString("FILE_PATH")); @@ -353,6 +371,7 @@ private void loadFileHashContent(final Bundle savedInstance) { } mRecyclerViewFile.setAdapter(mAdapterFile); + updateExportVisibility(); btnOpenFile.setOnClickListener(this::onClickSelectFile); edtFilePath.setOnClickListener(this::onClickSelectFile); @@ -374,6 +393,7 @@ private void loadFileHashContent(final Bundle savedInstance) { fileDataArrayList.clear(); mAdapterFile.notifyDataSetChanged(); + updateExportVisibility(); String compare = ""; if (edtFileCompare.getText() != null) { @@ -392,6 +412,7 @@ private void loadFileHashContent(final Bundle savedInstance) { fileDataArrayList.add(d); mAdapterFile.notifyItemInserted(fileDataArrayList.size()); } + updateExportVisibility(); })); pgbFile.setVisibility(View.VISIBLE); @@ -414,6 +435,9 @@ private void loadTextHashContent(final Bundle savedInstance) { final Button btnGenerate = findViewById(R.id.ButtonGenerateText); edtTextCompare = findViewById(R.id.Edit_TextCompare); + fabExportText = findViewById(R.id.FabExportText); + fabExportText.setOnClickListener(v -> showExportDialog(false)); + if (savedInstance != null) { if (savedInstance.containsKey("TEXT_DATA")) { edtTextData.setText(savedInstance.getString("TEXT_DATA")); @@ -431,6 +455,7 @@ private void loadTextHashContent(final Bundle savedInstance) { } mRecyclerViewText.setAdapter(mAdapterText); + updateExportVisibility(); btnGenerate.setOnClickListener(v -> { if (textLoading) return; @@ -441,8 +466,9 @@ private void loadTextHashContent(final Bundle savedInstance) { mRecyclerViewText.setAdapter(mAdapterText); - fileDataArrayList.clear(); + textDataArrayList.clear(); mAdapterText.notifyDataSetChanged(); + updateExportVisibility(); if (edtTextData.getText() == null || edtTextData.getText().toString().isEmpty()) { Toast.makeText(MainActivity.this, R.string.toast_error_notext, Toast.LENGTH_SHORT).show(); @@ -468,12 +494,181 @@ private void loadTextHashContent(final Bundle savedInstance) { textDataArrayList.add(d); mAdapterText.notifyItemInserted(textDataArrayList.size()); } + updateExportVisibility(); })); pgbText.setVisibility(View.VISIBLE); }); } + /** + * Register the ActivityResultLaunchers that are used to export hashes + */ + private void registerExportLaunchers() { + fileTxtExportLauncher = registerForActivityResult( + new ActivityResultContracts.CreateDocument("text/plain"), + uri -> exportHashes(uri, fileDataArrayList, false)); + + fileCsvExportLauncher = registerForActivityResult( + new ActivityResultContracts.CreateDocument("text/csv"), + uri -> exportHashes(uri, fileDataArrayList, true)); + + textTxtExportLauncher = registerForActivityResult( + new ActivityResultContracts.CreateDocument("text/plain"), + uri -> exportHashes(uri, textDataArrayList, false)); + + textCsvExportLauncher = registerForActivityResult( + new ActivityResultContracts.CreateDocument("text/csv"), + uri -> exportHashes(uri, textDataArrayList, true)); + } + + /** + * Show a dialog that lets the user choose the format to export the hashes in + * + * @param fileTab True if the file hashes should be exported, false for the text hashes + */ + private void showExportDialog(final boolean fileTab) { + final List hashDataList = fileTab ? fileDataArrayList : textDataArrayList; + if (hashDataList.isEmpty()) return; + + final String[] formats = { + getString(R.string.export_txt), + getString(R.string.export_csv) + }; + + new AlertDialog.Builder(this) + .setTitle(R.string.export_dialog_title) + .setItems(formats, (dialog, which) -> { + if (which == 0) { + (fileTab ? fileTxtExportLauncher : textTxtExportLauncher) + .launch(getExportFileName(fileTab, "txt")); + } else { + (fileTab ? fileCsvExportLauncher : textCsvExportLauncher) + .launch(getExportFileName(fileTab, "csv")); + } + }) + .show(); + } + + /** + * Show or hide the export buttons based on whether hashes are available + */ + private void updateExportVisibility() { + if (fabExportFile != null) { + fabExportFile.setVisibility(fileDataArrayList.isEmpty() ? View.GONE : View.VISIBLE); + } + if (fabExportText != null) { + fabExportText.setVisibility(textDataArrayList.isEmpty() ? View.GONE : View.VISIBLE); + } + } + + /** + * Get a suggested file name for an export + * + * @param fileTab True if the file hashes are exported, false for the text hashes + * @param extension The file extension, without a dot + * @return The suggested file name + */ + private String getExportFileName(final boolean fileTab, final String extension) { + String source = "text"; + if (fileTab) { + source = edtFilePath.getText().toString().trim(); + if (source.isEmpty()) { + source = "file"; + } else { + final int dotIndex = source.lastIndexOf('.'); + if (dotIndex > 0) { + source = source.substring(0, dotIndex); + } + } + } + + source = source.replaceAll("[\\\\/:*?\"<>|]", "_"); + if (source.length() > 60) { + source = source.substring(0, 60); + } + + return "DeadHash_" + source + "_hashes." + extension; + } + + /** + * Write hashes to the given Uri + * + * @param uri The Uri to write to, or null when the user cancelled the file picker + * @param hashDataList The List of HashData objects that should be exported + * @param csv True to export as CSV, false to export as plain text + */ + private void exportHashes(final Uri uri, final List hashDataList, final boolean csv) { + if (uri == null || hashDataList == null || hashDataList.isEmpty()) return; + + final String content = csv ? buildCsvContent(hashDataList) : buildTxtContent(hashDataList); + + CompletableFuture.runAsync(() -> { + try (final OutputStream outputStream = getContentResolver().openOutputStream(uri, "w")) { + if (outputStream == null) { + throw new IOException("Unable to open the selected file"); + } + + try (final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { + writer.write(content); + } + } catch (final IOException ex) { + runOnUiThread(() -> Toast.makeText(getApplicationContext(), R.string.export_error, Toast.LENGTH_SHORT).show()); + return; + } + + runOnUiThread(() -> Toast.makeText(getApplicationContext(), R.string.export_success, Toast.LENGTH_SHORT).show()); + }); + } + + /** + * Build the plain text representation of a List of HashData objects + * + * @param hashDataList The List of HashData objects + * @return The plain text representation + */ + private static String buildTxtContent(final List hashDataList) { + final StringBuilder builder = new StringBuilder(); + for (final HashData hashData : hashDataList) { + builder.append(hashData.getHashName()) + .append(": ") + .append(hashData.getHashData()) + .append('\n'); + } + return builder.toString(); + } + + /** + * Build the CSV representation of a List of HashData objects + * + * @param hashDataList The List of HashData objects + * @return The CSV representation + */ + private static String buildCsvContent(final List hashDataList) { + final StringBuilder builder = new StringBuilder("Algorithm,Hash\r\n"); + for (final HashData hashData : hashDataList) { + builder.append(escapeCsv(hashData.getHashName())) + .append(',') + .append(escapeCsv(hashData.getHashData())) + .append("\r\n"); + } + return builder.toString(); + } + + /** + * Escape a value for use in a CSV file + * + * @param value The value to escape + * @return The escaped value + */ + private static String escapeCsv(final String value) { + if (value == null || value.isEmpty()) return ""; + if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } + /** * Get the List of HashAlgorithm enums that can be used, according to user preferences * diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 0000000..86374f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/content_file.xml b/app/src/main/res/layout/content_file.xml index f8a43af..f10d4ce 100644 --- a/app/src/main/res/layout/content_file.xml +++ b/app/src/main/res/layout/content_file.xml @@ -97,11 +97,29 @@ android:layout_height="wrap_content" android:visibility="gone" /> - - + + + + + diff --git a/app/src/main/res/layout/content_text.xml b/app/src/main/res/layout/content_text.xml index 7514567..c12c700 100644 --- a/app/src/main/res/layout/content_text.xml +++ b/app/src/main/res/layout/content_text.xml @@ -73,11 +73,29 @@ android:layout_height="wrap_content" android:visibility="gone" /> - - + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5ea8822..c1d99e2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -47,4 +47,10 @@ Systemfehler Für die Nutzung von DeadHash sind Speicherberechtigungen erforderlich + Hashes exportieren + Hashes exportieren + TXT-Datei + CSV-Datei + Hashes erfolgreich exportiert! + Hashes konnten nicht exportiert werden! diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1527f09..55ae592 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -47,4 +47,10 @@ Seguir sistema El permiso de almacenamiento es necesario para usar DeadHash + Exportar hashes + Exportar hashes + Archivo TXT + Archivo CSV + ¡Hashes exportados correctamente! + ¡No se pudieron exportar los hashes! diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 74d7a7e..9e540ae 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -47,4 +47,10 @@ Défaillance du système Des autorisations de stockage sont requises pour utiliser DeadHash + Exporter les hachages + Exporter les hachages + Fichier TXT + Fichier CSV + Hachages exportés avec succès ! + Impossible d\'exporter les hachages ! diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ff0af72..2545bbe 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -47,4 +47,10 @@ Default del sistema Per utilizzare DeadHash sono necessarie autorizzazioni di archiviazione + Esporta hash + Esporta hash + File TXT + File CSV + Hash esportati con successo! + Impossibile esportare gli hash! diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 5cfea1d..0f9dd52 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -47,4 +47,10 @@ Жүйедегідей DeadHash-ті қолдану үшін құрылғы жадын оқуға рұқсат беруіңіз керек. + Хэштерді экспорттау + Хэштерді экспорттау + TXT файлы + CSV файлы + Хэштер сәтті экспортталды! + Хэштерді экспорттау мүмкін болмады! diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 372fab0..2caaae0 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -47,4 +47,10 @@ Systeemstandaard Om DeadHash te kunnen gebruiken zijn opslagrechten vereist + Hashes exporteren + Hashes exporteren + TXT-bestand + CSV-bestand + Hashes succesvol geëxporteerd! + Kan hashes niet exporteren! diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6d60057..76a6273 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -47,4 +47,10 @@ Sistema padrão Permissões de armazenamento são necessárias para usar DeadHash + Exportar hashes + Exportar hashes + Arquivo TXT + Arquivo CSV + Hashes exportados com sucesso! + Não foi possível exportar os hashes! diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7368e1c..9327f1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -47,4 +47,10 @@ Системная Для использования DeadHash необходимы разрешения на хранение + Экспортировать хеши + Экспортировать хеши + Файл TXT + Файл CSV + Хеши успешно экспортированы! + Не удалось экспортировать хеши! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7426559..925ce93 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,4 +59,10 @@ System default Storage permissions are required in order to use DeadHash + Export hashes + Export hashes + TXT file + CSV file + Hashes exported successfully! + Unable to export hashes! diff --git a/build.gradle b/build.gradle index 7b1977b..0c4b12d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { - agp_version = '9.4.1' + agp_version = '9.5.0' } repositories { mavenCentral() diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9b7cb4a..af0744f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ +#Sat Oct 31 01:49:18 CET 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500