0

personalization: improve display local wallpapers

On collections page, display a preview of local images. Clicking on
preview takes user to a new sub-page that displays all local images.
More work on error and loading behavior is necessary to match UX mock.

Render local images in trusted side because they are already data urls.
This reduces code complexity and memory overhead of copying
thumbnail previews across an iframe. Share styles between untrusted
and trusted so that image grids look the same in both cases.

BUG=b/189968254
TEST=browser_tests --gtest_filter="PersonalizationApp*"

Cq-Include-Trybots: luci.chrome.try:linux-chromeos-chrome
Change-Id: I36635c8f0567d0a08ae10566c197d5ba55cc2602
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2957198
Commit-Queue: Jeffrey Young <cowmoo@chromium.org>
Reviewed-by: Xiaohui Chen <xiaohuic@chromium.org>
Cr-Commit-Position: refs/heads/master@{#893091}
This commit is contained in:
Jeffrey Young
2021-06-16 19:12:50 +00:00
committed by Chromium LUCI CQ
parent 89b9f14b6b
commit 475dbba005
25 changed files with 660 additions and 118 deletions

@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {unguessableTokenToString} from 'chrome://personalization/common/utils.js';
import {assertTrue} from '../../chai_assert.js';
import {TestBrowserProxy} from '../../test_browser_proxy.m.js';
@ -56,6 +57,24 @@ export class TestWallpaperProvider extends TestBrowserProxy {
},
];
/** @type {?Array<!chromeos.personalizationApp.mojom.LocalImage>} */
this.localImages = [
{
id: {high: BigInt(100), low: BigInt(10)},
name: 'LocalImage0',
},
{
id: {high: BigInt(200), low: BigInt(20)},
name: 'LocalImage1',
}
];
/** @type {!Object<string, string>} */
this.localImageData = {
'100,10': 'localimage0data',
'200,20': 'localimage1data',
};
/**
* @public
* @type {!chromeos.personalizationApp.mojom.WallpaperImage}
@ -99,13 +118,14 @@ export class TestWallpaperProvider extends TestBrowserProxy {
/** @override */
getLocalImages() {
this.methodCalled('getLocalImages');
return Promise.resolve({images: []});
return Promise.resolve({images: this.localImages});
}
/** @override */
getLocalImageThumbnail(id) {
this.methodCalled('getLocalImageThumbnail', id);
return Promise.resolve({data: ''});
return Promise.resolve(
{data: this.localImageData[unguessableTokenToString(id)]});
}
/** @override */

@ -2,9 +2,10 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {unguessableTokenToString} from 'chrome://personalization/common/utils.js';
import {emptyState} from 'chrome://personalization/trusted/personalization_reducers.js';
import {promisifySendCollectionsForTesting, WallpaperCollections} from 'chrome://personalization/trusted/wallpaper_collections_element.js';
import {assertDeepEquals, assertFalse, assertTrue} from '../../chai_assert.js';
import {kMaximumImageThumbnailsCount, promisifyIframeFunctionsForTesting, WallpaperCollections} from 'chrome://personalization/trusted/wallpaper_collections_element.js';
import {assertDeepEquals, assertEquals, assertFalse, assertTrue} from '../../chai_assert.js';
import {waitAfterNextRender} from '../../test_util.m.js';
import {assertWindowObjectsEqual, baseSetup, initElement, teardownElement} from './personalization_app_test_utils.js';
import {TestWallpaperProvider} from './test_mojo_interface_provider.js';
@ -45,7 +46,8 @@ export function WallpaperCollectionsTest() {
});
test('shows wallpaper collections when loaded', async () => {
const sendCollectionsPromise = promisifySendCollectionsForTesting();
const {sendCollections: sendCollectionsPromise} =
promisifyIframeFunctionsForTesting();
wallpaperCollectionsElement = initElement(WallpaperCollections.is);
const spinner = wallpaperCollectionsElement.shadowRoot.querySelector(
@ -72,6 +74,33 @@ export function WallpaperCollectionsTest() {
assertDeepEquals(wallpaperProvider.collections, data);
});
test('sends local images when loaded', async () => {
const {sendLocalImages: sendLocalImagesPromise} =
promisifyIframeFunctionsForTesting();
wallpaperCollectionsElement = initElement(WallpaperCollections.is);
personalizationStore.data.loading = {
collections: false,
local: {images: false}
};
personalizationStore.data.local.images = wallpaperProvider.localImages;
personalizationStore.data.backdrop.collections =
wallpaperProvider.collections;
personalizationStore.notifyObservers();
// Wait for |sendLocalImages| to be called.
const [target, data] = await sendLocalImagesPromise;
await waitAfterNextRender(wallpaperCollectionsElement);
const iframe =
wallpaperCollectionsElement.shadowRoot.querySelector('iframe');
assertFalse(iframe.hidden);
assertWindowObjectsEqual(iframe.contentWindow, target);
assertDeepEquals(wallpaperProvider.localImages, data);
});
test('shows error when fails to load', async () => {
wallpaperCollectionsElement = initElement(WallpaperCollections.is);
@ -97,17 +126,24 @@ export function WallpaperCollectionsTest() {
wallpaperCollectionsElement.shadowRoot.querySelector('iframe').hidden);
});
test('loads backdrop data and saves to store', async () => {
test('loads backdrop and local data and saves to store', async () => {
// Make sure state starts at expected value.
assertDeepEquals(emptyState(), personalizationStore.data);
// Actually run the reducers.
personalizationStore.setReducersEnabled(true);
const sendCollectionsPromise = promisifySendCollectionsForTesting();
const {
sendCollections: sendCollectionsPromise,
sendLocalImages: sendLocalImagesPromise
} = promisifyIframeFunctionsForTesting();
wallpaperCollectionsElement = initElement(WallpaperCollections.is);
const [_, data] = await sendCollectionsPromise;
assertDeepEquals(wallpaperProvider.collections, data);
const [_, collections] = await sendCollectionsPromise;
assertDeepEquals(wallpaperProvider.collections, collections);
const [__, localImages] = await sendLocalImagesPromise;
assertDeepEquals(wallpaperProvider.localImages, localImages);
assertDeepEquals(
{
@ -119,6 +155,13 @@ export function WallpaperCollectionsTest() {
},
personalizationStore.data.backdrop,
);
assertDeepEquals(
{
images: wallpaperProvider.localImages,
data: wallpaperProvider.localImageData,
},
personalizationStore.data.local,
);
assertDeepEquals(
{
...emptyState().loading,
@ -127,8 +170,81 @@ export function WallpaperCollectionsTest() {
'id_0': false,
'id_1': false,
},
local: {
images: false,
data: {
'100,10': false,
'200,20': false,
},
},
},
personalizationStore.data.loading,
);
});
test(
'sends the first local images that successfully load thumbnails',
async () => {
// Set up store data. Local image list is loaded, but thumbnails are
// still loading in.
personalizationStore.data.loading.local.images = false;
personalizationStore.data.local.images = [];
for (let i = 0; i < kMaximumImageThumbnailsCount * 2; i++) {
personalizationStore.data.local.images.push(
{id: {high: BigInt(i * 2), low: BigInt(i)}, name: `local-${i}`});
personalizationStore.data.loading.local.data[`${i * 2},${i}`] = true;
}
// Collections are finished loading.
personalizationStore.data.backdrop.collections =
wallpaperProvider.collections;
personalizationStore.data.loading.collections = false;
let {sendLocalImages, sendLocalImageData} =
promisifyIframeFunctionsForTesting();
wallpaperCollectionsElement = initElement(WallpaperCollections.is);
await sendLocalImages;
// No thumbnails loaded so none sent.
assertEquals(0, wallpaperCollectionsElement.sentLocalImages_.size);
// First thumbnail loads in.
personalizationStore.data.loading.local.data = {'0,0': false};
personalizationStore.data.local.data = {'0,0': 'local_data_0'};
personalizationStore.notifyObservers();
// This thumbnail should have just loaded in.
let sent = await sendLocalImageData;
assertDeepEquals(
['0,0'], Array.from(wallpaperCollectionsElement.sentLocalImages_));
assertEquals('0,0', unguessableTokenToString(sent[1].id));
assertEquals('local_data_0', sent[2]);
sendLocalImageData =
promisifyIframeFunctionsForTesting().sendLocalImageData;
// Second thumbnail fails loading. Third succeeds.
personalizationStore.data.loading.local.data = {
...personalizationStore.data.loading.local.data,
'2,1': false,
'4,2': false,
};
personalizationStore.data.local.data = {
...personalizationStore.data.local.data,
'2,1': null,
'4,2': 'local_data_2',
};
personalizationStore.notifyObservers();
sent = await sendLocalImageData;
// '4,2' successfully loaded and '2,1' did not. '4,2' should have been
// sent to iframe.
assertDeepEquals(
['0,0', '4,2'],
Array.from(wallpaperCollectionsElement.sentLocalImages_));
assertEquals('4,2', unguessableTokenToString(sent[1].id));
assertEquals('local_data_2', sent[2]);
});
}

@ -16,6 +16,9 @@ js_library("iframe_api") {
]
}
js_library("styles") {
}
js_library("utils") {
}
@ -35,6 +38,7 @@ preprocess_if_expr("preprocess") {
in_files = [
"common/constants.js",
"common/iframe_api.js",
"common/styles.js",
"common/utils.js",
]
}

@ -14,13 +14,20 @@ export const trustedOrigin = 'chrome://personalization';
export const EventType = {
SEND_COLLECTIONS: 'send_collections',
SELECT_COLLECTION: 'select_collection',
SELECT_LOCAL_COLLECTION: 'select_local_collection',
SEND_IMAGES: 'send_images',
SEND_LOCAL_IMAGE_DATA: 'send_local_image_data',
SEND_LOCAL_IMAGES: 'send_local_images',
SELECT_IMAGE: 'select_image',
SELECT_LOCAL_IMAGE: 'select_local_image',
};
/**
* @typedef {{ type: EventType, collections:
* !Array<!chromeos.personalizationApp.mojom.WallpaperCollection> }}
* @typedef {{
* type: EventType,
* collections:
* !Array<!chromeos.personalizationApp.mojom.WallpaperCollection>,
* }}
*/
export let SendCollectionsEvent;
@ -30,11 +37,36 @@ export let SendCollectionsEvent;
export let SelectCollectionEvent;
/**
* @typedef {{ type: EventType, images:
* !Array<!chromeos.personalizationApp.mojom.WallpaperImage> }}
* @typedef {{ type: EventType }}
*/
export let SelectLocalCollectionEvent;
/**
* @typedef {{
* type: EventType,
* images: !Array<!chromeos.personalizationApp.mojom.WallpaperImage>,
* }}
*/
export let SendImagesEvent;
/**
* @typedef {{
* type: EventType,
* images: !Array<!chromeos.personalizationApp.mojom.LocalImage>,
* }}
*/
export let SendLocalImagesEvent;
/**
* @typedef {{
* type: EventType,
* id: !mojoBase.mojom.UnguessableToken,
* data: string,
* }}
*/
export let SendLocalImageDataEvent;
/**
* @typedef {{ type: EventType, assetId: bigint }}
*/

@ -3,7 +3,7 @@
// found in the LICENSE file.
import {assert, assertNotReached} from '/assert.m.js';
import {EventType, SelectCollectionEvent, SelectImageEvent, SendCollectionsEvent, SendImagesEvent, trustedOrigin, untrustedOrigin} from './constants.js';
import {EventType, SelectCollectionEvent, SelectImageEvent, SelectLocalCollectionEvent, SendCollectionsEvent, SendImagesEvent, SendLocalImageDataEvent, SendLocalImagesEvent, trustedOrigin, untrustedOrigin} from './constants.js';
import {isNonEmptyArray} from './utils.js';
/**
@ -29,7 +29,7 @@ export function sendCollections(target, collections) {
/**
* Send an array of wallpaper images to chrome-untrusted://.
* Will clear the page if images is empty array.
* @param {!Object} target the iframe window to send the message to.
* @param {!Window} target the iframe window to send the message to.
* @param {!Array<!chromeos.personalizationApp.mojom.WallpaperImage>} images
*/
export function sendImages(target, images) {
@ -38,22 +38,38 @@ export function sendImages(target, images) {
target.postMessage(event, untrustedOrigin);
}
/**
* Send an array of local images to chrome-untrusted://.
* @param {!Window} target the iframe window to send the message to.
* @param {!Array<!chromeos.personalizationApp.mojom.LocalImage>} images
*/
export function sendLocalImages(target, images) {
/** @type {!SendLocalImagesEvent} */
const event = {type: EventType.SEND_LOCAL_IMAGES, images};
target.postMessage(event, untrustedOrigin);
}
/**
* @param {!Window} target
* @param {!chromeos.personalizationApp.mojom.LocalImage} image
* @param {!string} data
*/
export function sendLocalImageData(target, image, data) {
/** @type {!SendLocalImageDataEvent} */
const event = {type: EventType.SEND_LOCAL_IMAGE_DATA, id: image.id, data};
target.postMessage(event, untrustedOrigin);
}
/**
* Called from trusted code to validate that a received postMessage event
* contains valid data. Ignores messages that are not of the expected type.
* @param {!Event} event from untrusted to select a collection or image
* @param {!EventType} expectedEventType
* @param {Array<!T>} choices array of valid objects to pick from
* @return {!T}
* @template T
*/
export function validateReceivedSelection(event, expectedEventType, choices) {
export function validateReceivedSelection(event, choices) {
assert(isNonEmptyArray(choices), 'choices must be a non-empty array');
assert(
event.origin === untrustedOrigin, 'Message not from the correct origin');
assert(
event.data.type === expectedEventType,
`Expected event type: ${expectedEventType}`);
/** @type {SelectCollectionEvent|SelectImageEvent} */
const data = event.data;
@ -92,6 +108,12 @@ export function selectCollection(target, collectionId) {
target.postMessage(event, trustedOrigin);
}
export function selectLocalCollection(target) {
/** @type {!SelectLocalCollectionEvent} */
const event = {type: EventType.SELECT_LOCAL_COLLECTION};
target.postMessage(event, trustedOrigin);
}
/**
* Select an image. Sent from untrusted to trusted.
* @param {!Object} target the window to post the message to.
@ -124,8 +146,9 @@ export function validateReceivedData(event, expectedEventType) {
case EventType.SEND_COLLECTIONS:
assert(isNonEmptyArray(data.collections), 'Expected collections array');
return data.collections;
case EventType.SEND_LOCAL_IMAGES:
case EventType.SEND_IMAGES:
// Images array may be empty to clear the prior view.
// Images array may be empty.
assert(Array.isArray(data.images), 'Expected images array');
return data.images;
default:

@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '//personalization/polymer/v3_0/polymer/polymer_bundled.min.js';
const styles = document.createElement('dom-module');
styles.innerHTML = `<template>
@ -17,7 +15,14 @@ styles.innerHTML = `<template>
object-fit: contain;
width: 100%;
}
.photo-container > .collection-name,
.photo-container > .image-name {
bottom: 0;
position: absolute;
text-align: center;
width: 100%;
}
</style>
</template>`;
styles.register('untrusted-style');
styles.register('common-style');

@ -7,6 +7,7 @@ import("//tools/grit/preprocess_if_expr.gni")
import("//tools/polymer/html_to_js.gni")
polymer_element_files = [
"local_images_element.js",
"wallpaper_collections_element.js",
"wallpaper_images_element.js",
"wallpaper_selected_element.js",
@ -25,6 +26,18 @@ static_files = [
"styles.js",
]
js_library("local_images_element") {
deps = [
":personalization_controller",
":styles",
"../common:constants",
"../common:styles",
"//third_party/polymer/v3_0/components-chromium/iron-list:iron-list",
"//third_party/polymer/v3_0/components-chromium/paper-spinner:paper-spinner-lite",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
]
}
js_library("mojo_interface_provider") {
deps = [
"../../mojom:mojom_js_library_for_compile",
@ -38,6 +51,7 @@ js_library("personalization_actions") {
js_library("personalization_app") {
deps = [
":local_images_element",
":personalization_message_handler",
":personalization_reducers",
":personalization_router_element",
@ -133,6 +147,7 @@ js_type_check("closure_compile") {
is_polymer3 = true
closure_flags = default_closure_args + [ "language_in=ECMASCRIPT_2020" ]
deps = [
":local_images_element",
":mojo_interface_provider",
":personalization_actions",
":personalization_app",

@ -0,0 +1,14 @@
<style include="trusted-style common-style"></style>
<paper-spinner-lite active="[[imagesLoading_]]">
</paper-spinner-lite>
<iron-list items="[[getImages_(hidden, images_)]]" grid>
<template>
<div class="photo-container">
<template is="dom-if"
if="[[shouldShowImage_(item, imageData_, imageDataLoading_)]]">
<img src="[[getImageData_(item, imageData_)]]">
</template>
<p class="image-name">[[item.name]]</p>
</div>
</template>
</iron-list>

@ -0,0 +1,119 @@
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview WallpaperImages displays a list of wallpaper images from a
* wallpaper collection. It requires a parameter collection-id to fetch
* and display the images. It also caches the list of wallpaper images by
* wallpaper collection id to avoid refetching data unnecessarily.
*/
import 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import './styles.js';
import '../common/styles.js';
import {html} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {unguessableTokenToString} from '../common/utils.js';
import {WithPersonalizationStore} from './personalization_store.js';
/** @polymer */
export class LocalImages extends WithPersonalizationStore {
static get is() {
return 'local-images';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
hidden: {
type: Boolean,
value: true,
reflectToAttribute: true,
},
/**
* @type {!Array<!chromeos.personalizationApp.mojom.LocalImage>}
* @private
*/
images_: {
type: Array,
},
/** @private */
imagesLoading_: {
type: Boolean,
},
/**
* Mapping of stringified local image id to data url.
* @type {!Object<string, string>}
* @private
*/
imageData_: {
type: Object,
},
/**
* Mapping of stringified local image id to boolean.
* @type {!Object<string, boolean>}
* @private
*/
imageDataLoading_: {
type: Object,
},
};
}
/** @override */
connectedCallback() {
super.connectedCallback();
this.watch('images_', state => state.local.images);
this.watch('imagesLoading_', state => state.loading.local.images);
this.watch('imageData_', state => state.local.data);
this.watch('imageDataLoading_', state => state.loading.local.data);
this.updateFromStore();
}
/**
* Forces iron-list to re-evaluate when hidden changes.
* @private
* @param {boolean} hidden
* @param {!Array<!chromeos.personalizationApp.mojom.LocalImage>} images
* @return {!Array<!chromeos.personalizationApp.mojom.LocalImage>}
*/
getImages_(hidden, images) {
return hidden ? [] : images;
}
/**
* @private
* @param {chromeos.personalizationApp.mojom.LocalImage} image
* @param {Object<string, string>} imageData
* @param {Object<string, boolean>} imageDataLoading
* @return {boolean}
*/
shouldShowImage_(image, imageData, imageDataLoading) {
if (!image || !imageData || !imageDataLoading) {
return false;
}
const key = unguessableTokenToString(image.id);
return !!imageData[key] && imageDataLoading[key] === false;
}
/**
* @private
* @param {chromeos.personalizationApp.mojom.LocalImage} image
* @param {Object<string, string>} imageData
* @return {string}
*/
getImageData_(image, imageData) {
const key = unguessableTokenToString(image.id);
return imageData[key];
}
}
customElements.define(LocalImages.is, LocalImages);

@ -9,6 +9,7 @@
*/
import '/strings.m.js';
import './local_images_element.js';
import './personalization_router_element.js';
import './wallpaper_collections_element.js';
import './wallpaper_images_element.js';

@ -18,30 +18,31 @@ import {PersonalizationStore} from './personalization_store.js';
* @param {!chromeos.personalizationApp.mojom.WallpaperProviderInterface}
* provider
* @param {!PersonalizationStore} store
* @return {!Promise<Array<chromeos.personalizationApp.mojom.WallpaperCollection>>}
*/
export async function fetchCollections(provider, store) {
async function fetchCollections(provider, store) {
let {collections} = await provider.fetchCollections();
if (!isNonEmptyArray(collections)) {
console.warn('Failed to fetch wallpaper collections');
collections = null;
}
store.dispatch(setCollectionsAction(collections));
return collections;
}
/**
* Fetch all of the wallpaper collections one at a time.
* @param {!Array<!chromeos.personalizationApp.mojom.WallpaperCollection>}
* collections
* @param {!chromeos.personalizationApp.mojom.WallpaperProviderInterface}
* provider
* @param {!PersonalizationStore} store
*/
export async function fetchAllImagesForCollections(
collections, provider, store) {
async function fetchAllImagesForCollections(provider, store) {
const collections = store.data.backdrop.collections;
if (!Array.isArray(collections)) {
console.warn(
'Cannot fetch data for collections when it is not initialized');
return;
}
store.dispatch(beginLoadImagesForCollectionsAction(collections));
for (const {id} of collections) {
for (const {id} of /** @type {!Array<{id: string}>} */ (collections)) {
let {images} = await provider.fetchImagesForCollection(id);
if (!isNonEmptyArray(images)) {
console.warn('Failed to fetch images for collection id', id);
@ -52,12 +53,12 @@ export async function fetchAllImagesForCollections(
}
/**
* Get list of local images from disk.
* Get list of local images from disk and save it to the store.
* @param {!chromeos.personalizationApp.mojom.WallpaperProviderInterface}
* provider
* @param {!PersonalizationStore} store
*/
export async function getLocalImages(provider, store) {
async function getLocalImages(provider, store) {
const {images} = await provider.getLocalImages();
if (images == null) {
console.warn('Failed to fetch local images');
@ -66,15 +67,15 @@ export async function getLocalImages(provider, store) {
}
/**
* Get an image thumbnail for each image one at a time.
* Get an image thumbnail for every local image one at a time.
* @param {!chromeos.personalizationApp.mojom.WallpaperProviderInterface}
* provider
* @param {!PersonalizationStore} store
*/
export async function getAllLocalImageThumbnails(provider, store) {
async function getAllLocalImageThumbnails(provider, store) {
const images = store.data.local.images;
if (!Array.isArray(images)) {
console.warn('Cannot fetch thumbnails when local image list is null');
console.warn('Cannot fetch thumbnails of null image list');
return;
}
for (const image of images) {
@ -124,9 +125,18 @@ export async function selectWallpaper(image, provider, store) {
* @param {!PersonalizationStore} store
*/
export async function initializeBackdropData(provider, store) {
const collections = await fetchCollections(provider, store);
if (!Array.isArray(collections)) {
return;
}
await fetchAllImagesForCollections(collections, provider, store);
await fetchCollections(provider, store);
await fetchAllImagesForCollections(provider, store);
}
/**
* Gets list of local images, then fetches image thumbnails for each local
* image.
* @param {!chromeos.personalizationApp.mojom.WallpaperProviderInterface}
* provider
* @param {!PersonalizationStore} store
*/
export async function initializeLocalData(provider, store) {
await getLocalImages(provider, store);
await getAllLocalImageThumbnails(provider, store);
}

@ -2,11 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {EventType} from '../common/constants.js';
import {assert} from '/assert.m.js';
import {EventType, untrustedOrigin} from '../common/constants.js';
import {validateReceivedSelection} from '../common/iframe_api.js';
import {getWallpaperProvider} from './mojo_interface_provider.js';
import {getCurrentWallpaper, selectWallpaper} from './personalization_controller.js';
import {selectWallpaper} from './personalization_controller.js';
import {PersonalizationRouter} from './personalization_router_element.js';
import {PersonalizationStore} from './personalization_store.js';
@ -18,22 +18,25 @@ import {PersonalizationStore} from './personalization_store.js';
* @param {!Event} event
*/
export function onMessageReceived(event) {
assert(
event.origin === untrustedOrigin, 'Message not from the correct origin');
const store = PersonalizationStore.getInstance();
switch (event.data.type) {
case EventType.SELECT_COLLECTION:
const collections = store.data.backdrop.collections;
const selectedCollection = validateReceivedSelection(
event, EventType.SELECT_COLLECTION, collections);
const selectedCollection = validateReceivedSelection(event, collections);
PersonalizationRouter.instance().selectCollection(selectedCollection.id);
break;
case EventType.SELECT_LOCAL_COLLECTION:
PersonalizationRouter.instance().selectLocalCollection();
break;
case EventType.SELECT_IMAGE:
const collectionId = PersonalizationRouter.instance().collectionId;
const images = store.data.backdrop.images[collectionId];
const selectedImage =
validateReceivedSelection(event, EventType.SELECT_IMAGE, images);
const selectedImage = validateReceivedSelection(event, images);
selectWallpaper(selectedImage, getWallpaperProvider(), store);
break;
}

@ -165,13 +165,24 @@ function loadingReducer(state, action) {
...state,
images: {...state.images, [action.collectionId]: false},
});
case ActionName.SET_LOCAL_IMAGES:
return /** @type {!LoadingState} */ ({
...state,
local: {
...state.local,
images: false,
},
});
case ActionName.SET_LOCAL_IMAGE_DATA:
return /** @type {!LoadingState} */ ({
...state,
local: {
...state.local,
data: {...state.local.data, [action.id]: action.data}
}
data: {
...state.local.data,
[action.id]: false,
},
},
});
case ActionName.SET_SELECTED_IMAGE:
return /** @type {!LoadingState} */ ({...state, selected: false});

@ -7,3 +7,5 @@
</wallpaper-collections>
<wallpaper-images collection-id="[[queryParams_.id]]"
hidden$="[[!shouldShowCollectionImages_(path_)]]"></wallpaper-images>
<!-- do not use hidden$ here - need to listen on property change in element. -->
<local-images hidden="[[!shouldShowLocalCollection_(path_)]]"></local-images>

@ -15,6 +15,7 @@ import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/poly
export const Paths = {
CollectionImages: '/collection',
Collections: '/',
LocalCollection: '/local',
};
export class PersonalizationRouter extends PolymerElement {
@ -66,6 +67,13 @@ export class PersonalizationRouter extends PolymerElement {
{path_: Paths.CollectionImages, queryParams_: {id: collectionId}});
}
/**
* Navigate to the local collection page.
*/
selectLocalCollection() {
this.setProperties({path_: Paths.LocalCollection, query_: ''});
}
/**
* @param {string} path
* @return {boolean}
@ -83,6 +91,15 @@ export class PersonalizationRouter extends PolymerElement {
shouldShowCollectionImages_(path) {
return path === Paths.CollectionImages;
}
/**
* @param {string} path
* @return {boolean}
* @private
*/
shouldShowLocalCollection_(path) {
return path === Paths.LocalCollection;
}
}
customElements.define(PersonalizationRouter.is, PersonalizationRouter);

@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
const styles = document.createElement('dom-module');
styles.innerHTML = `<template>
@ -16,11 +14,11 @@ styles.innerHTML = `<template>
paper-spinner-lite[active] {
display: block;
}
iframe {
iframe, iron-list {
height: 80vh;
width: 100%;
}
</style>
</template>`;
styles.register('shared-style');
styles.register('trusted-style');

@ -1,13 +1,7 @@
<style include="shared-style"></style>
<style include="trusted-style"></style>
<paper-spinner-lite active="[[collectionsLoading_]]"></paper-spinner-lite>
<!-- TODO(b/189968254) move local images to untrusted -->
<dom-repeat items="[[localImages_]]">
<template>
<img src="[[item]]">
</template>
</dom-repeat>
<!-- TODO(b/181697575) handle error cases and update error string to UI spec -->
<p hidden$="[[!hasError_]]" id="error">error</p>
<iframe id="collections-iframe" frameBorder="0" hidden$="[[!showCollections_]]"
src="chrome-untrusted://personalization/untrusted/collections.html">
</iframe>
</iframe>

@ -12,21 +12,42 @@ import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import './styles.js';
import {afterNextRender, html} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {sendCollections} from '../common/iframe_api.js';
import {sendCollections, sendLocalImageData, sendLocalImages} from '../common/iframe_api.js';
import {isNonEmptyArray, promisifyOnload, unguessableTokenToString} from '../common/utils.js';
import {getWallpaperProvider} from './mojo_interface_provider.js';
import {getAllLocalImageThumbnails, getLocalImages, initializeBackdropData} from './personalization_controller.js';
import {initializeBackdropData, initializeLocalData} from './personalization_controller.js';
import {WithPersonalizationStore} from './personalization_store.js';
let sendCollectionsFunction = sendCollections;
let sendLocalImagesFunction = sendLocalImages;
let sendLocalImageDataFunction = sendLocalImageData;
export function promisifySendCollectionsForTesting() {
let resolver;
const promise = new Promise((resolve) => resolver = resolve);
sendCollectionsFunction = (...args) => resolver(args);
return promise;
/**
* Mock out the iframe api functions for testing. Return promises that are
* resolved when the function is called by |WallpaperCollectionsElement|.
* @return {{
* sendCollections: Promise<?>,
* sendLocalImages: Promise<?>,
* sendLocalImageData: Promise<?>,
* }}
*/
export function promisifyIframeFunctionsForTesting() {
let resolvers = {};
const promises = [
sendCollections, sendLocalImages, sendLocalImageData
].reduce((result, next) => {
result[next.name] = new Promise(resolve => resolvers[next.name] = resolve);
return result;
}, {});
sendCollectionsFunction = (...args) => resolvers[sendCollections.name](args);
sendLocalImagesFunction = (...args) => resolvers[sendLocalImages.name](args);
sendLocalImageDataFunction = (...args) =>
resolvers[sendLocalImageData.name](args);
return promises;
}
export const kMaximumImageThumbnailsCount = 3;
/** @polymer */
export class WallpaperCollections extends WithPersonalizationStore {
static get is() {
@ -54,10 +75,21 @@ export class WallpaperCollections extends WithPersonalizationStore {
/**
* @private
* @type {!Array<string>}
* @type {Array<!chromeos.personalizationApp.mojom.LocalImage>}
*/
localImages_: {
type: Array,
observer: 'onLocalImagesChanged_',
},
/**
* Stores a mapping of local image id to thumbnail data.
* @private
* @type {Object<string, string>}
*/
localImageData_: {
type: Object,
observer: 'onLocalImageDataChanged_',
},
/** @private */
@ -77,12 +109,23 @@ export class WallpaperCollections extends WithPersonalizationStore {
};
}
static get observers() {
return ['onLocalImageDataChanged_(localImages_, localImageData_)'];
}
constructor() {
super();
/** @private */
this.wallpaperProvider_ = getWallpaperProvider();
this.iframePromise_ = /** @type {!Promise<!HTMLIFrameElement>} */ (
promisifyOnload(this, 'collections-iframe', afterNextRender));
/**
* Stores a set of local image ids that have already sent thumbnail data to
* untrusted.
* @type {!Set<string>}
*/
this.sentLocalImages_ = new Set();
}
/** @override */
@ -90,18 +133,12 @@ export class WallpaperCollections extends WithPersonalizationStore {
super.connectedCallback();
this.watch('collections_', state => state.backdrop.collections);
this.watch('collectionsLoading_', state => state.loading.collections);
this.watch(
'localImages_',
state => Array.isArray(state.local.images) ?
state.local.images.map(image => unguessableTokenToString(image.id))
.filter(id => !!state.local.data[id])
.map(id => state.local.data[id]) :
null);
this.watch('localImages_', state => state.local.images);
this.watch('localImageData_', state => state.local.data);
this.updateFromStore();
const store = this.getStore();
initializeBackdropData(this.wallpaperProvider_, store);
getLocalImages(this.wallpaperProvider_, store)
.then(() => getAllLocalImageThumbnails(this.wallpaperProvider_, store));
initializeLocalData(this.wallpaperProvider_, store);
}
/**
@ -136,6 +173,46 @@ export class WallpaperCollections extends WithPersonalizationStore {
sendCollectionsFunction(iframe.contentWindow, this.collections_);
}
}
/**
* Send updated local images list to the iframe.
* @param {?Array<!chromeos.personalizationApp.mojom.LocalImage>} value
*/
async onLocalImagesChanged_(value) {
if (Array.isArray(value)) {
const iframe = await this.iframePromise_;
sendLocalImagesFunction(
/** @type {!Window} */ (iframe.contentWindow), value);
}
}
/**
* Send up to |maximumImageThumbnailsCount| image thumbnails to untrusted.
* @param {?Array<!chromeos.personalizationApp.mojom.LocalImage>} images
* @param {?Object<string, string>} imageData
*/
async onLocalImageDataChanged_(images, imageData) {
if (!Array.isArray(images) || !imageData) {
return;
}
const iframe = await this.iframePromise_;
for (const image of images) {
if (this.sentLocalImages_.size >= kMaximumImageThumbnailsCount) {
return;
}
const key = unguessableTokenToString(image.id);
if (this.sentLocalImages_.has(key)) {
continue;
}
const data = imageData[key];
if (data) {
sendLocalImageDataFunction(
/** @type {!Window} */ (iframe.contentWindow), image, data);
this.sentLocalImages_.add(key);
}
}
}
}
customElements.define(WallpaperCollections.is, WallpaperCollections);

@ -1,4 +1,4 @@
<style include="shared-style"></style>
<style include="trusted-style"></style>
<paper-spinner-lite
active="[[isLoading_(imagesLoading_, collectionId)]]">
</paper-spinner-lite>

@ -8,10 +8,10 @@ import("//tools/polymer/html_to_js.gni")
js_library("collections_grid") {
deps = [
":styles",
"../../mojom:mojom_js_library_for_compile",
"../common:constants",
"../common:iframe_api",
"../common:styles",
"//third_party/polymer/v3_0/components-chromium/iron-list:iron-list",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
]
@ -19,31 +19,25 @@ js_library("collections_grid") {
js_library("images_grid") {
deps = [
":styles",
"../../mojom:mojom_js_library_for_compile",
"../common:constants",
"../common:iframe_api",
"../common:styles",
"//third_party/polymer/v3_0/components-chromium/iron-list:iron-list",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
]
}
js_library("styles") {
deps = [
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
]
}
js_type_check("closure_compile") {
is_polymer3 = true
closure_flags = default_closure_args + [
"language_in=ECMASCRIPT_2020",
"browser_resolver_prefix_replacements=\"chrome-untrusted://personalization/polymer/v3_0/=../../third_party/polymer/v3_0/components-chromium/\"",
"browser_resolver_prefix_replacements=\"chrome-untrusted://personalization/polymer/v3_0/polymer/polymer_bundled.min.js=../../third_party/polymer/v3_0/components-chromium/polymer/polymer_bundled.js\"",
]
deps = [
":collections_grid",
":images_grid",
":styles",
]
}
@ -58,7 +52,6 @@ copy("copy_static") {
sources = [
"collections.html",
"images.html",
"styles.js",
]
outputs = [ "$target_gen_dir/{{source_file_part}}" ]
}
@ -76,6 +69,5 @@ preprocess_if_expr("preprocess") {
"untrusted/collections_grid.js",
"untrusted/images.html",
"untrusted/images_grid.js",
"untrusted/styles.js",
]
}

@ -1,16 +1,11 @@
<style include="untrusted-style">
.collection-name {
bottom: 0;
position: absolute;
text-align: center;
width: 100%;
}
</style>
<iron-list items="[[collections_]]" grid>
<style include="common-style"></style>
<iron-list items="[[tiles_]]" grid>
<template>
<div class="photo-container" data-id$="[[item.id]]"
on-click="onCollectionClicked_">
<img src="[[item.preview.url]]">
on-click="onCollectionClicked_">
<template is="dom-if" if="[[item.preview]]">
<img src="[[item.preview.url]]">
</template>
<p class="collection-name">[[item.name]]</p>
</div>
</template>

@ -3,16 +3,47 @@
// found in the LICENSE file.
import '//personalization/polymer/v3_0/iron-list/iron-list.js';
import './styles.js';
import '../common/styles.js';
import {html, PolymerElement} from 'chrome-untrusted://personalization/polymer/v3_0/polymer/polymer_bundled.min.js';
import {EventType} from '../common/constants.js';
import {selectCollection, validateReceivedData} from '../common/iframe_api.js';
import {selectCollection, selectLocalCollection, validateReceivedData} from '../common/iframe_api.js';
import {unguessableTokenToString} from '../common/utils.js';
/**
* @fileoverview Responds to |SendCollectionsEvent| from trusted. Handles user
* input and responds with |SelectCollectionEvent| when an image is selected.
*/
const kLocalCollectionId = 'local_';
/**
* @typedef {{id: string, name: string, preview: ?url.mojom.Url}}
*/
let Tile;
/**
* A common display format between local images and WallpaperCollection.
* Get the first displayable image with data from the list of possible images.
* TODO(b/184774974) display a collage of up to three images.
* @param {Array<!chromeos.personalizationApp.mojom.LocalImage>} localImages
* @param {Object<string, string>} localImageData
* @return {!Tile}
*/
function getLocalTile(localImages, localImageData) {
if (localImageData && Array.isArray(localImages)) {
for (const {id, name} of localImages) {
const key = unguessableTokenToString(id);
const data = localImageData[key];
if (!data) {
continue;
}
return {name, preview: {url: data}, id: kLocalCollectionId};
}
}
// TODO(b/184774974) replace zero state with translated string from UI spec.
return {name: 'No Images', preview: null, id: kLocalCollectionId};
}
class CollectionsGrid extends PolymerElement {
static get is() {
return 'collections-grid';
@ -30,11 +61,38 @@ class CollectionsGrid extends PolymerElement {
*/
collections_: {
type: Array,
},
/**
* @type {!Array<!chromeos.personalizationApp.mojom.LocalImage>}
* @private
*/
localImages_: {
type: Array,
value: [],
},
/**
* Stores a mapping of local image id to thumbnail data.
* @private
* @type {!Object<string, string>}
*/
localImageData_: {
type: Object,
value: {},
},
/**
* @type {!Array<!Tile>}
*/
tiles_: {
type: Array,
computed: 'computeTiles_(collections_, localImages_, localImageData_)',
},
};
}
/** @override */
constructor() {
super();
this.onMessageReceived_ = this.onMessageReceived_.bind(this);
@ -52,6 +110,15 @@ class CollectionsGrid extends PolymerElement {
window.removeEventListener('message', this.onMessageReceived_);
}
/**
* @param {!Array<!chromeos.personalizationApp.mojom.WallpaperCollection>}
* collections
* @param {!Array<!chromeos.personalizationApp.mojom.LocalImage>} localImages
*/
computeTiles_(collections, localImages, localImageData) {
return [getLocalTile(localImages, localImageData), ...(collections || [])];
}
/**
* Handler for messages from trusted code. Expects only SendImagesEvent and
* will error on any other event.
@ -59,12 +126,34 @@ class CollectionsGrid extends PolymerElement {
* @private
*/
onMessageReceived_(message) {
try {
this.collections_ =
validateReceivedData(message, EventType.SEND_COLLECTIONS);
} catch (e) {
console.warn('Invalid collections received', e);
this.collections_ = [];
switch (message.data.type) {
case EventType.SEND_COLLECTIONS:
try {
this.collections_ =
validateReceivedData(message, EventType.SEND_COLLECTIONS);
} catch (e) {
console.warn('Invalid collections received', e);
this.collections_ = [];
}
break;
case EventType.SEND_LOCAL_IMAGES:
try {
this.localImages_ =
validateReceivedData(message, EventType.SEND_LOCAL_IMAGES);
} catch (e) {
console.warn('Invalid local images received', e);
this.localImages_ = [];
}
break;
case EventType.SEND_LOCAL_IMAGE_DATA:
this.localImageData_ = {
...this.localImageData_,
[unguessableTokenToString(message.data.id)]: message.data.data,
};
break;
default:
console.error(`Unexpected event type ${message.data.type}`);
break;
}
}
@ -74,7 +163,12 @@ class CollectionsGrid extends PolymerElement {
* @param {!Event} e
*/
onCollectionClicked_(e) {
selectCollection(window.parent, e.currentTarget.dataset.id);
const id = e.currentTarget.dataset.id;
if (id === kLocalCollectionId) {
selectLocalCollection(window.parent);
return;
}
selectCollection(window.parent, id);
}
}

@ -1,4 +1,4 @@
<style include="untrusted-style">
<style include="common-style">
</style>
<iron-list grid items="[[images_]]">
<template>

@ -3,7 +3,7 @@
// found in the LICENSE file.
import 'chrome-untrusted://personalization/polymer/v3_0/iron-list/iron-list.js';
import './styles.js';
import '../common/styles.js';
import {html, PolymerElement} from 'chrome-untrusted://personalization/polymer/v3_0/polymer/polymer_bundled.min.js';
import {EventType} from '../common/constants.js';
import {selectImage, validateReceivedData} from '../common/iframe_api.js';

@ -54,7 +54,7 @@ class UntrustedPersonalizationAppUI : public ui::UntrustedWebUIController {
// Allow images only from this url.
source->OverrideContentSecurityPolicy(
network::mojom::CSPDirectiveName::ImgSrc,
"img-src https://*.googleusercontent.com;");
"img-src data: https://*.googleusercontent.com;");
source->OverrideContentSecurityPolicy(
network::mojom::CSPDirectiveName::ScriptSrc, "script-src 'self';");