mobile/android/modules/WebappManager.jsm

branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
equal deleted inserted replaced
-1:000000000000 0:077b5367a95e
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 "use strict";
6
7 this.EXPORTED_SYMBOLS = ["WebappManager"];
8
9 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
10
11 const UPDATE_URL_PREF = "browser.webapps.updateCheckUrl";
12
13 Cu.import("resource://gre/modules/AppsUtils.jsm");
14 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
15 Cu.import("resource://gre/modules/Services.jsm");
16 Cu.import("resource://gre/modules/NetUtil.jsm");
17 Cu.import("resource://gre/modules/FileUtils.jsm");
18 Cu.import("resource://gre/modules/DOMRequestHelper.jsm");
19 Cu.import("resource://gre/modules/Webapps.jsm");
20 Cu.import("resource://gre/modules/osfile.jsm");
21 Cu.import("resource://gre/modules/Promise.jsm");
22 Cu.import("resource://gre/modules/Task.jsm");
23
24 XPCOMUtils.defineLazyModuleGetter(this, "Notifications", "resource://gre/modules/Notifications.jsm");
25 XPCOMUtils.defineLazyModuleGetter(this, "sendMessageToJava", "resource://gre/modules/Messaging.jsm");
26 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm");
27
28 XPCOMUtils.defineLazyGetter(this, "Strings", function() {
29 return Services.strings.createBundle("chrome://browser/locale/webapp.properties");
30 });
31
32 function debug(aMessage) {
33 // We use *dump* instead of Services.console.logStringMessage so the messages
34 // have the INFO level of severity instead of the ERROR level. And we don't
35 // append a newline character to the end of the message because *dump* spills
36 // into the Android native logging system, which strips newlines from messages
37 // and breaks messages into lines automatically at display time (i.e. logcat).
38 #ifdef DEBUG
39 dump(aMessage);
40 #endif
41 }
42
43 this.WebappManager = {
44 __proto__: DOMRequestIpcHelper.prototype,
45
46 get _testing() {
47 try {
48 return Services.prefs.getBoolPref("browser.webapps.testing");
49 } catch(ex) {
50 return false;
51 }
52 },
53
54 install: function(aMessage, aMessageManager) {
55 if (this._testing) {
56 // Go directly to DOM. Do not download/install APK, do not collect $200.
57 DOMApplicationRegistry.doInstall(aMessage, aMessageManager);
58 return;
59 }
60
61 this._installApk(aMessage, aMessageManager);
62 },
63
64 installPackage: function(aMessage, aMessageManager) {
65 if (this._testing) {
66 // Go directly to DOM. Do not download/install APK, do not collect $200.
67 DOMApplicationRegistry.doInstallPackage(aMessage, aMessageManager);
68 return;
69 }
70
71 this._installApk(aMessage, aMessageManager);
72 },
73
74 _installApk: function(aMessage, aMessageManager) { return Task.spawn((function*() {
75 let filePath;
76
77 try {
78 filePath = yield this._downloadApk(aMessage.app.manifestURL);
79 } catch(ex) {
80 aMessage.error = ex;
81 aMessageManager.sendAsyncMessage("Webapps:Install:Return:KO", aMessage);
82 debug("error downloading APK: " + ex);
83 return;
84 }
85
86 sendMessageToJava({
87 type: "Webapps:InstallApk",
88 filePath: filePath,
89 data: JSON.stringify(aMessage),
90 });
91 }).bind(this)); },
92
93 _downloadApk: function(aManifestUrl) {
94 debug("_downloadApk for " + aManifestUrl);
95 let deferred = Promise.defer();
96
97 // Get the endpoint URL and convert it to an nsIURI/nsIURL object.
98 const GENERATOR_URL_PREF = "browser.webapps.apkFactoryUrl";
99 const GENERATOR_URL_BASE = Services.prefs.getCharPref(GENERATOR_URL_PREF);
100 let generatorUrl = NetUtil.newURI(GENERATOR_URL_BASE).QueryInterface(Ci.nsIURL);
101
102 // Populate the query part of the URL with the manifest URL parameter.
103 let params = {
104 manifestUrl: aManifestUrl,
105 };
106 generatorUrl.query =
107 [p + "=" + encodeURIComponent(params[p]) for (p in params)].join("&");
108 debug("downloading APK from " + generatorUrl.spec);
109
110 let file = Cc["@mozilla.org/download-manager;1"].
111 getService(Ci.nsIDownloadManager).
112 defaultDownloadsDirectory.
113 clone();
114 file.append(aManifestUrl.replace(/[^a-zA-Z0-9]/gi, "") + ".apk");
115 file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
116 debug("downloading APK to " + file.path);
117
118 let worker = new ChromeWorker("resource://gre/modules/WebappManagerWorker.js");
119 worker.onmessage = function(event) {
120 let { type, message } = event.data;
121
122 worker.terminate();
123
124 if (type == "success") {
125 deferred.resolve(file.path);
126 } else { // type == "failure"
127 debug("error downloading APK: " + message);
128 deferred.reject(message);
129 }
130 }
131
132 // Trigger the download.
133 worker.postMessage({ url: generatorUrl.spec, path: file.path });
134
135 return deferred.promise;
136 },
137
138 askInstall: function(aData) {
139 let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
140 file.initWithPath(aData.profilePath);
141
142 // We don't yet support pre-installing an appcache because it isn't clear
143 // how to do it without degrading the user experience (since users expect
144 // apps to be available after the system tells them they've been installed,
145 // which has already happened) and because nsCacheService shuts down
146 // when we trigger the native install dialog and doesn't re-init itself
147 // afterward (TODO: file bug about this behavior).
148 if ("appcache_path" in aData.app.manifest) {
149 debug("deleting appcache_path from manifest: " + aData.app.manifest.appcache_path);
150 delete aData.app.manifest.appcache_path;
151 }
152
153 DOMApplicationRegistry.registryReady.then(() => {
154 DOMApplicationRegistry.confirmInstall(aData, file, (function(aManifest) {
155 let localeManifest = new ManifestHelper(aManifest, aData.app.origin);
156
157 // aData.app.origin may now point to the app: url that hosts this app.
158 sendMessageToJava({
159 type: "Webapps:Postinstall",
160 apkPackageName: aData.app.apkPackageName,
161 origin: aData.app.origin,
162 });
163
164 this.writeDefaultPrefs(file, localeManifest);
165 }).bind(this));
166 });
167 },
168
169 launch: function({ manifestURL, origin }) {
170 debug("launchWebapp: " + manifestURL);
171
172 sendMessageToJava({
173 type: "Webapps:Open",
174 manifestURL: manifestURL,
175 origin: origin
176 });
177 },
178
179 uninstall: function(aData) {
180 debug("uninstall: " + aData.manifestURL);
181
182 if (this._testing) {
183 // We don't have to do anything, as the registry does all the work.
184 return;
185 }
186
187 // TODO: uninstall the APK.
188 },
189
190 autoInstall: function(aData) {
191 let oldApp = DOMApplicationRegistry.getAppByManifestURL(aData.manifestURL);
192 if (oldApp) {
193 // If the app is already installed, update the existing installation.
194 this._autoUpdate(aData, oldApp);
195 return;
196 }
197
198 let mm = {
199 sendAsyncMessage: function (aMessageName, aData) {
200 // TODO hook this back to Java to report errors.
201 debug("sendAsyncMessage " + aMessageName + ": " + JSON.stringify(aData));
202 }
203 };
204
205 let origin = Services.io.newURI(aData.manifestURL, null, null).prePath;
206
207 let message = aData.request || {
208 app: {
209 origin: origin,
210 receipts: [],
211 }
212 };
213
214 if (aData.updateManifest) {
215 if (aData.zipFilePath) {
216 aData.updateManifest.package_path = aData.zipFilePath;
217 }
218 message.app.updateManifest = aData.updateManifest;
219 }
220
221 // The manifest url may be subtly different between the
222 // time the APK was built and the APK being installed.
223 // Thus, we should take the APK as the source of truth.
224 message.app.manifestURL = aData.manifestURL;
225 message.app.manifest = aData.manifest;
226 message.app.apkPackageName = aData.apkPackageName;
227 message.profilePath = aData.profilePath;
228 message.mm = mm;
229 message.apkInstall = true;
230
231 DOMApplicationRegistry.registryReady.then(() => {
232 switch (aData.type) { // can be hosted or packaged.
233 case "hosted":
234 DOMApplicationRegistry.doInstall(message, mm);
235 break;
236
237 case "packaged":
238 message.isPackage = true;
239 DOMApplicationRegistry.doInstallPackage(message, mm);
240 break;
241 }
242 });
243 },
244
245 _autoUpdate: function(aData, aOldApp) { return Task.spawn((function*() {
246 debug("_autoUpdate app of type " + aData.type);
247
248 if (aOldApp.apkPackageName != aData.apkPackageName) {
249 // This happens when the app was installed as a shortcut via the old
250 // runtime and is now being updated to an APK.
251 debug("update apkPackageName from " + aOldApp.apkPackageName + " to " + aData.apkPackageName);
252 aOldApp.apkPackageName = aData.apkPackageName;
253 }
254
255 if (aData.type == "hosted") {
256 let oldManifest = yield DOMApplicationRegistry.getManifestFor(aData.manifestURL);
257 DOMApplicationRegistry.updateHostedApp(aData, aOldApp.id, aOldApp, oldManifest, aData.manifest);
258 } else {
259 DOMApplicationRegistry.updatePackagedApp(aData, aOldApp.id, aOldApp, aData.manifest);
260 }
261 }).bind(this)); },
262
263 _checkingForUpdates: false,
264
265 checkForUpdates: function(userInitiated) { return Task.spawn((function*() {
266 debug("checkForUpdates");
267
268 // Don't start checking for updates if we're already doing so.
269 // TODO: Consider cancelling the old one and starting a new one anyway
270 // if the user requested this one.
271 if (this._checkingForUpdates) {
272 debug("already checking for updates");
273 return;
274 }
275 this._checkingForUpdates = true;
276
277 try {
278 let installedApps = yield this._getInstalledApps();
279 if (installedApps.length === 0) {
280 return;
281 }
282
283 // Map APK names to APK versions.
284 let apkNameToVersion = yield this._getAPKVersions(installedApps.map(app =>
285 app.apkPackageName).filter(apkPackageName => !!apkPackageName)
286 );
287
288 // Map manifest URLs to APK versions, which is what the service needs
289 // in order to tell us which apps are outdated; and also map them to app
290 // objects, which the downloader/installer uses to download/install APKs.
291 // XXX Will this cause us to update apps without packages, and if so,
292 // does that satisfy the legacy migration story?
293 let manifestUrlToApkVersion = {};
294 let manifestUrlToApp = {};
295 for (let app of installedApps) {
296 manifestUrlToApkVersion[app.manifestURL] = apkNameToVersion[app.apkPackageName] || 0;
297 manifestUrlToApp[app.manifestURL] = app;
298 }
299
300 let outdatedApps = yield this._getOutdatedApps(manifestUrlToApkVersion, userInitiated);
301
302 if (outdatedApps.length === 0) {
303 // If the user asked us to check for updates, tell 'em we came up empty.
304 if (userInitiated) {
305 this._notify({
306 title: Strings.GetStringFromName("noUpdatesTitle"),
307 message: Strings.GetStringFromName("noUpdatesMessage"),
308 icon: "drawable://alert_app",
309 });
310 }
311 return;
312 }
313
314 let names = [manifestUrlToApp[url].name for (url of outdatedApps)].join(", ");
315 let accepted = yield this._notify({
316 title: PluralForm.get(outdatedApps.length, Strings.GetStringFromName("downloadUpdateTitle")).
317 replace("#1", outdatedApps.length),
318 message: Strings.formatStringFromName("downloadUpdateMessage", [names], 1),
319 icon: "drawable://alert_app",
320 }).dismissed;
321
322 if (accepted) {
323 yield this._updateApks([manifestUrlToApp[url] for (url of outdatedApps)]);
324 }
325 }
326 // There isn't a catch block because we want the error to propagate through
327 // the promise chain, so callers can receive it and choose to respond to it.
328 finally {
329 // Ensure we update the _checkingForUpdates flag even if there's an error;
330 // otherwise the process will get stuck and never check for updates again.
331 this._checkingForUpdates = false;
332 }
333 }).bind(this)); },
334
335 _getAPKVersions: function(packageNames) {
336 let deferred = Promise.defer();
337
338 sendMessageToJava({
339 type: "Webapps:GetApkVersions",
340 packageNames: packageNames
341 }, data => deferred.resolve(data.versions));
342
343 return deferred.promise;
344 },
345
346 _getInstalledApps: function() {
347 let deferred = Promise.defer();
348 DOMApplicationRegistry.getAll(apps => deferred.resolve(apps));
349 return deferred.promise;
350 },
351
352 _getOutdatedApps: function(installedApps, userInitiated) {
353 let deferred = Promise.defer();
354
355 let data = JSON.stringify({ installed: installedApps });
356
357 let notification;
358 if (userInitiated) {
359 notification = this._notify({
360 title: Strings.GetStringFromName("checkingForUpdatesTitle"),
361 message: Strings.GetStringFromName("checkingForUpdatesMessage"),
362 // TODO: replace this with an animated icon.
363 icon: "drawable://alert_app",
364 progress: NaN,
365 });
366 }
367
368 let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
369 createInstance(Ci.nsIXMLHttpRequest).
370 QueryInterface(Ci.nsIXMLHttpRequestEventTarget);
371 request.mozBackgroundRequest = true;
372 request.open("POST", Services.prefs.getCharPref(UPDATE_URL_PREF), true);
373 request.channel.loadFlags = Ci.nsIChannel.LOAD_ANONYMOUS |
374 Ci.nsIChannel.LOAD_BYPASS_CACHE |
375 Ci.nsIChannel.INHIBIT_CACHING;
376 request.onload = function() {
377 if (userInitiated) {
378 notification.cancel();
379 }
380 deferred.resolve(JSON.parse(this.response).outdated);
381 };
382 request.onerror = function() {
383 if (userInitiated) {
384 notification.cancel();
385 }
386 deferred.reject(this.status || this.statusText);
387 };
388 request.setRequestHeader("Content-Type", "application/json");
389 request.setRequestHeader("Content-Length", data.length);
390
391 request.send(data);
392
393 return deferred.promise;
394 },
395
396 _updateApks: function(aApps) { return Task.spawn((function*() {
397 // Notify the user that we're in the progress of downloading updates.
398 let downloadingNames = [app.name for (app of aApps)].join(", ");
399 let notification = this._notify({
400 title: PluralForm.get(aApps.length, Strings.GetStringFromName("downloadingUpdateTitle")).
401 replace("#1", aApps.length),
402 message: Strings.formatStringFromName("downloadingUpdateMessage", [downloadingNames], 1),
403 // TODO: replace this with an animated icon. UpdateService uses
404 // android.R.drawable.stat_sys_download, but I don't think we can reference
405 // a system icon with a drawable: URL here, so we'll have to craft our own.
406 icon: "drawable://alert_download",
407 // TODO: make this a determinate progress indicator once we can determine
408 // the sizes of the APKs and observe their progress.
409 progress: NaN,
410 });
411
412 // Download the APKs for the given apps. We do this serially to avoid
413 // saturating the user's network connection.
414 // TODO: download APKs in parallel (or at least more than one at a time)
415 // if it seems reasonable.
416 let downloadedApks = [];
417 let downloadFailedApps = [];
418 for (let app of aApps) {
419 try {
420 let filePath = yield this._downloadApk(app.manifestURL);
421 downloadedApks.push({ app: app, filePath: filePath });
422 } catch(ex) {
423 downloadFailedApps.push(app);
424 }
425 }
426
427 notification.cancel();
428
429 // Notify the user if any downloads failed, but don't do anything
430 // when the user accepts/cancels the notification.
431 // In the future, we might prompt the user to retry the download.
432 if (downloadFailedApps.length > 0) {
433 let downloadFailedNames = [app.name for (app of downloadFailedApps)].join(", ");
434 this._notify({
435 title: PluralForm.get(downloadFailedApps.length, Strings.GetStringFromName("downloadFailedTitle")).
436 replace("#1", downloadFailedApps.length),
437 message: Strings.formatStringFromName("downloadFailedMessage", [downloadFailedNames], 1),
438 icon: "drawable://alert_app",
439 });
440 }
441
442 // If we weren't able to download any APKs, then there's nothing more to do.
443 if (downloadedApks.length === 0) {
444 return;
445 }
446
447 // Prompt the user to update the apps for which we downloaded APKs, and wait
448 // until they accept/cancel the notification.
449 let downloadedNames = [apk.app.name for (apk of downloadedApks)].join(", ");
450 let accepted = yield this._notify({
451 title: PluralForm.get(downloadedApks.length, Strings.GetStringFromName("installUpdateTitle")).
452 replace("#1", downloadedApks.length),
453 message: Strings.formatStringFromName("installUpdateMessage", [downloadedNames], 1),
454 icon: "drawable://alert_app",
455 }).dismissed;
456
457 if (accepted) {
458 // The user accepted the notification, so install the downloaded APKs.
459 for (let apk of downloadedApks) {
460 let msg = {
461 app: apk.app,
462 // TODO: figure out why Webapps:InstallApk needs the "from" property.
463 from: apk.app.installOrigin,
464 };
465 sendMessageToJava({
466 type: "Webapps:InstallApk",
467 filePath: apk.filePath,
468 data: JSON.stringify(msg),
469 });
470 }
471 } else {
472 // The user cancelled the notification, so remove the downloaded APKs.
473 for (let apk of downloadedApks) {
474 try {
475 yield OS.file.remove(apk.filePath);
476 } catch(ex) {
477 debug("error removing " + apk.filePath + " for cancelled update: " + ex);
478 }
479 }
480 }
481
482 }).bind(this)); },
483
484 _notify: function(aOptions) {
485 dump("_notify: " + aOptions.title);
486
487 // Resolves to true if the notification is "clicked" (i.e. touched)
488 // and false if the notification is "cancelled" by swiping it away.
489 let dismissed = Promise.defer();
490
491 // TODO: make notifications expandable so users can expand them to read text
492 // that gets cut off in standard notifications.
493 let id = Notifications.create({
494 title: aOptions.title,
495 message: aOptions.message,
496 icon: aOptions.icon,
497 progress: aOptions.progress,
498 onClick: function(aId, aCookie) {
499 dismissed.resolve(true);
500 },
501 onCancel: function(aId, aCookie) {
502 dismissed.resolve(false);
503 },
504 });
505
506 // Return an object with a promise that resolves when the notification
507 // is dismissed by the user along with a method for cancelling it,
508 // so callers who want to wait for user action can do so, while those
509 // who want to control the notification's lifecycle can do that instead.
510 return {
511 dismissed: dismissed.promise,
512 cancel: function() {
513 Notifications.cancel(id);
514 },
515 };
516 },
517
518 autoUninstall: function(aData) {
519 DOMApplicationRegistry.registryReady.then(() => {
520 for (let id in DOMApplicationRegistry.webapps) {
521 let app = DOMApplicationRegistry.webapps[id];
522 if (aData.apkPackageNames.indexOf(app.apkPackageName) > -1) {
523 debug("attempting to uninstall " + app.name);
524 DOMApplicationRegistry.uninstall(
525 app.manifestURL,
526 function() {
527 debug("success uninstalling " + app.name);
528 },
529 function(error) {
530 debug("error uninstalling " + app.name + ": " + error);
531 }
532 );
533 }
534 }
535 });
536 },
537
538 writeDefaultPrefs: function(aProfile, aManifest) {
539 // build any app specific default prefs
540 let prefs = [];
541 if (aManifest.orientation) {
542 let orientation = aManifest.orientation;
543 if (Array.isArray(orientation)) {
544 orientation = orientation.join(",");
545 }
546 prefs.push({ name: "app.orientation.default", value: orientation });
547 }
548
549 // write them into the app profile
550 let defaultPrefsFile = aProfile.clone();
551 defaultPrefsFile.append(this.DEFAULT_PREFS_FILENAME);
552 this._writeData(defaultPrefsFile, prefs);
553 },
554
555 _writeData: function(aFile, aPrefs) {
556 if (aPrefs.length > 0) {
557 let array = new TextEncoder().encode(JSON.stringify(aPrefs));
558 OS.File.writeAtomic(aFile.path, array, { tmpPath: aFile.path + ".tmp" }).then(null, function onError(reason) {
559 debug("Error writing default prefs: " + reason);
560 });
561 }
562 },
563
564 DEFAULT_PREFS_FILENAME: "default-prefs.js",
565
566 };

mercurial