diff --git a/app/build.gradle b/app/build.gradle index 5924a4c..cd4ef40 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,8 +7,8 @@ android { minSdkVersion 19 targetSdkVersion 28 multiDexEnabled true - versionCode 153200308 - versionName "1.5.3" + versionCode 154200316 + versionName "1.5.4" externalNativeBuild { cmake { cppFlags "-std=c++11 -pthread -frtti -fexceptions" diff --git a/app/src/main/java/it/pgp/xfiles/dialogs/ChecksumActivity.java b/app/src/main/java/it/pgp/xfiles/dialogs/ChecksumActivity.java index b7fc792..f875b32 100755 --- a/app/src/main/java/it/pgp/xfiles/dialogs/ChecksumActivity.java +++ b/app/src/main/java/it/pgp/xfiles/dialogs/ChecksumActivity.java @@ -21,10 +21,19 @@ import android.widget.TableRow; import android.widget.Toast; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; import java.util.BitSet; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import it.pgp.xfiles.BrowserItem; @@ -33,6 +42,7 @@ import it.pgp.xfiles.R; import it.pgp.xfiles.adapters.HashAlgorithmsAdapter; import it.pgp.xfiles.roothelperclient.HashRequestCodes; +import it.pgp.xfiles.utils.FileSaveFragment; import it.pgp.xfiles.utils.Misc; import it.pgp.xfiles.utils.pathcontent.BasePathContent; @@ -40,7 +50,7 @@ * Created by pgp on 21/02/18 */ -public class ChecksumActivity extends EffectActivity { +public class ChecksumActivity extends EffectActivity implements FileSaveFragment.Callbacks { private ChecksumTask checksumTask; @@ -49,7 +59,7 @@ public class ChecksumActivity extends EffectActivity { private HashAlgorithmsAdapter adapter; private TableLayout standardResultsLayout; - private Button computeChecksumsButton; + private Button computeChecksumsButton, exportChecksumsCSVButton, exportChecksumsJSONButton; private ClipboardManager clipboard; @@ -60,6 +70,9 @@ public class ChecksumActivity extends EffectActivity { private CheckBox dirHashIgnoreUnixHiddenFiles; private CheckBox dirHashIgnoreEmptyDirs; + private List> hashMatrix = new ArrayList<>(); + private Set selectedHashAlgorithms; // selected from the last run (not necessarily completed) + public void showLegend(View unused) { Dialog hashLegendDialog = new Dialog(this){ @Override @@ -132,10 +145,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { hashSelectorView.setAdapter(adapter); computeChecksumsButton = findViewById(R.id.computeChecksumsButton); - computeChecksumsButton.setOnClickListener(v -> { - checksumTask = new ChecksumTask(); - checksumTask.execute((Void[])null); - }); + exportChecksumsCSVButton = findViewById(R.id.exportChecksumsCSVButton); + exportChecksumsJSONButton = findViewById(R.id.exportChecksumsJSONButton); // check SHA-256 by default getViewByPosition(HashRequestCodes.sha256.ordinal(),hashSelectorView).findViewById(R.id.checksum_checkbox).performClick(); @@ -176,22 +187,62 @@ public boolean onContextItemSelected(MenuItem item) { return true; } - private class HashTextView extends android.support.v7.widget.AppCompatTextView { + // FIXME duplicated from CompressActivity, refactor + @Override + public boolean onCanSave(String absolutePath, String fileName) { + + // Catch the really stupid case. + if (absolutePath == null || absolutePath.length() ==0 || + fileName == null || fileName.length() == 0) { + Toast.makeText(this,R.string.alert_supply_filename, Toast.LENGTH_SHORT).show(); + return false; + } + + // Do we have a filename if the extension is thrown away? + String copyName = FileSaveFragment.NameNoExtension(fileName); + if (copyName == null || copyName.length() == 0 ) { + Toast.makeText(this,R.string.alert_supply_filename, Toast.LENGTH_SHORT).show(); + return false; + } + + // No overwrite of an existing file. + if (FileSaveFragment.FileExists(absolutePath, fileName)) { + Toast.makeText(this,R.string.alert_file_exists, Toast.LENGTH_SHORT).show(); + return false; + } + + return true; + } + + @Override + public void onConfirmSave(String absolutePath, String fileName) { + if (onCanSave(absolutePath,fileName)) { + // export CSV only for now + if(fileName.endsWith("csv")) + exportToCSV(absolutePath,fileName); + else exportToJSON(absolutePath,fileName); + } + } + + private static class HashTextView extends android.support.v7.widget.AppCompatTextView { - private CharSequence header; - private CharSequence content; + private HashRequestCodes code; + private CharSequence filename; + private CharSequence content; // the digest as hex string public HashTextView(Context context, CharSequence content, - CharSequence header) { + CharSequence filename, + HashRequestCodes code) { super(context); this.content = content; - this.header = header; - setText(header+": "+content); + this.code = code; + this.filename = filename; + setText(getHeader()+": "+content); } public CharSequence getHeader() { - return header; + return filename+", "+code.getLabel(); } public CharSequence getContent() { @@ -209,8 +260,12 @@ protected void onDestroy() { } private class ChecksumTask extends AsyncTask { - final Set selectedHashAlgorithms = adapter.getSelectedItems(); - final boolean someSelection = selectedHashAlgorithms.size()>0; + final boolean someSelection; + + { + selectedHashAlgorithms = adapter.getSelectedItems(); + someSelection = selectedHashAlgorithms.size()>0; + } @Override protected void onPreExecute() { @@ -235,14 +290,17 @@ private byte[] computeHashForLocalOrXREPaths(BasePathContent path, HashRequestCo @Override protected Void doInBackground(Void... unused) { + hashMatrix = new ArrayList<>(); final int[][] tvBackground = new int[][]{{Color.DKGRAY,Color.BLUE},{Color.RED,Color.GRAY}}; // TODO restructure hash request in RH protocol, allow multiple hashes per multiple files // then, make the RH task cancellable via another sub-request type (like in FindUpdatesThread) - int i = 0,j=0; + int i=0, j=0; try { if(!someSelection) return null; if (files.size()==1) { // algorithms on rows, 1 column (only 1 file) + List lhtv = new ArrayList<>(); // for csv/json export, keep the format coherent (1 row, multiple columns) + hashMatrix.add(lhtv); BasePathContent file = parentDir.concat(files.get(0).getFilename()); for (HashRequestCodes s : selectedHashAlgorithms) { if (checksumInterrupted) { @@ -258,15 +316,18 @@ protected Void doInBackground(Void... unused) { HashTextView t = new HashTextView( ChecksumActivity.this, Misc.toHexString(digest), - file.getName()+", "+s.getLabel() ); + file.getName(), s); t.setBackgroundColor(tvBackground[i][j++%2]); registerForContextMenu(t); + lhtv.add(t); runOnUiThread(()->tr.addView(t)); } } else for (BrowserItem file : files) { // files on rows TableRow tr = new TableRow(ChecksumActivity.this); + List lhtv = new ArrayList<>(); + hashMatrix.add(lhtv); runOnUiThread(()->standardResultsLayout.addView(tr)); for (HashRequestCodes s : selectedHashAlgorithms) { @@ -281,10 +342,11 @@ else for (BrowserItem file : files) { // files on rows HashTextView t = new HashTextView( ChecksumActivity.this, Misc.toHexString(digest), - file.getFilename()+", "+s.getLabel() ); + file.getFilename(), s); t.setBackgroundColor(tvBackground[i][j++%2]); registerForContextMenu(t); + lhtv.add(t); runOnUiThread(()->tr.addView(t)); } @@ -306,4 +368,78 @@ protected void onPostExecute(Void unused) { computeChecksumsButton.setEnabled(true); } } + + public void ok(View unused) { + checksumTask = new ChecksumTask(); + checksumTask.execute((Void[])null); + } + + public void openExportOutputSelector(View v) { + if(hashMatrix.isEmpty()) { + Toast.makeText(this, "No checksums to export", Toast.LENGTH_SHORT).show(); + return; + } + + String fragTag = getResources().getString(R.string.tag_fragment_FileSave); + + String ext = v.getId()==R.id.exportChecksumsCSVButton?"csv":"json"; + + // Get an instance supplying a default extension, captions and + // icon appropriate to the calling application/activity. + FileSaveFragment fsf = FileSaveFragment.newInstance(ext, + R.string.alert_OK, + R.string.alert_cancel, + R.string.app_name, + R.string.edit_hint, + R.string.checksums_export_filename_header, + R.drawable.xfiles_file_icon); + fsf.show(getFragmentManager(), fragTag); + } + + public void exportToCSV(String absolutePath, String fileName) { + File csvFile = new File(absolutePath +"/"+fileName+".csv"); + try(OutputStream o = new BufferedOutputStream(new FileOutputStream(csvFile))) { + // create header + Misc.csvWriteRow(o,new ArrayList(){{ + add("filename"); + for (HashRequestCodes code : selectedHashAlgorithms) add(code.getLabel()); + }}); + + for(List lhtv : hashMatrix) { + Misc.csvWriteRow(o,new ArrayList(){{ + // by construction, lhtv contains hashes for the same filename + add(""+lhtv.get(0).filename); + for(HashTextView htv: lhtv) add(""+htv.content); + }}); + } + + MainActivity.showToastOnUI("Checksums export complete"); + } catch (IOException e) { + e.printStackTrace(); + MainActivity.showToastOnUI("Error exporting checksums to CSV"); + } + } + + public void exportToJSON(String absolutePath, String fileName) { + List l = new ArrayList(); + for(List lhtv : hashMatrix) { + Map m = new HashMap(); + Map n = new HashMap(); + for(HashTextView htv : lhtv) { + n.put(htv.code.getLabel(),""+htv.content); + } + m.put("filename",""+lhtv.get(0).filename); + m.put("checksums",n); + l.add(m); + } + ObjectMapper mapper = new ObjectMapper(); + try { + mapper.writeValue(new File(absolutePath+"/"+fileName+".json"),l); + MainActivity.showToastOnUI("Checksums export complete"); + } + catch (IOException e) { + e.printStackTrace(); + MainActivity.showToastOnUI("Error exporting checksums to JSON"); + } + } } diff --git a/app/src/main/java/it/pgp/xfiles/sftpclient/SFTPProviderUsingPathContent.java b/app/src/main/java/it/pgp/xfiles/sftpclient/SFTPProviderUsingPathContent.java index cdac5ea..d6709a5 100755 --- a/app/src/main/java/it/pgp/xfiles/sftpclient/SFTPProviderUsingPathContent.java +++ b/app/src/main/java/it/pgp/xfiles/sftpclient/SFTPProviderUsingPathContent.java @@ -177,24 +177,6 @@ public void updateHostKey(String adjustedHostname, PublicKey key) throws IOExcep /************************************************************/ - // replaced by RemotePathContent -// private class GenericRemotePath { -// AuthData authData; // parsed from sftp://user@domain:port -// String remotePath; // format: /remote/path -// -// GenericRemotePath(String path) throws RuntimeException { -// if (path.startsWith("sftp://")) { -// String s = path.substring(7); // user@domain:port/remote/path -// String authString = s.split("/")[0]; // user@domain:port -// authData = new AuthData(authString); -// int idx = s.indexOf('/'); -// // remote path beginning immediately after port number, and beginning with '/' -// remotePath = s.substring(idx); -// } -// else throw new RuntimeException(""); -// } -// } - public SFTPProviderUsingPathContent(final MainActivity mainActivity) { this.mainActivity = mainActivity; sshIdsDir = new File(mainActivity.getApplicationContext().getFilesDir(),sshIdsDirName); diff --git a/app/src/main/java/it/pgp/xfiles/utils/FileSaveFragment.java b/app/src/main/java/it/pgp/xfiles/utils/FileSaveFragment.java index 2ae88a9..f529deb 100644 --- a/app/src/main/java/it/pgp/xfiles/utils/FileSaveFragment.java +++ b/app/src/main/java/it/pgp/xfiles/utils/FileSaveFragment.java @@ -31,11 +31,8 @@ import it.pgp.xfiles.utils.dircontent.GenericDirWithContent; import it.pgp.xfiles.utils.pathcontent.LocalPathContent; -/** Allow user to select destination directory and to enter filename. - * - * */ -public class FileSaveFragment extends DialogFragment - implements OnItemClickListener { +/** Allow user to select destination directory and to enter filename */ +public class FileSaveFragment extends DialogFragment implements OnItemClickListener { /* * Use the unicode "back" triangle to indicate there is a parent diff --git a/app/src/main/java/it/pgp/xfiles/utils/HashView.java b/app/src/main/java/it/pgp/xfiles/utils/HashView.java index 127668a..fa5c4fa 100644 --- a/app/src/main/java/it/pgp/xfiles/utils/HashView.java +++ b/app/src/main/java/it/pgp/xfiles/utils/HashView.java @@ -129,7 +129,7 @@ void draw2(Canvas canvas, PaintRect[][] M) { canvas.drawRect(anAM.rect, anAM.rPaint); } - private class PaintRect { + private static class PaintRect { Rect rect; Paint rPaint; diff --git a/app/src/main/java/it/pgp/xfiles/utils/Misc.java b/app/src/main/java/it/pgp/xfiles/utils/Misc.java index 6068d16..34657bf 100755 --- a/app/src/main/java/it/pgp/xfiles/utils/Misc.java +++ b/app/src/main/java/it/pgp/xfiles/utils/Misc.java @@ -9,6 +9,7 @@ import java.io.IOException; import java.io.OutputStream; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -197,4 +198,33 @@ public static List splitByteArrayOverByteAndEncode(byte[] b, byte target return outs; } + // CSV escape (for checksum export) + public static final String[] csvToBeEscaped = {"\"",",","\n"}; + + public static final byte[] crlf = new byte[]{'\r','\n'}; + + /** + * - if filename contains any between { \n " , }, use enclosing quotes + * - if filename contains " escape it as "" + */ + public static String escapeForCSV(String filename) { + boolean enclosingQuotes = false; + for(String x : csvToBeEscaped) + if(filename.contains(x)) { + enclosingQuotes = true; + break; + } + + return enclosingQuotes?"\""+filename.replace("\"","\"\"")+"\"":filename; + } + + public static void csvWriteRow(OutputStream o, List row) throws IOException { + for(int i=0;i(); } - public class ValueAsKeyException extends RuntimeException { + public static class ValueAsKeyException extends RuntimeException { public ValueAsKeyException() { super("Tried to access value as key!"); } diff --git a/app/src/main/java/it/pgp/xfiles/utils/popupwindow/MovablePopupWindowWithAutoClose.java b/app/src/main/java/it/pgp/xfiles/utils/popupwindow/MovablePopupWindowWithAutoClose.java index 4afcb77..5673e0b 100644 --- a/app/src/main/java/it/pgp/xfiles/utils/popupwindow/MovablePopupWindowWithAutoClose.java +++ b/app/src/main/java/it/pgp/xfiles/utils/popupwindow/MovablePopupWindowWithAutoClose.java @@ -56,7 +56,7 @@ public void dynamicDismiss() { } - private class SingleTapConfirm extends GestureDetector.SimpleOnGestureListener { + private static class SingleTapConfirm extends GestureDetector.SimpleOnGestureListener { @Override public boolean onSingleTapUp(MotionEvent event) { return true; diff --git a/app/src/main/res/layout/checksum_base_dialog.xml b/app/src/main/res/layout/checksum_base_dialog.xml index 0d46175..3698371 100644 --- a/app/src/main/res/layout/checksum_base_dialog.xml +++ b/app/src/main/res/layout/checksum_base_dialog.xml @@ -109,10 +109,29 @@ android:layout_height="wrap_content">