Add xhrInterceptor utility

Some features would benefit from being able to listen to calls made by
the Community Console client to the API. This way, the extension
wouldn't need to make additional calls to the API, since it can directly
get all the information from the current view via the xhrInterceptor.

This change adds a script which is injected into the Community Console
and acts as the interceptor. The src/common/xhrInterceptors.json5 file
defines which calls should be intercepted, and which data (the request
body or the response body) should be retrieved, and when the
xhrInterceptor finds that an API call matches one of those definitions,
it dispatches an event with the name defined in the json5 file and the
prefix "TWPT_". Then, content scripts can listen for these events in
order to work with the data provided in the event details.

Bug: 6
Change-Id: Iea4aeb1f9db84f2c013d82ec4155c59617b8f9f0
diff --git a/src/common/xhrInterceptorUtils.js b/src/common/xhrInterceptorUtils.js
new file mode 100644
index 0000000..baa2bd3
--- /dev/null
+++ b/src/common/xhrInterceptorUtils.js
@@ -0,0 +1,41 @@
+import xhrInterceptors from './xhrInterceptors.json5';
+
+export {xhrInterceptors};
+
+export function matchInterceptors(interceptFilter, url) {
+  return xhrInterceptors.interceptors.filter(interceptor => {
+    var regex = new RegExp(interceptor.urlRegex);
+    return interceptor.intercepts == interceptFilter && regex.test(url);
+  });
+}
+
+export function getResponseJSON(xhr) {
+  if (xhr.responseType === 'arraybuffer') {
+    var arrBuffer = xhr.response;
+    if (!arrBuffer) {
+      console.error('No array buffer.');
+      return undefined;
+    }
+    var byteArray = new Uint8Array(arrBuffer);
+    var dec = new TextDecoder('utf-8');
+    var rawResponse = dec.decode(byteArray);
+    return JSON.parse(rawResponse);
+  }
+  if (xhr.responseType === 'text' || xhr.responseType === '')
+    return JSON.parse(xhr.responseText);
+  if (xhr.responseType === 'json') return xhr.response;
+
+  console.error(
+      'Unexpected responseType ' + xhr.responseType + '. Request url: ',
+      xhr.$TWPTRequestURL);
+  return undefined;
+}
+
+export function triggerEvent(eventName, body) {
+  var evt = new CustomEvent('TWPT_' + eventName, {
+    detail: {
+      body,
+    }
+  });
+  window.dispatchEvent(evt);
+}
diff --git a/src/common/xhrInterceptors.json5 b/src/common/xhrInterceptors.json5
new file mode 100644
index 0000000..158edb9
--- /dev/null
+++ b/src/common/xhrInterceptors.json5
@@ -0,0 +1,9 @@
+{
+  interceptors: [
+    /*{
+      eventName: "ViewThreadResponse",
+      urlRegex: "api/ViewThread",
+      intercepts: "response",
+    },*/ // Example
+  ],
+}
diff --git a/src/contentScripts/communityConsole/start.js b/src/contentScripts/communityConsole/start.js
index 318e466..9c14a43 100644
--- a/src/contentScripts/communityConsole/start.js
+++ b/src/contentScripts/communityConsole/start.js
@@ -1,4 +1,4 @@
-import {injectStylesheet} from '../../common/contentScriptsUtils.js';
+import {injectStylesheet, injectScript} from '../../common/contentScriptsUtils.js';
 
 const SMEI_SORT_DIRECTION = 8;
 const SMEI_UNIFIED_PROFILES = 9;
@@ -42,4 +42,6 @@
         break;
     }
   }
+
+  injectScript(chrome.runtime.getURL('xhrInterceptorInject.bundle.js'));
 });
diff --git a/src/injections/xhrInterceptor.js b/src/injections/xhrInterceptor.js
new file mode 100644
index 0000000..8630ca3
--- /dev/null
+++ b/src/injections/xhrInterceptor.js
@@ -0,0 +1,46 @@
+import * as utils from '../common/xhrInterceptorUtils.js';
+
+const originalOpen = XMLHttpRequest.prototype.open;
+const originalSend = XMLHttpRequest.prototype.send;
+
+XMLHttpRequest.prototype.open = function() {
+  this.$TWPTRequestURL = arguments[1] || location.href;
+
+  let interceptors = utils.matchInterceptors('response', this.$TWPTRequestURL);
+  if (interceptors.length > 0) {
+    this.addEventListener('load', function() {
+      var body = utils.getResponseJSON(this);
+      if (body !== undefined)
+        interceptors.forEach(i => {
+          utils.triggerEvent(i.eventName, body);
+        });
+    });
+  }
+
+  originalOpen.apply(this, arguments);
+};
+
+XMLHttpRequest.prototype.send = function() {
+  originalSend.apply(this, arguments);
+
+  let interceptors =
+      utils.matchInterceptors('request', this.$TWPTRequestURL || location.href);
+  if (interceptors.length > 0) {
+    var rawBody = arguments[0];
+    if (typeof (rawBody) !== 'object' ||
+        !(rawBody instanceof Object.getPrototypeOf(Uint8Array))) {
+      console.error(
+          'Request body is not Uint8Array, but ' + typeof (rawBody) + '.',
+          this.$TWPTRequestUrl);
+      return;
+    }
+
+    var dec = new TextDecoder('utf-8');
+    var body = dec.decode(rawBody);
+    var JSONBody = JSON.parse(body);
+
+    interceptors.forEach(i => {
+      utils.triggerEvent(i.eventName, JSONBody);
+    });
+  }
+};
diff --git a/templates/manifest.gjson b/templates/manifest.gjson
index e3efb13..8a9fe9d 100644
--- a/templates/manifest.gjson
+++ b/templates/manifest.gjson
@@ -66,6 +66,7 @@
 #endif
         "profileIndicatorInject.bundle.js",
         "batchLockInject.bundle.js",
+        "xhrInterceptorInject.bundle.js",
 
         "css/profileindicator_inject.css",
         "css/ccdarktheme.css",
diff --git a/webpack.config.js b/webpack.config.js
index bdd988e..312a276 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -16,6 +16,7 @@
     // Injected JS
     profileIndicatorInject: './src/injections/profileIndicator.js',
     batchLockInject: './src/injections/batchLock.js',
+    xhrInterceptorInject: './src/injections/xhrInterceptor.js',
 
     // Options page
     optionsCommon: './src/optionsCommon.js',