Handling ‘mailto’ and ‘tel’ Links inside Android WebView

Overview

Previously we created wrappers around mailto and tel HTML links. Today we will see how to integrate those wrappers into our Android app to respond to user clicking mailto and tel links.

Introduction

By inspecting WebView class you can find that there’s no direct way to subscribe to events of user clicking a link or navigating to another area of the website. The only way available is through implementing a custom WebViewClient. The WebViewClient class allows you to take control of various aspects of WebView like page loading, scale changing, error handling, and many others. This class is very useful so you got to inspect it yourself. Here we will focus only on handling page loading event.

mailto and tel Code Listing

For your reference, here’s the full code listing for mailto and tel web links:

public abstract class WebLink {
  /// <summary&gt;
  /// Link prefix. Examples are: 'mailto:' and 'tel:'
  /// </summary&gt;
  public abstract string Prefix { get; }

  /// <summary&gt;
  /// Clears instance fields.
  /// </summary&gt;
  public abstract void ClearFields();

  /// <summary&gt;
  /// Loads link input into relevant fields.
  /// </summary&gt;
  public virtual void ReadLink(string link) {
    if (link == null)
      throw new ArgumentNullException("link");

    if (link.ToLower().StartsWith(Prefix.ToLower()) == false)
      throw new FormatException("Invalid link.");
  }

  /// <summary&gt;
  /// Generates link from instance fields.
  /// </summary&gt;
  public virtual string GenerateLink(bool includePrefix) {
    var str = string.Empty;

    if (includePrefix)
      str += Prefix;

    return str;
  }

  /// <summary&gt;
  /// Can be used to exclude prefix from a link string.
  /// </summary&gt;
  protected string ExcludePrefix(string link) {
    link = link.Trim();
    if (link.ToLower().StartsWith(Prefix.ToLower()))
      link = link.Substring(Prefix.Length).Trim();
    return link;
  }

  public override string ToString() {
    return GenerateLink(true);
  }
}


public class MailWebLink : WebLink {
  #region Prefix
  protected static string LinkPrefix { get { return "mailto:"; } }
  public override string Prefix =&gt; LinkPrefix;
  #endregion
 
  #region Delimiters
  protected static readonly char[] MailDelimiters = new char[] { '?' };
  protected static readonly char[] RecipientDelimiters = new char[] { ',', ';' };
  protected static readonly char[] ParamDelimiters = new char[] { '&amp;' };
  protected static readonly char[] ParamValueDelimiters = new char[] { '=' };
  #endregion

  #region Field Names
  protected static readonly string ToField = "to";
  protected static readonly string CcField = "cc";
  protected static readonly string BccField = "bcc";
  protected static readonly string SubjectField = "subject";
  protected static readonly string BodyField = "body";
  #endregion


  #region Fields
  public string[] To { get; set; }
  public string[] Cc { get; set; }
  public string[] Bcc { get; set; }
  public string Subject { get; set; }
  public string Body { get; set; }
  #endregion

  public MailWebLink() {

  }
  public MailWebLink(string link) {
    ReadLink(link);
  }

  public static bool CanHandle(string link) {
    return link.ToLower().Trim().StartsWith(LinkPrefix);
  }

  #region Link Loading
  public override void ClearFields() {
    To = Cc = Bcc = null;
    Subject = Body = null;
  }

  public override void ReadLink(string link) {
    base.ReadLink(link);

    try {
      ClearFields();

      // Exclude prefix if necessary
      link = ExcludePrefix(link);

      // Get mail 'To' Field
      string tmpVal = null;
      int idx = -1;

      idx = link.IndexOfAny(MailDelimiters);

      if (idx &gt; -1)
        tmpVal = link.Substring(0, idx);
      else
        tmpVal = link;

      this.To = LoadRecipients(tmpVal).ToArray();

      if (idx == -1)
        return;

      link = link.Substring(idx + 1);

      // Handle rest of fields
      var parameters = GetParameters(link, true);
      foreach (var par in parameters) {
        if (par.Key == ToField) // overrides the above code
          this.To = LoadRecipients(par.Value).ToArray();
        else if (par.Key == CcField)
          this.Cc = LoadRecipients(par.Value).ToArray();
        else if (par.Key == BccField)
          this.Bcc = LoadRecipients(par.Value).ToArray();
        else if (par.Key == SubjectField)
          this.Subject = par.Value;
        else if (par.Key == BodyField)
          this.Body = par.Value;
      }
    } catch {
      throw new FormatException();
    }
  }

  /// <summary&gt;
  /// Splits a mail string into a list of mail addresses.
  /// </summary&gt;
  protected virtual IEnumerable<string&gt; LoadRecipients(string val) {
    var items = val.Split(RecipientDelimiters, StringSplitOptions.RemoveEmptyEntries);
    return items.Select(s =&gt; s.Trim().ToLower()).Distinct();
  }

  /// <summary&gt;
  /// Splits a parameter string into a list of parameters (kay and value)
  /// </summary&gt;
  /// <param name="skipEmpty"&gt;Whether to skip empty parameters.</param&gt;
  protected virtual IEnumerable<KeyValuePair<string, string&gt;&gt; GetParameters(string val, bool skipEmpty = true) {
    var items = val.Split(ParamDelimiters, StringSplitOptions.RemoveEmptyEntries);

    foreach (var itm in items) {
      string key = string.Empty;
      string value = string.Empty;

      var delimiterIdx = itm.IndexOfAny(ParamValueDelimiters);
      if (delimiterIdx == -1)
        continue;

      key = itm.Substring(0, delimiterIdx).ToLower();
      value = itm.Substring(delimiterIdx + 1);
      value = UnscapeParamValue(value);

      if (key.Length == 0)
        continue;

      if (skipEmpty &amp;&amp; value.Length == 0)
        continue;

      yield return new KeyValuePair<string, string&gt; (key, value);
    }
  }
  #endregion

  #region Link Generation

  public virtual string GetLink() { return GenerateLink(true); }

  public override string GenerateLink(bool includePrefix) {
    string str = base.GenerateLink(includePrefix);

    if (this.To != null &amp;&amp; this.To.Length &gt; 0) {
      str += GetRecipientString(this.To);
    }

    str += MailDelimiters.First();

    if (this.Cc != null &amp;&amp; this.Cc.Length &gt; 0) {
      str += GetParameterString(CcField, GetRecipientString(this.Cc), false);
      str += ParamDelimiters.First();
    }

    if (this.Bcc != null &amp;&amp; this.Bcc.Length &gt; 0) {
      str += GetParameterString(BccField, GetRecipientString(this.Bcc), false);
      str += ParamDelimiters.First();
    }

    if (this.Subject != null &amp;&amp; this.Subject.Length &gt; 0) {
      str += GetParameterString(SubjectField, this.Subject, true);
      str += ParamDelimiters.First();
    }

    if (this.Body != null &amp;&amp; this.Body.Length &gt; 0) {
      str += GetParameterString(BodyField, this.Body, true);
      str += ParamDelimiters.First();
    }

    str = str.TrimEnd(MailDelimiters.Concat(ParamDelimiters).ToArray());

    return str;
  }

  /// <summary&gt;
  /// Joins a list of mail addresses into a string
  /// </summary&gt;
  protected virtual string GetRecipientString(string[] recipients) {
    return string.Join(RecipientDelimiters.First().ToString(), recipients);
  }

  /// <summary&gt;
  /// Joins a parameter (key and value) into a string
  /// </summary&gt;
  /// <param name="escapeValue"&gt;Whether to escape value.</param&gt;
  protected virtual string GetParameterString(string key, string value, bool escapeValue) {
    return string.Format("{0}{1}{2}",
      key,
      ParamValueDelimiters.First(),
      escapeValue ? EscapeParamValue(value) : value);
  }

  #endregion

  #region Helpers
  protected static readonly Dictionary<string, string&gt; CustomUnescapeCharacters =
    new Dictionary<string, string&gt;() { { "+", " " } };
 
  private static string EscapeParamValue(string value) {
    return Uri.EscapeDataString(value);
  }

  private static string UnscapeParamValue(string value) {
    foreach (var customChar in CustomUnescapeCharacters) {
      if (value.Contains(customChar.Key))
        value = value.Replace(customChar.Key, customChar.Value);
    }

    return Uri.UnescapeDataString(value);
  }
  #endregion
}

public class TelephoneWebLink : WebLink {
  #region Prefix
  protected static string LinkPrefix { get { return "tel:"; } }
  public override string Prefix =&gt; LinkPrefix;
  #endregion

  #region Delimiters
  protected static readonly char ExtensionDelimiter = 'p';
  #endregion

  #region Fields
  public string Number { get; set; }
  public string Extension { get; set; }
  #endregion


  public TelephoneWebLink() {

  }
  public TelephoneWebLink(string link) {
    ReadLink(link);
  }

  public static bool CanHandle(string link) {
    return link.ToLower().Trim().StartsWith(LinkPrefix);
  }

  public override void ClearFields() {
    Number = null;
    Extension = null;
  }

  public override void ReadLink(string link) {
    base.ReadLink(link);

    try {
      ClearFields();

      // Exclude prefix if necessary
      link = ExcludePrefix(link).Trim();

      Number = string.Empty;
      Extension = string.Empty;

      bool foundExtension = false;
      int idx = 0;
      foreach (var c in link) {
        if (idx == 0 &amp;&amp; c == '+')
          Number += "+";
        if (c == ExtensionDelimiter)
          foundExtension = true;
        else if (char.IsDigit(c)) {
          if (foundExtension == false)
            Number += c.ToString();
          else
            Extension += c.ToString();
        }
        idx++;
      }

    } catch {
      throw new FormatException();
    }
  }

  public override string GenerateLink(bool includePrefix) {
    var str = base.GenerateLink(includePrefix);

    if (Number != null)
      str += Number.ToString();

    if (Extension != null &amp;&amp; Extension.Length &gt; 0)
      str += ExtensionDelimiter.ToString() + Extension;

    return str;
  }
}

Client Implementation

Start by laying out your custom implementation of WebViewClient:

  public class CustomWebViewClient : WebViewClient {
    public event EventHandler<WebViewEventArgs&gt; PageStarted;
    public event EventHandler<WebViewEventArgs&gt; PageFinished;
    public event EventHandler<WebLinkEventArgs&gt; MailRequested;
    public event EventHandler<WebLinkEventArgs&gt; TelephoneRequested;

    /// <summary&gt;
    /// Give the host application a chance to take control when a URL is about to be loaded in the current WebView.
    /// </summary&gt;
    public override bool ShouldOverrideUrlLoading(WebView view, string url) {
      if (HandleCustomUrl(url))
        return true;

      view.LoadUrl(url);
      return true;
    }

    #region Custom URL Handling
    protected virtual bool HandleCustomUrl(string url) {
      try {
        if (MailWebLink.CanHandle(url)) {
          OnMailRequested(url);
          return true;
        }

        if (TelephoneWebLink.CanHandle(url)) {
          OnTelephoneRequested(url);
          return true;
        }

        return false;
      } catch (FormatException) {
        return false;
      }
    }

    private void OnMailRequested(string url) {
      if (MailRequested != null)
        MailRequested(this, new WebLinkEventArgs(url, new MailWebLink(url)));
    }

    private void OnTelephoneRequested(string url) {
      if (TelephoneRequested != null)
        TelephoneRequested(this, new WebLinkEventArgs(url, new TelephoneWebLink(url)));
    }
    #endregion


    #region Page Loading 
    public override void OnPageStarted(WebView view, string url, Bitmap favicon) {
      base.OnPageStarted(view, url, favicon);

      if (PageStarted != null)
        PageStarted(this, new WebViewEventArgs(url.ToLower()));
    }

    public override void OnPageFinished(WebView view, string url) {
      base.OnPageFinished(view, url);
      if (PageFinished != null)
        PageFinished(this, new WebViewEventArgs(url.ToLower()));
    }
    #endregion
  }

Few things to mention here:

  • ShouldOverrideUrlLoading would return true if you want to handle the loading events of the requested URL.
  • As an object-oriented approach, we created some events to notify the host activity of the various events happen inside the WebView. We also implemented two versions of EventArgs to hold the state data for the events. The code for the EventArgs is displayed next.
  • Before notifying the user of mailto and tel, we ensured first that they have the right format by calling CanHandle of both classes.

State Data

Now we would implement the EventArgs classes that would be passed to the host activity. The implementation is very straightforward.

  public class WebViewEventArgs : EventArgs{
    public string Url { get; set; }

    public WebViewEventArgs() { }
    public WebViewEventArgs(string url) {
      this.Url = url;
    }
  }


  public class WebLinkEventArgs : WebViewEventArgs {
    public WebLink WebLink { get; set; }

    public WebLinkEventArgs() { }
    public WebLinkEventArgs(string url, WebLink link) : base(url) {
      WebLink = link;
    }
  }

Linking the Client

Now link the client to the control by calling WebView.SetWebViewClient in activity’s OnCreate:

  protected WebView WebView { get; set; }
  protected CustomWebViewClient WebViewClient { get; set; }

  protected override void OnCreate(Bundle savedInstanceState) {
    base.OnCreate(savedInstanceState);

    this.WebView = this.FindViewById<WebView&gt;(Resource.Id.WebView_View);

    WebViewClient = new CustomWebViewClient();
    WebViewClient.MailRequested += WebViewClient_MailRequested;
    WebViewClient.TelephoneRequested += WebViewClient_TelephoneRequested;

    this.WebView.SetWebViewClient(WebViewClient);
  }

  private void WebViewClient_MailRequested(object sender, WebLinkEventArgs e) {
    var lnk = e.WebLink as MailWebLink;
    IntentHelper.MailTo(this, lnk); 
  }

  private void WebViewClient_TelephoneRequested(object sender, WebLinkEventArgs e) {
    var lnk = e.WebLink as TelephoneWebLink;
    if (lnk.Number.Length == 0)
      return;

    try {
      IntentHelper.PhoneCall(this, lnk.Number);
    } catch (Java.Lang.SecurityException ex) {
      // App is not granted permmission for phone calls
    }

In the previous code, we handled MailRequested and TelephoneRequested events and passed the data received to the IntentHelper class that we are going to create next.

Notice that Java.Lang.SecurityException will be thrown if the application is trying to make a phone call while not permitted. This should be handled to avoid app crashes.

Sending Emails

The code for sending an email is fairly easy. Next code will request the Android OS to open the mail app and display the relevant information. The OS might ask the user to select an app to handle this request.

  public static partial class IntentHelper {
    public static void MailTo(Context ctx, MailWebLink link, string activityTitle = null) {
      MailTo(ctx, link.To, link.Cc, link.Bcc, link.Subject, link.Body, activityTitle);
    }

    public static void MailTo(Context ctx, 
      string[] to,
      string[] cc, 
      string[] bcc, 
      string subject, 
      string body,
      string activityTitle = null) {

      Intent email = new Intent(Intent.ActionSend);
      email.SetType("message/rfc822");

      if (to != null)
       email.PutExtra(Intent.ExtraEmail,  to );

      if (cc != null)
        email.PutExtra(Intent.ExtraCc, cc);

      if (bcc != null)
        email.PutExtra(Intent.ExtraBcc, bcc);

      if (subject != null)
        email.PutExtra(Intent.ExtraSubject, subject);

      if (body != null)
        email.PutExtra(Intent.ExtraText, body);

      if (activityTitle == null)
        activityTitle = Application.Context.Resources.GetString(Resource.String.text_send);

      ctx.StartActivity(Intent.CreateChooser(email, activityTitle));
    }
  }

Making Phone Calls

While sending an email is easy, calling a number is easier:

  public static partial class IntentHelper {
    public static void PhoneCall(Context ctx, TelephoneWebLink lnk) {
      PhoneCall(ctx, lnk.Number);
    }

    public static void PhoneCall(Context ctx, string number) {
      Intent intent = new Intent(Intent.ActionCall);
      intent.SetData(Android.Net.Uri.Parse("tel: " + number));
      ctx.StartActivity(intent);
    }
  }

Conclusion

The WebViewClient opens the possibility of handling many aspects and behaviour of WebView. An idea, which we will see in a future post, is handling the browsing history and allowing the user to go back and forth. If you have and feedback, comments, or code updates please let me know.