diff --git a/core/includes/module.inc b/core/includes/module.inc
index 11f8244..16a0387 100644
--- a/core/includes/module.inc
+++ b/core/includes/module.inc
@@ -115,6 +115,7 @@ function system_list_reset() {
   drupal_static_reset('list_themes');
   cache('bootstrap')->delete('system_list');
   cache()->delete('system_info');
+  cache()->delete('system_project_list');
   // Remove last known theme data state.
   // This causes system_list() to call system_rebuild_theme_data() on its next
   // invocation. When enabling a module that implements hook_system_info_alter()
diff --git a/core/includes/project.inc b/core/includes/project.inc
new file mode 100644
index 0000000..0fe676d
--- /dev/null
+++ b/core/includes/project.inc
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * @file
+ * API for building lists of installed projects.
+ */
+
+use Drupal\Core\Project\ProjectBuilder;
+use Drupal\Core\Project\ProjectFactory;
+
+/**
+ * Fetches an array of installed and enabled projects.
+ *
+ * @todo Review this documentation for changes from array to object and array
+ *   properties versus getters/setters.
+ *
+ * This is only responsible for generating an array of projects (taking into
+ * account projects that include more than one module or theme). Other
+ * information like the specific version and install type (official release,
+ * dev snapshot, etc) is handled later in project_process_version_info() since
+ * that logic is only required when preparing the status report, not for
+ * fetching the available release data.
+ *
+ * This array is fairly expensive to construct, since it involves a lot of disk
+ * I/O, so modules using this list should cache the results.
+ *
+ * @return array
+ *   An associative array of currently enabled projects keyed by the
+ *   machine-readable project short name. Each project contains:
+ *   - name: The machine-readable project short name.
+ *   - info: An array with values from the main .info file for this project.
+ *     - name: The human-readable name of the project.
+ *     - package: The package that the project is grouped under.
+ *     - version: The version of the project.
+ *     - project: The Drupal.org project name.
+ *     - datestamp: The date stamp of the project's main .info file.
+ *     - _info_file_ctime: The maximum file change time for all of the .info
+ *       files included in this project.
+ *   - datestamp: The date stamp when the project was released, if known.
+ *   - includes: An associative array containing all projects included with this
+ *     project, keyed by the machine-readable short name with the human-readable
+ *     name as value.
+ *   - project_type: The type of project. Allowed values are 'module' and
+ *     'theme'.
+ *   - project_status: This indicates if the project is enabled and will always
+ *     be TRUE, as the function only returns enabled projects.
+ *   - sub_themes: If the project is a theme it contains an associative array of
+ *     all sub-themes.
+ *   - base_themes: If the project is a theme it contains an associative array
+ *     of all base-themes.
+ *
+ * @see project_process_version_info()
+ * @see update_get_projects()
+ * @see locale_translation_get_projects()
+ */
+function system_project_list() {
+  if ($cached = cache()->get('system_project_list')) {
+    $projects = $cached->data;
+  }
+  else {
+    $module_data = system_rebuild_module_data();
+    $theme_data = system_rebuild_theme_data();
+    $builder = new ProjectBuilder($module_data, $theme_data, new ProjectFactory());
+    $projects = $builder->getAllProjects();
+
+    // Allow other modules to alter projects before fetching and comparing.
+    drupal_alter('system_project_list', $projects);
+    // Cache the site's project data for at most 1 hour.
+    cache()->set('system_project_list', $projects, REQUEST_TIME + 3600);
+  }
+  return $projects;
+}
diff --git a/core/lib/Drupal/Component/Gettext/PoItem.php b/core/lib/Drupal/Component/Gettext/PoItem.php
index 599a58a..6e6139a 100644
--- a/core/lib/Drupal/Component/Gettext/PoItem.php
+++ b/core/lib/Drupal/Component/Gettext/PoItem.php
@@ -265,6 +265,7 @@ private function formatSingular() {
    * Formats a string for output on multiple lines.
    */
   private function formatString($string) {
+if (!is_string($string)) {debug($string);}
     // Escape characters for processing.
     $string = addcslashes($string, "\0..\37\\\"");
 
diff --git a/core/lib/Drupal/Core/Project/Project.php b/core/lib/Drupal/Core/Project/Project.php
new file mode 100644
index 0000000..14c1367
--- /dev/null
+++ b/core/lib/Drupal/Core/Project/Project.php
@@ -0,0 +1,284 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Project\Project.
+ */
+
+namespace Drupal\Core\Project;
+
+/**
+ * @todo
+ */
+class Project {
+
+  /**
+   * The machine readable project name.
+   *
+   * @var string
+   */
+  protected $name = '';
+
+  /**
+   * The type of project. e.g. module, theme, core, etc.
+   *
+   * @var string
+   */
+  protected $type = '';
+
+  /**
+   * The project status. TRUE = enabled, FALSE = disabled.
+   *
+   * @var string
+   */
+  protected $status = '';
+
+  /**
+   * Array of project info, as defined in the .info file.
+   *
+   * @var array
+   */
+  protected $info = array();
+
+  /**
+   * The timestamp of project release.
+   *
+   * @var int
+   */
+  protected $datestamp = 0;
+
+  /**
+   * Array of modules included in this project.
+   *
+   * @var array
+   */
+  protected $includes = array();
+
+  /**
+   * Array of disabled modules in this project.
+   *
+   * @var array
+   */
+  protected $disabled = array();
+
+  /**
+   * Array of base themes required by the project.
+   *
+   * @var array
+   */
+  protected $baseThemes = array();
+
+  /**
+   * Array of sub themes depending on this project.
+   *
+   * @var array
+   */
+  protected $subThemes = array();
+
+  /**
+   * @todo
+   */
+  public function __construct($name, $type, $status, $info) {
+    $this->name = $name;
+    $this->type = $type;
+    $this->status = (boolean) $status;
+    $this->info = $info;
+    $this->datestamp = isset($info['datestamp']) ? $info['datestamp'] : 0;
+    $this->setIncludes($name, $info['name']);
+  }
+
+  /**
+   * Sets the project datestamp to the maximum value.
+   *
+   * @param int $datestamp
+   *   Datestamp the module or theme was created. Only the most recent date
+   *   stamp of the included modules or themes will be stored.
+   */
+  public function setDatestamp($datestamp) {
+    $this->datestamp = max($this->datestamp, $datestamp);
+  }
+
+  /**
+   * Sets the info file change time to the maximum timestamp value.
+   *
+   * @param int $timestamp
+   *   Timestamp when the module's or theme's .info file was created. Only the
+   *   most recent time stamp of the included modules or themes will be stored.
+   */
+  public function setChangeTime($timestamp) {
+    $this->info['_info_file_ctime'] = max($this->info['_info_file_ctime'], $timestamp);
+  }
+
+  /**
+   * Adds inluded modules or themes to the project.
+   *
+   * @param string $name
+   *   Machine readable module or theme name.
+   * @param string $title
+   *   Human readable module or theme name.
+   */
+  public function setIncludes($name, $title) {
+    $this->includes[$name] = $title;
+  }
+
+  /**
+   * Adds disabled modules or themes to the project.
+   *
+   * @param string $name
+   *   Machine readable module or theme name.
+   * @param string $title
+   *   Human readable module or theme name.
+   */
+  public function setDisabled($name, $title) {
+    $this->disabled[$name] = $title;
+  }
+
+  /**
+   * Adds base themes related to this project.
+   *
+   * @param array $themes
+   *   Array of related base themes keyed by machine readable theme name.
+   */
+  public function setBaseThemes($themes) {
+    if (!empty($themes) && is_array($themes)) {
+      $this->baseThemes += $themes;
+    }
+  }
+
+  /**
+   * Adds the sub theme related to this project.
+   *
+   * @param array $themes
+   *   Array of related sub themes keyed by machine readable theme name.
+   */
+  public function setSubThemes($themes) {
+    if (!empty($themes) && is_array($themes)) {
+      $this->subThemes += $themes;
+    }
+  }
+
+  /**
+   * Adds project info data.
+   *
+   * @param array $info
+   *   Associative array of project info data.
+   */
+  public function setInfo($info) {
+    if (!empty($info) && is_array($info)) {
+      $this->info += $info;
+    }
+  }
+
+  /**
+   * Gets the machine readable name of the project.
+   *
+   * @return string
+   *   Machine readable name of the project.
+   */
+  public function getName() {
+    return $this->name;
+  }
+
+  /**
+   * Gets the project type.
+   *
+   * @return string
+   *   Project type. e.g. 'module', 'theme', 'core'.
+   */
+  public function getType() {
+    return $this->type;
+  }
+
+  /**
+   * Gets the project status.
+   *
+   * @return bool
+   *   TRUE: the project is enabled. FALSE if disabled.
+   */
+  public function getStatus() {
+    return $this->status;
+  }
+
+  /**
+   * Gets the timestamp of project release.
+   *
+   * @return int
+   *   Timestamp of project release. Zero if not set.
+   */
+  public function getDatestamp() {
+    return $this->datestamp;
+  }
+
+  /**
+   * Gets the modules included in this project.
+   *
+   * @return array
+   *   Modules included in this project, keyed by machine readable module name.
+   */
+  public function getIncludes() {
+    return $this->includes;
+  }
+
+  /**
+   * Gets the disabled modules in this project.
+   *
+   * @return array
+   *   Disabled modules in this project, keyed by machine readable module name.
+   */
+  public function getDisabled() {
+    return $this->disabled;
+  }
+
+  /**
+   * Gets the base themes required by this project.
+   *
+   * @return array
+   *   Related base themes, keyed by machine readable theme name.
+   */
+  public function getBaseThemes() {
+    return $this->baseThemes;
+  }
+
+  /**
+   * Gets the sub themes depending on this project.
+   *
+   * @return array
+   *   Related sub themes, keyed by machine readable theme name.
+   */
+  public function getSubThemes() {
+    return $this->subThemes;
+  }
+
+  /**
+   * Gets the raw project info data.
+   *
+   * @return array
+   *   Project info data.
+   */
+  public function getInfoRaw() {
+    return $this->info;
+  }
+
+  /**
+   * Gets specific project info data.
+   *
+   * @param string $param
+   *   The name of the project info data to return.
+   *
+   * @return string
+   *   The specified project info data or NULL is not found.
+   */
+  public function getInfo($param) {
+    return isset($this->info[$param]) ? $this->info[$param] : NULL;
+  }
+
+  /**
+   * Gets human readable project name.
+   *
+   * @return string
+   *   Human readable project name. Falls back to machine name if not available.
+   */
+  public function getProjectName() {
+    return isset($this->info['name']) ? $this->project->info['name'] : $this->name;
+  }
+}
diff --git a/core/lib/Drupal/Core/Project/ProjectBuilder.php b/core/lib/Drupal/Core/Project/ProjectBuilder.php
new file mode 100644
index 0000000..db3b130
--- /dev/null
+++ b/core/lib/Drupal/Core/Project/ProjectBuilder.php
@@ -0,0 +1,283 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Project\ProjectBuilder.
+ */
+
+namespace Drupal\Core\Project;
+
+use Drupal\Core\Project\Project;
+
+/**
+ * @todo
+ */
+class ProjectBuilder {
+
+  /**
+   * Array of Project objects.
+   *
+   * @var array
+   */
+  protected $projects = array();
+
+  /**
+   * The object that instantiates projects build by this project builder.
+   *
+   * @var Drupal\Core\Project\ProjectFactory.
+   */
+  protected $factory;
+
+  /**
+   * @todo Finish documentation
+   *
+   * @param array $module_data
+   * @apram array $theme_data
+   * @param Drupal\Core\Project\ProjectFactory $factory
+   *   Name of the class used to create the individual project objects.
+   */
+  // @todo Consider using a $project_class instead of $factory.
+  public function __construct($module_data, $theme_data, $factory) {
+    $this->factory = $factory;
+
+    // First parse data of enabled modules (or themes) only then parse data of
+    // the disabled ones.
+    $this->parse($module_data, 'module', TRUE);
+    $this->parse($module_data, 'module', FALSE);
+    $this->parse($theme_data, 'theme', TRUE);
+    $this->parse($theme_data, 'theme', FALSE);
+  }
+
+  /**
+   * Returns all build projects.
+   *
+   * @return array
+   *   Projects keyed by the machine readable project name.
+   */
+  public function getAllProjects() {
+    return $this->projects;
+  }
+
+  /**
+   * Returns a single project object.
+   *
+   * @return Drupal\Core\Project\Project
+   *   The selected project or NULL if not found.
+   */
+  public function getProject($name) {
+    if (isset($this->projects[$name])) {
+      return $this->projects[$name];
+    }
+  }
+
+  /**
+   * Stores a project in the internal project array.
+   *
+   * @param object $project
+   *   Project object.
+   */
+  protected function setProject($project) {
+    $this->projects[$project->getName()] = $project;
+  }
+
+  /**
+   * Parse project list and retrieve project data.
+   *
+   * This iterates over a list of the installed modules or themes and groups them
+   * by project and status. A few parts of this function assume that enabled
+   * modules and themes are always processed first, and if disabled modules or
+   * themes are being processed (there is a setting to control if disabled code
+   * should be included or not in the 'Available updates' report), those are only
+   * processed after $projects has been populated with information about the
+   * enabled code. Modules and themes set as hidden are always ignored. This
+   * function also records the latest change time on the .info files for each
+   * module or theme, which is important data which is used when deciding if the
+   * cached available update data should be invalidated.
+   *
+   * @param array $list
+   *   Data to process to add the relevant info to the $projects array.
+   * @param string $project_type
+   *   The kind of data in the list. Can be 'module' or 'theme'.
+   * @param bool $status
+   *   The status (enabled or disabled) to process out of the $list and add to
+   *   the $projects array.
+   */
+  public function parse($list, $project_type, $status) {
+    foreach ($list as $file) {
+      // A disabled base theme of an enabled sub-theme still has all of its code
+      // run by the sub-theme, so we include it in our "enabled" projects list.
+      if ($status && !$file->status && !empty($file->sub_themes)) {
+        foreach ($file->sub_themes as $key => $name) {
+          // Build a list of enabled sub-themes.
+          if ($list[$key]->status) {
+            $file->enabled_sub_themes[$key] = $name;
+          }
+        }
+        // If there are no enabled subthemes, we should ignore this base theme
+        // for the enabled case. If the site is trying to display disabled
+        // themes, we'll catch it then.
+        if (empty($file->enabled_sub_themes)) {
+          continue;
+        }
+      }
+      // Otherwise, just add projects of the proper status to our list.
+      elseif ($file->status != $status) {
+        continue;
+      }
+
+      // Skip if the .info file is broken.
+      if (empty($file->info)) {
+        continue;
+      }
+
+      // Skip if it's a hidden module or theme.
+      if (!empty($file->info['hidden'])) {
+        continue;
+      }
+
+      // If the .info doesn't define the 'project', try to figure it out.
+      if (!isset($file->info['project'])) {
+        $file->info['project'] = $this->fileGetName($file);
+      }
+
+      // If we still don't know the 'project', give up.
+      if (empty($file->info['project'])) {
+        continue;
+      }
+
+      // If we don't already know it, grab the change time on the .info file
+      // itself. Note: we need to use the ctime, not the mtime (modification
+      // time) since many (all?) tar implementations will go out of their way to
+      // set the mtime on the files it creates to the timestamps recorded in the
+      // tarball. We want to see the last time the file was changed on disk,
+      // which is left alone by tar and correctly set to the time the .info file
+      // was unpacked.
+      if (!isset($file->info['_info_file_ctime'])) {
+        $info_filename = dirname($file->uri) . '/' . $file->name . '.info';
+        $file->info['_info_file_ctime'] = filectime($info_filename);
+      }
+
+      if (!isset($file->info['datestamp'])) {
+        $file->info['datestamp'] = 0;
+      }
+
+      $project_name = $file->info['project'];
+
+      // Figure out what project type we're going to use to display this module
+      // or theme. If the project name is 'drupal', we don't want it to show up
+      // under the usual "Modules" section, we put it at a special "Drupal Core"
+      // section at the top of the report.
+      if ($project_name == 'drupal') {
+        $project_display_type = 'core';
+      }
+      else {
+        $project_display_type = $project_type;
+      }
+      if (empty($status) && empty($file->enabled_sub_themes)) {
+        // If we're processing disabled modules or themes, append a suffix.
+        // However, we don't do this to a a base theme with enabled
+        // subthemes, since we treat that case as if it is enabled.
+        $project_display_type .= '-disabled';
+      }
+      // Add a list of sub-themes that "depend on" the project and a list of base
+      // themes that are "required by" the project.
+      if ($project_name == 'drupal') {
+        // Drupal core is always required, so this extra info would be noise.
+        $sub_themes = array();
+        $base_themes = array();
+      }
+      else {
+        // Add list of enabled sub-themes.
+        $sub_themes = !empty($file->enabled_sub_themes) ? $file->enabled_sub_themes : array();
+        // Add list of base themes.
+        $base_themes = !empty($file->base_themes) ? $file->base_themes : array();
+      }
+      if (!$project = $this->getProject($project_name)) {
+        // Only process this if we haven't done this project, since a single
+        // project can have multiple modules or themes.
+        // @todo dynamically define a class.
+
+        // Only save attributes from the .info file we care about so we do not
+        // bloat our RAM usage needlessly.
+        $project = $this->factory->createInstance($project_name, $project_display_type, $status, $this->filterInfo($file->info));
+        $project->setSubThemes($sub_themes);
+        $project->setBaseThemes($base_themes);
+      }
+      elseif ($project->getType() == $project_display_type) {
+        // Only add the file we're processing to the 'includes' array for this
+        // project if it is of the same type and status (which is encoded in the
+        // $project_display_type). This prevents listing all the disabled
+        // modules included with an enabled project if we happen to be checking
+        // for disabled modules, too.
+        $project->setIncludes($file->name, $file->info['name']);
+        $project->setChangeTime($file->info['_info_file_ctime']);
+        $project->setDatestamp($file->info['datestamp']);
+        $project->setSubThemes($sub_themes);
+        $project->setBaseThemes($base_themes);
+      }
+      elseif (empty($status)) {
+        // If we have a project_name that matches, but the project_display_type
+        // does not, it means we're processing a disabled module or theme that
+        // belongs to a project that has some enabled code. In this case, we add
+        // the disabled thing into a separate array for separate display.
+        $project->setDisabled($file->name, $file->info['name']);
+      }
+
+      // Store the project.
+      if (!empty($project)) {
+        $this->setProject($project);
+      }
+    }
+  }
+
+  /**
+   * Determines what project a given file object belongs to.
+   *
+   * @param object $file
+   *   A file object as returned by system_get_files_database().
+   *
+   * @return string
+   *   The canonical project short name.
+   */
+  protected function fileGetName($file) {
+    $project_name = '';
+    if (isset($file->info['project'])) {
+      $project_name = $file->info['project'];
+    }
+    elseif (isset($file->filename) && (strpos($file->filename, 'core/modules') === 0)) {
+      $project_name = 'drupal';
+    }
+    return $project_name;
+  }
+
+  /**
+   * Filters the project .info data to only save attributes we need.
+   *
+   * The saved attributes will be a predefined list plus all the attributes
+   * whose name start with 'project'.
+   *
+   * @param array $info
+   *   Array of .info file data as returned by drupal_parse_info_file().
+   *
+   * @return array
+   *   Array of .info file data we need for the project list.
+   */
+  protected function filterInfo($info) {
+    $return = array();
+    $whitelist = array(
+      '_info_file_ctime',
+      'datestamp',
+      'major',
+      'name',
+      'package',
+      'version',
+    );
+
+    foreach ($info as $name => $data) {
+      if (in_array($name, $whitelist) || strpos($name, 'project') === 0) {
+        $return[$name] = $data;
+      }
+    }
+    return $return;
+  }
+}
diff --git a/core/lib/Drupal/Core/Project/ProjectFactory.php b/core/lib/Drupal/Core/Project/ProjectFactory.php
new file mode 100644
index 0000000..b0d6470
--- /dev/null
+++ b/core/lib/Drupal/Core/Project/ProjectFactory.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Project\ProjectFactory.
+ */
+
+namespace Drupal\Core\Project;
+
+class ProjectFactory {
+
+  /**
+   * @todo
+   */
+  public function createInstance($name, $type, $status, $info) {
+    return new Project($name, $type, $status, $info);
+  }
+}
diff --git a/core/modules/locale/lib/Drupal/locale/Tests/LocaleUpdateTest.php b/core/modules/locale/lib/Drupal/locale/Tests/LocaleUpdateTest.php
index e5dccbd..fd5eed9 100644
--- a/core/modules/locale/lib/Drupal/locale/Tests/LocaleUpdateTest.php
+++ b/core/modules/locale/lib/Drupal/locale/Tests/LocaleUpdateTest.php
@@ -183,9 +183,11 @@ private function makePoFile($path, $filename, $timestamp = NULL, $translations =
   private function setTranslationFiles() {
     $config = config('locale.settings');
 
-    // A flag is set to let the locale_test module replace the project data with
-    // a set of test projects which match the below project files.
+    // Set a flag to let the locale_test module replace the project data with
+    // a set of test projects which match the below project files. Clear the
+    // project list data to contain the new definition at the next request.
     state()->set('locale.test_projects_alter', TRUE);
+    cache()->delete('system_project_list');
 
     // Setup the environment.
     $public_path = variable_get('file_public_path', conf_path() . '/files');
@@ -320,7 +322,7 @@ function testUpdateProjects() {
     // Check if interface translation data is collected from hook_info.
     $projects = locale_translation_project_list();
     $this->assertFalse(isset($projects['locale_test_translate']), 'Hidden module not found');
-    $this->assertEqual($projects['locale_test']['info']['interface translation server pattern'], 'core/modules/locale/test/test.%language.po', 'Interface translation parameter found in project info.');
+    $this->assertEqual($projects['locale_test']['info']['project translation server pattern'], 'core/modules/locale/test/test.%language.po', 'Interface translation parameter found in project info.');
     $this->assertEqual($projects['locale_test']['name'] , 'locale_test', format_string('%key found in project info.', array('%key' => 'interface translation project')));
   }
 
@@ -352,8 +354,8 @@ function testUpdateProjectsHidden() {
    * The translation status process by default checks the status of the
    * installed projects. For testing purpose a predefined set of modules with
    * fixed file names and release versions is used. This custom project
-   * definition is applied using a hook_locale_translation_projects_alter
-   * implementation in the locale_test module.
+   * definition is applied using a hook_system_project_list_alter implementation in the
+   * locale_test module.
    *
    * This test generates a set of local and remote translation files in their
    * respective local and remote translation directory. The test checks whether
@@ -362,9 +364,6 @@ function testUpdateProjectsHidden() {
    */
   function testUpdateCheckStatus() {
     $config = config('locale.settings');
-    // Set a flag to let the locale_test module replace the project data with a
-    // set of test projects.
-    state()->set('locale.test_projects_alter', TRUE);
 
     // Create local and remote translations files.
     $this->setTranslationFiles();
diff --git a/core/modules/locale/locale.api.php b/core/modules/locale/locale.api.php
index 0db2091..a3dd9cc 100644
--- a/core/modules/locale/locale.api.php
+++ b/core/modules/locale/locale.api.php
@@ -29,7 +29,7 @@
  * in the module's folder.
  * @code
  * interface translation project = example_module
- * interface translation server pattern = modules/custom/example_module/%project-%version.%language.po
+ * project translation server pattern = modules/custom/example_module/%project-%version.%language.po
  * @endcode
  *
  * Streamwrappers can be used in the server pattern definition. The interface
@@ -37,29 +37,29 @@
  * using the "translations://" streamwrapper. But also other streamwrappers can
  * be used.
  * @code
- * interface translation server pattern = translations://%project-%version.%language.po
+ * project translation server pattern = translations://%project-%version.%language.po
  * @endcode
  * @code
- * interface translation server pattern = public://translations/%project-%version.%language.po
+ * project translation server pattern = public://translations/%project-%version.%language.po
  * @endcode
  *
  * Multiple custom modules or themes sharing the same po file should have
  * matching definitions. Such as modules and sub-modules or multiple modules in
  * the same project/code tree. Both "interface translation project" and
- * "interface translation server pattern" definitions of these modules should match.
+ * "project translation server pattern" definitions of these modules should match.
  *
  * Example .info.yml file properties for a custom module with a po file located
  * on a remote translation server.
  * @code
  * interface translation project = example_module
- * interface translation server pattern = http://example.com/files/translations/%core/%project/%project-%version.%language.po
+ * project translation server pattern = http://example.com/files/translations/%core/%project/%project-%version.%language.po
  * @endcode
  *
  * Custom themes, features and distributions can implement these .info.yml file
  * properties in their .info.yml file too.
  *
  * To change the interface translation settings of modules and themes hosted at
- * drupal.org use hook_locale_translation_projects_alter(). Possible changes
+ * drupal.org use hook_system_project_list_alter(). Possible changes
  * include changing the po file location (server pattern) or removing the
  * project from the translation update list.
  *
@@ -67,7 +67,7 @@
  * - "interface translation project": project name. Required.
  *   Name of the project a (sub-)module belongs to. Multiple modules sharing
  *   the same project name will be listed as one the translation status list.
- * - "interface translation server pattern": URL of the .po translation files
+ * - "project translation server pattern": URL of the .po translation files
  *   used to download the files from. The URL contains tokens which will be
  *   replaced by appropriate values. The file can be locate both at a local
  *   relative path, a local absolute path and a remote server location.
@@ -80,46 +80,3 @@
  *
  * @} End of "defgroup interface_translation_properties".
  */
-
-/**
- * @addtogroup hooks
- * @{
- */
-
-/**
- * Alter the list of projects to be updated by locale's interface translation.
- *
- * Locale module attempts to update the translation of those modules returned
- * by update_get_projects(). Using this hook, the data returned by
- * update_get_projects() can be altered or extended.
- *
- * Modules or distributions that use a dedicated translation server should use
- * this hook to specify the interface translation server pattern, or to add
- * additional custom/non-Drupal.org modules to the list of modules known to
- * locale.
- * - "interface translation server pattern": URL of the .po translation files
- *   used to download the files from. The URL contains tokens which will be
- *   replaced by appropriate values.
- * The following tokens are available for the server pattern:
- * - "%core": Core version. Value example: "8.x".
- * - "%project": Project name. Value examples: "drupal", "media_gallery".
- * - "%version": Project version release. Value examples: "8.1", "8.x-1.0".
- * - "%language": Language code. Value examples: "fr", "pt-pt".
- *
- * @param array $projects
- *   Project data as returned by update_get_projects().
- *
- * @see locale_translation_project_list().
- */
-function hook_locale_translation_projects_alter(&$projects) {
-  // The translations are located at a custom translation sever.
-  $projects['existing_project'] = array(
-    'info' => array(
-      'interface translation server pattern' => 'http://example.com/files/translations/%core/%project/%project-%version.%language.po',
-    ),
-  );
-}
-
-/**
- * @} End of "addtogroup hooks".
- */
diff --git a/core/modules/locale/locale.compare.inc b/core/modules/locale/locale.compare.inc
index 9eba52e..f2769bc 100644
--- a/core/modules/locale/locale.compare.inc
+++ b/core/modules/locale/locale.compare.inc
@@ -6,6 +6,7 @@
  */
 
 use Drupal\Core\Cache;
+use Drupal\locale\Project\LocaleProjectBuilder;
 
 /**
  * Load the common translation API.
@@ -45,10 +46,6 @@ function locale_translation_flush_projects() {
  *   - "status": Project status, 1 = enabled.
  */
 function locale_translation_build_projects() {
-  // This function depends on Update module. We degrade gracefully.
-  if (!module_exists('update')) {
-    return array();
-  }
 
   // Get the project list based on .info.yml files.
   $projects = locale_translation_project_list();
@@ -63,7 +60,7 @@ function locale_translation_build_projects() {
   $default_server = locale_translation_default_translation_server();
 
   // If project is a dev release, or core, find the latest available release.
-  $project_updates = update_get_available(TRUE);
+  $project_updates = module_exists('update') ? update_get_available(TRUE) : array();
   foreach ($projects as $name => $data) {
     if (isset($project_updates[$name]['releases']) && $project_updates[$name]['project_status'] != 'not-fetched') {
       // Find out if a dev version is installed.
@@ -74,7 +71,7 @@ function locale_translation_build_projects() {
           // dev release is the one. Releases are sorted the most recent first.
           // @todo http://drupal.org/node/1774024 Make a helper function.
           if ($project_release['version_major'] == $matches[1] &&
-              (!isset($project_release['version_extra']) || $project_release['version_extra'] != 'dev')) {
+            (!isset($project_release['version_extra']) || $project_release['version_extra'] != 'dev')) {
             $release = $project_release;
             break;
           }
@@ -99,7 +96,7 @@ function locale_translation_build_projects() {
       'core' => isset($data['info']['core']) ? $data['info']['core'] : DRUPAL_CORE_COMPATIBILITY,
       // A project can provide the path and filename pattern to download the
       // gettext file. Use the default if not.
-      'server_pattern' => isset($data['info']['interface translation server pattern']) && $data['info']['interface translation server pattern'] ? $data['info']['interface translation server pattern'] : $default_server['pattern'],
+      'server_pattern' => isset($data['info']['project translation server pattern']) && $data['info']['project translation server pattern'] ? $data['info']['project translation server pattern'] : $default_server['pattern'],
       'status' => !empty($data['project_status']) ? 1 : 0,
     );
     $project = (object) $data;
@@ -118,8 +115,8 @@ function locale_translation_build_projects() {
       ))
       ->execute();
 
-      // Invalidate the cache of translatable projects.
-      locale_translation_clear_cache_projects();
+    // Invalidate the cache of translatable projects.
+    locale_translation_clear_cache_projects();
   }
   return $projects;
 }
@@ -131,67 +128,31 @@ function locale_translation_build_projects() {
  *   Array of project data including .info.yml file data.
  */
 function locale_translation_project_list() {
-  // This function depends on Update module. We degrade gracefully.
-  if (!module_exists('update')) {
-    return array();
-  }
-
   $projects = &drupal_static(__FUNCTION__, array());
   if (empty($projects)) {
-    module_load_include('compare.inc', 'update');
-    $config = config('locale.settings');
-    $projects = array();
-
-    $additional_whitelist = array(
-      'interface translation project',
-      'interface translation server pattern',
-    );
-    $module_data = _locale_translation_prepare_project_list(system_rebuild_module_data(), 'module');
-    $theme_data = _locale_translation_prepare_project_list(system_rebuild_theme_data(), 'theme');
-    update_process_info_list($projects, $module_data, 'module', TRUE, $additional_whitelist);
-    update_process_info_list($projects, $theme_data, 'theme', TRUE, $additional_whitelist);
-    if ($config->get('translation.check_disabled_modules')) {
-      update_process_info_list($projects, $module_data, 'module', FALSE, $additional_whitelist);
-      update_process_info_list($projects, $theme_data, 'theme', FALSE, $additional_whitelist);
+    require_once DRUPAL_ROOT . '/core/includes/project.inc';
+
+    $include_disabled = config('locale.settings')->get('translation.check_disabled_modules');
+    $projects = array_filter(system_project_list(), function ($project) use ($include_disabled) {
+      return ($include_disabled || $project->getStatus()) &&
+        ($project->getInfo('project translation server pattern') !== FALSE);
+    });
+
+    // We convert the project objects to arrays to be compatible with the
+    // functions in locale module using this project list.
+    // @todo Create a follow-up issue to fix this.
+    foreach ($projects as $key => $project) {
+      $projects[$key] = array(
+        'name' => $project->getName(),
+        'project_type' => $project->getType(),
+        'project_status' => $project->getStatus(),
+        'info' => $project->getInfoRaw(),
+      );
     }
-
-    // Allow other modules to alter projects before fetching and comparing.
-    drupal_alter('locale_translation_projects', $projects);
   }
   return $projects;
 }
 
-/**
- * Prepare module and theme data.
- *
- * Modify .info.yml file data before it is processed by update_process_info_list().
- * In order for update_process_info_list() to recognize a project, it requires
- * the 'project' parameter in the .info.yml file data.
- *
- * Custom modules or themes can bring their own gettext translation file. To
- * enable import of this file the module or theme defines "interface translation
- * project = myproject" in its .info.yml file. This function will add a project
- * "myproject" to the info data.
- *
- * @param array $data
- *   Array of .info.yml file data.
- * @param string $type
- *   The project type. i.e. module, theme.
- *
- * @return array
- *   Array of .info.yml file data.
- */
-function _locale_translation_prepare_project_list($data, $type) {
-  foreach ($data as $name => $file) {
-    // Include interface translation projects. To allow
-    // update_process_info_list() to identify this as a project the 'project'
-    // property is filled with the 'interface translation project' value.
-    if (isset($file->info['interface translation project'])) {
-      $data[$name]->info['project'] = $file->info['interface translation project'];
-    }
-  }
-  return $data;
-}
 
 /**
  * Retrieve data for default server.
@@ -357,7 +318,7 @@ function _locale_translation_batch_status_operations($projects, $langcodes) {
  * translation directory is checked for the po file. When a server_pattern is
  * defined the specified location is checked. The server_pattern can be set in
  * the module's .info.yml file or by using
- * hook_locale_translation_projects_alter().
+ * locale_test_system_project_list_alter().
  *
  * @param array $projects
  *   Array of project names for which to check the state of translation files.
diff --git a/core/modules/locale/tests/modules/locale_test/locale_test.info.yml b/core/modules/locale/tests/modules/locale_test/locale_test.info.yml
index bd5f032..4e5f581 100644
--- a/core/modules/locale/tests/modules/locale_test/locale_test.info.yml
+++ b/core/modules/locale/tests/modules/locale_test/locale_test.info.yml
@@ -4,5 +4,7 @@ package: Testing
 version: '1.2'
 core: 8.x
 hidden: true
-'interface translation project': locale_test
-'interface translation server pattern': core/modules/locale/test/test.%language.po
+
+# Hide this project from update status.
+'project status url': '0'
+'project translation server pattern': core/modules/locale/test/test.%language.po
diff --git a/core/modules/locale/tests/modules/locale_test/locale_test.module b/core/modules/locale/tests/modules/locale_test/locale_test.module
index 88e581c..1d6b632 100644
--- a/core/modules/locale/tests/modules/locale_test/locale_test.module
+++ b/core/modules/locale/tests/modules/locale_test/locale_test.module
@@ -5,6 +5,9 @@
  * Simulate a custom module with a local po file.
  */
 
+use Drupal\Core\Project\ProjectBuilder;
+use Drupal\Core\Project\ProjectFactory;
+
 /**
  * Implements hook_system_info_alter().
  *
@@ -25,7 +28,7 @@ function locale_test_system_info_alter(&$info, $file, $type) {
 }
 
 /**
- * Implements hook_locale_translation_projects_alter().
+ * Implements hook_system_project_list_alter().
  *
  * The translation status process by default checks the status of the installed
  * projects. This function replaces the data of the installed modules by a
@@ -36,7 +39,7 @@ function locale_test_system_info_alter(&$info, $file, $type) {
  * The "locale.test_projects_alter" state variable must be set by the
  * test script in order for this hook to take effect.
  */
-function locale_test_locale_translation_projects_alter(&$projects) {
+function locale_test_system_project_list_alter(&$projects) {
   if (state()->get('locale.test_projects_alter')) {
 
     // Instead of the default ftp.drupal.org we use the file system of the test
@@ -46,98 +49,90 @@ function locale_test_locale_translation_projects_alter(&$projects) {
 
     // Completely replace the project data with a set of test projects.
     $base_url = url();
-    $projects = array (
-      'drupal' => array (
+    $module_data = array (
+      'drupal' => (object) array (
         'name' => 'drupal',
+        'status' => 1,
         'info' => array (
           'name' => 'Drupal',
-          'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
+          'project translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
           'package' => 'Core',
           'version' => '8.0',
           'project' => 'drupal',
           '_info_file_ctime' => 1348824632,
           'datestamp' => 0,
         ),
-        'datestamp' => 0,
-        'project_type' => 'core',
-        'project_status' => TRUE,
       ),
-      'contrib_module_one' => array (
+      'contrib_module_one' => (object) array (
         'name' => 'contrib_module_one',
         'info' => array (
           'name' => 'Contributed module one',
-          'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
+          'status' => 1,
+          'project translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
           'package' => 'Other',
           'version' => '8.x-1.1',
           'project' => 'contrib_module_one',
           'datestamp' => '1344471537',
           '_info_file_ctime' => 1348767306,
+          'project status url' => FALSE,
         ),
-        'datestamp' => '1344471537',
-        'project_type' => 'module',
-        'project_status' => TRUE,
       ),
-      'contrib_module_two' => array (
+      'contrib_module_two' => (object) array (
         'name' => 'contrib_module_two',
+        'status' => 1,
         'info' => array (
           'name' => 'Contributed module two',
-          'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
+          'project translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
           'package' => 'Other',
           'version' => '8.x-2.0-beta4',
           'project' => 'contrib_module_two',
           'datestamp' => '1344471537',
           '_info_file_ctime' => 1348767306,
+          'project status url' => FALSE,
         ),
-        'datestamp' => '1344471537',
-        'project_type' => 'module',
-        'project_status' => TRUE,
       ),
-      'contrib_module_three' => array (
+      'contrib_module_three' => (object) array (
         'name' => 'contrib_module_three',
+        'status' => 1,
         'info' => array (
           'name' => 'Contributed module three',
-          'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
+          'status' => 1,
+          'project translation server pattern' => $remote_url . '%core/%project/%project-%version.%language._po',
           'package' => 'Other',
           'version' => '8.x-1.0',
           'project' => 'contrib_module_three',
           'datestamp' => '1344471537',
           '_info_file_ctime' => 1348767306,
+          'project status url' => FALSE,
         ),
-        'datestamp' => '1344471537',
-        'project_type' => 'module',
-        'project_status' => TRUE,
       ),
-      'locale_test' => array (
+      'locale_test' => (object) array (
         'name' => 'locale_test',
+        'status' => 1,
         'info' => array (
           'name' => 'Locale test',
-          'interface translation project' => 'locale_test',
-          'interface translation server pattern' => 'core/modules/locale/tests/test.%language.po',
+          'project translation server pattern' => 'core/modules/locale/tests/test.%language.po',
           'package' => 'Other',
           'version' => NULL,
           'project' => 'locale_test',
           '_info_file_ctime' => 1348767306,
           'datestamp' => 0,
+          'project status url' => FALSE,
         ),
-        'datestamp' => 0,
-        'project_type' => 'module',
-        'project_status' => TRUE,
       ),
-      'custom_module_one' => array (
+      'custom_module_one' => (object) array (
         'name' => 'custom_module_one',
+        'status' => 1,
         'info' => array (
           'name' => 'Custom module one',
-          'interface translation project' => 'custom_module_one',
-          'interface translation server pattern' => 'translations://custom_module_one.%language.po',
+          'project translation server pattern' => 'translations://custom_module_one.%language.po',
           'package' => 'Other',
           'version' => NULL,
           'project' => 'custom_module_one',
           '_info_file_ctime' => 1348767306,
           'datestamp' => 0,
+          'project status url' => FALSE,
         ),
-        'datestamp' => 0,
-        'project_type' => 'module',
-        'project_status' => TRUE,
       ),
     );
   }
diff --git a/core/modules/locale/tests/modules/locale_test_translate/locale_test_translate.info.yml b/core/modules/locale/tests/modules/locale_test_translate/locale_test_translate.info.yml
index a212a14..d9044ba 100644
--- a/core/modules/locale/tests/modules/locale_test_translate/locale_test_translate.info.yml
+++ b/core/modules/locale/tests/modules/locale_test_translate/locale_test_translate.info.yml
@@ -4,5 +4,6 @@ package: Testing
 version: '1.3'
 core: 8.x
 hidden: true
-'interface translation project': locale_test_translate
-'interface translation server pattern': core/modules/locale/tests/test.%language.po
+
+# Definitions for interface translations.
+'project translation server pattern': core/modules/locale/tests/test.%language.po
diff --git a/core/modules/system/system.api.php b/core/modules/system/system.api.php
index 68127e2..0abd504 100644
--- a/core/modules/system/system.api.php
+++ b/core/modules/system/system.api.php
@@ -1495,6 +1495,103 @@ function hook_system_info_alter(&$info, $file, $type) {
 }
 
 /**
+ * Alter the list of projects before fetching data and comparing versions.
+ *
+ * @todo Review this documentation. $projects is now a list of objects.
+ *
+ * Most modules will never need to implement this hook. It is for advanced
+ * interaction with the Update Manager module. The primary use-case for this
+ * hook is to add projects to the list; for example, to provide update status
+ * data on disabled modules and themes. A contributed module might want to hide
+ * projects from the list; for example, if there is a site-specific module that
+ * doesn't have any official releases, that module could remove itself from this
+ * list to avoid "No available releases found" warnings on the available updates
+ * report. In rare cases, a module might want to alter the data associated with
+ * a project already in the list.
+ *
+ * If a project is not intended to be checked for code updates, add this data
+ * data in the info file: 'project status url = FALSE'
+ *
+ * Locale module attempts to update the translation of those modules returned
+ * by update_get_projects(). Using this hook, the data returned by
+ * update_get_projects() can be altered or extended.
+ *
+ * Modules or distributions that use a dedicated translation server should use
+ * this hook to specify the project translation server pattern, or to add
+ * additional custom/non-Drupal.org modules to the list of modules known to
+ * locale.
+ * - "project translation server pattern": URL of the .po translation files
+ *   used to download the files from. The URL contains tokens which will be
+ *   replaced by appropriate values.
+ * The following tokens are available for the server pattern:
+ * - "%core": Core version. Value example: "8.x".
+ * - "%project": Project name. Value examples: "drupal", "media_gallery".
+ * - "%version": Project version release. Value examples: "8.1", "8.x-1.0".
+ * - "%language": Language code. Value examples: "fr", "pt-pt".
+ *
+ * If a project is not intended to be checked for translation updates, add
+ * this line in the info file: 'project translation server pattern = FALSE'
+ *
+ * @param $projects
+ *   Reference to an array of the projects installed on the system. This
+ *   includes all the metadata documented in the comments below for each project
+ *   (either module or theme) that is currently enabled. The array is initially
+ *   populated inside project_list() with the help of
+ *   project_process_info_list(), so look there for examples of how to populate
+ *   the array with real values.
+ *
+ * @see system_project_list()
+ * @see project_process_info_list()
+ * @see update_get_projects()
+ * @see locale_translation_project_list()
+ */
+function hook_system_project_list_alter(&$projects) {
+  // Hide a site-specific module from the list.
+  unset($projects['site_specific_module']);
+
+  // Add a disabled module to the list.
+  // The key for the array should be the machine-readable project "short name".
+  $projects['disabled_project_name'] = array(
+    // Machine-readable project short name (same as the array key above).
+    'name' => 'disabled_project_name',
+    // Array of values from the main .info file for this project.
+    'info' => array(
+      'name' => 'Some disabled module',
+      'description' => 'A module not enabled on the site that you want to see in the available updates report.',
+      'version' => '8.x-1.0',
+      'core' => '8.x',
+      // The maximum file change time (the "ctime" returned by the filectime()
+      // PHP method) for all of the .info files included in this project.
+      '_info_file_ctime' => 1243888165,
+    ),
+    // The date stamp when the project was released, if known. If the disabled
+    // project was an officially packaged release from drupal.org, this will
+    // be included in the .info file as the 'datestamp' field. This only
+    // really matters for development snapshot releases that are regenerated,
+    // so it can be left undefined or set to 0 in most cases.
+    'datestamp' => 1243888185,
+    // Any modules (or themes) included in this project. Keyed by machine-
+    // readable "short name", value is the human-readable project name printed
+    // in the UI.
+    'includes' => array(
+      'disabled_project' => 'Disabled module',
+      'disabled_project_helper' => 'Disabled module helper module',
+      'disabled_project_foo' => 'Disabled module foo add-on module',
+    ),
+    // Does this project contain a 'module', 'theme', 'disabled-module', or
+    // 'disabled-theme'?
+    'project_type' => 'disabled-module',
+  );
+
+  // The translations are located at a custom translation sever.
+  $projects['existing_project'] = array(
+    'info' => array(
+      'project translation server pattern' => 'http://example.com/files/translations/%core/%project/%project-%version.%language.po',
+    ),
+  );
+}
+
+/**
  * Define user permissions.
  *
  * This hook can supply permissions that the module defines, so that they
diff --git a/core/modules/update/tests/modules/update_test/update_test.info.yml b/core/modules/update/tests/modules/update_test/update_test.info.yml
index b86daf3..5cd9588 100644
--- a/core/modules/update/tests/modules/update_test/update_test.info.yml
+++ b/core/modules/update/tests/modules/update_test/update_test.info.yml
@@ -3,4 +3,4 @@ description: 'Support module for update module testing.'
 package: Testing
 version: VERSION
 core: 8.x
-hidden: true
+# hidden: true
diff --git a/core/modules/update/update.api.php b/core/modules/update/update.api.php
index b5621dd..ae01200 100644
--- a/core/modules/update/update.api.php
+++ b/core/modules/update/update.api.php
@@ -11,69 +11,6 @@
  */
 
 /**
- * Alter the list of projects before fetching data and comparing versions.
- *
- * Most modules will never need to implement this hook. It is for advanced
- * interaction with the Update Manager module. The primary use-case for this
- * hook is to add projects to the list; for example, to provide update status
- * data on disabled modules and themes. A contributed module might want to hide
- * projects from the list; for example, if there is a site-specific module that
- * doesn't have any official releases, that module could remove itself from this
- * list to avoid "No available releases found" warnings on the available updates
- * report. In rare cases, a module might want to alter the data associated with
- * a project already in the list.
- *
- * @param $projects
- *   Reference to an array of the projects installed on the system. This
- *   includes all the metadata documented in the comments below for each project
- *   (either module or theme) that is currently enabled. The array is initially
- *   populated inside update_get_projects() with the help of
- *   update_process_info_list(), so look there for examples of how to populate
- *   the array with real values.
- *
- * @see update_get_projects()
- * @see update_process_info_list()
- */
-function hook_update_projects_alter(&$projects) {
-  // Hide a site-specific module from the list.
-  unset($projects['site_specific_module']);
-
-  // Add a disabled module to the list.
-  // The key for the array should be the machine-readable project "short name".
-  $projects['disabled_project_name'] = array(
-    // Machine-readable project short name (same as the array key above).
-    'name' => 'disabled_project_name',
-    // Array of values from the main .info.yml file for this project.
-    'info' => array(
-      'name' => 'Some disabled module',
-      'description' => 'A module not enabled on the site that you want to see in the available updates report.',
-      'version' => '8.x-1.0',
-      'core' => '8.x',
-      // The maximum file change time (the "ctime" returned by the filectime()
-      // PHP method) for all of the .info.yml files included in this project.
-      '_info_file_ctime' => 1243888165,
-    ),
-    // The date stamp when the project was released, if known. If the disabled
-    // project was an officially packaged release from drupal.org, this will
-    // be included in the .info.yml file as the 'datestamp' field. This only
-    // really matters for development snapshot releases that are regenerated,
-    // so it can be left undefined or set to 0 in most cases.
-    'datestamp' => 1243888185,
-    // Any modules (or themes) included in this project. Keyed by machine-
-    // readable "short name", value is the human-readable project name printed
-    // in the UI.
-    'includes' => array(
-      'disabled_project' => 'Disabled module',
-      'disabled_project_helper' => 'Disabled module helper module',
-      'disabled_project_foo' => 'Disabled module foo add-on module',
-    ),
-    // Does this project contain a 'module', 'theme', 'disabled-module', or
-    // 'disabled-theme'?
-    'project_type' => 'disabled-module',
-  );
-}
-
-/**
  * Alter the information about available updates for projects.
  *
  * @param $projects
diff --git a/core/modules/update/update.compare.inc b/core/modules/update/update.compare.inc
index c8f5958..c373fe3 100644
--- a/core/modules/update/update.compare.inc
+++ b/core/modules/update/update.compare.inc
@@ -5,827 +5,586 @@
  * Code required only when comparing available updates to existing data.
  */
 
+use Drupal\update\Project\UpdateProjectBuilder;
+
 /**
  * Fetches an array of installed and enabled projects.
  *
- * This is only responsible for generating an array of projects (taking into
- * account projects that include more than one module or theme). Other
- * information like the specific version and install type (official release,
- * dev snapshot, etc) is handled later in update_process_project_info() since
- * that logic is only required when preparing the status report, not for
- * fetching the available release data.
- *
- * This array is fairly expensive to construct, since it involves a lot of disk
- * I/O, so we store the results. However, since this is not the data about
- * available updates fetched from the network, it is acceptable to invalidate it
- * somewhat quickly. If we keep this data for very long, site administrators are
- * more likely to see incorrect results if they upgrade to a newer version of a
- * module or theme but do not visit certain pages that automatically clear this
- * data.
+ * Filter out projects that have the info property
+ * 'project status url' set to FALSE.
  *
- * @return
+ * @return array
  *   An associative array of currently enabled projects keyed by the
- *   machine-readable project short name. Each project contains:
- *   - name: The machine-readable project short name.
- *   - info: An array with values from the main .info.yml file for this project.
- *     - name: The human-readable name of the project.
- *     - package: The package that the project is grouped under.
- *     - version: The version of the project.
- *     - project: The Drupal.org project name.
- *     - datestamp: The date stamp of the project's main .info.yml file.
- *     - _info_file_ctime: The maximum file change time for all of the .info.yml
- *       files included in this project.
- *   - datestamp: The date stamp when the project was released, if known.
- *   - includes: An associative array containing all projects included with this
- *     project, keyed by the machine-readable short name with the human-readable
- *     name as value.
- *   - project_type: The type of project. Allowed values are 'module' and
- *     'theme'.
- *   - project_status: This indicates if the project is enabled and will always
- *     be TRUE, as the function only returns enabled projects.
- *   - sub_themes: If the project is a theme it contains an associative array of
- *     all sub-themes.
- *   - base_themes: If the project is a theme it contains an associative array
- *     of all base-themes.
+ *   machine-readable project name.
  *
- * @see update_process_project_info()
  * @see update_calculate_project_data()
  * @see update_project_storage()
  */
 function update_get_projects() {
   $projects = &drupal_static(__FUNCTION__, array());
+  // @todo Check the behaviour if 'project status url' in .info file is set at different values. Amnong which 'FALSE'.
   if (empty($projects)) {
-    // Retrieve the projects from storage, if present.
-    $projects = update_project_storage('update_project_projects');
-    if (empty($projects)) {
-      // Still empty, so we have to rebuild.
-      $module_data = system_rebuild_module_data();
-      $theme_data = system_rebuild_theme_data();
-      update_process_info_list($projects, $module_data, 'module', TRUE);
-      update_process_info_list($projects, $theme_data, 'theme', TRUE);
-      if (config('update.settings')->get('check.disabled_extensions')) {
-        update_process_info_list($projects, $module_data, 'module', FALSE);
-        update_process_info_list($projects, $theme_data, 'theme', FALSE);
-      }
-      // Allow other modules to alter projects before fetching and comparing.
-      drupal_alter('update_projects', $projects);
-      // Store the site's project data for at most 1 hour.
-      Drupal::keyValueExpirable('update')->setWithExpire('update_project_projects', $projects, 3600);
+    require_once DRUPAL_ROOT . '/core/includes/project.inc';
+    $include_disabled = config('update.settings')->get('check.disabled_extensions');
+    $projects = array_filter(system_project_list(), function ($project) use ($include_disabled) {
+      return ($include_disabled || $project->getStatus()) &&
+        ($project->getInfo('project status url') !== FALSE);
+    });
+
+    // We convert the project objects to arrays to be compatible with the
+    // functions in update module using this project list.
+    // @todo Create a follow-up issue to fix this.
+    foreach ($projects as $key => $project) {
+      $projects[$key] = array(
+        'name' => $project->getName(),
+        'project_type' => $project->getType(),
+        'project_status' => $project->getStatus(),
+        'datestamp' => $project->getDatestamp(),
+        'info' => $project->getInfoRaw(),
+        'includes' => $project->getIncludes(),
+        'base_themes' => $project->getBaseThemes(),
+        'sub_themes' => $project->getSubThemes(),
+      );
     }
+    return $projects;
   }
-  return $projects;
-}
 
-/**
- * Populates an array of project data.
- *
- * This iterates over a list of the installed modules or themes and groups
- * them by project and status. A few parts of this function assume that
- * enabled modules and themes are always processed first, and if disabled
- * modules or themes are being processed (there is a setting to control if
- * disabled code should be included in the Available updates report or not),
- * those are only processed after $projects has been populated with
- * information about the enabled code. 'Hidden' modules are always ignored.
- * 'Hidden' themes are ignored only if they have no enabled sub-themes.
- * This function also records the latest change time on the .info.yml
- * files for each module or theme, which is important data which is used when
- * deciding if the available update data should be invalidated.
- *
- * @param $projects
- *   Reference to the array of project data of what's installed on this site.
- * @param $list
- *   Array of data to process to add the relevant info to the $projects array.
- * @param $project_type
- *   The kind of data in the list. Can be 'module' or 'theme'.
- * @param $status
- *   Boolean that controls what status (enabled or disabled) to process out of
- *   the $list and add to the $projects array.
- * @param $additional_whitelist
- *   (optional) Array of additional elements to be collected from the .info.yml
- *   file. Defaults to array().
- *
- * @see update_get_projects()
- */
-function update_process_info_list(&$projects, $list, $project_type, $status, $additional_whitelist = array()) {
-  foreach ($list as $file) {
-    // A disabled or hidden base theme of an enabled sub-theme still has all
-    // of its code run by the sub-theme, so we include it in our "enabled"
-    // projects list.
-    if ($status && !empty($file->sub_themes)) {
-      foreach ($file->sub_themes as $key => $name) {
-        // Build a list of enabled sub-themes.
-        if ($list[$key]->status) {
-          $file->enabled_sub_themes[$key] = $name;
+  /**
+   * Determines version and type information for currently installed projects.
+   *
+   * Processes the list of projects on the system to figure out the currently
+   * installed versions, and other information that is required before we can
+   * compare against the available releases to produce the status report.
+   *
+   * @param $projects
+   *   Array of project information from update_get_projects().
+   */
+  function update_process_project_info(&$projects) {
+    foreach ($projects as $key => $project) {
+      // Assume an official release until we see otherwise.
+      $install_type = 'official';
+
+      $info = $project['info'];
+
+      if (isset($info['version'])) {
+        // Check for development snapshots
+        if (preg_match('@(dev|HEAD)@', $info['version'])) {
+          $install_type = 'dev';
+        }
+
+        // Figure out what the currently installed major version is. We need
+        // to handle both contribution (e.g. "5.x-1.3", major = 1) and core
+        // (e.g. "5.1", major = 5) version strings.
+        $matches = array();
+        if (preg_match('/^(\d+\.x-)?(\d+)\..*$/', $info['version'], $matches)) {
+          $info['major'] = $matches[2];
+        }
+        elseif (!isset($info['major'])) {
+          // This would only happen for version strings that don't follow the
+          // drupal.org convention. We let contribs define "major" in their
+          // .info.yml in this case, and only if that's missing would we hit this.
+          $info['major'] = -1;
         }
       }
-      // If the theme is disabled and there are no enabled subthemes, we
-      // should ignore this base theme for the enabled case. If the site is
-      // trying to display disabled themes, we'll catch it then.
-      if (!$file->status && empty($file->enabled_sub_themes)) {
-        continue;
+      else {
+        // No version info available at all.
+        $install_type = 'unknown';
+        $info['version'] = t('Unknown');
+        $info['major'] = -1;
       }
-    }
-    // Otherwise, just add projects of the proper status to our list.
-    elseif ($file->status != $status) {
-      continue;
-    }
-
-    // Skip if the .info.yml file is broken.
-    if (empty($file->info)) {
-      continue;
-    }
-
-    // Skip if it's a hidden module or hidden theme without enabled sub-themes.
-    if (!empty($file->info['hidden']) && empty($file->enabled_sub_themes)) {
-      continue;
-    }
-
-    // If the .info.yml doesn't define the 'project', try to figure it out.
-    if (!isset($file->info['project'])) {
-      $file->info['project'] = update_get_project_name($file);
-    }
 
-    // If we still don't know the 'project', give up.
-    if (empty($file->info['project'])) {
-      continue;
+      // Finally, save the results we care about into the $projects array.
+      $projects[$key]['existing_version'] = $info['version'];
+      $projects[$key]['existing_major'] = $info['major'];
+      $projects[$key]['install_type'] = $install_type;
     }
+  }
 
-    // If we don't already know it, grab the change time on the .info.yml file
-    // itself. Note: we need to use the ctime, not the mtime (modification
-    // time) since many (all?) tar implementations will go out of their way to
-    // set the mtime on the files it creates to the timestamps recorded in the
-    // tarball. We want to see the last time the file was changed on disk,
-    // which is left alone by tar and correctly set to the time the .info.yml
-    // file was unpacked.
-    if (!isset($file->info['_info_file_ctime'])) {
-      $info_filename = dirname($file->uri) . '/' . $file->name . '.info.yml';
-      $file->info['_info_file_ctime'] = filectime($info_filename);
-    }
-
-    if (!isset($file->info['datestamp'])) {
-      $file->info['datestamp'] = 0;
-    }
-
-    $project_name = $file->info['project'];
-
-    // Figure out what project type we're going to use to display this module
-    // or theme. If the project name is 'drupal', we don't want it to show up
-    // under the usual "Modules" section, we put it at a special "Drupal Core"
-    // section at the top of the report.
-    if ($project_name == 'drupal') {
-      $project_display_type = 'core';
-    }
-    else {
-      $project_display_type = $project_type;
-    }
-    if (empty($status) && empty($file->enabled_sub_themes)) {
-      // If we're processing disabled modules or themes, append a suffix.
-      // However, we don't do this to a a base theme with enabled
-      // subthemes, since we treat that case as if it is enabled.
-      $project_display_type .= '-disabled';
-    }
-    // Add a list of sub-themes that "depend on" the project and a list of base
-    // themes that are "required by" the project.
-    if ($project_name == 'drupal') {
-      // Drupal core is always required, so this extra info would be noise.
-      $sub_themes = array();
-      $base_themes = array();
-    }
-    else {
-      // Add list of enabled sub-themes.
-      $sub_themes = !empty($file->enabled_sub_themes) ? $file->enabled_sub_themes : array();
-      // Add list of base themes.
-      $base_themes = !empty($file->base_themes) ? $file->base_themes : array();
-    }
-    if (!isset($projects[$project_name])) {
-      // Only process this if we haven't done this project, since a single
-      // project can have multiple modules or themes.
-      $projects[$project_name] = array(
-        'name' => $project_name,
-        // Only save attributes from the .info.yml file we care about so we do
-        // not bloat our RAM usage needlessly.
-        'info' => update_filter_project_info($file->info, $additional_whitelist),
-        'datestamp' => $file->info['datestamp'],
-        'includes' => array($file->name => $file->info['name']),
-        'project_type' => $project_display_type,
-        'project_status' => $status,
-        'sub_themes' => $sub_themes,
-        'base_themes' => $base_themes,
-      );
-    }
-    elseif ($projects[$project_name]['project_type'] == $project_display_type) {
-      // Only add the file we're processing to the 'includes' array for this
-      // project if it is of the same type and status (which is encoded in the
-      // $project_display_type). This prevents listing all the disabled
-      // modules included with an enabled project if we happen to be checking
-      // for disabled modules, too.
-      $projects[$project_name]['includes'][$file->name] = $file->info['name'];
-      $projects[$project_name]['info']['_info_file_ctime'] = max($projects[$project_name]['info']['_info_file_ctime'], $file->info['_info_file_ctime']);
-      $projects[$project_name]['datestamp'] = max($projects[$project_name]['datestamp'], $file->info['datestamp']);
-      if (!empty($sub_themes)) {
-        $projects[$project_name]['sub_themes'] += $sub_themes;
+  /**
+   * Calculates the current update status of all projects on the site.
+   *
+   * The results of this function are expensive to compute, especially on sites
+   * with lots of modules or themes, since it involves a lot of comparisons and
+   * other operations. Therefore, we store the results. However, since this is not
+   * the data about available updates fetched from the network, it is ok to
+   * invalidate it somewhat quickly. If we keep this data for very long, site
+   * administrators are more likely to see incorrect results if they upgrade to a
+   * newer version of a module or theme but do not visit certain pages that
+   * automatically clear this.
+   *
+   * @param array $available
+   *   Data about available project releases.
+   *
+   * @return array
+   *   An array of installed projects with current update status information.
+   *
+   * @see update_get_available()
+   * @see update_get_projects()
+   * @see update_process_project_info()
+   * @see update_project_storage()
+   */
+  function update_calculate_project_data($available) {
+    // Retrieve the projects from storage, if present.
+    $projects = update_project_storage('update_project_data');
+    // If $projects is empty, then the data must be rebuilt.
+    // Otherwise, return the data and skip the rest of the function.
+    if (!empty($projects)) {
+      return $projects;
+    }
+    $projects = update_get_projects();
+    update_process_project_info($projects);
+    foreach ($projects as $project => $project_info) {
+      if (isset($available[$project])) {
+        update_calculate_project_update_status($projects[$project], $available[$project]);
       }
-      if (!empty($base_themes)) {
-        $projects[$project_name]['base_themes'] += $base_themes;
+      else {
+        $projects[$project]['status'] = UPDATE_UNKNOWN;
+        $projects[$project]['reason'] = t('No available releases found');
       }
     }
-    elseif (empty($status)) {
-      // If we have a project_name that matches, but the project_display_type
-      // does not, it means we're processing a disabled module or theme that
-      // belongs to a project that has some enabled code. In this case, we add
-      // the disabled thing into a separate array for separate display.
-      $projects[$project_name]['disabled'][$file->name] = $file->info['name'];
-    }
-  }
-}
+    // Give other modules a chance to alter the status (for example, to allow a
+    // contrib module to provide fine-grained settings to ignore specific
+    // projects or releases).
+    drupal_alter('update_status', $projects);
 
-/**
- * Determines what project a given file object belongs to.
- *
- * @param $file
- *   A file object as returned by system_get_files_database().
- *
- * @return
- *   The canonical project short name.
- *
- * @see system_get_files_database()
- */
-function update_get_project_name($file) {
-  $project_name = '';
-  if (isset($file->info['project'])) {
-    $project_name = $file->info['project'];
-  }
-  elseif (isset($file->filename) && (strpos($file->filename, 'core/modules') === 0)) {
-    $project_name = 'drupal';
+    // Store the site's update status for at most 1 hour.
+    Drupal::keyValueExpirable('update')->setWithExpire('update_project_data', $projects, 3600);
+    return $projects;
   }
-  return $project_name;
-}
-
-/**
- * Determines version and type information for currently installed projects.
- *
- * Processes the list of projects on the system to figure out the currently
- * installed versions, and other information that is required before we can
- * compare against the available releases to produce the status report.
- *
- * @param $projects
- *   Array of project information from update_get_projects().
- */
-function update_process_project_info(&$projects) {
-  foreach ($projects as $key => $project) {
-    // Assume an official release until we see otherwise.
-    $install_type = 'official';
 
-    $info = $project['info'];
-
-    if (isset($info['version'])) {
-      // Check for development snapshots
-      if (preg_match('@(dev|HEAD)@', $info['version'])) {
-        $install_type = 'dev';
+  /**
+   * Calculates the current update status of a specific project.
+   *
+   * This function is the heart of the update status feature. For each project it
+   * is invoked with, it first checks if the project has been flagged with a
+   * special status like "unsupported" or "insecure", or if the project node
+   * itself has been unpublished. In any of those cases, the project is marked
+   * with an error and the next project is considered.
+   *
+   * If the project itself is valid, the function decides what major release
+   * series to consider. The project defines what the currently supported major
+   * versions are for each version of core, so the first step is to make sure the
+   * current version is still supported. If so, that's the target version. If the
+   * current version is unsupported, the project maintainer's recommended major
+   * version is used. There's also a check to make sure that this function never
+   * recommends an earlier release than the currently installed major version.
+   *
+   * Given a target major version, the available releases are scanned looking for
+   * the specific release to recommend (avoiding beta releases and development
+   * snapshots if possible). For the target major version, the highest patch level
+   * is found. If there is a release at that patch level with no extra ("beta",
+   * etc.), then the release at that patch level with the most recent release date
+   * is recommended. If every release at that patch level has extra (only betas),
+   * then the latest release from the previous patch level is recommended. For
+   * example:
+   *
+   * - 1.6-bugfix <-- recommended version because 1.6 already exists.
+   * - 1.6
+   *
+   * or
+   *
+   * - 1.6-beta
+   * - 1.5 <-- recommended version because no 1.6 exists.
+   * - 1.4
+   *
+   * Also, the latest release from the same major version is looked for, even beta
+   * releases, to display to the user as the "Latest version" option.
+   * Additionally, the latest official release from any higher major versions that
+   * have been released is searched for to provide a set of "Also available"
+   * options.
+   *
+   * Finally, and most importantly, the release history continues to be scanned
+   * until the currently installed release is reached, searching for anything
+   * marked as a security update. If any security updates have been found between
+   * the recommended release and the installed version, all of the releases that
+   * included a security fix are recorded so that the site administrator can be
+   * warned their site is insecure, and links pointing to the release notes for
+   * each security update can be included (which, in turn, will link to the
+   * official security announcements for each vulnerability).
+   *
+   * This function relies on the fact that the .xml release history data comes
+   * sorted based on major version and patch level, then finally by release date
+   * if there are multiple releases such as betas from the same major.patch
+   * version (e.g., 5.x-1.5-beta1, 5.x-1.5-beta2, and 5.x-1.5). Development
+   * snapshots for a given major version are always listed last.
+   *
+   * @param $project_data
+   *   An array containing information about a specific project.
+   * @param $available
+   *   Data about available project releases of a specific project.
+   */
+  function update_calculate_project_update_status(&$project_data, $available) {
+    foreach (array('title', 'link') as $attribute) {
+      if (!isset($project_data[$attribute]) && isset($available[$attribute])) {
+        $project_data[$attribute] = $available[$attribute];
       }
+    }
 
-      // Figure out what the currently installed major version is. We need
-      // to handle both contribution (e.g. "5.x-1.3", major = 1) and core
-      // (e.g. "5.1", major = 5) version strings.
-      $matches = array();
-      if (preg_match('/^(\d+\.x-)?(\d+)\..*$/', $info['version'], $matches)) {
-        $info['major'] = $matches[2];
-      }
-      elseif (!isset($info['major'])) {
-        // This would only happen for version strings that don't follow the
-        // drupal.org convention. We let contribs define "major" in their
-        // .info.yml in this case, and only if that's missing would we hit this.
-        $info['major'] = -1;
+    // If the project status is marked as something bad, there's nothing else
+    // to consider.
+    if (isset($available['project_status'])) {
+      switch ($available['project_status']) {
+        case 'insecure':
+          $project_data['status'] = UPDATE_NOT_SECURE;
+          if (empty($project_data['extra'])) {
+            $project_data['extra'] = array();
+          }
+          $project_data['extra'][] = array(
+            'class' => array('project-not-secure'),
+            'label' => t('Project not secure'),
+            'data' => t('This project has been labeled insecure by the Drupal security team, and is no longer available for download. Immediately disabling everything included by this project is strongly recommended!'),
+          );
+          break;
+        case 'unpublished':
+        case 'revoked':
+          $project_data['status'] = UPDATE_REVOKED;
+          if (empty($project_data['extra'])) {
+            $project_data['extra'] = array();
+          }
+          $project_data['extra'][] = array(
+            'class' => array('project-revoked'),
+            'label' => t('Project revoked'),
+            'data' => t('This project has been revoked, and is no longer available for download. Disabling everything included by this project is strongly recommended!'),
+          );
+          break;
+        case 'unsupported':
+          $project_data['status'] = UPDATE_NOT_SUPPORTED;
+          if (empty($project_data['extra'])) {
+            $project_data['extra'] = array();
+          }
+          $project_data['extra'][] = array(
+            'class' => array('project-not-supported'),
+            'label' => t('Project not supported'),
+            'data' => t('This project is no longer supported, and is no longer available for download. Disabling everything included by this project is strongly recommended!'),
+          );
+          break;
+        case 'not-fetched':
+          $project_data['status'] = UPDATE_NOT_FETCHED;
+          $project_data['reason'] = t('Failed to get available update data.');
+          break;
+
+        default:
+          // Assume anything else (e.g. 'published') is valid and we should
+          // perform the rest of the logic in this function.
+          break;
       }
     }
-    else {
-      // No version info available at all.
-      $install_type = 'unknown';
-      $info['version'] = t('Unknown');
-      $info['major'] = -1;
-    }
 
-    // Finally, save the results we care about into the $projects array.
-    $projects[$key]['existing_version'] = $info['version'];
-    $projects[$key]['existing_major'] = $info['major'];
-    $projects[$key]['install_type'] = $install_type;
-  }
-}
+    if (!empty($project_data['status'])) {
+      // We already know the status for this project, so there's nothing else to
+      // compute. Record the project status into $project_data and we're done.
+      $project_data['project_status'] = $available['project_status'];
+      return;
+    }
 
-/**
- * Calculates the current update status of all projects on the site.
- *
- * The results of this function are expensive to compute, especially on sites
- * with lots of modules or themes, since it involves a lot of comparisons and
- * other operations. Therefore, we store the results. However, since this is not
- * the data about available updates fetched from the network, it is ok to
- * invalidate it somewhat quickly. If we keep this data for very long, site
- * administrators are more likely to see incorrect results if they upgrade to a
- * newer version of a module or theme but do not visit certain pages that
- * automatically clear this.
- *
- * @param array $available
- *   Data about available project releases.
- *
- * @return
- *   An array of installed projects with current update status information.
- *
- * @see update_get_available()
- * @see update_get_projects()
- * @see update_process_project_info()
- * @see update_project_storage()
- */
-function update_calculate_project_data($available) {
-  // Retrieve the projects from storage, if present.
-  $projects = update_project_storage('update_project_data');
-  // If $projects is empty, then the data must be rebuilt.
-  // Otherwise, return the data and skip the rest of the function.
-  if (!empty($projects)) {
-    return $projects;
-  }
-  $projects = update_get_projects();
-  update_process_project_info($projects);
-  foreach ($projects as $project => $project_info) {
-    if (isset($available[$project])) {
-      update_calculate_project_update_status($projects[$project], $available[$project]);
+    // Figure out the target major version.
+    $existing_major = $project_data['existing_major'];
+    $supported_majors = array();
+    if (isset($available['supported_majors'])) {
+      $supported_majors = explode(',', $available['supported_majors']);
     }
-    else {
-      $projects[$project]['status'] = UPDATE_UNKNOWN;
-      $projects[$project]['reason'] = t('No available releases found');
+    elseif (isset($available['default_major'])) {
+      // Older release history XML file without supported or recommended.
+      $supported_majors[] = $available['default_major'];
     }
-  }
-  // Give other modules a chance to alter the status (for example, to allow a
-  // contrib module to provide fine-grained settings to ignore specific
-  // projects or releases).
-  drupal_alter('update_status', $projects);
-
-  // Store the site's update status for at most 1 hour.
-  Drupal::keyValueExpirable('update')->setWithExpire('update_project_data', $projects, 3600);
-  return $projects;
-}
 
-/**
- * Calculates the current update status of a specific project.
- *
- * This function is the heart of the update status feature. For each project it
- * is invoked with, it first checks if the project has been flagged with a
- * special status like "unsupported" or "insecure", or if the project node
- * itself has been unpublished. In any of those cases, the project is marked
- * with an error and the next project is considered.
- *
- * If the project itself is valid, the function decides what major release
- * series to consider. The project defines what the currently supported major
- * versions are for each version of core, so the first step is to make sure the
- * current version is still supported. If so, that's the target version. If the
- * current version is unsupported, the project maintainer's recommended major
- * version is used. There's also a check to make sure that this function never
- * recommends an earlier release than the currently installed major version.
- *
- * Given a target major version, the available releases are scanned looking for
- * the specific release to recommend (avoiding beta releases and development
- * snapshots if possible). For the target major version, the highest patch level
- * is found. If there is a release at that patch level with no extra ("beta",
- * etc.), then the release at that patch level with the most recent release date
- * is recommended. If every release at that patch level has extra (only betas),
- * then the latest release from the previous patch level is recommended. For
- * example:
- *
- * - 1.6-bugfix <-- recommended version because 1.6 already exists.
- * - 1.6
- *
- * or
- *
- * - 1.6-beta
- * - 1.5 <-- recommended version because no 1.6 exists.
- * - 1.4
- *
- * Also, the latest release from the same major version is looked for, even beta
- * releases, to display to the user as the "Latest version" option.
- * Additionally, the latest official release from any higher major versions that
- * have been released is searched for to provide a set of "Also available"
- * options.
- *
- * Finally, and most importantly, the release history continues to be scanned
- * until the currently installed release is reached, searching for anything
- * marked as a security update. If any security updates have been found between
- * the recommended release and the installed version, all of the releases that
- * included a security fix are recorded so that the site administrator can be
- * warned their site is insecure, and links pointing to the release notes for
- * each security update can be included (which, in turn, will link to the
- * official security announcements for each vulnerability).
- *
- * This function relies on the fact that the .xml release history data comes
- * sorted based on major version and patch level, then finally by release date
- * if there are multiple releases such as betas from the same major.patch
- * version (e.g., 5.x-1.5-beta1, 5.x-1.5-beta2, and 5.x-1.5). Development
- * snapshots for a given major version are always listed last.
- *
- * @param $project_data
- *   An array containing information about a specific project.
- * @param $available
- *   Data about available project releases of a specific project.
- */
-function update_calculate_project_update_status(&$project_data, $available) {
-  foreach (array('title', 'link') as $attribute) {
-    if (!isset($project_data[$attribute]) && isset($available[$attribute])) {
-      $project_data[$attribute] = $available[$attribute];
+    if (in_array($existing_major, $supported_majors)) {
+      // Still supported, stay at the current major version.
+      $target_major = $existing_major;
     }
-  }
-
-  // If the project status is marked as something bad, there's nothing else
-  // to consider.
-  if (isset($available['project_status'])) {
-    switch ($available['project_status']) {
-      case 'insecure':
-        $project_data['status'] = UPDATE_NOT_SECURE;
-        if (empty($project_data['extra'])) {
-          $project_data['extra'] = array();
-        }
-        $project_data['extra'][] = array(
-          'class' => array('project-not-secure'),
-          'label' => t('Project not secure'),
-          'data' => t('This project has been labeled insecure by the Drupal security team, and is no longer available for download. Immediately disabling everything included by this project is strongly recommended!'),
-        );
-        break;
-      case 'unpublished':
-      case 'revoked':
-        $project_data['status'] = UPDATE_REVOKED;
-        if (empty($project_data['extra'])) {
-          $project_data['extra'] = array();
-        }
-        $project_data['extra'][] = array(
-          'class' => array('project-revoked'),
-          'label' => t('Project revoked'),
-          'data' => t('This project has been revoked, and is no longer available for download. Disabling everything included by this project is strongly recommended!'),
-        );
-        break;
-      case 'unsupported':
-        $project_data['status'] = UPDATE_NOT_SUPPORTED;
-        if (empty($project_data['extra'])) {
-          $project_data['extra'] = array();
-        }
-        $project_data['extra'][] = array(
-          'class' => array('project-not-supported'),
-          'label' => t('Project not supported'),
-          'data' => t('This project is no longer supported, and is no longer available for download. Disabling everything included by this project is strongly recommended!'),
-        );
-        break;
-      case 'not-fetched':
-        $project_data['status'] = UPDATE_NOT_FETCHED;
-        $project_data['reason'] = t('Failed to get available update data.');
-        break;
-
-      default:
-        // Assume anything else (e.g. 'published') is valid and we should
-        // perform the rest of the logic in this function.
-        break;
+    elseif (isset($available['recommended_major'])) {
+      // Since 'recommended_major' is defined, we know this is the new XML
+      // format. Therefore, we know the current release is unsupported since
+      // its major version was not in the 'supported_majors' list. We should
+      // find the best release from the recommended major version.
+      $target_major = $available['recommended_major'];
+      $project_data['status'] = UPDATE_NOT_SUPPORTED;
     }
-  }
-
-  if (!empty($project_data['status'])) {
-    // We already know the status for this project, so there's nothing else to
-    // compute. Record the project status into $project_data and we're done.
-    $project_data['project_status'] = $available['project_status'];
-    return;
-  }
-
-  // Figure out the target major version.
-  $existing_major = $project_data['existing_major'];
-  $supported_majors = array();
-  if (isset($available['supported_majors'])) {
-    $supported_majors = explode(',', $available['supported_majors']);
-  }
-  elseif (isset($available['default_major'])) {
-    // Older release history XML file without supported or recommended.
-    $supported_majors[] = $available['default_major'];
-  }
-
-  if (in_array($existing_major, $supported_majors)) {
-    // Still supported, stay at the current major version.
-    $target_major = $existing_major;
-  }
-  elseif (isset($available['recommended_major'])) {
-    // Since 'recommended_major' is defined, we know this is the new XML
-    // format. Therefore, we know the current release is unsupported since
-    // its major version was not in the 'supported_majors' list. We should
-    // find the best release from the recommended major version.
-    $target_major = $available['recommended_major'];
-    $project_data['status'] = UPDATE_NOT_SUPPORTED;
-  }
-  elseif (isset($available['default_major'])) {
-    // Older release history XML file without recommended, so recommend
-    // the currently defined "default_major" version.
-    $target_major = $available['default_major'];
-  }
-  else {
-    // Malformed XML file? Stick with the current version.
-    $target_major = $existing_major;
-  }
-
-  // Make sure we never tell the admin to downgrade. If we recommended an
-  // earlier version than the one they're running, they'd face an
-  // impossible data migration problem, since Drupal never supports a DB
-  // downgrade path. In the unfortunate case that what they're running is
-  // unsupported, and there's nothing newer for them to upgrade to, we
-  // can't print out a "Recommended version", but just have to tell them
-  // what they have is unsupported and let them figure it out.
-  $target_major = max($existing_major, $target_major);
-
-  $release_patch_changed = '';
-  $patch = '';
-
-  // If the project is marked as UPDATE_FETCH_PENDING, it means that the
-  // data we currently have (if any) is stale, and we've got a task queued
-  // up to (re)fetch the data. In that case, we mark it as such, merge in
-  // whatever data we have (e.g. project title and link), and move on.
-  if (!empty($available['fetch_status']) && $available['fetch_status'] == UPDATE_FETCH_PENDING) {
-    $project_data['status'] = UPDATE_FETCH_PENDING;
-    $project_data['reason'] = t('No available update data');
-    $project_data['fetch_status'] = $available['fetch_status'];
-    return;
-  }
-
-  // Defend ourselves from XML history files that contain no releases.
-  if (empty($available['releases'])) {
-    $project_data['status'] = UPDATE_UNKNOWN;
-    $project_data['reason'] = t('No available releases found');
-    return;
-  }
-  foreach ($available['releases'] as $version => $release) {
-    // First, if this is the existing release, check a few conditions.
-    if ($project_data['existing_version'] === $version) {
-      if (isset($release['terms']['Release type']) &&
+    elseif (isset($available['default_major'])) {
+      // Older release history XML file without recommended, so recommend
+      // the currently defined "default_major" version.
+      $target_major = $available['default_major'];
+    }
+    else {
+      // Malformed XML file? Stick with the current version.
+      $target_major = $existing_major;
+    }
+
+    // Make sure we never tell the admin to downgrade. If we recommended an
+    // earlier version than the one they're running, they'd face an
+    // impossible data migration problem, since Drupal never supports a DB
+    // downgrade path. In the unfortunate case that what they're running is
+    // unsupported, and there's nothing newer for them to upgrade to, we
+    // can't print out a "Recommended version", but just have to tell them
+    // what they have is unsupported and let them figure it out.
+    $target_major = max($existing_major, $target_major);
+
+    $release_patch_changed = '';
+    $patch = '';
+
+    // If the project is marked as UPDATE_FETCH_PENDING, it means that the
+    // data we currently have (if any) is stale, and we've got a task queued
+    // up to (re)fetch the data. In that case, we mark it as such, merge in
+    // whatever data we have (e.g. project title and link), and move on.
+    if (!empty($available['fetch_status']) && $available['fetch_status'] == UPDATE_FETCH_PENDING) {
+      $project_data['status'] = UPDATE_FETCH_PENDING;
+      $project_data['reason'] = t('No available update data');
+      $project_data['fetch_status'] = $available['fetch_status'];
+      return;
+    }
+
+    // Defend ourselves from XML history files that contain no releases.
+    if (empty($available['releases'])) {
+      $project_data['status'] = UPDATE_UNKNOWN;
+      $project_data['reason'] = t('No available releases found');
+      return;
+    }
+    foreach ($available['releases'] as $version => $release) {
+      // First, if this is the existing release, check a few conditions.
+      if ($project_data['existing_version'] === $version) {
+        if (isset($release['terms']['Release type']) &&
           in_array('Insecure', $release['terms']['Release type'])) {
-        $project_data['status'] = UPDATE_NOT_SECURE;
-      }
-      elseif ($release['status'] == 'unpublished') {
-        $project_data['status'] = UPDATE_REVOKED;
-        if (empty($project_data['extra'])) {
-          $project_data['extra'] = array();
+          $project_data['status'] = UPDATE_NOT_SECURE;
         }
-        $project_data['extra'][] = array(
-          'class' => array('release-revoked'),
-          'label' => t('Release revoked'),
-          'data' => t('Your currently installed release has been revoked, and is no longer available for download. Disabling everything included in this release or upgrading is strongly recommended!'),
-        );
-      }
-      elseif (isset($release['terms']['Release type']) &&
-              in_array('Unsupported', $release['terms']['Release type'])) {
-        $project_data['status'] = UPDATE_NOT_SUPPORTED;
-        if (empty($project_data['extra'])) {
-          $project_data['extra'] = array();
+        elseif ($release['status'] == 'unpublished') {
+          $project_data['status'] = UPDATE_REVOKED;
+          if (empty($project_data['extra'])) {
+            $project_data['extra'] = array();
+          }
+          $project_data['extra'][] = array(
+            'class' => array('release-revoked'),
+            'label' => t('Release revoked'),
+            'data' => t('Your currently installed release has been revoked, and is no longer available for download. Disabling everything included in this release or upgrading is strongly recommended!'),
+          );
+        }
+        elseif (isset($release['terms']['Release type']) &&
+          in_array('Unsupported', $release['terms']['Release type'])) {
+          $project_data['status'] = UPDATE_NOT_SUPPORTED;
+          if (empty($project_data['extra'])) {
+            $project_data['extra'] = array();
+          }
+          $project_data['extra'][] = array(
+            'class' => array('release-not-supported'),
+            'label' => t('Release not supported'),
+            'data' => t('Your currently installed release is now unsupported, and is no longer available for download. Disabling everything included in this release or upgrading is strongly recommended!'),
+          );
         }
-        $project_data['extra'][] = array(
-          'class' => array('release-not-supported'),
-          'label' => t('Release not supported'),
-          'data' => t('Your currently installed release is now unsupported, and is no longer available for download. Disabling everything included in this release or upgrading is strongly recommended!'),
-        );
       }
-    }
 
-    // Otherwise, ignore unpublished, insecure, or unsupported releases.
-    if ($release['status'] == 'unpublished' ||
+      // Otherwise, ignore unpublished, insecure, or unsupported releases.
+      if ($release['status'] == 'unpublished' ||
         (isset($release['terms']['Release type']) &&
-         (in_array('Insecure', $release['terms']['Release type']) ||
-          in_array('Unsupported', $release['terms']['Release type'])))) {
-      continue;
-    }
+          (in_array('Insecure', $release['terms']['Release type']) ||
+            in_array('Unsupported', $release['terms']['Release type'])))) {
+        continue;
+      }
 
-    // See if this is a higher major version than our target and yet still
-    // supported. If so, record it as an "Also available" release.
-    // Note: Some projects have a HEAD release from CVS days, which could
-    // be one of those being compared. They would not have version_major
-    // set, so we must call isset first.
-    if (isset($release['version_major']) && $release['version_major'] > $target_major) {
-      if (in_array($release['version_major'], $supported_majors)) {
-        if (!isset($project_data['also'])) {
-          $project_data['also'] = array();
-        }
-        if (!isset($project_data['also'][$release['version_major']])) {
-          $project_data['also'][$release['version_major']] = $version;
-          $project_data['releases'][$version] = $release;
+      // See if this is a higher major version than our target and yet still
+      // supported. If so, record it as an "Also available" release.
+      // Note: Some projects have a HEAD release from CVS days, which could
+      // be one of those being compared. They would not have version_major
+      // set, so we must call isset first.
+      if (isset($release['version_major']) && $release['version_major'] > $target_major) {
+        if (in_array($release['version_major'], $supported_majors)) {
+          if (!isset($project_data['also'])) {
+            $project_data['also'] = array();
+          }
+          if (!isset($project_data['also'][$release['version_major']])) {
+            $project_data['also'][$release['version_major']] = $version;
+            $project_data['releases'][$version] = $release;
+          }
         }
+        // Otherwise, this release can't matter to us, since it's neither
+        // from the release series we're currently using nor the recommended
+        // release. We don't even care about security updates for this
+        // branch, since if a project maintainer puts out a security release
+        // at a higher major version and not at the lower major version,
+        // they must remove the lower version from the supported major
+        // versions at the same time, in which case we won't hit this code.
+        continue;
       }
-      // Otherwise, this release can't matter to us, since it's neither
-      // from the release series we're currently using nor the recommended
-      // release. We don't even care about security updates for this
-      // branch, since if a project maintainer puts out a security release
-      // at a higher major version and not at the lower major version,
-      // they must remove the lower version from the supported major
-      // versions at the same time, in which case we won't hit this code.
-      continue;
-    }
 
-    // Look for the 'latest version' if we haven't found it yet. Latest is
-    // defined as the most recent version for the target major version.
-    if (!isset($project_data['latest_version'])
+      // Look for the 'latest version' if we haven't found it yet. Latest is
+      // defined as the most recent version for the target major version.
+      if (!isset($project_data['latest_version'])
         && $release['version_major'] == $target_major) {
-      $project_data['latest_version'] = $version;
-      $project_data['releases'][$version] = $release;
-    }
+        $project_data['latest_version'] = $version;
+        $project_data['releases'][$version] = $release;
+      }
 
-    // Look for the development snapshot release for this branch.
-    if (!isset($project_data['dev_version'])
+      // Look for the development snapshot release for this branch.
+      if (!isset($project_data['dev_version'])
         && $release['version_major'] == $target_major
         && isset($release['version_extra'])
         && $release['version_extra'] == 'dev') {
-      $project_data['dev_version'] = $version;
-      $project_data['releases'][$version] = $release;
-    }
+        $project_data['dev_version'] = $version;
+        $project_data['releases'][$version] = $release;
+      }
 
-    // Look for the 'recommended' version if we haven't found it yet (see
-    // phpdoc at the top of this function for the definition).
-    if (!isset($project_data['recommended'])
+      // Look for the 'recommended' version if we haven't found it yet (see
+      // phpdoc at the top of this function for the definition).
+      if (!isset($project_data['recommended'])
         && $release['version_major'] == $target_major
         && isset($release['version_patch'])) {
-      if ($patch != $release['version_patch']) {
-        $patch = $release['version_patch'];
-        $release_patch_changed = $release;
-      }
-      if (empty($release['version_extra']) && $patch == $release['version_patch']) {
-        $project_data['recommended'] = $release_patch_changed['version'];
-        $project_data['releases'][$release_patch_changed['version']] = $release_patch_changed;
+        if ($patch != $release['version_patch']) {
+          $patch = $release['version_patch'];
+          $release_patch_changed = $release;
+        }
+        if (empty($release['version_extra']) && $patch == $release['version_patch']) {
+          $project_data['recommended'] = $release_patch_changed['version'];
+          $project_data['releases'][$release_patch_changed['version']] = $release_patch_changed;
+        }
       }
-    }
-
-    // Stop searching once we hit the currently installed version.
-    if ($project_data['existing_version'] === $version) {
-      break;
-    }
 
-    // If we're running a dev snapshot and have a timestamp, stop
-    // searching for security updates once we hit an official release
-    // older than what we've got. Allow 100 seconds of leeway to handle
-    // differences between the datestamp in the .info.yml file and the
-    // timestamp of the tarball itself (which are usually off by 1 or 2
-    // seconds) so that we don't flag that as a new release.
-    if ($project_data['install_type'] == 'dev') {
-      if (empty($project_data['datestamp'])) {
-        // We don't have current timestamp info, so we can't know.
-        continue;
+      // Stop searching once we hit the currently installed version.
+      if ($project_data['existing_version'] === $version) {
+        break;
       }
-      elseif (isset($release['date']) && ($project_data['datestamp'] + 100 > $release['date'])) {
-        // We're newer than this, so we can skip it.
-        continue;
+
+      // If we're running a dev snapshot and have a timestamp, stop
+      // searching for security updates once we hit an official release
+      // older than what we've got. Allow 100 seconds of leeway to handle
+      // differences between the datestamp in the .info.yml file and the
+      // timestamp of the tarball itself (which are usually off by 1 or 2
+      // seconds) so that we don't flag that as a new release.
+      if ($project_data['install_type'] == 'dev') {
+        if (empty($project_data['datestamp'])) {
+          // We don't have current timestamp info, so we can't know.
+          continue;
+        }
+        elseif (isset($release['date']) && ($project_data['datestamp'] + 100 > $release['date'])) {
+          // We're newer than this, so we can skip it.
+          continue;
+        }
       }
-    }
 
-    // See if this release is a security update.
-    if (isset($release['terms']['Release type'])
+      // See if this release is a security update.
+      if (isset($release['terms']['Release type'])
         && in_array('Security update', $release['terms']['Release type'])) {
-      $project_data['security updates'][] = $release;
+        $project_data['security updates'][] = $release;
+      }
     }
-  }
-
-  // If we were unable to find a recommended version, then make the latest
-  // version the recommended version if possible.
-  if (!isset($project_data['recommended']) && isset($project_data['latest_version'])) {
-    $project_data['recommended'] = $project_data['latest_version'];
-  }
-
-  //
-  // Check to see if we need an update or not.
-  //
 
-  if (!empty($project_data['security updates'])) {
-    // If we found security updates, that always trumps any other status.
-    $project_data['status'] = UPDATE_NOT_SECURE;
-  }
+    // If we were unable to find a recommended version, then make the latest
+    // version the recommended version if possible.
+    if (!isset($project_data['recommended']) && isset($project_data['latest_version'])) {
+      $project_data['recommended'] = $project_data['latest_version'];
+    }
 
-  if (isset($project_data['status'])) {
-    // If we already know the status, we're done.
-    return;
-  }
+    //
+    // Check to see if we need an update or not.
+    //
 
-  // If we don't know what to recommend, there's nothing we can report.
-  // Bail out early.
-  if (!isset($project_data['recommended'])) {
-    $project_data['status'] = UPDATE_UNKNOWN;
-    $project_data['reason'] = t('No available releases found');
-    return;
-  }
+    if (!empty($project_data['security updates'])) {
+      // If we found security updates, that always trumps any other status.
+      $project_data['status'] = UPDATE_NOT_SECURE;
+    }
 
-  // If we're running a dev snapshot, compare the date of the dev snapshot
-  // with the latest official version, and record the absolute latest in
-  // 'latest_dev' so we can correctly decide if there's a newer release
-  // than our current snapshot.
-  if ($project_data['install_type'] == 'dev') {
-    if (isset($project_data['dev_version']) && $available['releases'][$project_data['dev_version']]['date'] > $available['releases'][$project_data['latest_version']]['date']) {
-      $project_data['latest_dev'] = $project_data['dev_version'];
+    if (isset($project_data['status'])) {
+      // If we already know the status, we're done.
+      return;
     }
-    else {
-      $project_data['latest_dev'] = $project_data['latest_version'];
+
+    // If we don't know what to recommend, there's nothing we can report.
+    // Bail out early.
+    if (!isset($project_data['recommended'])) {
+      $project_data['status'] = UPDATE_UNKNOWN;
+      $project_data['reason'] = t('No available releases found');
+      return;
     }
-  }
 
-  // Figure out the status, based on what we've seen and the install type.
-  switch ($project_data['install_type']) {
-    case 'official':
-      if ($project_data['existing_version'] === $project_data['recommended'] || $project_data['existing_version'] === $project_data['latest_version']) {
-        $project_data['status'] = UPDATE_CURRENT;
+    // If we're running a dev snapshot, compare the date of the dev snapshot
+    // with the latest official version, and record the absolute latest in
+    // 'latest_dev' so we can correctly decide if there's a newer release
+    // than our current snapshot.
+    if ($project_data['install_type'] == 'dev') {
+      if (isset($project_data['dev_version']) && $available['releases'][$project_data['dev_version']]['date'] > $available['releases'][$project_data['latest_version']]['date']) {
+        $project_data['latest_dev'] = $project_data['dev_version'];
       }
       else {
-        $project_data['status'] = UPDATE_NOT_CURRENT;
+        $project_data['latest_dev'] = $project_data['latest_version'];
       }
-      break;
+    }
 
-    case 'dev':
-      $latest = $available['releases'][$project_data['latest_dev']];
-      if (empty($project_data['datestamp'])) {
-        $project_data['status'] = UPDATE_NOT_CHECKED;
-        $project_data['reason'] = t('Unknown release date');
-      }
-      elseif (($project_data['datestamp'] + 100 > $latest['date'])) {
-        $project_data['status'] = UPDATE_CURRENT;
-      }
-      else {
-        $project_data['status'] = UPDATE_NOT_CURRENT;
-      }
-      break;
+    // Figure out the status, based on what we've seen and the install type.
+    switch ($project_data['install_type']) {
+      case 'official':
+        if ($project_data['existing_version'] === $project_data['recommended'] || $project_data['existing_version'] === $project_data['latest_version']) {
+          $project_data['status'] = UPDATE_CURRENT;
+        }
+        else {
+          $project_data['status'] = UPDATE_NOT_CURRENT;
+        }
+        break;
 
-    default:
-      $project_data['status'] = UPDATE_UNKNOWN;
-      $project_data['reason'] = t('Invalid info');
-  }
-}
+      case 'dev':
+        $latest = $available['releases'][$project_data['latest_dev']];
+        if (empty($project_data['datestamp'])) {
+          $project_data['status'] = UPDATE_NOT_CHECKED;
+          $project_data['reason'] = t('Unknown release date');
+        }
+        elseif (($project_data['datestamp'] + 100 > $latest['date'])) {
+          $project_data['status'] = UPDATE_CURRENT;
+        }
+        else {
+          $project_data['status'] = UPDATE_NOT_CURRENT;
+        }
+        break;
 
-/**
- * Retrieves update storage data or empties it.
- *
- * Two very expensive arrays computed by this module are the list of all
- * installed modules and themes (and .info.yml data, project associations, etc), and
- * the current status of the site relative to the currently available releases.
- * These two arrays are stored and used whenever possible. The data is cleared
- * whenever the administrator visits the status report, available updates
- * report, or the module or theme administration pages, since we should always
- * recompute the most current values on any of those pages.
- *
- * Note: while both of these arrays are expensive to compute (in terms of disk
- * I/O and some fairly heavy CPU processing), neither of these is the actual
- * data about available updates that we have to fetch over the network from
- * updates.drupal.org. That information is stored in the
- * 'update_available_releases' collection -- it needs to persist longer than 1
- * hour and never get invalidated just by visiting a page on the site.
- *
- * @param $key
- *   The key of data to return. Valid options are 'update_project_data' and
- *   'update_project_projects'.
- *
- * @return
- *   The stored value of the $projects array generated by
- *   update_calculate_project_data() or update_get_projects(), or an empty array
- *   when the storage is cleared.
- */
-function update_project_storage($key) {
-  $projects = array();
-
-  // On certain paths, we should clear the data and recompute the projects for
-  // update status of the site to avoid presenting stale information.
-  $paths = array(
-    'admin/modules',
-    'admin/modules/update',
-    'admin/appearance',
-    'admin/appearance/update',
-    'admin/reports',
-    'admin/reports/updates',
-    'admin/reports/updates/update',
-    'admin/reports/status',
-    'admin/reports/updates/check',
-  );
-  if (in_array(current_path(), $paths)) {
-    Drupal::keyValueExpirable('update')->delete($key);
-  }
-  else {
-    $projects = Drupal::keyValueExpirable('update')->get($key);
+      default:
+        $project_data['status'] = UPDATE_UNKNOWN;
+        $project_data['reason'] = t('Invalid info');
+    }
   }
-  return $projects;
-}
 
-/**
- * Filters the project .info.yml data to only save attributes we need.
- *
- * @param array $info
- *   Array of .info.yml file data as returned by drupal_parse_info_file().
- * @param $additional_whitelist
- *   (optional) Array of additional elements to be collected from the .info.yml
- *   file. Defaults to array().
- *
- * @return
- *   Array of .info.yml file data we need for the update manager.
- *
- * @see update_process_info_list()
- */
-function update_filter_project_info($info, $additional_whitelist = array()) {
-  $whitelist = array(
-    '_info_file_ctime',
-    'datestamp',
-    'major',
-    'name',
-    'package',
-    'project',
-    'project status url',
-    'version',
-  );
-  $whitelist = array_merge($whitelist, $additional_whitelist);
-  return array_intersect_key($info, drupal_map_assoc($whitelist));
-}
+  /**
+   * Retrieves update storage data or empties it.
+   *
+   * Two very expensive arrays computed by this module are the list of all
+   * installed modules and themes (and .info.yml data, project associations, etc), and
+   * the current status of the site relative to the currently available releases.
+   * These two arrays are stored and used whenever possible. The data is cleared
+   * whenever the administrator visits the status report, available updates
+   * report, or the module or theme administration pages, since we should always
+   * recompute the most current values on any of those pages.
+   *
+   * Note: while both of these arrays are expensive to compute (in terms of disk
+   * I/O and some fairly heavy CPU processing), neither of these is the actual
+   * data about available updates that we have to fetch over the network from
+   * updates.drupal.org. That information is stored in the
+   * 'update_available_releases' collection -- it needs to persist longer than 1
+   * hour and never get invalidated just by visiting a page on the site.
+   *
+   * @param $key
+   *   The key of data to return. Valid options are 'update_project_data' and
+   *   'update_project_projects'.
+   *
+   * @return
+   *   The stored value of the $projects array generated by
+   *   update_calculate_project_data() or update_get_projects(), or an empty array
+   *   when the storage is cleared.
+   */
+  function update_project_storage($key) {
+    $projects = array();
+
+    // On certain paths, we should clear the data and recompute the projects for
+    // update status of the site to avoid presenting stale information.
+    $paths = array(
+      'admin/modules',
+      'admin/modules/update',
+      'admin/appearance',
+      'admin/appearance/update',
+      'admin/reports',
+      'admin/reports/updates',
+      'admin/reports/updates/update',
+      'admin/reports/status',
+      'admin/reports/updates/check',
+    );
+    if (in_array(current_path(), $paths)) {
+      Drupal::keyValueExpirable('update')->delete($key);
+    }
+    else {
+      $projects = Drupal::keyValueExpirable('update')->get($key);
+    }
+    return $projects;
+  }
