From f7ffc52747758659f2d763c631e7222c27710b83 Mon Sep 17 00:00:00 2001 From: M66B Date: Tue, 13 Jul 2021 10:37:02 +0200 Subject: [PATCH] Added support for BIMI --- .../eu/faircode/email/AdapterMessage.java | 5 + .../java/eu/faircode/email/ContactInfo.java | 203 ++++++++++++------ .../java/eu/faircode/email/DnsHelper.java | 8 + .../eu/faircode/email/FragmentOptions.java | 2 +- .../email/FragmentOptionsDisplay.java | 23 +- .../java/eu/faircode/email/ImageHelper.java | 25 +++ .../res/layout/fragment_options_display.xml | 23 +- .../res/layout/include_message_compact.xml | 13 +- .../res/layout/include_message_normal.xml | 13 +- app/src/main/res/values/strings.xml | 1 + 10 files changed, 243 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/eu/faircode/email/AdapterMessage.java b/app/src/main/java/eu/faircode/email/AdapterMessage.java index 306a25de57..50eba4899e 100644 --- a/app/src/main/java/eu/faircode/email/AdapterMessage.java +++ b/app/src/main/java/eu/faircode/email/AdapterMessage.java @@ -315,6 +315,7 @@ public class AdapterMessage extends RecyclerView.Adapter> futures = new ArrayList<>(); - - String host = domain; - while (host.indexOf('.') > 0) { - final URL base = new URL("https://" + host); - final URL www = new URL("https://www." + host); - - futures.add(executorFavicon.submit(new Callable() { - @Override - public Bitmap call() throws Exception { - return parseFavicon(base, scaleToPixels, context); - } - })); - - futures.add(executorFavicon.submit(new Callable() { - @Override - public Bitmap call() throws Exception { - return parseFavicon(www, scaleToPixels, context); - } - })); - - futures.add(executorFavicon.submit(new Callable() { - @Override - public Bitmap call() throws Exception { - return getFavicon(new URL(base, "favicon.ico"), null, scaleToPixels, context); - } - })); + List> futures = new ArrayList<>(); - futures.add(executorFavicon.submit(new Callable() { + if (bimi) { + final String txt = "default._bimi." + domain; + futures.add(executorFavicon.submit(new Callable() { @Override - public Bitmap call() throws Exception { - return getFavicon(new URL(www, "favicon.ico"), null, scaleToPixels, context); + public Favicon call() throws Exception { + Log.i("BIMI fetch TXT=" + txt); + DnsHelper.DnsRecord[] bimi = DnsHelper.lookup(context, txt, "txt"); + if (bimi.length == 0) + return null; + Log.i("BIMI got TXT=" + bimi[0].name); + + Bitmap bitmap = null; + boolean verified = true; + String[] params = bimi[0].name.split(";"); + for (String param : params) { + String[] kv = param.split("="); + if (kv.length != 2) + continue; + + switch (kv[0].trim().toLowerCase()) { + case "v": + break; + + case "l": { + URL url = new URL(kv[1].trim()); + + Log.i("BIMI favicon " + url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setReadTimeout(FAVICON_READ_TIMEOUT); + connection.setConnectTimeout(FAVICON_CONNECT_TIMEOUT); + connection.setInstanceFollowRedirects(true); + connection.setRequestProperty("User-Agent", WebViewEx.getUserAgent(context)); + connection.connect(); + + try { + bitmap = ImageHelper.renderSvg( + connection.getInputStream(), + Color.WHITE, scaleToPixels); + } finally { + connection.disconnect(); + } + + break; + } + + case "a": + verified = true; + break; + } + } + + return (bitmap == null ? null : new Favicon(bitmap, verified)); } })); + } - int dot = host.indexOf('.'); - host = host.substring(dot + 1); + if (favicons) { + String host = domain; + while (host.indexOf('.') > 0) { + final URL base = new URL("https://" + host); + final URL www = new URL("https://www." + host); + + futures.add(executorFavicon.submit(new Callable() { + @Override + public Favicon call() throws Exception { + return parseFavicon(base, scaleToPixels, context); + } + })); + + futures.add(executorFavicon.submit(new Callable() { + @Override + public Favicon call() throws Exception { + return parseFavicon(www, scaleToPixels, context); + } + })); + + futures.add(executorFavicon.submit(new Callable() { + @Override + public Favicon call() throws Exception { + return getFavicon(new URL(base, "favicon.ico"), null, scaleToPixels, context); + } + })); + + futures.add(executorFavicon.submit(new Callable() { + @Override + public Favicon call() throws Exception { + return getFavicon(new URL(www, "favicon.ico"), null, scaleToPixels, context); + } + })); + + int dot = host.indexOf('.'); + host = host.substring(dot + 1); + } } Throwable ex = null; - for (Future future : futures) + for (Future future : futures) try { - info.bitmap = future.get(); - if (info.bitmap != null) + Favicon favicon = future.get(); + if (favicon != null) { + info.bitmap = favicon.bitmap; + info.verified = favicon.verified; break; + } } catch (ExecutionException exex) { ex = exex.getCause(); } catch (Throwable exex) { @@ -429,6 +496,8 @@ public class ContactInfo { try (OutputStream os = new BufferedOutputStream(new FileOutputStream(file))) { info.bitmap.compress(Bitmap.CompressFormat.PNG, 90, os); } + if (info.verified) + new File(dir, domain + "-verified").createNewFile(); } } catch (Throwable ex) { if (isRecoverable(ex, context)) @@ -484,7 +553,7 @@ public class ContactInfo { return info; } - private static Bitmap parseFavicon(URL base, int scaleToPixels, Context context) throws IOException { + private static Favicon parseFavicon(URL base, int scaleToPixels, Context context) throws IOException { Log.i("PARSE favicon " + base); HttpsURLConnection connection = (HttpsURLConnection) base.openConnection(); connection.setRequestMethod("GET"); @@ -618,7 +687,7 @@ public class ContactInfo { for (int i = 0; i < imgs.size(); i++) Log.i("Favicon " + i + "=" + imgs.get(i) + " @" + base); - List> futures = new ArrayList<>(); + List> futures = new ArrayList<>(); for (Element img : imgs) { String rel = img.attr("rel").trim().toLowerCase(Locale.ROOT); if (REL_EXCLUDE.contains(rel)) // dns-prefetch: gmx.net @@ -631,15 +700,15 @@ public class ContactInfo { continue; final URL url = new URL(base, favicon); - futures.add(executorFavicon.submit(new Callable() { + futures.add(executorFavicon.submit(new Callable() { @Override - public Bitmap call() throws Exception { + public Favicon call() throws Exception { return getFavicon(url, img.attr("type"), scaleToPixels, context); } })); } - for (Future future : futures) + for (Future future : futures) try { return future.get(); } catch (Throwable ex) { @@ -677,7 +746,7 @@ public class ContactInfo { } @NonNull - private static Bitmap getFavicon(URL url, String type, int scaleToPixels, Context context) throws IOException { + private static Favicon getFavicon(URL url, String type, int scaleToPixels, Context context) throws IOException { Log.i("GET favicon " + url); if (!"https".equals(url.getProtocol())) @@ -702,26 +771,10 @@ public class ContactInfo { if (status != HttpURLConnection.HTTP_OK) throw new FileNotFoundException("Error " + status + ":" + connection.getResponseMessage()); - if ("image/svg+xml".equals(type) || url.getPath().endsWith(".svg")) - try { - SVG svg = SVG.getFromInputStream(connection.getInputStream()); - float w = svg.getDocumentWidth(); - float h = svg.getDocumentHeight(); - if (w < 0 || h < 0) { - w = 1; - h = 1; - } - Bitmap favicon = Bitmap.createBitmap( - scaleToPixels, - Math.round(scaleToPixels * h / w), - Bitmap.Config.ARGB_8888); - favicon.eraseColor(Color.WHITE); - Canvas canvas = new Canvas(favicon); - svg.renderToCanvas(canvas); - return favicon; - } catch (Throwable ex) { - throw new IOException("SVG", ex); - } + if ("image/svg+xml".equals(type) || url.getPath().endsWith(".svg")) { + Bitmap bitmap = ImageHelper.renderSvg(connection.getInputStream(), Color.WHITE, scaleToPixels); + return new Favicon(bitmap); + } Bitmap bitmap = ImageHelper.getScaledBitmap(connection.getInputStream(), url.toString(), scaleToPixels); if (bitmap == null) @@ -732,7 +785,7 @@ public class ContactInfo { Canvas canvas = new Canvas(favicon); canvas.drawBitmap(bitmap, 0, 0, null); bitmap.recycle(); - return favicon; + return new Favicon(favicon); } } finally { connection.disconnect(); @@ -1001,6 +1054,20 @@ public class ContactInfo { String displayName; } + private static class Favicon { + private Bitmap bitmap; + private boolean verified; + + private Favicon(@NonNull Bitmap bitmap) { + this(bitmap, false); + } + + private Favicon(@NonNull Bitmap bitmap, boolean verified) { + this.bitmap = bitmap; + this.verified = verified; + } + } + private static class Avatar { private boolean available; private long time; diff --git a/app/src/main/java/eu/faircode/email/DnsHelper.java b/app/src/main/java/eu/faircode/email/DnsHelper.java index c42f00bdb9..80f4e93cf8 100644 --- a/app/src/main/java/eu/faircode/email/DnsHelper.java +++ b/app/src/main/java/eu/faircode/email/DnsHelper.java @@ -35,6 +35,7 @@ import org.xbill.DNS.Message; import org.xbill.DNS.Record; import org.xbill.DNS.SRVRecord; import org.xbill.DNS.SimpleResolver; +import org.xbill.DNS.TXTRecord; import org.xbill.DNS.TextParseException; import org.xbill.DNS.Type; @@ -113,6 +114,9 @@ public class DnsHelper { case "srv": rtype = Type.SRV; break; + case "txt": + rtype = Type.TXT; + break; default: throw new IllegalArgumentException(type); } @@ -214,6 +218,10 @@ public class DnsHelper { } else if (record instanceof SRVRecord) { SRVRecord srv = (SRVRecord) record; result.add(new DnsRecord(srv.getTarget().toString(true), srv.getPort())); + } else if (record instanceof TXTRecord) { + TXTRecord txt = (TXTRecord) record; + for (Object content : txt.getStrings()) + result.add(new DnsRecord(content.toString(), 0)); } else throw new IllegalArgumentException(record.getClass().getName()); } diff --git a/app/src/main/java/eu/faircode/email/FragmentOptions.java b/app/src/main/java/eu/faircode/email/FragmentOptions.java index 108c6cfb78..4751b5d7f5 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptions.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptions.java @@ -117,7 +117,7 @@ public class FragmentOptions extends FragmentBase { "cards", "beige", "tabular_card_bg", "shadow_unread", "indentation", "date", "date_bold", "threading", "threading_unread", "highlight_unread", "highlight_color", "color_stripe", - "avatars", "gravatars", "favicons", "generated_icons", "identicons", "circular", "saturation", "brightness", "threshold", + "avatars", "gravatars", "bimi", "favicons", "generated_icons", "identicons", "circular", "saturation", "brightness", "threshold", "email_format", "prefer_contact", "only_contact", "distinguish_contacts", "show_recipients", "authentication", "subject_top", "font_size_sender", "font_size_subject", "subject_italic", "highlight_subject", "subject_ellipsize", "keywords_header", "labels_header", "flags", "flags_background", "preview", "preview_italic", "preview_lines", diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java b/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java index 1931d17819..fb6429bbea 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java @@ -40,6 +40,7 @@ import android.widget.AdapterView; import android.widget.Button; import android.widget.CompoundButton; import android.widget.EditText; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.SeekBar; import android.widget.Spinner; @@ -86,6 +87,8 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer private SwitchCompat swAvatars; private TextView tvGravatarsHint; private SwitchCompat swGravatars; + private SwitchCompat swBimi; + private ImageButton ibBimi; private SwitchCompat swFavicons; private TextView tvFaviconsHint; private SwitchCompat swGeneratedIcons; @@ -152,7 +155,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer "date", "date_bold", "navbar_colorize", "portrait2", "landscape", "landscape3", "threading", "threading_unread", "indentation", "seekbar", "actionbar", "actionbar_color", "highlight_unread", "highlight_color", "color_stripe", - "avatars", "gravatars", "favicons", "generated_icons", "identicons", "circular", "saturation", "brightness", "threshold", + "avatars", "gravatars", "bimi", "favicons", "generated_icons", "identicons", "circular", "saturation", "brightness", "threshold", "email_format", "prefer_contact", "only_contact", "distinguish_contacts", "show_recipients", "subject_top", "font_size_sender", "font_size_subject", "subject_italic", "highlight_subject", "subject_ellipsize", "keywords_header", "labels_header", "flags", "flags_background", @@ -200,6 +203,8 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer swAvatars = view.findViewById(R.id.swAvatars); swGravatars = view.findViewById(R.id.swGravatars); tvGravatarsHint = view.findViewById(R.id.tvGravatarsHint); + swBimi = view.findViewById(R.id.swBimi); + ibBimi = view.findViewById(R.id.ibBimi); swFavicons = view.findViewById(R.id.swFavicons); tvFaviconsHint = view.findViewById(R.id.tvFaviconsHint); swGeneratedIcons = view.findViewById(R.id.swGeneratedIcons); @@ -479,6 +484,21 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer } }); + swBimi.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("bimi", checked).apply(); + ContactInfo.clearCache(getContext()); + } + }); + + ibBimi.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.view(v.getContext(), Uri.parse("https://bimigroup.org/"), true); + } + }); + swFavicons.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -997,6 +1017,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer swColorStripe.setChecked(prefs.getBoolean("color_stripe", true)); swAvatars.setChecked(prefs.getBoolean("avatars", true)); swGravatars.setChecked(prefs.getBoolean("gravatars", false)); + swBimi.setChecked(prefs.getBoolean("bimi", false)); swFavicons.setChecked(prefs.getBoolean("favicons", false)); swGeneratedIcons.setChecked(prefs.getBoolean("generated_icons", true)); swIdenticons.setChecked(prefs.getBoolean("identicons", false)); diff --git a/app/src/main/java/eu/faircode/email/ImageHelper.java b/app/src/main/java/eu/faircode/email/ImageHelper.java index 5480c3bfa1..7b26efbc7d 100644 --- a/app/src/main/java/eu/faircode/email/ImageHelper.java +++ b/app/src/main/java/eu/faircode/email/ImageHelper.java @@ -57,6 +57,8 @@ import androidx.core.graphics.ColorUtils; import androidx.exifinterface.media.ExifInterface; import androidx.preference.PreferenceManager; +import com.caverock.androidsvg.SVG; + import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; @@ -242,6 +244,29 @@ class ImageHelper { return round; } + @NonNull + static Bitmap renderSvg(InputStream is, int fillColor, int scaleToPixels) throws IOException { + try { + SVG svg = SVG.getFromInputStream(is); + float w = svg.getDocumentWidth(); + float h = svg.getDocumentHeight(); + if (w < 0 || h < 0) { + w = 1; + h = 1; + } + Bitmap bm = Bitmap.createBitmap( + scaleToPixels, + Math.round(scaleToPixels * h / w), + Bitmap.Config.ARGB_8888); + bm.eraseColor(fillColor); + Canvas canvas = new Canvas(bm); + svg.renderToCanvas(canvas); + return bm; + } catch (Throwable ex) { + throw new IOException("SVG, ex"); + } + } + static Drawable decodeImage(final Context context, final long id, String source, boolean show, int zoom, final float scale, final TextView view) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean inline = prefs.getBoolean("inline_images", false); diff --git a/app/src/main/res/layout/fragment_options_display.xml b/app/src/main/res/layout/fragment_options_display.xml index 946a8bbc71..f533c00e15 100644 --- a/app/src/main/res/layout/fragment_options_display.xml +++ b/app/src/main/res/layout/fragment_options_display.xml @@ -510,6 +510,27 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/swGravatars" /> + + + + + + diff --git a/app/src/main/res/layout/include_message_normal.xml b/app/src/main/res/layout/include_message_normal.xml index 9f92932dc6..f52be82611 100644 --- a/app/src/main/res/layout/include_message_normal.xml +++ b/app/src/main/res/layout/include_message_normal.xml @@ -56,6 +56,17 @@ app:layout_constraintTop_toBottomOf="@id/paddingTop" app:srcCompat="@drawable/twotone_person_24" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c66b4362f..c40c039dd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -396,6 +396,7 @@ Show color stripe Show contact photos Show Gravatars + Show Brand Indicators for Message Identification (BIMI) Show favicons Show generated icons Show identicons