Added support for BIMI

pull/199/head
M66B 4 years ago
parent 3ad79fe6a2
commit f7ffc52747

@ -315,6 +315,7 @@ public class AdapterMessage extends RecyclerView.Adapter<AdapterMessage.ViewHold
private View vwColor;
private ImageButton ibExpander;
private ImageView ibFlagged;
private ImageView ivVerified;
private ImageButton ibAvatar;
private ImageButton ibAuth;
private ImageButton ibPriority;
@ -487,6 +488,7 @@ public class AdapterMessage extends RecyclerView.Adapter<AdapterMessage.ViewHold
vwColor = itemView.findViewById(R.id.vwColor);
ibExpander = itemView.findViewById(R.id.ibExpander);
ibFlagged = itemView.findViewById(R.id.ibFlagged);
ivVerified = itemView.findViewById(R.id.ivVerified);
ibAvatar = itemView.findViewById(R.id.ibAvatar);
ibAuth = itemView.findViewById(R.id.ibAuth);
ibPriority = itemView.findViewById(R.id.ibPriority);
@ -970,6 +972,7 @@ public class AdapterMessage extends RecyclerView.Adapter<AdapterMessage.ViewHold
if (viewType == ViewType.THREAD) {
boolean dim = (message.duplicate || EntityFolder.TRASH.equals(message.folderType));
ibFlagged.setAlpha(dim ? Helper.LOW_LIGHT : 1.0f);
ivVerified.setAlpha(dim ? Helper.LOW_LIGHT : 1.0f);
ibAvatar.setAlpha(dim ? Helper.LOW_LIGHT : 1.0f);
ibAuth.setAlpha(dim ? Helper.LOW_LIGHT : 1.0f);
ibPriority.setAlpha(dim ? Helper.LOW_LIGHT : 1.0f);
@ -1020,6 +1023,7 @@ public class AdapterMessage extends RecyclerView.Adapter<AdapterMessage.ViewHold
ibExpander.setVisibility(View.GONE);
// Photo
ivVerified.setVisibility(View.GONE);
ibAvatar.setVisibility(avatars ? View.INVISIBLE : View.GONE);
// Line 1
@ -1518,6 +1522,7 @@ public class AdapterMessage extends RecyclerView.Adapter<AdapterMessage.ViewHold
ibAvatar.setTag(lookupUri);
ibAvatar.setEnabled(lookupUri != null);
}
ivVerified.setVisibility(main == null || !main.isVerified() ? View.GONE : View.VISIBLE);
ibAvatar.setVisibility(main == null || !main.hasPhoto() ? View.GONE : View.VISIBLE);
}

@ -38,8 +38,6 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import com.caverock.androidsvg.SVG;
import org.json.JSONArray;
import org.json.JSONObject;
import org.jsoup.nodes.Document;
@ -90,6 +88,7 @@ import javax.net.ssl.SSLSession;
public class ContactInfo {
private String email;
private Bitmap bitmap;
private boolean verified;
private String displayName;
private Uri lookupUri;
private boolean known;
@ -125,6 +124,10 @@ public class ContactInfo {
private ContactInfo() {
}
boolean isVerified() {
return (bitmap != null && verified);
}
boolean hasPhoto() {
return (bitmap != null);
}
@ -248,6 +251,7 @@ public class ContactInfo {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
boolean avatars = prefs.getBoolean("avatars", true);
boolean gravatars = prefs.getBoolean("gravatars", false);
boolean bimi = prefs.getBoolean("bimi", false);
boolean favicons = prefs.getBoolean("favicons", false);
boolean generated = prefs.getBoolean("generated_icons", true);
boolean identicons = prefs.getBoolean("identicons", false);
@ -345,10 +349,9 @@ public class ContactInfo {
}
// Favicon
if (info.bitmap == null && favicons &&
if (info.bitmap == null && (bimi || favicons) &&
!EntityFolder.JUNK.equals(folderType)) {
int at = (info.email == null ? -1 : info.email.indexOf('@'));
String domain = (at < 0 ? null : info.email.substring(at + 1).toLowerCase(Locale.ROOT));
String domain = UriHelper.getEmailDomain(info.email);
if (domain != null) {
if ("google.com".equals(domain) ||
@ -366,56 +369,120 @@ public class ContactInfo {
if (file.exists())
if (file.length() == 0)
Log.i("Favicon blacklisted domain=" + domain);
else
else {
info.bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
info.verified = new File(dir, domain + "-verified").exists();
}
else {
final int scaleToPixels = Helper.dp2pixels(context, FAVICON_ICON_SIZE);
List<Future<Bitmap>> 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<Bitmap>() {
@Override
public Bitmap call() throws Exception {
return parseFavicon(base, scaleToPixels, context);
}
}));
futures.add(executorFavicon.submit(new Callable<Bitmap>() {
@Override
public Bitmap call() throws Exception {
return parseFavicon(www, scaleToPixels, context);
}
}));
futures.add(executorFavicon.submit(new Callable<Bitmap>() {
@Override
public Bitmap call() throws Exception {
return getFavicon(new URL(base, "favicon.ico"), null, scaleToPixels, context);
}
}));
List<Future<Favicon>> futures = new ArrayList<>();
futures.add(executorFavicon.submit(new Callable<Bitmap>() {
if (bimi) {
final String txt = "default._bimi." + domain;
futures.add(executorFavicon.submit(new Callable<Favicon>() {
@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<Favicon>() {
@Override
public Favicon call() throws Exception {
return parseFavicon(base, scaleToPixels, context);
}
}));
futures.add(executorFavicon.submit(new Callable<Favicon>() {
@Override
public Favicon call() throws Exception {
return parseFavicon(www, scaleToPixels, context);
}
}));
futures.add(executorFavicon.submit(new Callable<Favicon>() {
@Override
public Favicon call() throws Exception {
return getFavicon(new URL(base, "favicon.ico"), null, scaleToPixels, context);
}
}));
futures.add(executorFavicon.submit(new Callable<Favicon>() {
@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<Bitmap> future : futures)
for (Future<Favicon> 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<Future<Bitmap>> futures = new ArrayList<>();
List<Future<Favicon>> 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<Bitmap>() {
futures.add(executorFavicon.submit(new Callable<Favicon>() {
@Override
public Bitmap call() throws Exception {
public Favicon call() throws Exception {
return getFavicon(url, img.attr("type"), scaleToPixels, context);
}
}));
}
for (Future<Bitmap> future : futures)
for (Future<Favicon> 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;

@ -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());
}

@ -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",

@ -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));

@ -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);

@ -510,6 +510,27 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/swGravatars" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/swBimi"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/title_advanced_bimi"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvGravatarsHint"
app:switchPadding="12dp" />
<ImageButton
android:id="@+id/ibBimi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/title_info"
android:tooltipText="@string/title_info"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/swBimi"
app:srcCompat="@drawable/twotone_info_24" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/swFavicons"
android:layout_width="0dp"
@ -518,7 +539,7 @@
android:text="@string/title_advanced_favicons"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvGravatarsHint"
app:layout_constraintTop_toBottomOf="@id/ibBimi"
app:switchPadding="12dp" />
<eu.faircode.email.FixedTextView

@ -57,6 +57,17 @@
app:layout_constraintTop_toBottomOf="@id/paddingTop"
app:srcCompat="@drawable/twotone_person_24" />
<ImageView
android:id="@+id/ivVerified"
android:layout_width="21dp"
android:layout_height="21dp"
android:layout_marginStart="6dp"
app:layout_constraintBottom_toBottomOf="@+id/tvFrom"
app:layout_constraintStart_toEndOf="@id/ibAvatar"
app:layout_constraintTop_toTopOf="@+id/tvFrom"
app:srcCompat="@drawable/twotone_check_24"
app:tint="?attr/colorAccent" />
<ImageButton
android:id="@+id/ibAuth"
android:layout_width="21dp"
@ -65,7 +76,7 @@
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/title_legend_auth"
app:layout_constraintBottom_toBottomOf="@+id/tvFrom"
app:layout_constraintStart_toEndOf="@id/ibAvatar"
app:layout_constraintStart_toEndOf="@id/ivVerified"
app:layout_constraintTop_toTopOf="@+id/tvFrom"
app:srcCompat="@drawable/twotone_flag_24"
app:tint="?attr/colorWarning" />

@ -56,6 +56,17 @@
app:layout_constraintTop_toBottomOf="@id/paddingTop"
app:srcCompat="@drawable/twotone_person_24" />
<ImageView
android:id="@+id/ivVerified"
android:layout_width="21dp"
android:layout_height="21dp"
android:layout_marginStart="6dp"
app:layout_constraintBottom_toBottomOf="@+id/tvFrom"
app:layout_constraintStart_toEndOf="@id/ibAvatar"
app:layout_constraintTop_toTopOf="@+id/tvFrom"
app:srcCompat="@drawable/twotone_check_24"
app:tint="?attr/colorAccent" />
<ImageButton
android:id="@+id/ibAuth"
android:layout_width="21dp"
@ -64,7 +75,7 @@
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/title_legend_auth"
app:layout_constraintBottom_toBottomOf="@+id/tvFrom"
app:layout_constraintStart_toEndOf="@id/ibAvatar"
app:layout_constraintStart_toEndOf="@id/ivVerified"
app:layout_constraintTop_toTopOf="@+id/tvFrom"
app:srcCompat="@drawable/twotone_flag_24"
app:tint="?attr/colorWarning" />

@ -396,6 +396,7 @@
<string name="title_advanced_color_stripe">Show color stripe</string>
<string name="title_advanced_avatars">Show contact photos</string>
<string name="title_advanced_gravatars">Show Gravatars</string>
<string name="title_advanced_bimi" translatable="false">Show Brand Indicators for Message Identification (BIMI)</string>
<string name="title_advanced_favicons">Show favicons</string>
<string name="title_advanced_generated_icons">Show generated icons</string>
<string name="title_advanced_identicons">Show identicons</string>

Loading…
Cancel
Save