chromium/ash/webui/personalization_app/resources/js/wallpaper/wallpaper_controller.ts

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {FullscreenPreviewState} from 'chrome://resources/ash/common/personalization/wallpaper_state.js';
import {isNonEmptyArray, isNonEmptyFilePath} from 'chrome://resources/ash/common/sea_pen/sea_pen_utils.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {FilePath} from 'chrome://resources/mojo/mojo/public/mojom/base/file_path.mojom-webui.js';
import {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';

import {CurrentWallpaper, GooglePhotosAlbum, GooglePhotosEnablementState, GooglePhotosPhoto, WallpaperCollection, WallpaperImage, WallpaperLayout, WallpaperProviderInterface, WallpaperType} from '../../personalization_app.mojom-webui.js';
import {setErrorAction} from '../personalization_actions.js';
import {PersonalizationStore} from '../personalization_store.js';

import {DisplayableImage} from './constants.js';
import {isDefaultImage, isGooglePhotosPhoto, isImageAMatchForKey, isImageEqualToSelected, isWallpaperImage} from './utils.js';
import * as action from './wallpaper_actions.js';
import {DailyRefreshType} from './wallpaper_state.js';

/**
 * @fileoverview contains all of the functions to interact with C++ side through
 * mojom calls. Handles setting |PersonalizationStore| state in response to
 * mojom data.
 */

/** Fetch wallpaper collections and save them to the store. */
export async function fetchCollections(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  let {collections} = await provider.fetchCollections();
  if (!isNonEmptyArray(collections)) {
    console.warn('Failed to fetch wallpaper collections');
    collections = null;
  }
  store.dispatch(action.setCollectionsAction(collections));
}

/** Helper function to fetch and dispatch images for a single collection. */
async function fetchAndDispatchCollectionImages(
    provider: WallpaperProviderInterface, store: PersonalizationStore,
    collection: WallpaperCollection): Promise<void> {
  let {images} = await provider.fetchImagesForCollection(collection.id);
  if (!isNonEmptyArray(images)) {
    console.warn('Failed to fetch images for collection id', collection.id);
    images = null;
  }
  store.dispatch(action.setImagesForCollectionAction(collection.id, images));
}

/** Fetch all of the wallpaper collection images in parallel. */
async function fetchAllImagesForCollections(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  const collections = store.data.wallpaper.backdrop.collections;
  if (!Array.isArray(collections)) {
    console.warn(
        'Cannot fetch data for collections when it is not initialized');
    return;
  }

  store.dispatch(action.beginLoadImagesForCollectionsAction(collections));

  await Promise.all(collections.map(
      collection =>
          fetchAndDispatchCollectionImages(provider, store, collection)));
}

/**
 * Appends a suffix to request wallpaper images with the longest of width or
 * height being 512 pixels. This should ensure that the wallpaper image is
 * large enough to cover a grid item but not significantly more so.
 */
function appendMaxResolutionSuffix(value: Url): Url {
  return {...value, url: value.url + '=s512'};
}

/**
 * Fetches the list of Google Photos photos for the album associated with the
 * specified id and saves it to the store.
 */
export async function fetchGooglePhotosAlbum(
    provider: WallpaperProviderInterface, store: PersonalizationStore,
    albumId: string): Promise<void> {
  // Photos should only be fetched after determining whether access is allowed.
  const enabled = store.data.wallpaper.googlePhotos.enabled;
  assert(enabled !== undefined);

  store.dispatch(action.beginLoadGooglePhotosAlbumAction(albumId));

  // If access is *not* allowed, short-circuit the request.
  if (enabled !== GooglePhotosEnablementState.kEnabled) {
    store.dispatch(action.appendGooglePhotosPhotosAction(
        /*photos=*/ null, /*resumeToken=*/ null));
    return;
  }

  let photos: GooglePhotosPhoto[]|null = [];
  let resumeToken =
      store.data.wallpaper.googlePhotos.resumeTokens.photosByAlbumId[albumId] ||
      null;

  const {response} = await provider.fetchGooglePhotosPhotos(
      /*itemId=*/ null, albumId, resumeToken);
  if (Array.isArray(response.photos)) {
    photos.push(...response.photos);
    resumeToken = response.resumeToken || null;
  } else {
    console.warn('Failed to fetch Google Photos album');
    photos = null;
    // NOTE: `resumeToken` is intentionally *not* modified so that the request
    // which failed can be reattempted.
  }

  // Impose max resolution.
  if (photos !== null) {
    photos = photos.map(
        photo => ({...photo, url: appendMaxResolutionSuffix(photo.url)}));
  }

  store.dispatch(
      action.appendGooglePhotosAlbumAction(albumId, photos, resumeToken));
}

/** Fetches the list of Google Photos owned albums and saves it to the store. */
export async function fetchGooglePhotosAlbums(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  // Albums should only be fetched after determining whether access is allowed.
  const enabled = store.data.wallpaper.googlePhotos.enabled;
  assert(enabled !== undefined, 'Google Photos albums not enabled.');

  store.dispatch(action.beginLoadGooglePhotosAlbumsAction());

  // If access is *not* allowed, short-circuit the request.
  if (enabled !== GooglePhotosEnablementState.kEnabled) {
    store.dispatch(action.appendGooglePhotosAlbumsAction(
        /*albums=*/ null, /*resumeToken=*/ null));
    return;
  }

  let albums: GooglePhotosAlbum[]|null = [];
  let resumeToken = store.data.wallpaper.googlePhotos.resumeTokens.albums;

  const {response} = await provider.fetchGooglePhotosAlbums(resumeToken);
  if (Array.isArray(response.albums)) {
    albums.push(...response.albums);
    resumeToken = response.resumeToken || null;
  } else {
    console.warn('Failed to fetch Google Photos owned albums');
    albums = null;
    // NOTE: `resumeToken` is intentionally *not* modified so that the request
    // which failed can be reattempted.
  }

  // Impose max resolution.
  if (albums !== null) {
    albums = albums.map(
        album =>
            ({...album, preview: appendMaxResolutionSuffix(album.preview)}));
  }

  store.dispatch(action.appendGooglePhotosAlbumsAction(albums, resumeToken));
}

/**
 * Fetches the list of Google Photos shared albums and saves it to the store.
 */
export async function fetchGooglePhotosSharedAlbums(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  // Albums should only be fetched after determining whether access is allowed.
  const enabled = store.data.wallpaper.googlePhotos.enabled;
  assert(
      enabled !== undefined, 'Google photos enablement state not initialized.');

  store.dispatch(action.beginLoadGooglePhotosSharedAlbumsAction());

  // If access is *not* allowed, short-circuit the request.
  if (enabled !== GooglePhotosEnablementState.kEnabled) {
    store.dispatch(action.appendGooglePhotosSharedAlbumsAction(
        /*albums=*/ null, /*resumeToken=*/ null));
    return;
  }

  let albums: GooglePhotosAlbum[]|null = [];
  let resumeToken = store.data.wallpaper.googlePhotos.resumeTokens.albumsShared;

  const {response} = await provider.fetchGooglePhotosSharedAlbums(resumeToken);
  if (Array.isArray(response.albums)) {
    albums.push(...response.albums);
    resumeToken = response.resumeToken || null;
  } else {
    console.warn('Failed to fetch Google Photos shared albums');
    albums = null;
    // NOTE: `resumeToken` is intentionally *not* modified so that the request
    // which failed can be reattempted.
  }

  // Impose max resolution.
  if (albums !== null) {
    albums = albums.map(
        album =>
            ({...album, preview: appendMaxResolutionSuffix(album.preview)}));
  }

  store.dispatch(
      action.appendGooglePhotosSharedAlbumsAction(albums, resumeToken));
}

/** Fetches whether the user is allowed to access Google Photos. */
export async function fetchGooglePhotosEnabled(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  // Whether access is allowed should only be fetched once.
  if (store.data.wallpaper.googlePhotos.enabled !== undefined) {
    return;
  }

  store.dispatch(action.beginLoadGooglePhotosEnabledAction());
  const {state} = await provider.fetchGooglePhotosEnabled();
  if (state === GooglePhotosEnablementState.kError) {
    console.warn('Failed to fetch Google Photos enabled');
  }
  store.dispatch(action.setGooglePhotosEnabledAction(state));
}

/** Fetches the list of Google Photos photos and saves it to the store. */
export async function fetchGooglePhotosPhotos(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  // Photos should only be fetched after determining whether access is allowed.
  const enabled = store.data.wallpaper.googlePhotos.enabled;
  assert(enabled !== undefined);

  store.dispatch(action.beginLoadGooglePhotosPhotosAction());

  // If access is *not* allowed, short-circuit the request.
  if (enabled !== GooglePhotosEnablementState.kEnabled) {
    store.dispatch(action.appendGooglePhotosPhotosAction(
        /*photos=*/ null, /*resumeToken=*/ null));
    return;
  }

  let photos: GooglePhotosPhoto[]|null = [];
  let resumeToken = store.data.wallpaper.googlePhotos.resumeTokens.photos;

  const {response} = await provider.fetchGooglePhotosPhotos(
      /*itemId=*/ null, /*albumId=*/ null, resumeToken);
  if (Array.isArray(response.photos)) {
    photos.push(...response.photos);
    resumeToken = response.resumeToken || null;
  } else {
    console.warn('Failed to fetch Google Photos photos');
    photos = null;
    // NOTE: `resumeToken` is intentionally *not* modified so that the request
    // which failed can be reattempted.
  }

  // Impose max resolution.
  if (photos !== null) {
    photos = photos.map(
        photo => ({...photo, url: appendMaxResolutionSuffix(photo.url)}));
  }

  store.dispatch(action.appendGooglePhotosPhotosAction(photos, resumeToken));
}

export async function getDefaultImageThumbnail(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  store.dispatch(action.beginLoadDefaultImageThubmnailAction());
  const {data} = await provider.getDefaultImageThumbnail();
  if (data.url.length === 0) {
    console.error('Failed to load default image thumbnail');
  }
  store.dispatch(action.setDefaultImageThumbnailAction(data));
}

/** Get list of local images from disk and save it to the store. */
export async function getLocalImages(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  store.dispatch(action.beginLoadLocalImagesAction());
  const {images} = await provider.getLocalImages();
  if (images == null) {
    console.warn('Failed to fetch local images');
  }
  store.dispatch(action.setLocalImagesAction(images));
}

/**
 * Because thumbnail loading can happen asynchronously and is triggered
 * on page load and on window focus, multiple "threads" can be fetching
 * thumbnails simultaneously. Synchronize them with a task queue.
 */
const imageThumbnailsToFetch = new Set<FilePath['path']>();

/**
 * Get an image thumbnail one at a time for every local image that does not have
 * a thumbnail yet.
 */
async function getMissingLocalImageThumbnails(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  if (!Array.isArray(store.data.wallpaper.local.images)) {
    console.warn('Cannot fetch thumbnails with invalid image list');
    return;
  }

  // Set correct loading state for each image thumbnail. Do in a batch update to
  // reduce number of times that polymer must re-render.
  store.beginBatchUpdate();
  for (const image of store.data.wallpaper.local.images) {
    if (isDefaultImage(image)) {
      continue;
    }
    if (store.data.wallpaper.local.data[image.path] ||
        store.data.wallpaper.loading.local.data[image.path] ||
        imageThumbnailsToFetch.has(image.path)) {
      // Do not re-load thumbnail if already present, or already loading.
      continue;
    }
    imageThumbnailsToFetch.add(image.path);
    store.dispatch(action.beginLoadLocalImageDataAction(image));
  }
  store.endBatchUpdate();

  // There may be multiple async tasks triggered that pull off this queue.
  while (imageThumbnailsToFetch.size) {
    await Promise.all(Array.from(imageThumbnailsToFetch).map(async path => {
      imageThumbnailsToFetch.delete(path);
      const {data} = await provider.getLocalImageThumbnail({path});
      if (!data) {
        console.warn('Failed to fetch local image data', path);
      }
      store.dispatch(action.setLocalImageDataAction({path}, data));
    }));
  }
}

export async function selectWallpaper(
    image: DisplayableImage, provider: WallpaperProviderInterface,
    store: PersonalizationStore,
    layout: WallpaperLayout = WallpaperLayout.kCenterCropped): Promise<void> {
  const currentWallpaper = store.data.wallpaper.currentSelected;
  if (currentWallpaper && isImageEqualToSelected(image, currentWallpaper)) {
    return;
  }

  // Batch these changes together to reduce polymer churn as multiple state
  // fields change quickly.
  store.beginBatchUpdate();
  store.dispatch(action.beginSelectImageAction(image));
  store.dispatch(action.beginLoadSelectedImageAction());
  const {tabletMode} = await provider.isInTabletMode();
  const shouldPreview = tabletMode && !isDefaultImage(image);
  if (shouldPreview) {
    provider.makeTransparent();
    store.dispatch(
        action.setFullscreenStateAction(FullscreenPreviewState.LOADING));
  }
  store.endBatchUpdate();
  const {success} = await (() => {
    if (isWallpaperImage(image)) {
      return provider.selectWallpaper(
          image.unitId, /*preview_mode=*/ shouldPreview);
    } else if (isDefaultImage(image)) {
      return provider.selectDefaultImage();
    } else if (isNonEmptyFilePath(image)) {
      return provider.selectLocalImage(
          image, layout, /*preview_mode=*/ shouldPreview);
    } else if (isGooglePhotosPhoto(image)) {
      return provider.selectGooglePhotosPhoto(
          image.id, layout, /*preview_mode=*/ shouldPreview);
    } else {
      console.warn('Image must be a local image or a WallpaperImage');
      return {success: false};
    }
  })();
  store.beginBatchUpdate();
  store.dispatch(action.endSelectImageAction(image, success));
  if (!success) {
    console.warn('Error setting wallpaper');
    store.dispatch(action.setFullscreenStateAction(FullscreenPreviewState.OFF));
    store.dispatch(
        action.setAttributionAction(store.data.wallpaper.attribution));
    store.dispatch(
        action.setSelectedImageAction(store.data.wallpaper.currentSelected));
  }
  store.endBatchUpdate();
}

export async function setCurrentWallpaperLayout(
    layout: WallpaperLayout, provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  const image = store.data.wallpaper.currentSelected;
  assert(image);
  assert(
      image.type === WallpaperType.kCustomized ||
      image.type === WallpaperType.kOnceGooglePhotos);
  assert(
      layout === WallpaperLayout.kCenter ||
      layout === WallpaperLayout.kCenterCropped);

  if (image.layout === layout) {
    return;
  }

  store.dispatch(action.beginLoadSelectedImageAction());
  await provider.setCurrentWallpaperLayout(layout);
}

// Do not trigger the loading UI if the currently selected wallpaper is a
// matching type for the incoming selection and if the currently selected
// wallpaper is in the chosen album.
function dailyRefreshShouldTriggerLoading(
    id: string, types: Set<WallpaperType>,
    currentSelected: CurrentWallpaper|null,
    imagesById:
        Record<string, Array<WallpaperImage|GooglePhotosPhoto>|null|undefined>):
    boolean {
  if (!id) {
    // No loading shown if clearing daily refresh state.
    return false;
  }
  if (!currentSelected) {
    return true;
  }
  if (types.has(currentSelected.type)) {
    return !imagesById[id]?.some(
        image => isImageAMatchForKey(image, currentSelected.key));
  }
  return true;
}

export async function setDailyRefreshCollectionId(
    collectionId: WallpaperCollection['id'],
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  if (dailyRefreshShouldTriggerLoading(
          collectionId, new Set([WallpaperType.kOnline, WallpaperType.kDaily]),
          store.data.wallpaper.currentSelected,
          store.data.wallpaper.backdrop.images)) {
    store.dispatch(action.beginUpdateDailyRefreshImageAction());
  }
  const {success} = await provider.setDailyRefreshCollectionId(collectionId);
  if (!success) {
    store.dispatch(
        setErrorAction({message: loadTimeData.getString('setWallpaperError')}));
  }
  await getDailyRefreshState(provider, store);
}

export async function selectGooglePhotosAlbum(
    albumId: GooglePhotosAlbum['id'], provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  if (dailyRefreshShouldTriggerLoading(
          albumId, new Set([
            WallpaperType.kOnceGooglePhotos,
            WallpaperType.kDailyGooglePhotos,
          ]),
          store.data.wallpaper.currentSelected,
          store.data.wallpaper.googlePhotos.photosByAlbumId)) {
    store.dispatch(action.beginUpdateDailyRefreshImageAction());
  }
  const {success} = await provider.selectGooglePhotosAlbum(albumId);
  if (!success) {
    store.dispatch(
        setErrorAction({message: loadTimeData.getString('googlePhotosError')}));
  }
  await getDailyRefreshState(provider, store);
}

/**
 * Get the currently active daily refresh id for Backdrop and Google Photos.
 * One or both will be empty, depending on which, if either, is enabled.
 */
export async function getDailyRefreshState(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  const [{collectionId}, {albumId}] = await Promise.all([
    provider.getDailyRefreshCollectionId(),
    provider.getGooglePhotosDailyRefreshAlbumId(),
  ]);

  // Daily refresh should only be active for either Backdrop or Google Photos
  assert(!collectionId || !albumId);

  if (collectionId) {
    store.dispatch(action.setDailyRefreshCollectionIdAction(collectionId));
  } else if (albumId) {
    store.dispatch(action.setGooglePhotosDailyRefreshAlbumIdAction(albumId));
  } else {
    store.dispatch(action.clearDailyRefreshAction());
  }
}

/** Refresh the wallpaper. Noop if daily refresh is not enabled. */
export async function updateDailyRefreshWallpaper(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  store.dispatch(action.beginUpdateDailyRefreshImageAction());
  store.dispatch(action.beginLoadSelectedImageAction());
  const {success} = await provider.updateDailyRefreshWallpaper();
  if (success) {
    store.dispatch(action.setUpdatedDailyRefreshImageAction());
  } else {
    const currentAttribution = store.data.wallpaper.attribution;
    const currentWallpaper = store.data.wallpaper.currentSelected;
    const dailyRefresh = store.data.wallpaper.dailyRefresh;
    // Displays error if daily refresh is activated for Google Photos album
    // and refresh failed to fetch a new Google Photo wallpaper.
    // Also dispatches setUpdatedDailyRefreshImageAction() and
    // setSelectedImageAction() to avoid pending UI.
    // TODO (b/266257678): displays error message when daily refresh fails for
    // online wallpaper collections.
    if (!!dailyRefresh && dailyRefresh.type == DailyRefreshType.GOOGLE_PHOTOS) {
      store.dispatch(action.setUpdatedDailyRefreshImageAction());
      store.dispatch(action.setAttributionAction(currentAttribution));
      store.dispatch(action.setSelectedImageAction(currentWallpaper));
      store.dispatch(setErrorAction(
          {message: loadTimeData.getString('googlePhotosError')}));
    }
  }
}

/** Confirm and set preview wallpaper as actual wallpaper. */
export async function confirmPreviewWallpaper(
    provider: WallpaperProviderInterface): Promise<void> {
  provider.makeOpaque();
  provider.confirmPreviewWallpaper();
}

/** Cancel preview wallpaper and show the previous wallpaper. */
export async function cancelPreviewWallpaper(
    provider: WallpaperProviderInterface): Promise<void> {
  provider.makeOpaque();
  provider.cancelPreviewWallpaper();
}

export async function getShouldShowTimeOfDayWallpaperDialog(
    provider: WallpaperProviderInterface, store: PersonalizationStore) {
  const {shouldShowDialog} =
      await provider.shouldShowTimeOfDayWallpaperDialog();

  // Dispatch action to set the should show dialog boolean.
  store.dispatch(
      action.setShouldShowTimeOfDayWallpaperDialog(shouldShowDialog));
}

/**
 * Fetches list of collections, then fetches list of images for each
 * collection.
 */
export async function initializeBackdropData(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  await fetchCollections(provider, store);
  await fetchAllImagesForCollections(provider, store);
}

/**
 * Gets list of local images, then fetches image thumbnails for each local
 * image.
 */
export async function fetchLocalData(
    provider: WallpaperProviderInterface,
    store: PersonalizationStore): Promise<void> {
  // Do not restart loading local image list if a load is already in progress.
  if (!store.data.wallpaper.loading.local.images) {
    await getLocalImages(provider, store);
  }
  await getMissingLocalImageThumbnails(provider, store);
}