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 the 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 have 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,
  1. public abstract class WebLink {
  2. /// <summary>
  3. /// Link prefix. Examples are: 'mailto:' and 'tel:'
  4. /// </summary>
  5. public abstract string Prefix { get; }
  6. /// <summary>
  7. /// Clears instance fields.
  8. /// </summary>
  9. public abstract void ClearFields();
  10. /// <summary>
  11. /// Loads link input into relevant fields.
  12. /// </summary>
  13. public virtual void ReadLink(string link) {
  14. if (link == null)
  15. throw new ArgumentNullException("link");
  16. if (link.ToLower().StartsWith(Prefix.ToLower()) == false)
  17. throw new FormatException("Invalid link.");
  18. }
  19. /// <summary>
  20. /// Generates link from instance fields.
  21. /// </summary>
  22. public virtual string GenerateLink(bool includePrefix) {
  23. var str = string.Empty;
  24. if (includePrefix)
  25. str += Prefix;
  26. return str;
  27. }
  28. /// <summary>
  29. /// Can be used to exclude prefix from a link string.
  30. /// </summary>
  31. protected string ExcludePrefix(string link) {
  32. link = link.Trim();
  33. if (link.ToLower().StartsWith(Prefix.ToLower()))
  34. link = link.Substring(Prefix.Length).Trim();
  35. return link;
  36. }
  37. public override string ToString() {
  38. return GenerateLink(true);
  39. }
  40. }
  41. public class MailWebLink : WebLink {
  42. #region Prefix
  43. protected static string LinkPrefix { get { return "mailto:"; } }
  44. public override string Prefix => LinkPrefix;
  45. #endregion
  46. #region Delimiters
  47. protected static readonly char[] MailDelimiters = new char[] { '?' };
  48. protected static readonly char[] RecipientDelimiters = new char[] { ',', ';' };
  49. protected static readonly char[] ParamDelimiters = new char[] { '&' };
  50. protected static readonly char[] ParamValueDelimiters = new char[] { '=' };
  51. #endregion
  52. #region Field Names
  53. protected static readonly string ToField = "to";
  54. protected static readonly string CcField = "cc";
  55. protected static readonly string BccField = "bcc";
  56. protected static readonly string SubjectField = "subject";
  57. protected static readonly string BodyField = "body";
  58. #endregion
  59. #region Fields
  60. public string[] To { get; set; }
  61. public string[] Cc { get; set; }
  62. public string[] Bcc { get; set; }
  63. public string Subject { get; set; }
  64. public string Body { get; set; }
  65. #endregion
  66. public MailWebLink() {
  67. }
  68. public MailWebLink(string link) {
  69. ReadLink(link);
  70. }
  71. public static bool CanHandle(string link) {
  72. return link.ToLower().Trim().StartsWith(LinkPrefix);
  73. }
  74. #region Link Loading
  75. public override void ClearFields() {
  76. To = Cc = Bcc = null;
  77. Subject = Body = null;
  78. }
  79. public override void ReadLink(string link) {
  80. base.ReadLink(link);
  81. try {
  82. ClearFields();
  83. // Exclude prefix if necessary
  84. link = ExcludePrefix(link);
  85. // Get mail 'To' Field
  86. string tmpVal = null;
  87. int idx = -1;
  88. idx = link.IndexOfAny(MailDelimiters);
  89. if (idx > -1)
  90. tmpVal = link.Substring(0, idx);
  91. else
  92. tmpVal = link;
  93. this.To = LoadRecipients(tmpVal).ToArray();
  94. if (idx == -1)
  95. return;
  96. link = link.Substring(idx + 1);
  97. // Handle rest of fields
  98. var parameters = GetParameters(link, true);
  99. foreach (var par in parameters) {
  100. if (par.Key == ToField) // overrides the above code
  101. this.To = LoadRecipients(par.Value).ToArray();
  102. else if (par.Key == CcField)
  103. this.Cc = LoadRecipients(par.Value).ToArray();
  104. else if (par.Key == BccField)
  105. this.Bcc = LoadRecipients(par.Value).ToArray();
  106. else if (par.Key == SubjectField)
  107. this.Subject = par.Value;
  108. else if (par.Key == BodyField)
  109. this.Body = par.Value;
  110. }
  111. } catch {
  112. throw new FormatException();
  113. }
  114. }
  115. /// <summary>
  116. /// Splits a mail string into a list of mail addresses.
  117. /// </summary>
  118. protected virtual IEnumerable<string> LoadRecipients(string val) {
  119. var items = val.Split(RecipientDelimiters, StringSplitOptions.RemoveEmptyEntries);
  120. return items.Select(s => s.Trim().ToLower()).Distinct();
  121. }
  122. /// <summary>
  123. /// Splits a parameter string into a list of parameters (kay and value)
  124. /// </summary>
  125. /// <param name="skipEmpty">Whether to skip empty parameters.</param>
  126. protected virtual IEnumerable<KeyValuePair<string, string>> GetParameters(string val, bool skipEmpty = true) {
  127. var items = val.Split(ParamDelimiters, StringSplitOptions.RemoveEmptyEntries);
  128. foreach (var itm in items) {
  129. string key = string.Empty;
  130. string value = string.Empty;
  131. var delimiterIdx = itm.IndexOfAny(ParamValueDelimiters);
  132. if (delimiterIdx == -1)
  133. continue;
  134. key = itm.Substring(0, delimiterIdx).ToLower();
  135. value = itm.Substring(delimiterIdx + 1);
  136. value = UnscapeParamValue(value);
  137. if (key.Length == 0)
  138. continue;
  139. if (skipEmpty && value.Length == 0)
  140. continue;
  141. yield return new KeyValuePair<string, string> (key, value);
  142. }
  143. }
  144. #endregion
  145. #region Link Generation
  146. public virtual string GetLink() { return GenerateLink(true); }
  147. public override string GenerateLink(bool includePrefix) {
  148. string str = base.GenerateLink(includePrefix);
  149. if (this.To != null && this.To.Length > 0) {
  150. str += GetRecipientString(this.To);
  151. }
  152. str += MailDelimiters.First();
  153. if (this.Cc != null && this.Cc.Length > 0) {
  154. str += GetParameterString(CcField, GetRecipientString(this.Cc), false);
  155. str += ParamDelimiters.First();
  156. }
  157. if (this.Bcc != null && this.Bcc.Length > 0) {
  158. str += GetParameterString(BccField, GetRecipientString(this.Bcc), false);
  159. str += ParamDelimiters.First();
  160. }
  161. if (this.Subject != null && this.Subject.Length > 0) {
  162. str += GetParameterString(SubjectField, this.Subject, true);
  163. str += ParamDelimiters.First();
  164. }
  165. if (this.Body != null && this.Body.Length > 0) {
  166. str += GetParameterString(BodyField, this.Body, true);
  167. str += ParamDelimiters.First();
  168. }
  169. str = str.TrimEnd(MailDelimiters.Concat(ParamDelimiters).ToArray());
  170. return str;
  171. }
  172. /// <summary>
  173. /// Joins a list of mail addresses into a string
  174. /// </summary>
  175. protected virtual string GetRecipientString(string[] recipients) {
  176. return string.Join(RecipientDelimiters.First().ToString(), recipients);
  177. }
  178. /// <summary>
  179. /// Joins a parameter (key and value) into a string
  180. /// </summary>
  181. /// <param name="escapeValue">Whether to escape value.</param>
  182. protected virtual string GetParameterString(string key, string value, bool escapeValue) {
  183. return string.Format("{0}{1}{2}",
  184. key,
  185. ParamValueDelimiters.First(),
  186. escapeValue ? EscapeParamValue(value) : value);
  187. }
  188. #endregion
  189. #region Helpers
  190. protected static readonly Dictionary<string, string> CustomUnescapeCharacters =
  191. new Dictionary<string, string>() { { "+", " " } };
  192. private static string EscapeParamValue(string value) {
  193. return Uri.EscapeDataString(value);
  194. }
  195. private static string UnscapeParamValue(string value) {
  196. foreach (var customChar in CustomUnescapeCharacters) {
  197. if (value.Contains(customChar.Key))
  198. value = value.Replace(customChar.Key, customChar.Value);
  199. }
  200. return Uri.UnescapeDataString(value);
  201. }
  202. #endregion
  203. }
  204. public class TelephoneWebLink : WebLink {
  205. #region Prefix
  206. protected static string LinkPrefix { get { return "tel:"; } }
  207. public override string Prefix => LinkPrefix;
  208. #endregion
  209. #region Delimiters
  210. protected static readonly char ExtensionDelimiter = 'p';
  211. #endregion
  212. #region Fields
  213. public string Number { get; set; }
  214. public string Extension { get; set; }
  215. #endregion
  216. public TelephoneWebLink() {
  217. }
  218. public TelephoneWebLink(string link) {
  219. ReadLink(link);
  220. }
  221. public static bool CanHandle(string link) {
  222. return link.ToLower().Trim().StartsWith(LinkPrefix);
  223. }
  224. public override void ClearFields() {
  225. Number = null;
  226. Extension = null;
  227. }
  228. public override void ReadLink(string link) {
  229. base.ReadLink(link);
  230. try {
  231. ClearFields();
  232. // Exclude prefix if necessary
  233. link = ExcludePrefix(link).Trim();
  234. Number = string.Empty;
  235. Extension = string.Empty;
  236. bool foundExtension = false;
  237. int idx = 0;
  238. foreach (var c in link) {
  239. if (idx == 0 && c == '+')
  240. Number += "+";
  241. if (c == ExtensionDelimiter)
  242. foundExtension = true;
  243. else if (char.IsDigit(c)) {
  244. if (foundExtension == false)
  245. Number += c.ToString();
  246. else
  247. Extension += c.ToString();
  248. }
  249. idx++;
  250. }
  251. } catch {
  252. throw new FormatException();
  253. }
  254. }
  255. public override string GenerateLink(bool includePrefix) {
  256. var str = base.GenerateLink(includePrefix);
  257. if (Number != null)
  258. str += Number.ToString();
  259. if (Extension != null && Extension.Length > 0)
  260. str += ExtensionDelimiter.ToString() + Extension;
  261. return str;
  262. }
  263. }

Client Implementation

Start by laying out your custom implementation of WebViewClient,
  1. public class CustomWebViewClient : WebViewClient {
  2. public event EventHandler<WebViewEventArgs> PageStarted;
  3. public event EventHandler<WebViewEventArgs> PageFinished;
  4. public event EventHandler<WebLinkEventArgs> MailRequested;
  5. public event EventHandler<WebLinkEventArgs> TelephoneRequested;
  6. /// <summary>
  7. /// Give the host application a chance to take control when a URL is about to be loaded in the current WebView.
  8. /// </summary>
  9. public override bool ShouldOverrideUrlLoading(WebView view, string url) {
  10. if (HandleCustomUrl(url))
  11. return true;
  12. view.LoadUrl(url);
  13. return true;
  14. }
  15. #region Custom URL Handling
  16. protected virtual bool HandleCustomUrl(string url) {
  17. try {
  18. if (MailWebLink.CanHandle(url)) {
  19. OnMailRequested(url);
  20. return true;
  21. }
  22. if (TelephoneWebLink.CanHandle(url)) {
  23. OnTelephoneRequested(url);
  24. return true;
  25. }
  26. return false;
  27. } catch (FormatException) {
  28. return false;
  29. }
  30. }
  31. private void OnMailRequested(string url) {
  32. if (MailRequested != null)
  33. MailRequested(this, new WebLinkEventArgs(url, new MailWebLink(url)));
  34. }
  35. private void OnTelephoneRequested(string url) {
  36. if (TelephoneRequested != null)
  37. TelephoneRequested(this, new WebLinkEventArgs(url, new TelephoneWebLink(url)));
  38. }
  39. #endregion
  40. #region Page Loading
  41. public override void OnPageStarted(WebView view, string url, Bitmap favicon) {
  42. base.OnPageStarted(view, url, favicon);
  43. if (PageStarted != null)
  44. PageStarted(this, new WebViewEventArgs(url.ToLower()));
  45. }
  46. public override void OnPageFinished(WebView view, string url) {
  47. base.OnPageFinished(view, url);
  48. if (PageFinished != null)
  49. PageFinished(this, new WebViewEventArgs(url.ToLower()));
  50. }
  51. #endregion
  52. }
A few things to mention here,

State Data

Now we would implement the EventArgs classes that would be passed to the host activity. The implementation is very straightforward.
  1. public class WebViewEventArgs : EventArgs{
  2. public string Url { get; set; }
  3. public WebViewEventArgs() { }
  4. public WebViewEventArgs(string url) {
  5. this.Url = url;
  6. }
  7. }
  8. public class WebLinkEventArgs : WebViewEventArgs {
  9. public WebLink WebLink { get; set; }
  10. public WebLinkEventArgs() { }
  11. public WebLinkEventArgs(string url, WebLink link) : base(url) {
  12. WebLink = link;
  13. }
  14. }

Linking the Client

Now link the client to the control by calling WebView.SetWebViewClient in activity’s OnCreate,
  1. protected WebView WebView { get; set; }
  2. protected CustomWebViewClient WebViewClient { get; set; }
  3. protected override void OnCreate(Bundle savedInstanceState) {
  4. base.OnCreate(savedInstanceState);
  5. this.WebView = this.FindViewById<WebView>(Resource.Id.WebView_View);
  6. WebViewClient = new CustomWebViewClient();
  7. WebViewClient.MailRequested += WebViewClient_MailRequested;
  8. WebViewClient.TelephoneRequested += WebViewClient_TelephoneRequested;
  9. this.WebView.SetWebViewClient(WebViewClient);
  10. }
  11. private void WebViewClient_MailRequested(object sender, WebLinkEventArgs e) {
  12. var lnk = e.WebLink as MailWebLink;
  13. IntentHelper.MailTo(this, lnk);
  14. }
  15. private void WebViewClient_TelephoneRequested(object sender, WebLinkEventArgs e) {
  16. var lnk = e.WebLink as TelephoneWebLink;
  17. if (lnk.Number.Length == 0)
  18. return;
  19. try {
  20. IntentHelper.PhoneCall(this, lnk.Number);
  21. } catch (Java.Lang.SecurityException ex) {
  22. // App is not granted permmission for phone calls
  23. }
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.
  1. public static partial class IntentHelper {
  2. public static void MailTo(Context ctx, MailWebLink link, string activityTitle = null) {
  3. MailTo(ctx, link.To, link.Cc, link.Bcc, link.Subject, link.Body, activityTitle);
  4. }
  5. public static void MailTo(Context ctx,
  6. string[] to,
  7. string[] cc,
  8. string[] bcc,
  9. string subject,
  10. string body,
  11. string activityTitle = null) {
  12. Intent email = new Intent(Intent.ActionSend);
  13. email.SetType("message/rfc822");
  14. if (to != null)
  15. email.PutExtra(Intent.ExtraEmail, to );
  16. if (cc != null)
  17. email.PutExtra(Intent.ExtraCc, cc);
  18. if (bcc != null)
  19. email.PutExtra(Intent.ExtraBcc, bcc);
  20. if (subject != null)
  21. email.PutExtra(Intent.ExtraSubject, subject);
  22. if (body != null)
  23. email.PutExtra(Intent.ExtraText, body);
  24. if (activityTitle == null)
  25. activityTitle = Application.Context.Resources.GetString(Resource.String.text_send);
  26. ctx.StartActivity(Intent.CreateChooser(email, activityTitle));
  27. }
  28. }

Making Phone Calls

While sending an email is easy, calling a number is easier,
  1. public static partial class IntentHelper {
  2. public static void PhoneCall(Context ctx, TelephoneWebLink lnk) {
  3. PhoneCall(ctx, lnk.Number);
  4. }
  5. public static void PhoneCall(Context ctx, string number) {
  6. Intent intent = new Intent(Intent.ActionCall);
  7. intent.SetData(Android.Net.Uri.Parse("tel: " + number));
  8. ctx.StartActivity(intent);
  9. }
  10. }

Conclusion

The WebViewClient opens the possibility of handling many aspects and behavior 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 any feedback, comments, or code updates please let me know.