refactor: group options-related logic in //src/common/options

Change-Id: Ib505da278accd878478399967efd72c8d89095ce
diff --git a/src/common/options/optionsPermissions.js b/src/common/options/optionsPermissions.js
new file mode 100644
index 0000000..f005131
--- /dev/null
+++ b/src/common/options/optionsPermissions.js
@@ -0,0 +1,212 @@
+import actionApi from '../actionApi.js';
+import {optionsPrototype} from './optionsPrototype';
+import {getOptions} from './optionsUtils.js';
+
+// Required permissions, including host permissions.
+//
+// IMPORTANT: This should be kept in sync with the "permissions",
+// "host_permissions" and "content_scripts" keys in //templates/manifest.gjson.
+const requiredPermissions = {
+  permissions: new Set([
+    'storage', 'alarms',
+    // #!if browser_target == 'chromium_mv3'
+    'scripting',
+    // #!endif
+  ]),
+  origins: new Set([
+    // Host permissions:
+    'https://support.google.com/*',
+
+    // Content scripts matches:
+    'https://support.google.com/s/community*',
+    'https://support.google.com/*/threads*',
+    'https://support.google.com/*/thread/*',
+    'https://support.google.com/*/community-guide/*',
+    'https://support.google.com/*/community-video/*',
+    'https://support.google.com/*/profile/*',
+    'https://support.google.com/profile/*',
+  ]),
+};
+
+const permissionTypes = ['origins', 'permissions'];
+
+// Returns an array of optional permissions needed by |feature|.
+export function requiredOptPermissions(feature) {
+  if (!(feature in optionsPrototype)) {
+    console.error('"' + feature + '" feature doesn\'t exist.');
+    return [];
+  }
+
+  return optionsPrototype[feature]?.requiredOptPermissions ?? [];
+}
+
+// Returns a promise resolving to the optional permissions needed by all the
+// current enabled features.
+export function currentRequiredOptPermissions() {
+  return getOptions(null, /* requireOptionalPermissions = */ false)
+      .then(options => {
+        let permissions = {
+          origins: [],
+          permissions: [],
+        };
+
+        // For each option
+        for (const [opt, optMeta] of Object.entries(optionsPrototype))
+          // If the option is enabled
+          if (options[opt])
+            // Add its required optional permissions to the list
+            for (const type of permissionTypes)
+              permissions[type].push(
+                  ...(optMeta.requiredOptPermissions?.[type] ?? []));
+
+        return permissions;
+      });
+}
+
+// Ensures that all the optional permissions required by |feature| are granted,
+// and requests them otherwise. It returns a promise which resolves specifying
+// whether the permissions were granted or not.
+export function ensureOptPermissions(feature) {
+  return new Promise((resolve, reject) => {
+    let permissions = requiredOptPermissions(feature);
+
+    chrome.permissions.contains(permissions, isAlreadyGranted => {
+      if (isAlreadyGranted) return resolve(true);
+
+      chrome.permissions.request(permissions, granted => {
+        // If there was an error, reject the promise.
+        if (granted === undefined)
+          return reject(new Error(
+              chrome.runtime.lastError.message ??
+              'An unknown error occurred while requesting the permisisons'));
+
+        // If the permission is granted we should maybe remove the warning
+        // badge.
+        if (granted) cleanUpOptPermissions(/* removeLeftoverPerms = */ false);
+
+        return resolve(granted);
+      });
+    });
+  });
+}
+
+// Returns a promise resolving to the currently granted optional permissions
+// (i.e. excluding required permissions).
+export function grantedOptPermissions() {
+  return new Promise((resolve, reject) => {
+    chrome.permissions.getAll(response => {
+      if (response === undefined)
+        return reject(new Error(
+            chrome.runtime.lastError.message ??
+            'An unknown error occurred while calling chrome.permissions.getAll()'));
+
+      let optPermissions = {};
+      for (const type of permissionTypes)
+        optPermissions[type] =
+            response[type].filter(p => !requiredPermissions[type].has(p));
+      resolve(optPermissions);
+    });
+  });
+}
+
+// Returns a promise resolving to an object with 2 properties:
+//   - missingPermissions: optional permissions which are required by enabled
+//     features and haven't been granted yet.
+//   - leftoverPermissions: optional permissions which are granted but are no
+//     longer needed.
+export function diffPermissions() {
+  return Promise
+      .all([
+        grantedOptPermissions(),
+        currentRequiredOptPermissions(),
+      ])
+      .then(perms => {
+        let diff = {
+          missingPermissions: {},
+          leftoverPermissions: {},
+        };
+        for (const type of permissionTypes) {
+          diff.missingPermissions[type] =
+              perms[1][type].filter(p => !perms[0][type].includes(p));
+          diff.leftoverPermissions[type] =
+              perms[0][type].filter(p => !perms[1][type].includes(p));
+        }
+        return diff;
+      })
+      .catch(cause => {
+        throw new Error(
+            'Couldn\'t compute the missing and leftover permissions.', {cause});
+      });
+}
+
+// Returns a promise which resolves to the required optional permissions of
+// |feature| which are missing.
+//
+// Accepts an argument |grantedPermissions| with the granted permissions,
+// otherwise the function will call grantedOptPermissions() to retrieve them.
+// This can be used to prevent calling chrome.permissions.getAll() repeteadly.
+export function missingPermissions(feature, grantedPermissions = null) {
+  let grantedOptPermissionsPromise;
+  if (grantedPermissions !== null)
+    grantedOptPermissionsPromise = new Promise((res, rej) => {
+      res(grantedPermissions);
+    });
+  else
+    grantedOptPermissionsPromise = grantedOptPermissions();
+
+  return Promise
+      .all([
+        grantedOptPermissionsPromise,
+        requiredOptPermissions(feature),
+      ])
+      .then(perms => {
+        let missingPerms = {};
+        for (const type of permissionTypes)
+          missingPerms[type] =
+              perms[1][type].filter(p => !perms[0][type].includes(p))
+          return missingPerms;
+      })
+      .catch(cause => {
+        throw new Error(
+            'Couldn\'t compute the missing permissions for "' + feature + '",',
+            {cause});
+      });
+}
+
+// Returns true if permissions (a chrome.permissions.Permissions object) is
+// empty (that is, if their properties have empty arrays).
+export function isPermissionsObjectEmpty(permissions) {
+  for (const type of permissionTypes) {
+    if ((permissions[type]?.length ?? 0) > 0) return false;
+  }
+  return true;
+}
+
+// Deletes optional permissions which are no longer needed by the current
+// set of enabled features (if |removeLeftoverPerms| is set to true), and sets a
+// badge if some needed permissions are missing.
+export function cleanUpOptPermissions(removeLeftoverPerms = true) {
+  return diffPermissions()
+      .then(perms => {
+        let {missingPermissions, leftoverPermissions} = perms;
+
+        if (!isPermissionsObjectEmpty(missingPermissions)) {
+          actionApi.setBadgeBackgroundColor({color: '#B71C1C'});
+          actionApi.setBadgeText({text: '!'});
+          actionApi.setTitle({
+            title: chrome.i18n.getMessage('actionbadge_permissions_requested')
+          });
+        } else {
+          actionApi.setBadgeText({text: ''});
+          actionApi.setTitle({title: ''});
+        }
+
+        if (removeLeftoverPerms) {
+          chrome.permissions.remove(leftoverPermissions);
+        }
+      })
+      .catch(err => {
+        console.error(
+            'An error ocurred while cleaning optional permissions: ', err);
+      });
+}