diff --git a/Makefile b/Makefile
index ab10ea85cd34..e929f9f66795 100644
--- a/Makefile
+++ b/Makefile
@@ -899,7 +899,7 @@ ifdef APP
JSHINTED_PATH = apps/$(APP)
GJSLINTED_PATH = $(shell grep "^apps/$(APP)" build/jshint/xfail.list | ( while read file ; do test -f "$$file" && echo $$file ; done ) )
else
- JSHINTED_PATH = apps shared build/test/unit
+ JSHINTED_PATH = apps shared build/test/unit test_apps/home2
GJSLINTED_PATH = $(shell ( while read file ; do test -f "$$file" && echo $$file ; done ) < build/jshint/xfail.list )
endif
endif
diff --git a/build/config/phone/apps-engineering.list b/build/config/phone/apps-engineering.list
index 26027c796494..60006999c9bf 100644
--- a/build/config/phone/apps-engineering.list
+++ b/build/config/phone/apps-engineering.list
@@ -6,6 +6,7 @@ test_apps/bookmarks-reader
test_apps/ds-test
test_apps/demo-keyboard
test_apps/geoloc
+test_apps/home2
test_apps/membuster
test_apps/music2
test_apps/test-ime
diff --git a/test_apps/home2/index.html b/test_apps/home2/index.html
new file mode 100644
index 000000000000..1fe41641b9ae
--- /dev/null
+++ b/test_apps/home2/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ FirefoxOS Homescreen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test_apps/home2/js/.jshintrc b/test_apps/home2/js/.jshintrc
new file mode 100644
index 000000000000..fd33e5ab7783
--- /dev/null
+++ b/test_apps/home2/js/.jshintrc
@@ -0,0 +1,6 @@
+{
+ "extends": "../../../.jshintrc",
+ "predef": [
+ "app"
+ ]
+}
diff --git a/test_apps/home2/js/app.js b/test_apps/home2/js/app.js
new file mode 100644
index 000000000000..ff7fd2423f92
--- /dev/null
+++ b/test_apps/home2/js/app.js
@@ -0,0 +1,197 @@
+'use strict';
+/* global Divider */
+/* global DragDrop */
+/* global Icon */
+/* global Zoom */
+
+(function(exports) {
+
+ // For now we inject a divider every few icons for testing.
+ var tempDivideEvery = 6;
+ var tempCurrent = 0;
+
+ // Hidden manifest roles that we do not show
+ const HIDDEN_ROLES = ['system', 'keyboard', 'homescreen', 'search'];
+
+ function App() {
+ this.zoom = new Zoom();
+ this.dragdrop = new DragDrop();
+
+ this.container = document.getElementById('icons');
+ this.iconLaunch = this.clickIcon.bind(this);
+ }
+
+ App.prototype = {
+
+ /**
+ * List of all application icons.
+ * Maps an icon identifier to an icon object.
+ */
+ icons: {},
+
+ /**
+ * Lists of all displayed objects in the homescreen.
+ * Includes app icons, dividers, and bookmarks.
+ */
+ items: [],
+
+ /**
+ * Fetch all icons and render them.
+ */
+ init: function() {
+ navigator.mozApps.mgmt.getAll().onsuccess = function(event) {
+ event.target.result.forEach(this.makeIcons.bind(this));
+ this.render();
+ this.start();
+ }.bind(this);
+ },
+
+ start: function() {
+ this.container.addEventListener('click', this.iconLaunch);
+ },
+
+ stop: function() {
+ this.container.removeEventListener('click', this.iconLaunch);
+ },
+
+ /**
+ * Creates icons for an app based on hidden roles and entry points.
+ */
+ makeIcons: function(app) {
+ if (HIDDEN_ROLES.indexOf(app.manifest.role) !== -1) {
+ return;
+ }
+
+ function eachIcon(icon) {
+ /* jshint validthis:true */
+
+ // If there is no icon entry, do not push it onto items.
+ if (!icon.icon) {
+ return;
+ }
+
+ // FIXME: Remove after we have real divider insertion/remembering.
+ tempCurrent++;
+ if (tempCurrent >= tempDivideEvery) {
+ this.items.push(new Divider());
+ tempCurrent = 0;
+ }
+
+ this.items.push(icon);
+ this.icons[icon.identifier] = icon;
+ }
+
+ if (app.manifest.entry_points) {
+ for (var i in app.manifest.entry_points) {
+ eachIcon.call(this, new Icon(app, i));
+ }
+ } else {
+ eachIcon.call(this, new Icon(app));
+ }
+ },
+
+ /**
+ * Scrubs the list of items, removing empty sections.
+ */
+ cleanItems: function() {
+ var appCount = 0;
+ var toRemove = [];
+
+ this.items.forEach(function(item, idx) {
+ if (item instanceof Divider) {
+ if (appCount === 0) {
+ toRemove.push(idx);
+ }
+ appCount = 0;
+ } else {
+ appCount++;
+ }
+ }, this);
+
+ toRemove.reverse();
+ toRemove.forEach(function(idx) {
+ var removed = this.items.splice(idx, 1)[0];
+ removed.remove();
+ }, this);
+
+ // There should always be a divider at the end, it's hidden in CSS when
+ // not in edit mode.
+ var lastItem = this.items[this.items.length - 1];
+ if (!(lastItem instanceof Divider)) {
+ this.items.push(new Divider());
+ }
+ },
+
+ /**
+ * Renders all icons.
+ * Positions app icons and dividers accoriding to available space
+ * on the grid.
+ */
+ render: function() {
+
+ app.cleanItems();
+
+ // Reset offset steps
+ this.zoom.offsetY = 0;
+
+ // Grid render coordinates
+ var x = 0;
+ var y = 0;
+
+ /**
+ * Steps the y-axis.
+ * @param {Object} item
+ */
+ function step(item) {
+ app.zoom.stepYAxis(item.pixelHeight);
+
+ x = 0;
+ y++;
+ }
+
+ this.items.forEach(function(item, idx) {
+
+ // If the item would go over the boundary before rendering,
+ // step the y-axis.
+ if (x > 0 && item.gridWidth > 1 &&
+ x + item.gridWidth >= this.zoom.perRow) {
+ // Step the y-axis by the size of the last row.
+ // For now we just check the height of the last item.
+ var lastItem = this.items[idx - 1];
+ step(lastItem);
+ }
+
+ item.render({
+ x: x,
+ y: y
+ }, idx);
+
+ // Increment the x-step by the sizing of the item.
+ // If we go over the current boundary, reset it, and step the y-axis.
+ x += item.gridWidth;
+ if (x >= this.zoom.perRow) {
+ step(item);
+ }
+ }, this);
+ },
+
+ /**
+ * Launches an app.
+ */
+ clickIcon: function(e) {
+ var container = e.target;
+ var identifier = container.dataset.identifier;
+ var icon = this.icons[identifier];
+
+ if (!icon) {
+ return;
+ }
+
+ icon.launch();
+ }
+ };
+
+ exports.app = new App();
+ exports.app.init();
+
+}(window));
diff --git a/test_apps/home2/js/divider.js b/test_apps/home2/js/divider.js
new file mode 100644
index 000000000000..818bf885ef47
--- /dev/null
+++ b/test_apps/home2/js/divider.js
@@ -0,0 +1,58 @@
+'use strict';
+
+(function() {
+ // Icon container
+ var container = document.getElementById('icons');
+
+ /**
+ * Represents a single divider on the homepage.
+ */
+ function Divider() {}
+
+ Divider.prototype = {
+
+ x: 0,
+ y: 0,
+
+ /**
+ * Height in pixels of each divider.
+ */
+ pixelHeight: 70,
+
+ /**
+ * Width in grid units for each divider.
+ */
+ gridWidth: 4,
+
+ scale: 1,
+
+ /**
+ * Renders the icon to the container.
+ * @param {Object} coordinates Grid coordinates to render to.
+ * @param {Number} itemIndex The index of the items list of this item.
+ */
+ render: function(coordinates, itemIndex) {
+ // Generate the content if we need to
+ if (!this.divider) {
+ var divider = document.createElement('div');
+ divider.className = 'divider';
+ this.divider = divider;
+
+ container.appendChild(divider);
+ }
+
+ var y = app.zoom.offsetY;
+ this.divider.style.transform = 'translate(0 ,' + y + 'px)';
+
+ this.itemIndex = itemIndex;
+ this.y = y;
+ },
+
+ remove: function() {
+ this.divider.parentNode.removeChild(this.divider);
+ }
+ };
+
+ window.Divider = Divider;
+
+}());
diff --git a/test_apps/home2/js/dragdrop.js b/test_apps/home2/js/dragdrop.js
new file mode 100644
index 000000000000..55528e5fc8c0
--- /dev/null
+++ b/test_apps/home2/js/dragdrop.js
@@ -0,0 +1,231 @@
+'use strict';
+
+(function(exports) {
+
+ const activateDelay = 600;
+
+ const activeScaleAdjust = 0.4;
+
+ var container = document.getElementById('icons');
+
+ function DragDrop() {
+ container.addEventListener('touchstart', this);
+ container.addEventListener('touchmove', this);
+ container.addEventListener('touchend', this);
+ }
+
+ DragDrop.prototype = {
+
+ /**
+ * The current touchmove target.
+ * @type {DomElement}
+ */
+ target: null,
+
+ /**
+ * Begins the drag/drop interaction.
+ * Enlarges the icon.
+ * Sets additional data to make the touchmove handler faster.
+ */
+ begin: function(e) {
+ if (!this.target || !this.icon) {
+ return;
+ }
+
+ // Stop icon launching while we are in active state
+ app.stop();
+
+ this.active = true;
+ container.classList.add('edit-mode');
+ this.target.classList.add('active');
+
+ // Testing with some extra offset (20)
+ this.xAdjust = app.zoom.gridItemHeight / 2 + 20;
+ this.yAdjust = app.zoom.gridItemWidth / 2 + 20;
+
+ // Make the icon larger
+ this.icon.transform(
+ e.touches[0].pageX - this.xAdjust,
+ e.touches[0].pageY - this.yAdjust,
+ this.icon.scale + activeScaleAdjust);
+ },
+
+ /**
+ * Scrolls the page if needed.
+ * The page is scrolled via javascript if an icon is being moved,
+ * and is within a percentage of a page edge.
+ * @param {Object} e A touch object from a touchmove event.
+ */
+ scrollIfNeeded: function() {
+ var scrollStep = 2;
+
+ var touch = this.currentTouch;
+ if (!touch) {
+ this.isScrolling = false;
+ return;
+ }
+
+ function doScroll(amount) {
+ /* jshint validthis:true */
+ this.isScrolling = true;
+ document.documentElement.scrollTop += amount;
+ exports.requestAnimationFrame(this.scrollIfNeeded.bind(this));
+ touch.pageY += amount;
+ this.positionIcon(touch.pageX, touch.pageY);
+ }
+
+ var docScroll = document.documentElement.scrollTop;
+ if (touch.pageY - docScroll > window.innerHeight - 50) {
+ doScroll.call(this, scrollStep);
+ } else if (touch.pageY > 0 && touch.pageY - docScroll < 50) {
+ doScroll.call(this, 0 - scrollStep);
+ } else {
+ this.isScrolling = false;
+ }
+ },
+
+ /**
+ * Positions an icon on the grid.
+ * @param {Integer} pageX
+ * @param {Integer} posY
+ */
+ positionIcon: function(pageX, pageY) {
+ this.icon.transform(
+ pageX - this.xAdjust,
+ pageY - this.yAdjust,
+ this.icon.scale + activeScaleAdjust);
+
+ // Reposition in the icons array if necessary.
+ // Find the icon with the closest X/Y position of the move,
+ // and insert ours before it.
+ // Todo: this could be more efficient with a binary search.
+ var leastDistance;
+ var foundIndex;
+ for (var i = 0, iLen = app.items.length; i < iLen; i++) {
+ var item = app.items[i];
+ var distance = Math.sqrt(
+ (pageX - item.x) * (pageX - item.x) +
+ (pageY - item.y) * (pageY - item.y));
+ if (!leastDistance || distance < leastDistance) {
+ leastDistance = distance;
+ foundIndex = i;
+ }
+ }
+
+ // Insert at the found position
+ var myIndex = this.icon.itemIndex;
+ if (foundIndex !== myIndex) {
+ this.icon.noRender = true;
+ app.items.splice(foundIndex, 0, app.items.splice(myIndex, 1)[0]);
+ app.render();
+ }
+ },
+
+ /**
+ * General event handler.
+ */
+ handleEvent: function(e) {
+ var touch;
+
+ switch(e.type) {
+ case 'touchstart':
+ // If we get a second touch, cancel everything.
+ if (e.touches.length > 1) {
+ clearTimeout(this.timeout);
+ return;
+ }
+
+ touch = e.touches[0];
+ this.startTouch = {
+ pageX: touch.pageX,
+ pageY: touch.pageY
+ };
+
+ this.target = touch.target;
+
+ var identifier = this.target.dataset.identifier;
+ this.icon = app.icons[identifier];
+
+ if (!this.icon) {
+ return;
+ }
+
+ this.timeout = setTimeout(this.begin.bind(this, e),
+ activateDelay);
+
+ break;
+ case 'touchmove':
+ if (!this.startTouch) {
+ return;
+ }
+
+ // If we have an activate timeout, and our finger has moved past
+ // some threshold, cancel it.
+ touch = e.touches[0];
+ var distance = Math.sqrt(
+ (touch.pageX - this.startTouch.pageX) *
+ (touch.pageX - this.startTouch.pageX) +
+ (touch.pageY - this.startTouch.pageY) *
+ (touch.pageY - this.startTouch.pageY));
+
+ if (!this.active && this.timeout && distance > 20) {
+ clearTimeout(this.timeout);
+ return;
+ }
+
+ if (!this.active || !this.icon) {
+ return;
+ }
+
+ e.stopImmediatePropagation();
+ e.preventDefault();
+
+ this.currentTouch = {
+ pageX: touch.pageX,
+ pageY: touch.pageY
+ };
+
+ this.positionIcon(touch.pageX, touch.pageY);
+
+ if (!this.isScrolling) {
+ this.scrollIfNeeded();
+ }
+
+ break;
+ case 'touchend':
+ clearTimeout(this.timeout);
+
+ if (!this.active) {
+ return;
+ }
+
+ // Ensure the app is not launched
+ e.stopImmediatePropagation();
+ e.preventDefault();
+
+ this.currentTouch = null;
+ this.active = false;
+ container.classList.remove('edit-mode');
+
+ delete this.icon.noRender;
+ this.icon = null;
+
+ if (this.target) {
+ this.target.classList.remove('active');
+ }
+ app.render();
+
+ this.target = null;
+
+ setTimeout(function nextTick() {
+ app.start();
+ });
+
+ break;
+ }
+ }
+ };
+
+ exports.DragDrop = DragDrop;
+
+}(window));
diff --git a/test_apps/home2/js/icon.js b/test_apps/home2/js/icon.js
new file mode 100644
index 000000000000..78197c0bd7d2
--- /dev/null
+++ b/test_apps/home2/js/icon.js
@@ -0,0 +1,134 @@
+'use strict';
+
+(function(exports) {
+ // Icon container
+ var container = document.getElementById('icons');
+
+ /**
+ * Represents a single app icon on the homepage.
+ */
+ function Icon(app, entryPoint) {
+ this.app = app;
+ this.entryPoint = entryPoint;
+ }
+
+ Icon.prototype = {
+
+ /**
+ * Returns the height in pixels of each icon.
+ */
+ get pixelHeight() {
+ return app.zoom.gridItemHeight;
+ },
+
+ /**
+ * Width in grid units for each icon.
+ */
+ gridWidth: 1,
+
+ get name() {
+ var name = this.descriptor.name;
+ var userLang = document.documentElement.lang;
+
+ if (name[userLang]) {
+ return name[userLang];
+ }
+ return name;
+ },
+
+ get icon() {
+ if (!this.descriptor.icons) {
+ return '';
+ }
+
+ var lastIcon = 0;
+ for (var i in this.descriptor.icons) {
+ if (i > lastIcon) {
+ lastIcon = i;
+ }
+ }
+ return this.descriptor.icons[lastIcon];
+ },
+
+ get descriptor() {
+ if (this.entryPoint) {
+ return this.app.manifest.entry_points[this.entryPoint];
+ }
+ return this.app.manifest;
+ },
+
+ get identifier() {
+ var identifier = [this.app.origin];
+
+ if (this.entryPoint) {
+ identifier.push(this.entryPoint);
+ } else {
+ identifier.push(0);
+ }
+
+ return identifier.join('-');
+ },
+
+ /**
+ * Renders the icon to the container.
+ * @param {Object} coordinates Grid coordinates to render to.
+ * @param {Number} itemIndex The index of the items list of this item.
+ */
+ render: function(coordinates, itemIndex) {
+ var x = coordinates.x * app.zoom.gridItemWidth;
+ var y = app.zoom.offsetY;
+
+ // Generate the tile if we need to
+ if (!this.tile) {
+ var tile = document.createElement('div');
+ tile.className = 'icon';
+ tile.dataset.identifier = this.identifier;
+ tile.style.backgroundImage = 'url(' + this.app.origin + this.icon + ')';
+
+ var nameEl = document.createElement('span');
+ nameEl.className = 'title';
+ nameEl.textContent = this.name;
+ tile.appendChild(nameEl);
+
+ this.tile = tile;
+
+ container.appendChild(tile);
+ }
+
+ this.itemIndex = itemIndex;
+ this.x = x;
+ this.y = y;
+ this.scale = app.zoom.percent;
+
+ // Avoid rendering the icon during a drag to prevent jumpiness
+ if (this.noRender) {
+ return;
+ }
+
+ this.transform(x, y, app.zoom.percent);
+ },
+
+ /**
+ * Positions and scales an icon.
+ */
+ transform: function(x, y, scale) {
+ scale = scale || 1;
+ this.tile.style.transform =
+ 'translate(' + x + 'px,' + y + 'px) scale(' + scale + ')';
+ },
+
+ /**
+ * Launches the application for this icon.
+ */
+ launch: function() {
+ if (this.entryPoint) {
+ this.app.launch(this.entryPoint);
+ } else {
+ this.app.launch();
+ }
+ }
+ };
+
+ exports.Icon = Icon;
+
+}(window));
diff --git a/test_apps/home2/js/zoom.js b/test_apps/home2/js/zoom.js
new file mode 100644
index 000000000000..75b9bde517df
--- /dev/null
+++ b/test_apps/home2/js/zoom.js
@@ -0,0 +1,126 @@
+'use strict';
+
+(function(exports) {
+
+ const maxIconsPerCol = 4;
+
+ const maxIconsPerRow = 4;
+
+ const minIconsPerRow = 3;
+
+ const windowHeight = window.innerHeight;
+
+ const windowWidth = window.innerWidth;
+
+ function Zoom() {
+ this.touches = 0;
+ this.zoomStartTouches = [];
+
+ window.addEventListener('touchstart', this);
+ window.addEventListener('touchmove', this);
+ }
+
+ Zoom.prototype = {
+
+ perRow: minIconsPerRow,
+
+ minIconsPerRow: minIconsPerRow,
+
+ maxIconsPerRow: maxIconsPerRow,
+
+ _offsetY: 0,
+
+ _percent: minIconsPerRow / minIconsPerRow,
+
+ get percent() {
+ return this._percent;
+ },
+
+ set percent(value) {
+
+ // Reset the y-offset because we will re-render everything anyway.
+ this._offsetY = 0;
+
+ this._percent = value;
+ this.perRow = maxIconsPerRow + minIconsPerRow - maxIconsPerRow * value;
+ },
+
+ /**
+ * The height of each grid item.
+ * This number changes based on current zoom level.
+ */
+ get gridItemHeight() {
+ return windowHeight / maxIconsPerCol * this.percent;
+ },
+
+ /**
+ * The width of each grid item.
+ * This number changes based on current zoom level.
+ */
+ get gridItemWidth() {
+ return windowWidth / this.perRow;
+ },
+
+ /**
+ * Gets the current offset of the Y-axis for the current zoom level.
+ * This value is updated by calling zoom.stepYAxis. For example, each
+ * group of three icons, or a divider, should increment this value.
+ * The value is reset and recalculated when the zoom level changes.
+ */
+ get offsetY() {
+ return this._offsetY;
+ },
+
+ set offsetY(value) {
+ this._offsetY = value;
+ },
+
+ /**
+ * After we render a row we need to store the current position of the y-axis
+ */
+ stepYAxis: function(value) {
+ this._offsetY += value;
+ },
+
+ /**
+ * General Event Handler
+ */
+ handleEvent: function(e) {
+
+ if (e.type === 'touchend') {
+ console.log('touchend: ', e.touches.length);
+ }
+
+ if (!e.touches || e.touches.length !== 2) {
+ return;
+ }
+
+ // Sort touches by ascending pageX position.
+ var touches = [e.touches[0], e.touches[1]].sort(function(a, b) {
+ return a.pageX - b.pageX;
+ });
+
+ switch(e.type) {
+ case 'touchstart':
+ this.zoomStartTouches = touches;
+ break;
+ case 'touchmove':
+ if (this.perRow < maxIconsPerRow &&
+ touches[1].pageX < this.zoomStartTouches[1].pageX) {
+ this.percent = 0.75;
+ app.render();
+ } else if (this.perRow > minIconsPerRow &&
+ touches[1].pageX > this.zoomStartTouches[1].pageX) {
+ this.percent = 1;
+ app.render();
+ }
+
+ break;
+ }
+ }
+
+ };
+
+ exports.Zoom = Zoom;
+
+}(window));
diff --git a/test_apps/home2/locales/homescreen.en-US.properties b/test_apps/home2/locales/homescreen.en-US.properties
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/test_apps/home2/locales/locales.ini b/test_apps/home2/locales/locales.ini
new file mode 100644
index 000000000000..c7dd5f0afffa
--- /dev/null
+++ b/test_apps/home2/locales/locales.ini
@@ -0,0 +1 @@
+@import url(homescreen.en-US.properties)
diff --git a/test_apps/home2/manifest.webapp b/test_apps/home2/manifest.webapp
new file mode 100644
index 000000000000..1e936798093f
--- /dev/null
+++ b/test_apps/home2/manifest.webapp
@@ -0,0 +1,22 @@
+{
+ "name": "homescreen2",
+ "description": "A FirefoxOS vertical homepage.",
+ "launch_path": "/index.html",
+ "type": "certified",
+ "role": "homescreen",
+ "developer": {
+ "name": "The Gaia Team",
+ "url": "https://github.com/mozilla-b2g/gaia"
+ },
+ "permissions": {
+ "webapps-manage": {},
+ "storage": {}
+ },
+ "default_locale": "en-US",
+ "icons": {
+ "60": "/style/icons/blank.png"
+ },
+ "orientation": "portrait-primary",
+ "activities": {},
+ "messages": []
+}
diff --git a/test_apps/home2/style/css/app.css b/test_apps/home2/style/css/app.css
new file mode 100644
index 000000000000..758d1773fbfb
--- /dev/null
+++ b/test_apps/home2/style/css/app.css
@@ -0,0 +1,60 @@
+html {
+ font-size: 10px;
+}
+
+html, body, .icons {
+ height: 100%;
+ padding: 0;
+ margin: 0;
+}
+
+.icons {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 100%;
+ height: 100%;
+ will-change: scroll-position;
+}
+
+.icon {
+ position: absolute;
+ transform-origin: 0 0;
+ will-change: transform;
+ display: inline-block;
+ width: calc(100% / 3);
+ height: 9rem;
+ text-align: center;
+ background: no-repeat center;
+ background-size: 8rem;
+ transition: transform 500ms ease;
+}
+
+.icon .title {
+ display: block;
+ margin-top: 9rem;
+ pointer-events: none;
+ color: #fff;
+ font-size: 1.2rem;
+ text-shadow: 0 0 0.5rem rgba(0,0,0,0.5);
+}
+
+.icon.active {
+ transition: none;
+}
+
+.divider {
+ position: absolute;
+ height: 0.4rem;
+ margin: 2.3rem 1rem;
+ background: rgba(255, 255, 255, 0.3);
+ width: calc(100% - 2rem);
+ transition: transform 500ms ease;
+}
+
+.divider:last-child {
+ visibility: hidden;
+}
+
+.edit-mode .divider:last-child {
+ visibility: visible;
+}
diff --git a/test_apps/home2/style/icons/blank.png b/test_apps/home2/style/icons/blank.png
new file mode 100644
index 000000000000..953508a97ada
Binary files /dev/null and b/test_apps/home2/style/icons/blank.png differ