diff --git a/FAQ.md b/FAQ.md
index 8acc30a579..8b48f39a4d 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -4858,21 +4858,15 @@ else you can double tap or long press the marked text to show suggestions.
**(181) How do I use VirusTotal?**
-[VirusTotal](https://www.virustotal.com/) integration needs to be enabled in the miscellaneous settings.
-This will show a *scan* icon button for each attachment.
+VirusTotal integration needs to be enabled in the miscellaneous settings and an API key needs to be entered.
+To get an API key, you'll need to sign up via the [VirusTotal website](https://www.virustotal.com/).
-Without entering an API key, tapping on the scan button will calculate the SHA-256 hash of the attached file and open the corresponding file report on the VirusTotal website.
-If the file is not known by VirusTotal ("*Item not found*"), it is probably okay, unless it contains a new virus not being detected by virus scanners yet.
+When integration is enabled and an API key is available, a *scan* icon button will be shown for each attachment.
+Tapping on the scan button will calculate the SHA-256 hash of the attachment and lookup the file via the VirusTotal API.
+If the file is known by VirusTotal, the number of virus scanners considering the file as malicious will be shown.
+If the file isn't known by VirusTotal, an upload button will be shown to upload the file for analysis by VirusTotal.
-With entering an API key, there will be a dialog showing the number of virus scanners detecting the file.
-Tapping on the info button will open the corresponding file report on the VirusTotal website.
-
-To get an API key, you'll need to register on the VirusTotal website.
-You can enter the API key in the miscellaneous settings of the app.
-
-Note that only the hash of a file will be sent and that files won't be uploaded.
-
-This feature was added in version 1.1941 and is available in non Play store versions only.
+This feature was added in version 1.1942 and is available in non Play store versions of the app only.
diff --git a/app/src/extra/java/eu/faircode/email/VirusTotal.java b/app/src/extra/java/eu/faircode/email/VirusTotal.java
index adaafd8f5b..5ff967fc55 100644
--- a/app/src/extra/java/eu/faircode/email/VirusTotal.java
+++ b/app/src/extra/java/eu/faircode/email/VirusTotal.java
@@ -21,8 +21,12 @@ package eu.faircode.email;
import android.content.Context;
import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
import android.util.Pair;
+import androidx.annotation.NonNull;
+
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@@ -37,6 +41,8 @@ import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.URL;
import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.List;
import java.util.concurrent.TimeoutException;
import javax.net.ssl.HttpsURLConnection;
@@ -72,25 +78,17 @@ public class VirusTotal {
JSONObject jclassification = jattributes.optJSONObject("popular_threat_classification");
String label = (jclassification == null ? null : jclassification.getString("suggested_threat_label"));
- int count = 0;
- int malicious = 0;
+ List scanResult = new ArrayList<>();
JSONObject janalysis = jattributes.getJSONObject("last_analysis_results");
JSONArray jnames = janalysis.names();
for (int i = 0; i < jnames.length(); i++) {
String name = jnames.getString(i);
JSONObject jresult = janalysis.getJSONObject(name);
String category = jresult.getString("category");
- //Log.i("VT " + name + "=" + category);
- if (!"type-unsupported".equals(category))
- count++;
- if ("malicious".equals(category))
- malicious++;
+ scanResult.add(new ScanResult(name, category));
}
- Log.i("VT lookup=" + malicious + "/" + count + " label=" + label);
-
- result.putInt("count", count);
- result.putInt("malicious", malicious);
+ result.putParcelableArrayList("scans", (ArrayList extends Parcelable>) scanResult);
result.putString("label", label);
}
@@ -236,4 +234,42 @@ public class VirusTotal {
connection.disconnect();
}
}
+
+ public static class ScanResult implements Parcelable {
+ public String name;
+ public String category;
+
+ ScanResult(String name, String category) {
+ this.name = name;
+ this.category = category;
+ }
+
+ protected ScanResult(Parcel in) {
+ name = in.readString();
+ category = in.readString();
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel parcel, int i) {
+ parcel.writeString(name);
+ parcel.writeString(category);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public ScanResult createFromParcel(Parcel in) {
+ return new ScanResult(in);
+ }
+
+ @Override
+ public ScanResult[] newArray(int size) {
+ return new ScanResult[size];
+ }
+ };
+ }
}
diff --git a/app/src/main/java/eu/faircode/email/AdapterAttachment.java b/app/src/main/java/eu/faircode/email/AdapterAttachment.java
index 2a7556a540..05ef913957 100644
--- a/app/src/main/java/eu/faircode/email/AdapterAttachment.java
+++ b/app/src/main/java/eu/faircode/email/AdapterAttachment.java
@@ -21,10 +21,10 @@ package eu.faircode.email;
import android.app.Dialog;
import android.content.Context;
-import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
+import android.graphics.Typeface;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
@@ -41,7 +41,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
-import androidx.constraintlayout.widget.Group;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
@@ -51,10 +50,12 @@ import androidx.lifecycle.OnLifecycleEvent;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.ListUpdateCallback;
import androidx.recyclerview.widget.RecyclerView;
import java.io.File;
+import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -71,6 +72,7 @@ public class AdapterAttachment extends RecyclerView.Adapter taskLookup = new SimpleTask() {
@Override
protected void onPreExecute(Bundle args) {
+ tvError.setVisibility(View.GONE);
pbWait.setVisibility(View.VISIBLE);
}
@@ -572,23 +589,34 @@ public class AdapterAttachment extends RecyclerView.Adapter scans = result.getParcelableArrayList("scans");
String label = result.getString("label");
- pbAnalysis.setMax(count);
- pbAnalysis.setProgress(malicious);
- tvCount.setText(malicious + "/" + count);
+ int malicious = 0;
+ if (scans != null)
+ for (VirusTotal.ScanResult scan : scans)
+ if ("malicious".equals(scan.category))
+ malicious++;
+
+ NumberFormat NF = NumberFormat.getNumberInstance();
+
+ tvUnknown.setVisibility(scans == null ? View.VISIBLE : View.GONE);
+ tvSummary.setText(getString(R.string.title_vt_summary, NF.format(malicious)));
+ tvSummary.setTextColor(Helper.resolveColor(context,
+ malicious == 0 ? android.R.attr.textColorPrimary : R.attr.colorWarning));
+ tvSummary.setTypeface(malicious == 0 ? Typeface.DEFAULT : Typeface.DEFAULT_BOLD);
+ tvSummary.setVisibility(scans == null ? View.GONE : View.VISIBLE);
tvLabel.setText(label);
- tvLabel.setVisibility(TextUtils.isEmpty(label) ? View.GONE : View.VISIBLE);
- tvUnknown.setVisibility(count == 0 ? View.VISIBLE : View.GONE);
- btnUpload.setVisibility(count == 0 && !TextUtils.isEmpty(apiKey) ? View.VISIBLE : View.GONE);
- grpAnalysis.setVisibility(count == 0 ? View.GONE : View.VISIBLE);
+ tvReport.setVisibility(scans == null ? View.GONE : View.VISIBLE);
+ adapter.set(scans == null ? new ArrayList<>() : scans);
+ rvScan.setVisibility(scans == null ? View.GONE : View.VISIBLE);
+ btnUpload.setVisibility(scans == null && !TextUtils.isEmpty(apiKey) ? View.VISIBLE : View.GONE);
}
@Override
protected void onException(Bundle args, Throwable ex) {
- Log.unexpectedError(getParentFragmentManager(), ex);
+ tvError.setText(Log.formatThrowable(ex, false));
+ tvError.setVisibility(View.VISIBLE);
}
};
@@ -655,6 +683,13 @@ public class AdapterAttachment extends RecyclerView.Adapter.
+
+ Copyright 2018-2022 by Marcel Bokhorst (M66B)
+*/
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.OnLifecycleEvent;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ListUpdateCallback;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+public class AdapterVirusTotal extends RecyclerView.Adapter {
+ private Context context;
+ private LifecycleOwner owner;
+ private LayoutInflater inflater;
+ private int colorWarning;
+ private int textColorSecondary;
+
+ private List items = new ArrayList<>();
+
+ public class ViewHolder extends RecyclerView.ViewHolder {
+ private View view;
+ private TextView tvName;
+ private TextView tvCategory;
+
+ ViewHolder(View itemView) {
+ super(itemView);
+
+ view = itemView.findViewById(R.id.clItem);
+ tvName = itemView.findViewById(R.id.tvName);
+ tvCategory = itemView.findViewById(R.id.tvCategory);
+ }
+
+ private void wire() {
+ }
+
+ private void unwire() {
+ }
+
+ private void bindTo(VirusTotal.ScanResult scan) {
+ boolean malicious = "malicious".equals(scan.category);
+ tvName.setText(scan.name);
+ tvCategory.setText(scan.category);
+ tvCategory.setTextColor(malicious ? colorWarning : textColorSecondary);
+ tvCategory.setTypeface(malicious ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
+ }
+ }
+
+ AdapterVirusTotal(Context context, LifecycleOwner owner) {
+ this.context = context;
+ this.owner = owner;
+ this.inflater = LayoutInflater.from(context);
+ this.colorWarning = Helper.resolveColor(context, R.attr.colorWarning);
+ this.textColorSecondary = Helper.resolveColor(context, android.R.attr.textColorSecondary);
+
+ setHasStableIds(true);
+
+ owner.getLifecycle().addObserver(new LifecycleObserver() {
+ @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ public void onDestroyed() {
+ Log.d(AdapterVirusTotal.this + " parent destroyed");
+ owner.getLifecycle().removeObserver(this);
+ }
+ });
+ }
+
+ public void set(@NonNull List scans) {
+ Log.i("Set scans=" + scans.size());
+
+ DiffUtil.DiffResult diff = DiffUtil.calculateDiff(new DiffCallback(items, scans), false);
+
+ items = scans;
+
+ diff.dispatchUpdatesTo(new ListUpdateCallback() {
+ @Override
+ public void onInserted(int position, int count) {
+ Log.d("Inserted @" + position + " #" + count);
+ }
+
+ @Override
+ public void onRemoved(int position, int count) {
+ Log.d("Removed @" + position + " #" + count);
+ }
+
+ @Override
+ public void onMoved(int fromPosition, int toPosition) {
+ Log.d("Moved " + fromPosition + ">" + toPosition);
+ }
+
+ @Override
+ public void onChanged(int position, int count, Object payload) {
+ Log.d("Changed @" + position + " #" + count);
+ }
+ });
+
+ try {
+ diff.dispatchUpdatesTo(this);
+ } catch (Throwable ex) {
+ Log.e(ex);
+ }
+ }
+
+ private static class DiffCallback extends DiffUtil.Callback {
+ private List prev = new ArrayList<>();
+ private List next = new ArrayList<>();
+
+ DiffCallback(List prev, List next) {
+ this.prev.addAll(prev);
+ this.next.addAll(next);
+ }
+
+ @Override
+ public int getOldListSize() {
+ return prev.size();
+ }
+
+ @Override
+ public int getNewListSize() {
+ return next.size();
+ }
+
+ @Override
+ public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
+ VirusTotal.ScanResult m1 = prev.get(oldItemPosition);
+ VirusTotal.ScanResult m2 = next.get(newItemPosition);
+ return m1.name.equals(m2.name);
+ }
+
+ @Override
+ public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
+ VirusTotal.ScanResult m1 = prev.get(oldItemPosition);
+ VirusTotal.ScanResult m2 = next.get(newItemPosition);
+ return (m1.name.equals(m2.name) &&
+ Objects.equals(m1.category, m2.category));
+ }
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return items.get(position).name.hashCode();
+ }
+
+ @Override
+ public int getItemCount() {
+ return items.size();
+ }
+
+ @Override
+ @NonNull
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return new ViewHolder(inflater.inflate(R.layout.item_virus_total, parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ holder.unwire();
+ VirusTotal.ScanResult scan = items.get(position);
+ holder.bindTo(scan);
+ holder.wire();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/faircode/email/FragmentOptions.java b/app/src/main/java/eu/faircode/email/FragmentOptions.java
index cde9f01538..37994f0a8d 100644
--- a/app/src/main/java/eu/faircode/email/FragmentOptions.java
+++ b/app/src/main/java/eu/faircode/email/FragmentOptions.java
@@ -157,7 +157,8 @@ public class FragmentOptions extends FragmentBase {
"webview_legacy", "browser_zoom", "fake_dark",
"show_recent",
"biometrics",
- "default_light"
+ "default_light",
+ "vt_enabled", "vt_apikey"
};
@Override
diff --git a/app/src/main/res/layout/dialog_virus_total.xml b/app/src/main/res/layout/dialog_virus_total.xml
index 6a4ef6f306..3a0c855553 100644
--- a/app/src/main/res/layout/dialog_virus_total.xml
+++ b/app/src/main/res/layout/dialog_virus_total.xml
@@ -28,51 +28,22 @@
android:layout_marginTop="24dp"
android:text="File name"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
+ android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvTitle" />
-
-
-
-
-
-
+ app:layout_constraintTop_toBottomOf="@id/tvName" />
+ app:layout_constraintTop_toBottomOf="@id/tvError" />
@@ -117,6 +89,53 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnUpload" />
+
+
+
+
+
+
+
+
-
-
-
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_virus_total.xml b/app/src/main/res/layout/item_virus_total.xml
new file mode 100644
index 0000000000..11113fa7b4
--- /dev/null
+++ b/app/src/main/res/layout/item_virus_total.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f3da25e7ce..c4bc53cc07 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1467,10 +1467,11 @@
Status report
Request receipt
Most providers and email clients ignore receipt requests
- Number of virus scanners that flag this file as malicious:
- This file hasn\'t been seen by VirusTotal before, so it has not been scanned yet
+ This file is not yet known to VirusTotal
+ %1$s virus scanners report this file as malicious
+ Full report
Upload
- Analyzing, this may take a while …
+ VirusTotal is analyzing the file, this may take a while …
Sender missing
PGP keys available