Переглянути джерело

Merge pull request #826 from threema-ch/579-report-tool-p1

Enhanced logging
Lennart Grahl 6 роки тому
батько
коміт
41491913d6
68 змінених файлів з 2375 додано та 915 видалено
  1. 1 1
      karma.conf.js
  2. 5 0
      package-lock.json
  3. 1 0
      package.json
  4. 0 32
      src/app.ts
  5. 32 9
      src/config.ts
  6. 6 8
      src/controller_model/avatar.ts
  7. 14 13
      src/controller_model/contact.ts
  8. 16 16
      src/controller_model/distributionList.ts
  9. 24 24
      src/controller_model/group.ts
  10. 11 9
      src/controller_model/me.ts
  11. 23 19
      src/controllers/status.ts
  12. 7 10
      src/directives/avatar.ts
  13. 6 6
      src/directives/avatar_area.ts
  14. 10 10
      src/directives/avatar_editor.ts
  15. 11 12
      src/directives/compose_area.ts
  16. 7 7
      src/directives/drag_file.ts
  17. 6 9
      src/directives/mediabox.ts
  18. 5 3
      src/directives/member_list_editor.ts
  19. 9 10
      src/directives/message.ts
  20. 18 25
      src/directives/message_media.ts
  21. 5 3
      src/directives/verification_level.ts
  22. 2 25
      src/filters.ts
  23. 58 27
      src/helpers.ts
  24. 214 0
      src/helpers/confidential.ts
  25. 302 0
      src/helpers/logger.ts
  26. 69 74
      src/partials/messenger.ts
  27. 31 29
      src/partials/welcome.ts
  28. 2 0
      src/services.ts
  29. 12 2
      src/services/battery.ts
  30. 8 10
      src/services/browser.ts
  31. 0 7
      src/services/controller.ts
  32. 17 12
      src/services/controller_model.ts
  33. 0 7
      src/services/fingerprint.ts
  34. 11 11
      src/services/keystore.ts
  35. 88 0
      src/services/log.ts
  36. 9 9
      src/services/mediabox.ts
  37. 2 7
      src/services/message.ts
  38. 2 4
      src/services/mime.ts
  39. 27 24
      src/services/notification.ts
  40. 27 54
      src/services/peerconnection.ts
  41. 22 20
      src/services/push.ts
  42. 11 10
      src/services/settings.ts
  43. 17 15
      src/services/state.ts
  44. 15 20
      src/services/timeout.ts
  45. 3 6
      src/services/title.ts
  46. 19 17
      src/services/version.ts
  47. 138 150
      src/services/webclient.ts
  48. 35 7
      src/threema.d.ts
  49. 14 8
      src/threema/container.ts
  50. 6 1
      tests/bootstrap.ts
  51. 1 1
      tests/filters.js
  52. 12 0
      tests/init.js
  53. 1 1
      tests/service/browser.js
  54. 0 9
      tests/service/keystore.js
  55. 165 0
      tests/service/log.js
  56. 0 9
      tests/service/message.js
  57. 0 2
      tests/service/mime.js
  58. 0 1
      tests/service/notification.js
  59. 0 9
      tests/service/qrcode.js
  60. 0 2
      tests/service/receiver.js
  61. 0 2
      tests/service/string.js
  62. 0 2
      tests/service/uri.js
  63. 0 93
      tests/service/webclient.js
  64. 1 1
      tests/testsuite.html
  65. 303 0
      tests/ts/confidential_helpers.ts
  66. 511 0
      tests/ts/logger_helpers.ts
  67. 2 0
      tests/ts/main.ts
  68. 1 1
      tsconfig.json

+ 1 - 1
karma.conf.js

@@ -34,10 +34,10 @@ module.exports = function(config) {
             'tests/service/mime.js',
             'tests/service/qrcode.js',
             'tests/service/uri.js',
-            'tests/service/webclient.js',
             'tests/service/string.js',
             'tests/service/browser.js',
             'tests/service/keystore.js',
+            'tests/service/log.js',
             'tests/service/notification.js',
             'tests/service/receiver.js',
         ],

+ 5 - 0
package-lock.json

@@ -9653,6 +9653,11 @@
         }
       }
     },
+    "ts-log": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.1.4.tgz",
+      "integrity": "sha512-P1EJSoyV+N3bR/IWFeAqXzKPZwHpnLY6j7j58mAvewHRipo+BQM2Y1f9Y9BjEQznKwgqqZm7H8iuixmssU7tYQ=="
+    },
     "ts-node": {
       "version": "8.3.0",
       "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.3.0.tgz",

+ 1 - 0
package.json

@@ -74,6 +74,7 @@
     "sdp": "~2.9.0",
     "ts-events": "^3.3.1",
     "ts-loader": "^6",
+    "ts-log": "^2.1.4",
     "tsify": "^4.0.1",
     "tweetnacl": "^1.0.1",
     "twemoji": "^11.3.0",

+ 0 - 32
src/app.ts

@@ -27,7 +27,6 @@ import './filters';
 import './partials/messenger';
 import './partials/welcome';
 import './services';
-import {BrowserService} from './services/browser';
 import './threema/container';
 
 // Configure asynchronous events
@@ -147,35 +146,4 @@ angular.module('3ema', [
     }]);
 }])
 
-.run([
-    '$log', 'CONFIG', 'BrowserService',
-    function($log: ng.ILogService, CONFIG: threema.Config, browserService: BrowserService) {
-        // For Safari (when in DEBUG mode), monkey-patch $log to show timestamps.
-
-        if (!(CONFIG.VERBOSE_DEBUGGING && browserService.getBrowser().isSafari(false))) {
-            return;
-        }
-
-        const oldLog = $log.log;
-        const oldInfo = $log.info;
-        const oldWarn = $log.warn;
-        const oldDebug = $log.debug;
-        const oldError = $log.error;
-
-        function enhanceLogging(wrapped) {
-            return function(data) {
-                const now = new Date();
-                const currentDate = `[${now.toISOString()}.${now.getMilliseconds()}]`;
-                wrapped.apply(this, [currentDate, ...arguments]);
-            };
-        }
-
-        $log.log = enhanceLogging(oldLog);
-        $log.info = enhanceLogging(oldInfo);
-        $log.warn = enhanceLogging(oldWarn);
-        $log.debug = enhanceLogging(oldDebug);
-        $log.error = enhanceLogging(oldError);
-    },
-])
-
 ;

+ 32 - 9
src/config.ts

@@ -22,7 +22,6 @@ export default {
     SALTYRTC_HOST_SUFFIX: '.threema.ch',
     SALTYRTC_PORT: 443,
     SALTYRTC_SERVER_KEY: 'b1337fc8402f7db8ea639e05ed05d65463e24809792f91eca29e88101b4a2171',
-    SALTYRTC_LOG_LEVEL: 'warn',
 
     // ICE
     ICE_SERVERS: [{
@@ -38,13 +37,37 @@ export default {
     // Push
     PUSH_URL: 'https://push-web.threema.ch/push',
 
-    // Very verbose logging that potentially affects performance and may also
-    // contain sensitive information.
-    VERBOSE_DEBUGGING: false,
-    // Logs all incoming and outgoing protocol messages.
-    MSG_DEBUGGING: false,
-    // Logs URLs to visualise MsgPack messages for all incoming and outgoing
-    // protocol messages.
-    MSGPACK_DEBUGGING: false,
+    // Padding length (in characters) of the log tag
+    // Note: The padding will be stripped by the report log.
+    LOG_TAG_PADDING: 20,
+    // Console log level
+    // Note: It is advisable to set this to `info` on production.
+    CONSOLE_LOG_LEVEL: 'info',
+    // Report log level and maximum amount of log records to keep in memory.
+    // Note: There's no reason to change this unless you want to disable
+    //       the report tool.
+    REPORT_LOG_LEVEL: 'debug',
+    REPORT_LOG_LIMIT: 1000,
+    // Compose area log level
+    COMPOSE_AREA_LOG_LEVEL: 'warn',
+    // SaltyRTC log level
+    SALTYRTC_LOG_LEVEL: 'warn',
+    // Timer (created by the TimeoutService) log level.
+    // Note: Log records filtered by this level will prevent them from being
+    //       picked up by the console and the report logger.
+    TIMER_LOG_LEVEL: 'info',
+    // App remote protocol log level.
+    // Note: Log records filtered by this level will prevent them from being
+    //       picked up by the console and the report logger.
+    ARP_LOG_LEVEL: 'debug',
+    // Toggles expensive or sensitive logging operations. Toggles logging of
+    // all chunks and messages exchanged by or associated with the app remote
+    // protocol.
+    // Note: Affects performance and contains sensitive information.
+    ARP_LOG_TRACE: false,
+    // Toggles URL logging to visualise MsgPack messages for all incoming and
+    // outgoing protocol messages.
+    // Note: Affects performance and contains sensitive information.
+    MSGPACK_LOG_TRACE: false,
 
 } as threema.Config;

+ 6 - 8
src/controller_model/avatar.ts

@@ -16,33 +16,31 @@
  */
 
 import {hasValue} from '../helpers';
+import {LogService} from '../services/log';
 import {WebClientService} from '../services/webclient';
 
 export class AvatarControllerModel {
-    private logTag: string = '[AvatarControllerModel]';
-
-    private $log: ng.ILogService;
     private avatar: ArrayBuffer | null = null;
     private loadAvatar: Promise<ArrayBuffer | null>;
     private onChangeAvatar: (image: ArrayBuffer) => void;
     private _avatarChanged: boolean = false;
 
-    constructor($log: ng.ILogService,
+    constructor(logService: LogService,
                 webClientService: WebClientService,
                 receiver: threema.Receiver | null) {
-        this.$log = $log;
+        const log = logService.getLogger('Avatar-CM');
         this.loadAvatar = new Promise((resolve, reject) => {
             if (!hasValue(receiver)) {
-                $log.debug(this.logTag, 'loadAvatar: No receiver defined, no avatar');
+                log.debug('No receiver defined, no avatar');
                 resolve(null);
                 return;
             } else if (!hasValue(receiver.avatar) || !hasValue(receiver.avatar.high)) {
-                $log.debug(this.logTag, 'loadAvatar: Requesting high res avatar from app');
+                log.debug('Requesting high res avatar from app');
                 webClientService.requestAvatar(receiver, true)
                     .then((data: ArrayBuffer) => resolve(data))
                     .catch((error) => reject(error));
             } else {
-                $log.debug(this.logTag, 'loadAvatar: Returning cached version');
+                log.debug('Returning cached avatar');
                 resolve(receiver.avatar.high);
             }
         });

+ 14 - 13
src/controller_model/contact.ts

@@ -15,6 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+import {LogService} from '../services/log';
 import {WebClientService} from '../services/webclient';
 import {AvatarControllerModel} from './avatar';
 
@@ -22,10 +24,7 @@ import {AvatarControllerModel} from './avatar';
 import ControllerModelMode = threema.ControllerModelMode;
 
 export class ContactControllerModel implements threema.ControllerModel<threema.ContactReceiver> {
-    private logTag = '[ContactControllerModel]';
-
     // Angular services
-    private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
     private $mdDialog: ng.material.IDialogService;
 
@@ -37,19 +36,21 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
     public access: threema.ContactReceiverAccess;
     public isLoading = false;
 
+    private readonly log: Logger;
     private contact: threema.ContactReceiver | null;
     private webClientService: WebClientService;
     private firstNameLabel: string;
     private avatarController: AvatarControllerModel;
     private mode = ControllerModelMode.NEW;
 
-    constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
-                webClientService: WebClientService,
+    constructor($translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
+                logService: LogService, webClientService: WebClientService,
                 mode: ControllerModelMode,
                 contact?: threema.ContactReceiver) {
-        this.$log = $log;
         this.$translate = $translate;
         this.$mdDialog = $mdDialog;
+        this.log = logService.getLogger('Contact-CM');
+
         if (contact === undefined) {
             if (mode !== ControllerModelMode.NEW) {
                 throw new Error('ContactControllerModel: Contact may not be undefined for mode ' + mode);
@@ -66,7 +67,7 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
                 this.firstName = this.contact!.firstName;
                 this.lastName = this.contact!.lastName;
                 this.avatarController = new AvatarControllerModel(
-                    this.$log, this.webClientService, this.contact,
+                    logService, this.webClientService, this.contact,
                 );
 
                 this.access = this.contact!.access;
@@ -86,7 +87,7 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
                 break;
 
             default:
-                $log.error(this.logTag, 'Invalid controller model mode: ', this.getMode());
+                this.log.error('Invalid controller model mode: ', this.getMode());
         }
     }
 
@@ -133,17 +134,17 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
         this.$mdDialog.show(confirm).then(() => {
             this.reallyClean();
         }, () => {
-            this.$log.debug(this.logTag, 'Clean canceled');
+            this.log.debug('Clean cancelled');
         });
     }
 
     private reallyClean(): any {
         if (!this.contact) {
-            this.$log.error(this.logTag, 'reallyClean: Contact is null');
+            this.log.error('reallyClean: Contact is null');
             return;
         }
         if (!this.canClean()) {
-            this.$log.error(this.logTag, 'Not allowed to clean this contact');
+            this.log.error('Not allowed to clean this contact');
             return;
         }
 
@@ -154,7 +155,7 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
             })
             .catch((error) => {
                 // TODO: Handle this properly / show an error message
-                this.$log.error(this.logTag, `Cleaning receiver conversation failed: ${error}`);
+                this.log.error(`Cleaning receiver conversation failed: ${error}`);
                 this.isLoading = false;
             });
     }
@@ -175,7 +176,7 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
             case ControllerModelMode.NEW:
                 return this.webClientService.addContact(this.identity);
             default:
-                this.$log.error(this.logTag, 'Cannot save contact, invalid mode');
+                this.log.error('Cannot save contact, invalid mode');
                 return Promise.reject('Cannot save contact, invalid mode');
         }
     }

+ 16 - 16
src/controller_model/distributionList.ts

@@ -15,17 +15,17 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+import {LogService} from '../services/log';
 import {WebClientService} from '../services/webclient';
 
 // Type aliases
 import ControllerModelMode = threema.ControllerModelMode;
 
 export class DistributionListControllerModel implements threema.ControllerModel<threema.DistributionListReceiver> {
-    private logTag = '[DistributionListControllerModel]';
-
-    private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
     private $mdDialog: ng.material.IDialogService;
+    private readonly log: Logger;
     public members: string[];
     public name: string;
     public subject: string;
@@ -37,13 +37,13 @@ export class DistributionListControllerModel implements threema.ControllerModel<
     private mode: ControllerModelMode;
     private onRemovedCallback: threema.OnRemovedCallback;
 
-    constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
-                webClientService: WebClientService,
+    constructor($translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
+                logService: LogService, webClientService: WebClientService,
                 mode: ControllerModelMode,
                 distributionList?: threema.DistributionListReceiver) {
-        this.$log = $log;
         this.$translate = $translate;
         this.$mdDialog = $mdDialog;
+        this.log = logService.getLogger('DistributionList-CM');
 
         if (distributionList === undefined) {
             if (mode !== ControllerModelMode.NEW) {
@@ -76,7 +76,7 @@ export class DistributionListControllerModel implements threema.ControllerModel<
                 break;
 
             default:
-                $log.error(this.logTag, 'Invalid controller model mode: ', this.getMode());
+                this.log.error('Invalid controller model mode: ', this.getMode());
         }
     }
 
@@ -118,17 +118,17 @@ export class DistributionListControllerModel implements threema.ControllerModel<
         this.$mdDialog.show(confirm).then(() => {
             this.reallyClean();
         }, () => {
-            this.$log.debug(this.logTag, 'Clean canceled');
+            this.log.debug('Clean cancelled');
         });
     }
 
     private reallyClean(): any {
         if (!this.distributionList) {
-            this.$log.error(this.logTag, 'reallyClean: Distribution list is null');
+            this.log.error('reallyClean: Distribution list is null');
             return;
         }
         if (!this.canClean()) {
-            this.$log.error(this.logTag, 'Not allowed to clean this distribution list');
+            this.log.error('Not allowed to clean this distribution list');
             return;
         }
 
@@ -139,7 +139,7 @@ export class DistributionListControllerModel implements threema.ControllerModel<
             })
             .catch((error) => {
                 // TODO: Handle this properly / show an error message
-                this.$log.error(this.logTag, `Cleaning receiver conversation failed: ${error}`);
+                this.log.error(`Cleaning receiver conversation failed: ${error}`);
                 this.isLoading = false;
             });
     }
@@ -159,17 +159,17 @@ export class DistributionListControllerModel implements threema.ControllerModel<
         this.$mdDialog.show(confirm).then(() => {
             this.reallyDelete();
         }, () => {
-            this.$log.debug(this.logTag, 'Delete canceled');
+            this.log.debug('Delete cancelled');
         });
     }
 
     private reallyDelete(): void {
         if (!this.distributionList) {
-            this.$log.error(this.logTag, 'reallyDelete: Distribution list is null');
+            this.log.error('reallyDelete: Distribution list is null');
             return;
         }
         if (!this.distributionList.access.canDelete) {
-            this.$log.error(this.logTag, 'Not allowed to delete this distribution list');
+            this.log.error('Not allowed to delete this distribution list');
             return;
         }
 
@@ -181,7 +181,7 @@ export class DistributionListControllerModel implements threema.ControllerModel<
             }
         }).catch((error) => {
             // TODO: Handle this properly / show an error message
-            this.$log.error(this.logTag, `Deleting distribution list failed: ${error}`);
+            this.log.error(`Deleting distribution list failed: ${error}`);
             this.isLoading = false;
         });
     }
@@ -199,7 +199,7 @@ export class DistributionListControllerModel implements threema.ControllerModel<
                     this.members,
                     this.name);
             default:
-                this.$log.error(this.logTag, 'Cannot save distribution list, invalid mode');
+                this.log.error('Cannot save distribution list, invalid mode');
                 return Promise.reject('Cannot save distribution list, invalid mode');
         }
     }

+ 24 - 24
src/controller_model/group.ts

@@ -15,6 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+import {LogService} from '../services/log';
 import {WebClientService} from '../services/webclient';
 import {AvatarControllerModel} from './avatar';
 
@@ -22,9 +24,7 @@ import {AvatarControllerModel} from './avatar';
 import ControllerModelMode = threema.ControllerModelMode;
 
 export class GroupControllerModel implements threema.ControllerModel<threema.GroupReceiver> {
-    private logTag = '[GroupControllerModel]';
-
-    private $log: ng.ILogService;
+    private log: Logger;
     private $translate: ng.translate.ITranslateService;
     private $mdDialog: ng.material.IDialogService;
     public members: string[];
@@ -41,13 +41,13 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
     private mode: ControllerModelMode;
     private onRemovedCallback: threema.OnRemovedCallback;
 
-    constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
-                webClientService: WebClientService,
+    constructor($translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
+                logService: LogService, webClientService: WebClientService,
                 mode: ControllerModelMode,
                 group?: threema.GroupReceiver) {
-        this.$log = $log;
         this.$translate = $translate;
         this.$mdDialog = $mdDialog;
+        this.log = logService.getLogger('Group-CM');
 
         if (group === undefined) {
             if (mode !== ControllerModelMode.NEW) {
@@ -66,7 +66,7 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
                 this.name = this.group!.displayName;
                 this.members = this.group!.members;
                 this.avatarController = new AvatarControllerModel(
-                    this.$log, this.webClientService, this.group!,
+                    logService, this.webClientService, this.group!,
                 );
                 this.access = this.group!.access;
                 break;
@@ -82,12 +82,12 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
                 this.subject = $translate.instant('messenger.CREATE_GROUP');
                 this.members = [];
                 this.avatarController = new AvatarControllerModel(
-                    this.$log, this.webClientService, null,
+                    logService, this.webClientService, null,
                 );
                 break;
 
             default:
-                $log.error(this.logTag, 'Invalid controller model mode: ', this.getMode());
+                this.log.error('Invalid controller model mode: ', this.getMode());
         }
     }
 
@@ -136,17 +136,17 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
         this.$mdDialog.show(confirm).then(() => {
             this.reallyClean();
         }, () => {
-            this.$log.debug('clean canceled');
+            this.log.debug('Clean cancelled');
         });
     }
 
     private reallyClean(): any {
         if (!this.group) {
-            this.$log.error(this.logTag, 'reallyClean: Group is null');
+            this.log.error('reallyClean: Group is null');
             return;
         }
         if (!this.canClean()) {
-            this.$log.error(this.logTag, 'Not allowed to clean this group');
+            this.log.error('Not allowed to clean this group');
             return;
         }
 
@@ -157,7 +157,7 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
             })
             .catch((error) => {
                 // TODO: Handle this properly / show an error message
-                this.$log.error(this.logTag, `Cleaning receiver conversation failed: ${error}`);
+                this.log.error(`Cleaning receiver conversation failed: ${error}`);
                 this.isLoading = false;
             });
     }
@@ -168,7 +168,7 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
 
     public leave(ev): void {
         if (!this.group) {
-            this.$log.error(this.logTag, 'leave: Group is null');
+            this.log.error('leave: Group is null');
             return;
         }
         const confirm = this.$mdDialog.confirm()
@@ -184,13 +184,13 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
         this.$mdDialog.show(confirm).then(() => {
             this.reallyLeave(this.group!);
         }, () => {
-            this.$log.debug(this.logTag, 'Leave canceled');
+            this.log.debug('Leave cancelled');
         });
     }
 
     private reallyLeave(group: threema.GroupReceiver): void {
         if (!group.access.canLeave) {
-            this.$log.error(this.logTag, 'Cannot leave group');
+            this.log.error('Cannot leave group');
             return;
         }
 
@@ -201,14 +201,14 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
             })
             .catch((error) => {
                 // TODO: Handle this properly / show an error message
-                this.$log.error(`Leaving group failed: ${error}`);
+                this.log.error(`Leaving group failed: ${error}`);
                 this.isLoading = false;
             });
     }
 
     public delete(ev): void {
         if (!this.group) {
-            this.$log.error(this.logTag, 'delete: Group is null');
+            this.log.error('delete: Group is null');
             return;
         }
 
@@ -222,13 +222,13 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
         this.$mdDialog.show(confirm).then(() => {
             this.reallyDelete(this.group!);
         }, () => {
-            this.$log.debug('delete canceled');
+            this.log.debug('Delete cancelled');
         });
     }
 
     private reallyDelete(group: threema.GroupReceiver): void {
         if (!this.access.canDelete) {
-            this.$log.error('can not delete group');
+            this.log.error('Can not delete group');
             return;
         }
 
@@ -242,18 +242,18 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
             })
             .catch((error) => {
                 // TODO: Handle this properly / show an error message
-                this.$log.error(`Deleting group failed: ${error}`);
+                this.log.error(`Deleting group failed: ${error}`);
                 this.isLoading = false;
             });
     }
 
     public sync(ev): void {
         if (!this.group) {
-            this.$log.error(this.logTag, 'sync: Group is null');
+            this.log.error('sync: Group is null');
             return;
         }
         if (!this.access.canSync) {
-            this.$log.error(this.logTag, 'Cannot sync group');
+            this.log.error('Cannot sync group');
             return;
         }
 
@@ -284,7 +284,7 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
                     this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
                 );
             default:
-                this.$log.error(this.logTag, 'Cannot save group, invalid mode');
+                this.log.error('Cannot save group, invalid mode');
                 return Promise.reject('Cannot save group, invalid mode');
         }
     }

+ 11 - 9
src/controller_model/me.ts

@@ -15,20 +15,22 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
 import {hasValue} from '../helpers';
+import {LogService} from '../services/log';
 import {WebClientService} from '../services/webclient';
 import {AvatarControllerModel} from './avatar';
 
 import ControllerModelMode = threema.ControllerModelMode;
 
 export class MeControllerModel implements threema.ControllerModel<threema.MeReceiver> {
-    private logTag: string = '[MeControllerModel]';
-
     // Angular services
-    private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
     private $mdDialog: ng.material.IDialogService;
 
+    // Logging
+    private readonly log: Logger;
+
     // Own services
     private webClientService: WebClientService;
 
@@ -48,13 +50,13 @@ export class MeControllerModel implements threema.ControllerModel<threema.MeRece
     // Editing mode
     private mode = ControllerModelMode.VIEW;
 
-    constructor($log: ng.ILogService,
-                $translate: ng.translate.ITranslateService,
+    constructor($translate: ng.translate.ITranslateService,
                 $mdDialog: ng.material.IDialogService,
+                logService: LogService,
                 webClientService: WebClientService,
                 mode: ControllerModelMode,
                 me: threema.MeReceiver) {
-        this.$log = $log;
+        this.log = logService.getLogger('Me-CM');
         this.$translate = $translate;
         this.$mdDialog = $mdDialog;
         this.me = me;
@@ -66,14 +68,14 @@ export class MeControllerModel implements threema.ControllerModel<threema.MeRece
             case ControllerModelMode.EDIT:
                 this.subject = $translate.instant('messenger.EDIT_RECEIVER');
                 this.avatarController = new AvatarControllerModel(
-                    this.$log, this.webClientService, this.me,
+                    logService, this.webClientService, this.me,
                 );
                 break;
             case ControllerModelMode.VIEW:
                 this.subject = $translate.instant('messenger.MY_THREEMA_ID');
                 break;
             default:
-                $log.error(this.logTag, 'Invalid controller model mode: ', this.getMode());
+                this.log.error('Invalid controller model mode: ', this.getMode());
         }
     }
 
@@ -173,7 +175,7 @@ export class MeControllerModel implements threema.ControllerModel<threema.MeRece
                     return this.me;
                 });
             default:
-                this.$log.error(this.logTag, 'Not allowed to save profile: Invalid mode');
+                this.log.error('Not allowed to save profile: Invalid mode');
                 return Promise.reject('unknown');
         }
     }

+ 23 - 19
src/controllers/status.ts

@@ -15,9 +15,12 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
 import {StateService as UiStateService} from '@uirouter/angularjs';
 
 import {ControllerService} from '../services/controller';
+import {LogService} from '../services/log';
 import {StateService} from '../services/state';
 import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
@@ -33,8 +36,8 @@ import DisconnectReason = threema.DisconnectReason;
  * Status updates should be done through the state service.
  */
 export class StatusController {
-
-    private logTag: string = '[StatusController]';
+    // Logging
+    private readonly log: Logger;
 
     // State variable
     private state = GlobalConnectionState.Error;
@@ -50,7 +53,6 @@ export class StatusController {
 
     // Angular services
     private $timeout: ng.ITimeoutService;
-    private $log: ng.ILogService;
     private $state: UiStateService;
 
     // Custom services
@@ -60,16 +62,18 @@ export class StatusController {
     private webClientService: WebClientService;
 
     public static $inject = [
-        '$scope', '$timeout', '$log', '$state',
-        'ControllerService', 'StateService', 'TimeoutService', 'WebClientService',
+        '$scope', '$timeout', '$state',
+        'ControllerService', 'StateService', 'LogService', 'TimeoutService', 'WebClientService',
     ];
-    constructor($scope, $timeout: ng.ITimeoutService, $log: ng.ILogService, $state: UiStateService,
-                controllerService: ControllerService, stateService: StateService,
+    constructor($scope, $timeout: ng.ITimeoutService, $state: UiStateService,
+                controllerService: ControllerService, stateService: StateService, logService: LogService,
                 timeoutService: TimeoutService, webClientService: WebClientService) {
 
+        // Logging
+        this.log = logService.getLogger('Status-C', 'color: #000; background-color: #ffff99');
+
         // Angular services
         this.$timeout = $timeout;
-        this.$log = $log;
         this.$state = $state;
 
         // Custom services
@@ -105,7 +109,7 @@ export class StatusController {
      */
     private onStateChange(newValue: threema.GlobalConnectionState,
                           oldValue: threema.GlobalConnectionState): void {
-        this.$log.debug(this.logTag, 'State change:', oldValue, '->', newValue);
+        this.log.debug('State change:', oldValue, '->', newValue);
         if (newValue === oldValue) {
             return;
         }
@@ -136,7 +140,7 @@ export class StatusController {
                 }
                 break;
             default:
-                this.$log.error(this.logTag, 'Invalid state change: From', oldValue, 'to', newValue);
+                this.log.error('Invalid state change: From', oldValue, 'to', newValue);
         }
     }
 
@@ -163,7 +167,7 @@ export class StatusController {
      * Attempt to reconnect an Android device after a connection loss.
      */
     private reconnectAndroid(): void {
-        this.$log.info(this.logTag, `Connection lost (Android). Reconnect attempt #${this.stateService.attempt + 1}`);
+        this.log.info(`Connection lost (Android). Reconnect attempt #${this.stateService.attempt + 1}`);
 
         // Show expanded status bar (if on 'messenger')
         if (this.$state.includes('messenger')) {
@@ -198,12 +202,12 @@ export class StatusController {
             .then(
                 () => { /* ignored */ },
                 (error) => {
-                    this.$log.error(this.logTag, 'Error state:', error);
+                    this.log.error('Error state:', error);
                     // Note: The web client service has already been stopped at
                     // this point.
                 },
                 (progress: threema.ConnectionBuildupStateChange) => {
-                    this.$log.debug(this.logTag, 'Connection buildup advanced:', progress);
+                    this.log.debug('Connection buildup advanced:', progress);
                 },
             )
             .finally(() => {
@@ -217,7 +221,7 @@ export class StatusController {
      * Attempt to reconnect an iOS device after a connection loss.
      */
     private reconnectIos(): void {
-        this.$log.info(this.logTag, `Connection lost (iOS). Reconnect attempt #${++this.stateService.attempt}`);
+        this.log.info(`Connection lost (iOS). Reconnect attempt #${++this.stateService.attempt}`);
 
         // Get original keys
         const originalKeyStore = this.webClientService.salty.keyStore;
@@ -226,7 +230,7 @@ export class StatusController {
         // Delay connecting a bit to wait for old websocket to close
         // TODO: Make this more robust and hopefully faster
         const startTimeout = 500;
-        this.$log.debug(this.logTag, 'Stopping old connection');
+        this.log.debug('Stopping old connection');
         this.webClientService.stop({
             reason: DisconnectReason.SessionStopped,
             send: true,
@@ -262,9 +266,9 @@ export class StatusController {
         this.$timeout.cancel(this.reconnectTimeout);
         this.reconnectTimeout = this.$timeout(() => {
             if (push.send) {
-                this.$log.debug(`Starting new connection with push, reason: ${push.reason}`);
+                this.log.debug(`Starting new connection with push, reason: ${push.reason}`);
             } else {
-                this.$log.debug('Starting new connection without push');
+                this.log.debug('Starting new connection without push');
             }
             this.webClientService.init({
                 keyStore: originalKeyStore,
@@ -275,12 +279,12 @@ export class StatusController {
             this.webClientService.start(!push.send).then(
                 () => { /* ignored */ },
                 (error) => {
-                    this.$log.error(this.logTag, 'Error state:', error);
+                    this.log.error('Error state:', error);
                     // Note: The web client service has already been stopped at
                     // this point.
                 },
                 (progress: threema.ConnectionBuildupStateChange) => {
-                    this.$log.debug(this.logTag, 'Connection buildup advanced:', progress);
+                    this.log.debug('Connection buildup advanced:', progress);
                 },
             );
         }, startTimeout);

+ 7 - 10
src/directives/avatar.ts

@@ -15,21 +15,23 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import {bufferToUrl, hasValue, logAdapter} from '../helpers';
+import {bufferToUrl, hasValue} from '../helpers';
 import {isEchoContact, isGatewayContact} from '../receiver_helpers';
+import {LogService} from '../services/log';
 import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
 import {isContactReceiver} from '../typeguards';
 
 export default [
     '$rootScope',
-    '$log',
+    'LogService',
     'TimeoutService',
     'WebClientService',
     function($rootScope: ng.IRootScopeService,
-             $log: ng.ILogService,
+             logService: LogService,
              timeoutService: TimeoutService,
              webClientService: WebClientService) {
+        const log = logService.getLogger('Avatar-C');
         return {
             restrict: 'E',
             scope: {},
@@ -81,8 +83,6 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.logTag = '[Directives.Avatar]';
-
                 let loadingPromise: ng.IPromise<any> = null;
 
                 /**
@@ -99,10 +99,7 @@ export default [
                     if (avatarUri[res] === null) {
                         // Cache avatar image URI
                         avatarUri[res] = bufferToUrl(
-                            data,
-                            webClientService.appCapabilities.imageFormat.avatar,
-                            logAdapter($log.warn, this.logTag),
-                        );
+                            data, webClientService.appCapabilities.imageFormat.avatar, log);
                     }
                     return avatarUri[res];
                 };
@@ -220,7 +217,7 @@ export default [
                                     })
                                     .catch((error) => {
                                         // TODO: Handle this properly / show an error message
-                                        $log.error(this.logTag, `Avatar request has been rejected: ${error}`);
+                                        log.error(`Avatar request has been rejected: ${error}`);
                                         $rootScope.$apply(() => {
                                             this.isLoading = false;
                                         });

+ 6 - 6
src/directives/avatar_area.ts

@@ -17,7 +17,8 @@
 
 // tslint:disable:max-line-length
 
-import {bufferToUrl, logAdapter} from '../helpers';
+import {bufferToUrl} from '../helpers';
+import {LogService} from '../services/log';
 import {WebClientService} from '../services/webclient';
 
 /**
@@ -25,13 +26,14 @@ import {WebClientService} from '../services/webclient';
  */
 export default [
     '$rootScope',
-    '$log',
     '$mdDialog',
+    'LogService',
     'WebClientService',
     function($rootScope: ng.IRootScopeService,
-             $log: ng.ILogService,
              $mdDialog: ng.material.IDialogService,
+             logService: LogService,
              webClientService: WebClientService) {
+        const log = logService.getLogger('AvatarArea-C');
         return {
             restrict: 'EA',
             scope: true,
@@ -43,8 +45,6 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                const logTag = '[AvatarAreaDirective]';
-
                 this.isLoading = false;
                 this.avatar = null; // String
                 const avatarFormat = webClientService.appCapabilities.imageFormat.avatar;
@@ -53,7 +53,7 @@ export default [
                     this.setAvatar = (avatarBytes: ArrayBuffer) => {
                         this.avatar = (avatarBytes === null)
                             ? null
-                            : bufferToUrl(avatarBytes, avatarFormat, logAdapter($log.warn, logTag));
+                            : bufferToUrl(avatarBytes, avatarFormat, log);
                     };
 
                     this.imageChanged = (image: ArrayBuffer, notify = true) => {

+ 10 - 10
src/directives/avatar_editor.ts

@@ -20,22 +20,22 @@
 
 // tslint:disable:max-line-length
 
-import {bufferToUrl, logAdapter} from '../helpers';
+import {bufferToUrl} from '../helpers';
+import {LogService} from '../services/log';
 
 /**
  * Support uploading and resizing avatar
  */
 export default [
-    '$log',
-    function($log: ng.ILogService) {
+    'LogService',
+    function(logService: LogService) {
+        const log = logService.getLogger('AvatarEditor-C');
         return {
             restrict: 'EA',
             scope: {
                 onChange: '=',
             },
             link(scope: any, element, attrs, controller) {
-                const logTag: string = '[AvatarEditorDirective]';
-
                 // Constants
                 const DRAGOVER_CSS_CLASS = 'is-dragover';
                 const VIEWPORT_SIZE = 220;
@@ -123,10 +123,10 @@ export default [
                     }
                     // get first
                     fetchFileContent(fileList[0]).then((data: ArrayBuffer) => {
-                        const image = bufferToUrl(data, 'image/jpeg', logAdapter($log.warn, logTag));
+                        const image = bufferToUrl(data, 'image/jpeg', log);
                         setImage(image);
                     }).catch((ev: ErrorEvent) => {
-                        $log.error(logTag, 'Could not load file:', ev.message);
+                        log.error('Could not load file:', ev.message);
                     });
                 }
 
@@ -177,7 +177,7 @@ export default [
                     // load image to calculate size
                     const img = new Image();
                     img.addEventListener('load', async () => {
-                        $log.debug(logTag, 'Image loaded');
+                        log.debug('Image loaded');
 
                         const w = img.naturalWidth;
                         const h = img.naturalHeight;
@@ -197,14 +197,14 @@ export default [
                                 points: imageSize,
                             });
                         } catch (error) {
-                            $log.error(logTag, 'Could not bind avatar preview:', error);
+                            log.error('Could not bind avatar preview:', error);
                         }
                         loading(false);
                     });
 
                     img.addEventListener('error', function(e) {
                         // this is not a valid image
-                        $log.error(logTag, 'Could not load image:', e);
+                        log.error('Could not load image:', e);
                         loading(false);
                     });
 

+ 11 - 12
src/directives/compose_area.ts

@@ -20,6 +20,7 @@ import {ComposeArea} from '@threema/compose-area';
 import {isActionTrigger} from '../helpers';
 import {emojifyNew, shortnameToUnicode} from '../helpers/emoji';
 import {BrowserService} from '../services/browser';
+import {LogService} from '../services/log';
 import {ReceiverService} from '../services/receiver';
 import {StringService} from '../services/string';
 import {TimeoutService} from '../services/timeout';
@@ -30,6 +31,7 @@ import {isEmojiInfo} from '../typeguards';
  */
 export default [
     'BrowserService',
+    'LogService',
     'StringService',
     'TimeoutService',
     'ReceiverService',
@@ -37,10 +39,10 @@ export default [
     '$translate',
     '$mdDialog',
     '$filter',
-    '$log',
     '$rootScope',
     'CONFIG',
     function(browserService: BrowserService,
+             logService: LogService,
              stringService: StringService,
              timeoutService: TimeoutService,
              receiverService: ReceiverService,
@@ -48,9 +50,9 @@ export default [
              $translate: ng.translate.ITranslateService,
              $mdDialog: ng.material.IDialogService,
              $filter: ng.IFilterService,
-             $log: ng.ILogService,
              $rootScope: ng.IRootScopeService,
              CONFIG: threema.Config) {
+        const log = logService.getLogger('ComposeArea-C');
         return {
             restrict: 'EA',
             scope: {
@@ -76,9 +78,6 @@ export default [
                 receiver: '<receiver',
             },
             link: function(scope: any, wrapper: JQLite) {
-                // Logging
-                const logTag = '[Directives.ComposeArea]';
-
                 // Constants
                 const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
                 const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
@@ -93,7 +92,7 @@ export default [
                 const fileInput = select('input.file-input') as JQuery<HTMLInputElement>;
 
                 // Initialize compose area lib
-                const composeArea = ComposeArea.bind_to(composeDiv[0], CONFIG.VERBOSE_DEBUGGING ? 'debug' : 'warn');
+                const composeArea = ComposeArea.bind_to(composeDiv[0], CONFIG.COMPOSE_AREA_LOG_LEVEL);
                 if (scope.onInit) {
                     scope.onInit(composeArea);
                 }
@@ -111,7 +110,7 @@ export default [
                 // Function to update blocking state
                 function setChatBlocked(blocked: boolean) {
                     chatBlocked = blocked;
-                    $log.debug(logTag, 'Receiver blocked:', blocked);
+                    log.debug('Receiver blocked:', blocked);
                     if (blocked) {
                         sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                         emojiTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
@@ -238,7 +237,7 @@ export default [
                             updateView();
                         }).catch(() => {
                             // do nothing
-                            $log.warn(logTag, 'Failed to submit text');
+                            log.warn('Failed to submit text');
                         });
 
                         return true;
@@ -376,11 +375,11 @@ export default [
                         });
                         scope
                             .submit('file', fileMessages)
-                            .catch((msg) => $log.error('Could not send file:', msg));
+                            .catch((e) => log.error('Could not send file:', e));
                         scope.onUploading(false);
 
                     }).catch((ev: ErrorEvent) => {
-                        $log.error(logTag, 'Could not load file:', ev.message);
+                        log.error('Could not load file:', ev.message);
                     });
                 }
 
@@ -428,7 +427,7 @@ export default [
                                 const fileExt = blob.type.split(';')[0].split('/')[1];
                                 fileName = 'clipboard.' + fileExt;
                             } else {
-                                $log.warn(logTag, 'Pasted file has an invalid MIME type: "' + blob.type + '"');
+                                log.warn('Pasted file has an invalid MIME type: "' + blob.type + '"');
                                 return;
                             }
 
@@ -441,7 +440,7 @@ export default [
                             };
                             scope
                                 .submit('file', [fileMessageData])
-                                .catch((msg) => $log.error('Could not send file:', msg));
+                                .catch((msg) => log.error('Could not send file:', msg));
                         };
                         reader.readAsArrayBuffer(blob);
 

+ 7 - 7
src/directives/drag_file.ts

@@ -15,12 +15,15 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {LogService} from '../services/log';
+
 /**
  * Allow to drag and drop elements, set class to parent object
  */
 export default [
-    '$log',
-    function($log: ng.ILogService) {
+    'LogService',
+    function(logService: LogService) {
+        const log = logService.getLogger('DragFile-C');
         return {
             restrict: 'EA',
             scope: {
@@ -28,9 +31,6 @@ export default [
                 onUploading: '=',
             },
             link(scope: any, element) {
-                // Logging
-                const logTag = '[Directives.DragFile]';
-
                 // Constants
                 const DRAGOVER_CSS_CLASS = 'is-dragover';
 
@@ -49,7 +49,7 @@ export default [
                                 resolve(buffers);
                             }
                             if (error !== undefined) {
-                                $log.error(logTag, 'Error:', error);
+                                log.error('Error:', error);
                             }
                         };
 
@@ -91,7 +91,7 @@ export default [
                         scope.onUploading(false);
 
                     }).catch((ev: ErrorEvent) => {
-                        $log.error(logTag, 'Could not load file:', ev.message);
+                        log.error('Could not load file:', ev.message);
                     });
                 }
 

+ 6 - 9
src/directives/mediabox.ts

@@ -17,26 +17,26 @@
 
 import {saveAs} from 'file-saver';
 
-import {bufferToUrl, logAdapter} from '../helpers';
+import {bufferToUrl} from '../helpers';
+import {LogService} from '../services/log';
 import {MediaboxService} from '../services/mediabox';
 
 export default [
     '$rootScope',
     '$document',
-    '$log',
+    'LogService',
     'MediaboxService',
     function($rootScope: ng.IRootScopeService,
              $document: ng.IDocumentService,
-             $log: ng.ILogService,
+             logService: LogService,
              mediaboxService: MediaboxService) {
+        const log = logService.getLogger('Mediabox-C');
         return {
             restrict: 'E',
             scope: {},
             bindToController: {},
             controllerAs: 'ctrl',
             controller: [function() {
-                this.logTag = '[MediaboxDirective]';
-
                 // Data attributes
                 this.imageDataUrl = null;
                 this.caption = '';
@@ -62,10 +62,7 @@ export default [
                     $rootScope.$apply(() => {
                         if (dataAvailable) {
                             this.imageDataUrl = bufferToUrl(
-                                mediaboxService.data,
-                                mediaboxService.mimetype,
-                                logAdapter($log.debug, this.logTag),
-                            );
+                                mediaboxService.data, mediaboxService.mimetype, log);
                             this.caption = mediaboxService.caption || mediaboxService.filename;
                         } else {
                             this.close();

+ 5 - 3
src/directives/member_list_editor.ts

@@ -16,13 +16,15 @@
  */
 
 import {hasFeature} from '../helpers';
+import {LogService} from '../services/log';
 import {WebClientService} from '../services/webclient';
 
 const AUTOCOMPLETE_MAX_RESULTS = 20;
 
 export default [
-    '$log', 'WebClientService',
-    function($log: ng.ILogService, webClientService: WebClientService) {
+    'LogService', 'WebClientService',
+    function(logService: LogService, webClientService: WebClientService) {
+        const log = logService.getLogger('MemberListEditor-C');
         return {
             restrict: 'EA',
             scope: {},
@@ -39,7 +41,7 @@ export default [
                     .filter((contactReceiver: threema.ContactReceiver) => hasFeature(
                         contactReceiver,
                         threema.ContactReceiverFeature.GROUP_CHAT,
-                        $log,
+                        log,
                     )) as threema.ContactReceiver[];
 
                 this.selectedItemChange = (contactReceiver: threema.ContactReceiver) => {

+ 9 - 10
src/directives/message.ts

@@ -22,12 +22,14 @@ import {saveAs} from 'file-saver';
 import {BrowserInfo} from '../helpers/browser_info';
 import {getSenderIdentity} from '../helpers/messages';
 import {BrowserService} from '../services/browser';
+import {LogService} from '../services/log';
 import {MessageService} from '../services/message';
 import {ReceiverService} from '../services/receiver';
 import {WebClientService} from '../services/webclient';
 
 export default [
     'BrowserService',
+    'LogService',
     'MessageService',
     'ReceiverService',
     'WebClientService',
@@ -35,17 +37,16 @@ export default [
     '$mdToast',
     '$translate',
     '$rootScope',
-    '$log',
     function(browserService: BrowserService,
+             logService: LogService,
              messageService: MessageService,
              receiverService: ReceiverService,
              webClientService: WebClientService,
              $mdDialog: ng.material.IDialogService,
              $mdToast: ng.material.IToastService,
              $translate: ng.translate.ITranslateService,
-             $rootScope: ng.IRootScopeService,
-             $log: ng.ILogService) {
-
+             $rootScope: ng.IRootScopeService) {
+        const log = logService.getLogger('Message-C');
         return {
             restrict: 'E',
             scope: {},
@@ -57,8 +58,6 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.logTag = '[MessageDirective]';
-
                 // Determine browser
                 this.browserInfo = browserService.getBrowser();
 
@@ -147,12 +146,12 @@ export default [
                         try {
                             const successful = document.execCommand('copy');
                             if (!successful) {
-                                $log.warn(this.logTag, 'Could not copy text to clipboard');
+                                log.warn('Could not copy text to clipboard');
                             } else {
                                 toastString = 'messenger.COPIED';
                             }
                         } catch (err) {
-                            $log.warn(this.logTag, 'Could not copy text to clipboard:', err);
+                            log.warn('Could not copy text to clipboard:', err);
                         }
                         document.body.removeChild(textArea);
 
@@ -178,13 +177,13 @@ export default [
                                             saveAs(new Blob([blobInfo.buffer]), blobInfo.filename);
                                             break;
                                         default:
-                                            $log.warn(this.logTag, 'Ignored download request for message type', this.message.type);
+                                            log.warn('Ignored download request for message type', this.message.type);
                                     }
                                 });
                             })
                             .catch((error) => {
                                 // TODO: Handle this properly / show an error message
-                                $log.error(this.logTag, `Error downloading blob: ${error}`);
+                                log.error(`Error downloading blob: ${error}`);
                                 this.downloading = false;
                             });
                     };

+ 18 - 25
src/directives/message_media.ts

@@ -18,7 +18,8 @@
 import {Transition as UiTransition, TransitionService as UiTransitionService} from '@uirouter/angularjs';
 import {saveAs} from 'file-saver';
 
-import {bufferToUrl, hasValue, logAdapter} from '../helpers';
+import {bufferToUrl, hasValue} from '../helpers';
+import {LogService} from '../services/log';
 import {MediaboxService} from '../services/mediabox';
 import {MessageService} from '../services/message';
 import {TimeoutService} from '../services/timeout';
@@ -26,18 +27,15 @@ import {WebClientService} from '../services/webclient';
 
 function showAudioDialog(
     $mdDialog: ng.material.IDialogService,
-    $log: ng.ILogService,
+    logService: LogService,
     blobInfo: threema.BlobInfo,
 ): void {
+    const log = logService.getLogger('AudioPlayerDialog-C');
     $mdDialog.show({
         controllerAs: 'ctrl',
         controller: function() {
             this.cancel = () => $mdDialog.cancel();
-            this.audioSrc = bufferToUrl(
-                blobInfo.buffer,
-                blobInfo.mimetype,
-                logAdapter($log.warn, '[AudioPlayerDialog]'),
-            );
+            this.audioSrc = bufferToUrl(blobInfo.buffer, blobInfo.mimetype, log);
         },
         template: `
             <md-dialog translate-attr="{'aria-label': 'messageTypes.AUDIO_MESSAGE'}">
@@ -63,6 +61,7 @@ function showAudioDialog(
 }
 
 export default [
+    'LogService',
     'WebClientService',
     'MediaboxService',
     'MessageService',
@@ -72,10 +71,10 @@ export default [
     '$timeout',
     '$transitions',
     '$translate',
-    '$log',
     '$filter',
     '$window',
-    function(webClientService: WebClientService,
+    function(logService: LogService,
+             webClientService: WebClientService,
              mediaboxService: MediaboxService,
              messageService: MessageService,
              timeoutService: TimeoutService,
@@ -84,9 +83,9 @@ export default [
              $timeout: ng.ITimeoutService,
              $transitions: UiTransitionService,
              $translate: ng.translate.ITranslateService,
-             $log: ng.ILogService,
              $filter: ng.IFilterService,
              $window: ng.IWindowService) {
+        const log = logService.getLogger('MessageMedia-C');
         return {
             restrict: 'EA',
             scope: {},
@@ -97,8 +96,6 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.logTag = '[MessageMedia]';
-
                 // On state transitions, clear mediabox
                 $transitions.onStart({}, function(trans: UiTransition) {
                     mediaboxService.clearMedia();
@@ -130,7 +127,7 @@ export default [
                             thumbnailPreviewUri = bufferToUrl(
                                 (this.message as threema.Message).thumbnail.preview,
                                 webClientService.appCapabilities.imageFormat.thumbnail,
-                                logAdapter($log.warn, this.logTag),
+                                log,
                             );
                         }
                         return thumbnailPreviewUri;
@@ -175,7 +172,7 @@ export default [
                                     this.thumbnail = bufferToUrl(
                                         buf,
                                         webClientService.appCapabilities.imageFormat.thumbnail,
-                                        logAdapter($log.warn, this.logTag),
+                                        log,
                                     );
                                 };
 
@@ -194,7 +191,7 @@ export default [
                                             .catch((error) => {
                                                 // TODO: Handle this properly / show an error message
                                                 const message = `Thumbnail request has been rejected: ${error}`;
-                                                this.$log.error(this.logTag, message);
+                                                this.log.error(message);
                                             });
                                     }, 1000, false, 'thumbnail');
                                 }
@@ -215,13 +212,13 @@ export default [
                     };
 
                     // Play a Audio file in a dialog
-                    this.playAudio = (blobInfo: threema.BlobInfo) => showAudioDialog($mdDialog, $log, blobInfo);
+                    this.playAudio = (blobInfo: threema.BlobInfo) => showAudioDialog($mdDialog, logService, blobInfo);
 
                     // Download function
                     this.download = () => {
-                        $log.debug(this.logTag, 'Download blob');
+                        log.debug('Download blob');
                         if (this.downloading) {
-                            $log.debug(this.logTag, 'Download already in progress...');
+                            log.debug('Download already in progress...');
                             return;
                         }
                         const message: threema.Message = this.message;
@@ -230,7 +227,7 @@ export default [
                         webClientService.requestBlob(message.id, receiver)
                             .then((blobInfo: threema.BlobInfo) => {
                                 $rootScope.$apply(() => {
-                                    $log.debug(this.logTag, 'Blob loaded');
+                                    log.debug('Blob loaded');
                                     this.downloading = false;
                                     this.downloaded = true;
 
@@ -251,10 +248,7 @@ export default [
                                             if (this.message.file.type === 'image/gif') {
                                                 // Show inline
                                                 this.blobBufferUrl = bufferToUrl(
-                                                    blobInfo.buffer,
-                                                    'image/gif',
-                                                    logAdapter($log.warn, this.logTag),
-                                                );
+                                                    blobInfo.buffer, 'image/gif', log);
                                                 // Hide thumbnail
                                                 this.showThumbnail = false;
                                             } else {
@@ -266,8 +260,7 @@ export default [
                                             this.playAudio(blobInfo);
                                             break;
                                         default:
-                                            $log.warn(this.logTag,
-                                                'Ignored download request for message type', this.message.type);
+                                            log.warn('Ignored download request for message type', this.message.type);
                                     }
                                 });
                             })

+ 5 - 3
src/directives/verification_level.ts

@@ -14,10 +14,12 @@
  * You should have received a copy of the GNU Affero General Public License
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
+import {LogService} from '../services/log';
 
 export default [
-    '$log', '$translate',
-    function($log: ng.ILogService, $translate: ng.translate.ITranslateService) {
+    '$translate', 'LogService',
+    function($translate: ng.translate.ITranslateService, logService: LogService) {
+        const log = logService.getLogger('VerificationLevel-C');
         return {
             restrict: 'EA',
             scope: {},
@@ -52,7 +54,7 @@ export default [
                     }
 
                     if (label === undefined) {
-                        $log.error('invalid verification level', this.level);
+                        log.error('invalid verification level', this.level);
                         return;
                     }
 

+ 2 - 25
src/filters.ts

@@ -17,13 +17,11 @@
 
 import Autolinker from 'autolinker';
 
-import {bufferToUrl, escapeRegExp, filter, hasValue, logAdapter} from './helpers';
-import {emojify, enlargeSingleEmoji} from './helpers/emoji';
+import {bufferToUrl, escapeRegExp, filter, hasValue} from './helpers';
+import {emojify} from './helpers/emoji';
 import {markify} from './markup_parser';
 import {MimeService} from './services/mime';
-import {NotificationService} from './services/notification';
 import {WebClientService} from './services/webclient';
-import {isContactReceiver} from './typeguards';
 
 angular.module('3ema.filters', [])
 
@@ -197,27 +195,6 @@ angular.module('3ema.filters', [])
     };
 })
 
-/**
- * Convert an ArrayBuffer to a data URL.
- *
- * Warning: Make sure that this is not called repeatedly on big data, or performance will decrease.
- */
-.filter('bufferToUrl', ['$sce', '$log', function($sce, $log) {
-    const logTag = '[filters.bufferToUrl]';
-    return function(buffer: ArrayBuffer, mimeType: string, trust: boolean = true) {
-        if (!buffer) {
-            $log.error(logTag, 'Could not apply bufferToUrl filter: buffer is', buffer);
-            return '';
-        }
-        const uri = bufferToUrl(buffer, mimeType, logAdapter($log.warn, logTag));
-        if (trust) {
-            return $sce.trustAsResourceUrl(uri);
-        } else {
-            return uri;
-        }
-    };
-}])
-
 .filter('mapLink', function() {
     return function(location: threema.LocationInfo) {
         return 'https://www.openstreetmap.org/?mlat='

+ 58 - 27
src/helpers.ts

@@ -17,6 +17,8 @@
 // tslint:disable:no-reference
 /// <reference path="threema.d.ts" />
 
+import {Logger} from 'ts-log';
+
 /**
  * Convert an Uint8Array to a hex string.
  *
@@ -282,28 +284,24 @@ export function msgpackVisualizer(array: Uint8Array): string {
  */
 export function hasFeature(contactReceiver: threema.ContactReceiver,
                            feature: threema.ContactReceiverFeature,
-                           $log: ng.ILogService): boolean {
-    const logTag = '[helpers.hasFeature]';
+                           log: Logger): boolean {
     if (contactReceiver !== undefined) {
         if (contactReceiver.featureMask === 0) {
-            $log.warn(logTag, contactReceiver.id, 'featureMask', contactReceiver.featureMask);
+            log.warn(`Contact receiver with id ${contactReceiver.id} has featureMask 0`);
             return false;
         }
         // tslint:disable:no-bitwise
         return (contactReceiver.featureMask & feature) !== 0;
         // tslint:enable:no-bitwise
     }
-    $log.warn(logTag, 'Cannot check featureMask of a undefined contactReceiver');
+    log.warn('Cannot check featureMask of a undefined contact receiver');
     return false;
 }
 
 /**
  * Convert an ArrayBuffer to a data URL.
  */
-export function bufferToUrl(buffer: ArrayBuffer, mimeType: string, logWarning: (msg: string) => void): string {
-    if (buffer === null || buffer === undefined) {
-        throw new Error('Called bufferToUrl on null or undefined');
-    }
+export function bufferToUrl(buffer: ArrayBuffer, mimeType: string, log: Logger): string {
     switch (mimeType) {
         case 'image/jpg':
         case 'image/jpeg':
@@ -317,24 +315,14 @@ export function bufferToUrl(buffer: ArrayBuffer, mimeType: string, logWarning: (
             // OK
             break;
         default:
-            logWarning('bufferToUrl: Unknown mimeType: ' + mimeType);
-            mimeType = 'image/jpeg';
+            const fallbackMimeType = 'image/jpeg';
+            log.warn(`Unknown mimeType "${mimeType}", falling back to "${fallbackMimeType}"`);
+            mimeType = fallbackMimeType;
             break;
     }
     return 'data:' + mimeType + ';base64,' + u8aToBase64(new Uint8Array(buffer));
 }
 
-/**
- * Adapter for creating a logging function.
- *
- * Example usage:
- *
- * const logWarning = logAdapter($log.warn, '[AvatarService]');
- */
-export function logAdapter(logFunc: (...msg: string[]) => void, logTag: string): ((msg: string) => void) {
-    return (msg: string) => logFunc(logTag, msg);
-}
-
 /**
  * Return whether a value is not null and not undefined.
  */
@@ -382,18 +370,61 @@ export function isActionTrigger(ev: KeyboardEvent): boolean {
     }
 }
 
-/*
+/**
  * Create a shallow copy of an object.
  */
-export function copyShallow<T extends object>(obj: T): T {
-    return Object.assign({}, obj);
+export function copyShallow(object: object): object {
+    return Object.assign({}, object);
 }
 
 /**
- * Create a deep copy of an object by serializing and deserializing it.
+ * Create a deep copy (mostly).
+ *
+ * This handles the following types:
+ *
+ * - copies `undefined` and `null`,
+ * - copies `Boolean`, `Number` and `String`,
+ * - copies `object` recursively,
+ * - copies `Array` recursively,
+ * - copies `ArrayBuffer`,
+ * - copies `Uint8Array`,
+ *
+ * Everything else will be **referenced**.
  */
-export function copyDeep<T extends object>(obj: T): T {
-    return JSON.parse(JSON.stringify(obj));
+export function copyDeepOrReference(value: any): any {
+    // Handle `null` and `undefined` early
+    if (value === null || value === undefined) {
+        return value;
+    }
+
+    // Plain object
+    if (value.constructor === Object) {
+        const object = {};
+        for (const [k, v] of Object.entries(value)) {
+            object[k] = copyDeepOrReference(v);
+        }
+        return object;
+    }
+
+    // Plain array
+    if (value instanceof Array) {
+        return value.map((item) => copyDeepOrReference(item));
+    }
+
+    // ArrayBuffer
+    if (value instanceof ArrayBuffer) {
+        return value.slice(0);
+    }
+
+    // Uint8Array
+    if (value instanceof Uint8Array) {
+        // Note: To mimic the byte offset, we copy the whole underlying buffer.
+        const buffer = value.buffer.slice(0);
+        return new Uint8Array(buffer, value.byteOffset, value.byteLength);
+    }
+
+    // Reference everything else
+    return value;
 }
 
 /**

+ 214 - 0
src/helpers/confidential.ts

@@ -0,0 +1,214 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import * as SDPUtils from 'sdp';
+
+/**
+ * Recursively sanitises `value` in the following way:
+ *
+ * - `null` and `undefined` will be returned as is,
+ * - an object implementing the `Confidential` interface will be sanitised,
+ * - booleans and numbers will only reveal the type,
+ * - strings will reveal the type and the length of the string,
+ * - the binary types `Uint8Array` and `Blob` will only return meta
+ *   information about the content, and
+ * - array values will be sanitised recursively as described in this list,
+ * - object values will be sanitised recursively as described in this list,
+ *   and
+ * - everything else will only reveal the value's type.
+ */
+export function censor(value: any): any {
+    // Handle `null` and `undefined` early
+    if (value === null || value === undefined) {
+        return value;
+    }
+
+    // Apply filter to confidential data
+    if (value instanceof BaseConfidential) {
+        return value.censored();
+    }
+
+    // Filter string
+    if (value.constructor === String) {
+        return `[String: length=${value.length}]`;
+    }
+
+    // Filter array
+    if (value instanceof Array) {
+        return value.map((item) => censor(item));
+    }
+
+    // Filter binary data
+    if (value instanceof ArrayBuffer) {
+        return `[ArrayBuffer: length=${value.byteLength}]`;
+    }
+    if (value instanceof Uint8Array) {
+        return `[Uint8Array: length=${value.byteLength}, offset=${value.byteOffset}]`;
+    }
+    if (value instanceof Blob) {
+        return `[Blob: length=${value.size}, type=${value.type}]`;
+    }
+
+    // Plain object
+    if (value.constructor === Object) {
+        const object = {};
+        for (const [k, v] of Object.entries(value)) {
+            // Store sanitised
+            object[k] = censor(v);
+        }
+        return object;
+    }
+
+    // Not listed
+    return `[${value.constructor.name}]`;
+}
+
+/**
+ * Abstract confidential class.
+ *
+ * Solely exists to be able to detect with the `instanceof` operator.
+ */
+export abstract class BaseConfidential<U, C> implements threema.Confidential<U, C> {
+    public abstract uncensored: U;
+    public abstract censored(): C;
+}
+
+/**
+ * Wraps an array of confidential instances.
+ *
+ * When sanitising, all items will be sanitised.
+ * When accessing uncensored, all items will be returned uncensored.
+ */
+export class ConfidentialArray<U, C, A extends threema.Confidential<U, C>>
+    extends BaseConfidential<U[], C[]> {
+    private readonly array: A[];
+
+    constructor(array: A[]) {
+        super();
+        this.array = array;
+    }
+
+    public get uncensored(): U[] {
+        return this.array.map((item) => item.uncensored);
+    }
+
+    public censored(): C[] {
+        return this.array.map((item) => item.censored());
+    }
+}
+
+/**
+ * Wraps an object.
+ *
+ * When sanitising, all key's values will be sanitised recursively by usage of
+ * the `censor` function.
+ */
+export class ConfidentialObjectValues extends BaseConfidential<object, object> {
+    public readonly uncensored: object;
+
+    constructor(object: object) {
+        super();
+        this.uncensored = object;
+    }
+
+    public censored(): object {
+        return censor(this.uncensored);
+    }
+}
+
+/**
+ * Wraps a wire message.
+ *
+ * When sanitising, this returns all data unchanged except for the `data` and
+ * `arg` keys whose value will be sanitised recursively by usage of the
+ * `censor` function.
+ */
+export class ConfidentialWireMessage extends BaseConfidential<threema.WireMessage, threema.WireMessage> {
+    public readonly uncensored: threema.WireMessage;
+
+    constructor(message: threema.WireMessage) {
+        super();
+        this.uncensored = message;
+    }
+
+    public censored(): threema.WireMessage {
+        const message = Object.assign({}, this.uncensored);
+
+        // Sanitise args and data (if existing)
+        if (message.args !== undefined) {
+            message.args = censor(message.args);
+        }
+        if (message.data !== undefined) {
+            message.data = censor(message.data);
+        }
+
+        return message;
+    }
+}
+
+/**
+ * Wraps an ICE candidate's SDP attribute.
+ *
+ * When sanitising, this returns all attributes unchanged apart from the
+ * candidate's IP address which will be partially sanitised.
+ */
+export class ConfidentialIceCandidate extends BaseConfidential<string, string> {
+    public readonly uncensored: string;
+
+    constructor(candidateSdp: string) {
+        super();
+        this.uncensored = candidateSdp;
+    }
+
+    public censored(): string {
+        try {
+            // Parse into candidate object
+            const candidate = SDPUtils.parseCandidate(this.uncensored);
+
+            // Sanitise IP and port
+            if (candidate.type !== 'relay') {
+                candidate.address = candidate.ip = ConfidentialIceCandidate.censorIp(candidate.address);
+            }
+            if (candidate.relatedAddress !== undefined) {
+                candidate.relatedAddress = ConfidentialIceCandidate.censorIp(candidate.relatedAddress);
+            }
+
+            // Return as SDP
+            return SDPUtils.writeCandidate(candidate);
+        } catch (error) {
+            return this.uncensored;
+        }
+    }
+
+    private static censorIp(ip: string): string {
+        // Handle UUID (mDNS)
+        if (ip.includes('-')) {
+            return ip;
+        }
+
+        // Handle IPv4 address
+        const ipv4 = ip.split('.');
+        if (ipv4.length > 1) {
+            return `${ipv4.slice(0, 2).join('.')}.*.*`;
+        }
+
+        // Handle IPv6 address (catch-all)
+        const ipv6 = ip.split(':');
+        const head = ipv6.shift();
+        return `${head}:${ipv6.map((item) => item.length > 0 ? '*' : item).join(':')}`;
+    }
+}

+ 302 - 0
src/helpers/logger.ts

@@ -0,0 +1,302 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {Logger} from 'ts-log';
+import {BaseConfidential} from './confidential';
+import LogType = threema.LogType;
+import LogLevel = threema.LogLevel;
+import LogRecord = threema.LogRecord;
+
+type LogFunction = (message?: any, ...args: any[]) => void;
+
+// Supported log (level) types
+const LOG_TYPES: LogType[] = ['debug', 'trace', 'info', 'warn', 'error'];
+// Types allowed for serialisation
+const ALLOWED_TYPES: any[] = [Boolean, Number, String, Array];
+
+/**
+ * Forwards log records to one or more loggers.
+ */
+export class TeeLogger implements Logger {
+    private readonly loggers: Logger[];
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(loggers: Logger[]) {
+        this.loggers = loggers;
+
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.forward.bind(this, type);
+        }
+    }
+
+    /**
+     * Forward a log record to each underlying logger.
+     * @param type The log record level type.
+     * @param message The log message.
+     * @param args Further arguments of the log record.
+     */
+    private forward(type: LogType, message?: any, ...args: any[]): void {
+        for (const log of this.loggers) {
+            log[type](message, ...args);
+        }
+    }
+}
+
+/**
+ * Filters log messages depending on the applied log level.
+ *
+ * Wraps a normal logger and forwards all log records that have not been
+ * filtered by the log level.
+ */
+export class LevelLogger implements Logger {
+    public readonly logger: Logger;
+    public readonly level: LogLevel;
+    public readonly debug: LogFunction = this.noop;
+    public readonly trace: LogFunction = this.noop;
+    public readonly info: LogFunction = this.noop;
+    public readonly warn: LogFunction = this.noop;
+    public readonly error: LogFunction = this.noop;
+
+    constructor(logger: Logger, level: LogLevel) {
+        this.logger = logger;
+        this.level = level;
+
+        // Bind corresponding method to log level type, if enabled
+        // noinspection FallThroughInSwitchStatementJS
+        switch (level) {
+            case 'debug':
+                this.debug = this.logger.debug.bind(this.logger);
+                this.trace = this.logger.trace.bind(this.logger);
+            case 'info':
+                this.info = this.logger.info.bind(this.logger);
+            case 'warn':
+                this.warn = this.logger.warn.bind(this.warn);
+            case 'error':
+                this.error = this.logger.error.bind(this.error);
+            default:
+                break;
+        }
+    }
+
+    private noop(): void {
+        // noop
+    }
+}
+
+/**
+ * Adds a prefix before forwarding log records to another logger.
+ */
+export class TagLogger implements Logger {
+    public readonly logger: Logger;
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(logger: Logger, ...tag: string[]) {
+        this.logger = logger;
+
+        // Apply a tag to each log level type method of the logger
+        for (const type of LOG_TYPES) {
+            this[type] = logger[type].bind(logger, ...tag);
+        }
+    }
+}
+
+/**
+ * Forwards all log records to another logger while unveiling confidential
+ * log records.
+ */
+export class UnveilLogger implements Logger {
+    public readonly logger: Logger;
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(logger: Logger) {
+        this.logger = logger;
+
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.unveil.bind(this, type);
+        }
+    }
+
+    private unveil(type: LogType, ...args: any[]): void {
+        args = args.map((item) => item instanceof BaseConfidential ? item.uncensored : item);
+        this.logger[type](...args);
+    }
+}
+
+/**
+ * Forwards all log records to the default `Console` logger.
+ */
+export class ConsoleLogger implements Logger {
+    // tslint:disable:no-console
+    public readonly debug: LogFunction = console.debug;
+    public readonly trace: LogFunction = console.trace;
+    public readonly info: LogFunction = console.info;
+    public readonly warn: LogFunction = console.warn;
+    public readonly error: LogFunction = console.error;
+    // tslint:enable:no-console
+}
+
+/**
+ * Stores log records in memory.
+ *
+ * A limit can be provided which results in a circular memory buffer, where old
+ * log records are being continuously dropped in case the limit would be
+ * exceeded by a new log record.
+ *
+ * Since serialisation can be expensive, this holds references to objects
+ * until explicit serialisation is being requested.
+ *
+ * Note: This logger will serialise confidential log arguments censored.
+ */
+export class MemoryLogger implements Logger {
+    private readonly records: LogRecord[] = [];
+    public readonly limit: number = 0;
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(limit: number = 0) {
+        this.limit = limit;
+
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.append.bind(this, type);
+        }
+    }
+
+    /**
+     * Append a log record to the memory buffer.
+     *
+     * Drops the oldest log record if the log record limit would be exceeded.
+     *
+     * @param type The log record level type.
+     * @param message The log message.
+     * @param args Further arguments of the log record
+     */
+    private append(type: LogType, message?: any, ...args: any[]): void {
+        // Remove oldest record if needed
+        if (this.limit > 0 && this.records.length >= this.limit) {
+            this.records.shift();
+        }
+
+        // Add newest record
+        this.records.push([new Date(), type, message, ...args]);
+    }
+
+    /**
+     * Serialise all log records to JSON.
+     *
+     * While serialising, a recursive filter will be applied:
+     *
+     * - the types `null`, `string`, `number` and `boolean` will be returned
+     *   unmodified,
+     * - an object implementing the `Confidential` interface will be returned
+     *   sanitised,
+     * - an `Error` instance will be left as is,
+     * - the binary types `Uint8Array` and `Blob` will only return meta
+     *   information about the content, and
+     * - everything else will return the value's type instead of the value
+     *   itself.
+     *
+     * @param space Amount of white spaces used for nested block indentation.
+     */
+    public serialize(space: number = 2): string {
+        const records = this.records.map(([date, type, message, ...args]: LogRecord) => {
+            // Strip message formatting
+            if (message !== null && message !== undefined && message.constructor === String) {
+                let stripped = false;
+
+                // Strip first style formatting placeholder if any
+                message = message.replace(/%c/, () => {
+                    stripped = true;
+                    return '';
+                });
+
+                // Trim
+                message = message.trim();
+
+                // Remove next argument if stripped
+                if (stripped) {
+                    args.shift();
+                }
+            }
+
+            // Convert date to a timestamp with millisecond accuracy
+            const timestampMs = date.getTime();
+            return [timestampMs, type, message, ...args];
+        });
+
+        // Serialise to JSON
+        return JSON.stringify(records, (_, value) => {
+            // Handle `null` and `undefined` early
+            if (value === null || value === undefined) {
+                return value;
+            }
+
+            // Apply filter to confidential data
+            if (value instanceof BaseConfidential) {
+                return value.censored();
+            }
+
+            // Allowed (standard) types
+            for (const allowedType of ALLOWED_TYPES) {
+                if (value.constructor === allowedType) {
+                    return value;
+                }
+            }
+
+            // Allow exceptions
+            if (value instanceof Error) {
+                return value.toString();
+            }
+
+            // Filter binary data
+            if (value instanceof ArrayBuffer) {
+                return `[ArrayBuffer: length=${value.byteLength}]`;
+            }
+            if (value instanceof Uint8Array) {
+                return `[Uint8Array: length=${value.byteLength}, offset=${value.byteOffset}]`;
+            }
+            if (value instanceof Blob) {
+                return `[Blob: length=${value.size}, type=${value.type}]`;
+            }
+
+            // Plain object
+            if (value.constructor === Object) {
+                return value;
+            }
+
+            // Not listed
+            return `[${value.constructor.name}]`;
+        }, space);
+    }
+}

+ 69 - 74
src/partials/messenger.ts

@@ -22,15 +22,17 @@ import {
     Transition as UiTransition,
     TransitionService as UiTransitionService,
 } from '@uirouter/angularjs';
+import {Logger} from 'ts-log';
 
 import {ContactControllerModel} from '../controller_model/contact';
-import {bufferToUrl, hasValue, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
+import {bufferToUrl, hasValue, supportsPassive, throttle, u8aToHex} from '../helpers';
 import {emojify} from '../helpers/emoji';
 import {ContactService} from '../services/contact';
 import {ControllerService} from '../services/controller';
 import {ControllerModelService} from '../services/controller_model';
 import {FingerPrintService} from '../services/fingerprint';
 import {TrustedKeyStoreService} from '../services/keystore';
+import {LogService} from '../services/log';
 import {MimeService} from '../services/mime';
 import {NotificationService} from '../services/notification';
 import {ReceiverService} from '../services/receiver';
@@ -86,8 +88,7 @@ class DialogController {
  * Handle sending of files.
  */
 class SendFileController extends DialogController {
-    public static $inject = ['$mdDialog', '$log', 'CONFIG', 'preview'];
-    private logTag: string = '[SendFileController]';
+    public static $inject = ['$mdDialog', 'CONFIG', 'LogService', 'preview'];
 
     public caption: string;
     public sendAsFile: boolean = false;
@@ -95,17 +96,14 @@ class SendFileController extends DialogController {
     public previewDataUrl: string | null = null;
 
     constructor($mdDialog: ng.material.IDialogService,
-                $log: ng.ILogService,
                 CONFIG: threema.Config,
+                logService: LogService,
                 preview: threema.FileMessageData) {
         super($mdDialog, CONFIG);
+        const log = logService.getLogger('SendFile-C');
         this.preview = preview;
         if (preview !== null) {
-            this.previewDataUrl = bufferToUrl(
-                this.preview.data,
-                this.preview.fileType,
-                logAdapter($log.warn, this.logTag),
-            );
+            this.previewDataUrl = bufferToUrl(this.preview.data, this.preview.fileType, log);
         }
     }
 
@@ -274,12 +272,10 @@ interface ConversationStateParams extends UiStateParams {
 
 class ConversationController {
     public name = 'navigation';
-    private logTag: string = '[ConversationController]';
 
     // Angular services
     private $stateParams;
     private $state: UiStateService;
-    private $log: ng.ILogService;
     private $scope: ng.IScope;
     private $rootScope: ng.IRootScopeService;
     private $filter: ng.IFilterService;
@@ -296,6 +292,9 @@ class ConversationController {
     private $mdDialog: ng.material.IDialogService;
     private $mdToast: ng.material.IToastService;
 
+    // Logging
+    private readonly log: Logger;
+
     // Controller model
     private controllerModel: threema.ControllerModel<threema.Receiver>;
 
@@ -343,14 +342,13 @@ class ConversationController {
     };
 
     public static $inject = [
-        '$stateParams', '$log', '$scope', '$rootScope',
+        '$stateParams', '$scope', '$rootScope',
         '$mdDialog', '$mdToast', '$translate', '$filter',
         '$state', '$transitions',
-        'WebClientService', 'StateService', 'ReceiverService', 'MimeService', 'VersionService',
-        'ControllerModelService', 'TimeoutService',
+        'LogService', 'WebClientService', 'StateService', 'ReceiverService', 'MimeService',
+        'VersionService', 'ControllerModelService', 'TimeoutService',
     ];
     constructor($stateParams: ConversationStateParams,
-                $log: ng.ILogService,
                 $scope: ng.IScope,
                 $rootScope: ng.IRootScopeService,
                 $mdDialog: ng.material.IDialogService,
@@ -359,6 +357,7 @@ class ConversationController {
                 $filter: ng.IFilterService,
                 $state: UiStateService,
                 $transitions: UiTransitionService,
+                logService: LogService,
                 webClientService: WebClientService,
                 stateService: StateService,
                 receiverService: ReceiverService,
@@ -367,13 +366,14 @@ class ConversationController {
                 controllerModelService: ControllerModelService,
                 timeoutService: TimeoutService) {
         this.$stateParams = $stateParams;
-        this.$log = $log;
         this.webClientService = webClientService;
         this.receiverService = receiverService;
         this.stateService = stateService;
         this.mimeService = mimeService;
         this.timeoutService = timeoutService;
 
+        this.log = logService.getLogger('Conversation-C');
+
         this.$state = $state;
         this.$scope = $scope;
         this.$filter = $filter;
@@ -400,7 +400,7 @@ class ConversationController {
 
         // Redirect to welcome if necessary
         if (stateService.state === 'error') {
-            $log.debug('ConversationController: WebClient not yet running, redirecting to welcome screen');
+            this.log.debug('WebClient not yet running, redirecting to welcome screen');
             $state.go('welcome');
             return;
         }
@@ -447,7 +447,7 @@ class ConversationController {
                         this.receiver as threema.DistributionListReceiver, mode);
                     break;
                 default:
-                    $log.error(this.logTag, 'Cannot initialize controller model:',
+                    this.log.error('Cannot initialize controller model:',
                         'Invalid receiver type "' + this.receiver.type + '"');
                     $state.go('messenger.home');
                     return;
@@ -455,7 +455,7 @@ class ConversationController {
 
             // Check if this receiver may be chatted with
             if (this.controllerModel.canChat() === false) {
-                $log.warn(this.logTag, 'Cannot chat with this receiver, redirecting to home');
+                this.log.warn('Cannot chat with this receiver, redirecting to home');
                 $state.go('messenger.home');
                 return;
             }
@@ -536,8 +536,7 @@ class ConversationController {
                 }
             }
         } catch (error) {
-            $log.error('Could not set receiver and type');
-            $log.debug(error.stack);
+            this.log.error('Could not set receiver and type');
             $state.go('messenger.home');
         }
 
@@ -547,7 +546,7 @@ class ConversationController {
         }, () => {
             if (this.locked !== this.receiver.locked) {
                 $state.reload().catch((error) => {
-                    this.$log.error('Unable to reload state:', error);
+                    this.log.error('Unable to reload state:', error);
                 });
             }
         });
@@ -707,7 +706,7 @@ class ConversationController {
                                     nextCallback(index);
                                 })
                                 .catch((error) => {
-                                    this.$log.error(error);
+                                    this.log.error(error);
                                     // TODO: Should probably be an alert instead of a toast
                                     this.showError(error);
                                     success = false;
@@ -731,7 +730,7 @@ class ConversationController {
                                 nextCallback(index);
                             })
                             .catch((error) => {
-                                this.$log.error(error);
+                                this.log.error(error);
                                 // TODO: Should probably be an alert instead of a toast
                                 this.showError(error);
                                 success = false;
@@ -740,7 +739,7 @@ class ConversationController {
                     });
                     return;
                 default:
-                    this.$log.warn('Invalid message type:', type);
+                    this.log.warn('Invalid message type:', type);
                     reject();
             }
         });
@@ -934,15 +933,15 @@ class ConversationController {
      */
     public pinConversation(): void {
         if (!hasValue(this.conversation)) {
-            this.$log.warn(this.logTag, 'Cannot pin, no conversation exists');
+            this.log.warn('Cannot pin, no conversation exists');
             return;
         }
         this.webClientService
             .modifyConversation(this.conversation, true)
             .then(() => this.showMessage('messenger.PINNED_CONVERSATION_OK'))
-            .catch((msg) => {
+            .catch((e) => {
                 this.showMessage('messenger.PINNED_CONVERSATION_ERROR');
-                this.$log.error(this.logTag, 'Pinning conversation failed: ' + msg);
+                this.log.error('Pinning conversation failed: ' + e);
             });
     }
 
@@ -951,15 +950,15 @@ class ConversationController {
      */
     public unpinConversation(): void {
         if (!hasValue(this.conversation)) {
-            this.$log.warn(this.logTag, 'Cannot unpin, no conversation exists');
+            this.log.warn('Cannot unpin, no conversation exists');
             return;
         }
         this.webClientService
             .modifyConversation(this.conversation, false)
             .then(() => this.showMessage('messenger.UNPINNED_CONVERSATION_OK'))
-            .catch((msg) => {
+            .catch((e) => {
                 this.showMessage('messenger.UNPINNED_CONVERSATION_ERROR');
-                this.$log.error(this.logTag, 'Unpinning conversation failed: ' + msg);
+                this.log.error('Unpinning conversation failed: ' + e);
             });
     }
 }
@@ -983,19 +982,20 @@ class NavigationController {
     private $state: UiStateService;
 
     public static $inject = [
-        '$log', '$state', '$mdDialog', '$translate',
-        'WebClientService', 'StateService', 'ReceiverService', 'NotificationService', 'TrustedKeyStore',
+        '$state', '$mdDialog', '$translate',
+        'LogService', 'WebClientService', 'StateService', 'ReceiverService', 'NotificationService', 'TrustedKeyStore',
     ];
 
-    constructor($log: ng.ILogService, $state: UiStateService,
-                $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
-                webClientService: WebClientService, stateService: StateService,
+    constructor($state: UiStateService, $mdDialog: ng.material.IDialogService,
+                $translate: ng.translate.ITranslateService,
+                logService: LogService, webClientService: WebClientService, stateService: StateService,
                 receiverService: ReceiverService, notificationService: NotificationService,
                 trustedKeyStoreService: TrustedKeyStoreService) {
+        const log = logService.getLogger('Navigation-C');
 
         // Redirect to welcome if necessary
         if (stateService.state === 'error') {
-            $log.debug('NavigationController: WebClient not yet running, redirecting to welcome screen');
+            log.debug('WebClient not yet running, redirecting to welcome screen');
             $state.go('welcome');
             return;
         }
@@ -1236,24 +1236,23 @@ class NavigationController {
 }
 
 class MessengerController {
-    private logTag: string = '[MessengerController]';
-
     public name = 'messenger';
     private receiverService: ReceiverService;
     private $state;
     private webClientService: WebClientService;
 
     public static $inject = [
-        '$scope', '$state', '$log', '$mdDialog', '$translate',
-        'StateService', 'ReceiverService', 'WebClientService', 'ControllerService',
+        '$scope', '$state', '$mdDialog', '$translate',
+        'LogService', 'StateService', 'ReceiverService', 'WebClientService', 'ControllerService',
     ];
-    constructor($scope, $state, $log: ng.ILogService, $mdDialog: ng.material.IDialogService,
-                $translate: ng.translate.ITranslateService,
-                stateService: StateService, receiverService: ReceiverService,
+    constructor($scope, $state, $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
+                logService: LogService, stateService: StateService, receiverService: ReceiverService,
                 webClientService: WebClientService, controllerService: ControllerService) {
+        const log = logService.getLogger('Messenger-C');
+
         // Redirect to welcome if necessary
         if (stateService.state === 'error') {
-            $log.debug(this.logTag, 'MessengerController: WebClient not yet running, redirecting to welcome screen');
+            log.debug('WebClient not yet running, redirecting to welcome screen');
             $state.go('welcome');
             return;
         }
@@ -1280,7 +1279,6 @@ class MessengerController {
             }
         }, true);
 
-        const logTag = this.logTag;
         this.webClientService.setReceiverListener({
             onConversationRemoved(receiver: threema.Receiver) {
                 switch ($state.current.name) {
@@ -1298,7 +1296,7 @@ class MessengerController {
                         }
                         break;
                     default:
-                        $log.debug(logTag, 'Ignored onRemoved event for state', $state.current.name);
+                        log.debug('Ignored onRemoved event for state', $state.current.name);
                 }
             },
         });
@@ -1310,8 +1308,6 @@ class MessengerController {
 }
 
 class ReceiverDetailController {
-    private logTag: string = '[ReceiverDetailController]';
-
     // Angular services
     private $mdDialog: any;
     private $scope: ng.IScope;
@@ -1338,14 +1334,13 @@ class ReceiverDetailController {
     private controllerModel: threema.ControllerModel<threema.Receiver>;
 
     public static $inject = [
-        '$scope', '$log', '$stateParams', '$state', '$mdDialog', '$translate',
-        'WebClientService', 'FingerPrintService', 'ContactService', 'ControllerModelService',
+        '$scope', '$stateParams', '$state', '$mdDialog', '$translate',
+        'LogService', 'WebClientService', 'FingerPrintService', 'ContactService', 'ControllerModelService',
     ];
-    constructor($scope: ng.IScope, $log: ng.ILogService, $stateParams, $state: UiStateService,
+    constructor($scope: ng.IScope, $stateParams, $state: UiStateService,
                 $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
-                webClientService: WebClientService, fingerPrintService: FingerPrintService,
+                logService: LogService, webClientService: WebClientService, fingerPrintService: FingerPrintService,
                 contactService: ContactService, controllerModelService: ControllerModelService) {
-
         this.$mdDialog = $mdDialog;
         this.$scope = $scope;
         this.$state = $state;
@@ -1356,6 +1351,8 @@ class ReceiverDetailController {
         this.receiver = webClientService.receivers.getData($stateParams);
         this.me = webClientService.me;
 
+        const log = logService.getLogger('ReceiverDetail-C');
+
         // Append group membership
         if (isContactReceiver(this.receiver)) {
             const contactReceiver = this.receiver;
@@ -1369,7 +1366,7 @@ class ReceiverDetailController {
                 })
                 .catch((error) => {
                     // TODO: Redirect or show an alert?
-                    $log.error(this.logTag, `Contact detail request has been rejected: ${error}`);
+                    log.error(`Contact detail request has been rejected: ${error}`);
                 });
 
             this.isWorkReceiver = contactReceiver.identityType === threema.IdentityType.Work;
@@ -1424,7 +1421,7 @@ class ReceiverDetailController {
                     .distributionList(this.receiver as threema.DistributionListReceiver, ControllerModelMode.VIEW);
                 break;
             default:
-                $log.error(this.logTag, 'Cannot initialize controller model:',
+                log.error('Cannot initialize controller model:',
                     'Invalid receiver type "' + this.receiver.type + '"');
                 $state.go('messenger.home');
                 return;
@@ -1432,7 +1429,7 @@ class ReceiverDetailController {
 
         // If this receiver was removed, navigate to "home" view
         this.controllerModel.setOnRemoved((receiverId: string) => {
-            $log.warn(this.logTag, 'Receiver removed, redirecting to home');
+            log.warn('Receiver removed, redirecting to home');
             this.$state.go('messenger.home');
         });
 
@@ -1508,8 +1505,6 @@ class ReceiverDetailController {
  * fields, validate and save routines are implemented in the specific ControllerModel
  */
 class ReceiverEditController {
-    private logTag: string = '[ReceiverEditController]';
-
     public $mdDialog: any;
     private $scope: ng.IScope;
     public $state: UiStateService;
@@ -1523,19 +1518,21 @@ class ReceiverEditController {
     public type: string;
 
     public static $inject = [
-        '$log', '$scope', '$stateParams', '$state', '$mdDialog',
-        '$timeout', '$translate', 'WebClientService', 'ControllerModelService',
+        '$scope', '$stateParams', '$state', '$mdDialog',
+        '$timeout', '$translate', 'LogService', 'WebClientService', 'ControllerModelService',
     ];
-    constructor($log: ng.ILogService, $scope: ng.IScope, $stateParams, $state: UiStateService,
+    constructor($scope: ng.IScope, $stateParams, $state: UiStateService,
                 $mdDialog, $timeout: ng.ITimeoutService, $translate: ng.translate.ITranslateService,
-                webClientService: WebClientService, controllerModelService: ControllerModelService) {
-
+                logService: LogService, webClientService: WebClientService,
+                controllerModelService: ControllerModelService) {
         this.$scope = $scope;
         this.$mdDialog = $mdDialog;
         this.$state = $state;
         this.$timeout = $timeout;
         this.$translate = $translate;
 
+        const log = logService.getLogger('ReceiverEdit-C');
+
         const receiver = webClientService.receivers.getData($stateParams);
         switch (receiver.type) {
             case 'me':
@@ -1563,7 +1560,7 @@ class ReceiverEditController {
                 );
                 break;
             default:
-                $log.error(this.logTag, 'Cannot initialize controller model:',
+                log.error('Cannot initialize controller model:',
                     'Invalid receiver type "' + receiver.type + '"');
                 $state.go('messenger.home');
                 return;
@@ -1624,12 +1621,9 @@ interface CreateReceiverStateParams extends UiStateParams {
  * fields, validate and save routines are implemented in the specific ControllerModel
  */
 class ReceiverCreateController {
-    private logTag: string = '[ReceiverEditController]';
-
     public $mdDialog: any;
     private $scope: ng.IScope;
     private $timeout: ng.ITimeoutService;
-    private $log: ng.ILogService;
     private $state: UiStateService;
     private $mdToast: any;
     public identity = '';
@@ -1640,22 +1634,23 @@ class ReceiverCreateController {
     public controllerModel: threema.ControllerModel<threema.Receiver>;
 
     public static $inject = ['$stateParams', '$mdDialog', '$scope', '$mdToast', '$translate',
-        '$timeout', '$state', '$log', 'ControllerModelService'];
+        '$timeout', '$state', 'LogService', 'ControllerModelService'];
     constructor($stateParams: CreateReceiverStateParams, $mdDialog, $scope: ng.IScope, $mdToast, $translate,
-                $timeout: ng.ITimeoutService, $state: UiStateService, $log: ng.ILogService,
-                controllerModelService: ControllerModelService) {
+                $timeout: ng.ITimeoutService, $state: UiStateService,
+                logService: LogService, controllerModelService: ControllerModelService) {
         this.$mdDialog = $mdDialog;
         this.$scope = $scope;
         this.$timeout = $timeout;
         this.$state = $state;
-        this.$log = $log;
         this.$mdToast = $mdToast;
         this.$translate = $translate;
 
+        const log = logService.getLogger('ReceiverEdit-C');
+
         this.type = $stateParams.type;
         switch (this.type) {
             case 'me':
-                $log.warn(this.logTag, 'Cannot create own contact');
+                log.warn('Cannot create own contact');
                 $state.go('messenger.home');
                 return;
             case 'contact':
@@ -1672,7 +1667,7 @@ class ReceiverCreateController {
                 this.controllerModel = controllerModelService.distributionList(null, ControllerModelMode.NEW);
                 break;
             default:
-                this.$log.error('invalid type', this.type);
+                log.error('Invalid type', this.type);
         }
     }
 

+ 31 - 29
src/partials/welcome.ts

@@ -19,6 +19,8 @@
 
 /// <reference path="../types/broadcastchannel.d.ts" />
 
+import {Logger} from 'ts-log';
+
 import {
     StateProvider as UiStateProvider,
     StateService as UiStateService,
@@ -28,6 +30,7 @@ import {BrowserInfo} from '../helpers/browser_info';
 import {BrowserService} from '../services/browser';
 import {ControllerService} from '../services/controller';
 import {TrustedKeyStoreService} from '../services/keystore';
+import {LogService} from '../services/log';
 import {PushService} from '../services/push';
 import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
@@ -55,14 +58,10 @@ class DialogController {
 }
 
 class WelcomeController {
-
     private static REDIRECT_DELAY = 500;
 
-    private logTag: string = '[WelcomeController]';
-
     // Angular services
     private $scope: ng.IScope;
-    private $log: ng.ILogService;
     private $window: ng.IWindowService;
     private $state: UiStateService;
 
@@ -79,6 +78,9 @@ class WelcomeController {
     private timeoutService: TimeoutService;
     private config: threema.Config;
 
+    // Logging
+    private readonly log: Logger;
+
     // Other
     public name = 'welcome';
     private mode: 'scan' | 'unlock';
@@ -90,14 +92,15 @@ class WelcomeController {
     private browserWarningShown: boolean = false;
 
     public static $inject = [
-        '$scope', '$state', '$log', '$window', '$mdDialog', '$translate',
-        'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
+        '$scope', '$state', '$window', '$mdDialog', '$translate',
+        'LogService', 'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
         'VersionService', 'SettingsService', 'TimeoutService', 'ControllerService',
         'BROWSER_MIN_VERSIONS', 'CONFIG',
     ];
     constructor($scope: ng.IScope, $state: UiStateService,
-                $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
+                $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
+                logService: LogService,
                 webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
                 stateService: StateService, pushService: PushService,
                 browserService: BrowserService,
@@ -111,7 +114,6 @@ class WelcomeController {
         // Angular services
         this.$scope = $scope;
         this.$state = $state;
-        this.$log = $log;
         this.$window = $window;
         this.$mdDialog = $mdDialog;
         this.$translate = $translate;
@@ -125,37 +127,40 @@ class WelcomeController {
         this.timeoutService = timeoutService;
         this.config = config;
 
+        // Logging
+        this.log = logService.getLogger('Welcome-C');
+
         // TODO: Allow to trigger below behaviour by using state parameters
 
         // Determine whether browser warning should be shown
         this.browser = browserService.getBrowser();
         const version = this.browser.version;
-        $log.debug('Detected browser:', this.browser.description());
+        this.log.debug('Detected browser:', this.browser.description());
         if (!this.browser.wasDetermined()) {
-            $log.warn('Could not determine browser version');
+            this.log.warn('Could not determine browser version');
             this.showBrowserWarning();
         } else if (this.browser.name === threema.BrowserName.Chrome) {
             if (version < minVersions.CHROME) {
-                $log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')');
+                this.log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')');
                 this.showBrowserWarning();
             }
         } else if (this.browser.name === threema.BrowserName.Firefox) {
             if (version < minVersions.FF) {
-                $log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')');
+                this.log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')');
                 this.showBrowserWarning();
             }
         } else if (this.browser.name === threema.BrowserName.Opera) {
             if (version < minVersions.OPERA) {
-                $log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')');
+                this.log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')');
                 this.showBrowserWarning();
             }
         } else if (this.browser.name === threema.BrowserName.Safari) {
             if (version < minVersions.SAFARI) {
-                $log.warn('Safari is too old (' + version + ' < ' + minVersions.SAFARI + ')');
+                this.log.warn('Safari is too old (' + version + ' < ' + minVersions.SAFARI + ')');
                 this.showBrowserWarning();
             }
         } else {
-            $log.warn('Non-supported browser, please use Chrome, Firefox or Opera');
+            this.log.warn('Non-supported browser, please use Chrome, Firefox or Opera');
             this.showBrowserWarning();
         }
 
@@ -165,7 +170,7 @@ class WelcomeController {
 
         // Determine whether local storage is available
         if (this.trustedKeyStore.blocked === true) {
-            $log.error('Cannot access local storage. Is it being blocked by a browser add-on?');
+            this.log.error('Cannot access local storage. Is it being blocked by a browser add-on?');
             this.showLocalStorageWarning();
         }
 
@@ -190,7 +195,7 @@ class WelcomeController {
         try {
             hasTrustedKey = this.trustedKeyStore.hasTrustedKey();
         } catch (e) {
-            $log.error('Exception while accessing local storage:', e);
+            this.log.error('Exception while accessing local storage:', e);
             this.showLocalStorageException(e);
         }
 
@@ -257,7 +262,7 @@ class WelcomeController {
      * Initiate a new session by scanning a new QR code.
      */
     private scan(stopArguments?: threema.WebClientServiceStopArguments): void {
-        this.$log.info(this.logTag, 'Initialize session by scanning QR code...');
+        this.log.info('Initialize session by scanning QR code...');
 
         // Initialize webclient with new keystore
         this.webClientService.stop(stopArguments !== undefined ? stopArguments : {
@@ -288,7 +293,7 @@ class WelcomeController {
      */
     private unlock(): void {
         this.stateService.reset('new');
-        this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...');
+        this.log.info('Initialize session by unlocking trusted key...');
     }
 
     /**
@@ -324,7 +329,7 @@ class WelcomeController {
     private setupBroadcastChannel(publicKeyHex: string) {
         if (!('BroadcastChannel' in this.$window)) {
             // No BroadcastChannel support in this browser
-            this.$log.warn(this.logTag, 'BroadcastChannel not supported in this browser');
+            this.log.warn('BroadcastChannel not supported in this browser');
             return;
         }
 
@@ -347,10 +352,7 @@ class WelcomeController {
                             && (this.stateService.connectionBuildupState === 'loading'
                              || this.stateService.connectionBuildupState === 'done')) {
                         // Yes it is, notify them that the session is already active
-                        this.$log.debug(
-                            this.logTag,
-                            'Another tab is trying to connect to our session. Respond with a broadcast.',
-                        );
+                        this.log.debug('Another tab is trying to connect to our session. Respond with a broadcast.');
                         channel.postMessage(JSON.stringify({
                             type: TYPE_ALREADY_OPEN,
                             key: publicKeyHex,
@@ -361,7 +363,7 @@ class WelcomeController {
                     // Another tab notified us that the session we're trying to connect to
                     // is already active.
                     if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') {
-                        this.$log.error(this.logTag, 'Session already connected in another tab or window');
+                        this.log.error('Session already connected in another tab or window');
                         this.timeoutService.register(() => {
                             this.stateService.updateConnectionBuildupState('already_connected');
                             this.stateService.state = GlobalConnectionState.Error;
@@ -369,13 +371,13 @@ class WelcomeController {
                     }
                     break;
                 default:
-                    this.$log.warn(this.logTag, 'Unknown broadcast message type:', message.type);
+                    this.log.warn('Unknown broadcast message type:', message.type);
                     break;
             }
         };
 
         // Notify other tabs that we're trying to connect
-        this.$log.debug(this.logTag, 'Checking if the session is already open in another tab or window');
+        this.log.debug('Checking if the session is already open in another tab or window');
         channel.postMessage(JSON.stringify({
             type: TYPE_PUBLIC_KEY,
             key: publicKeyHex,
@@ -543,7 +545,7 @@ class WelcomeController {
         } else if (len <= 586) {
             version = 16;
         } else {
-            this.$log.error(this.logTag, 'QR Code payload too large: Is your SaltyRTC host string huge?');
+            this.log.error('QR Code payload too large: Is your SaltyRTC host string huge?');
             version = 40;
         }
         return {
@@ -581,7 +583,7 @@ class WelcomeController {
 
             // If an error occurs...
             (error) => {
-                this.$log.error(this.logTag, 'Error state:', error);
+                this.log.error('Error state:', error);
                 // Note: On rejection, the web client service will already
                 //       redirect to 'welcome' and show a protocol error.
             },

+ 2 - 0
src/services.ts

@@ -22,6 +22,7 @@ import {ControllerService} from './services/controller';
 import {ControllerModelService} from './services/controller_model';
 import {FingerPrintService} from './services/fingerprint';
 import {TrustedKeyStoreService} from './services/keystore';
+import {LogService} from './services/log';
 import {MediaboxService} from './services/mediabox';
 import {MessageService} from './services/message';
 import {MimeService} from './services/mime';
@@ -42,6 +43,7 @@ import {WebClientService} from './services/webclient';
 angular.module('3ema.services', [])
 
 // Register services
+.service('LogService', LogService)
 .service('BatteryStatusService', BatteryStatusService)
 .service('ContactService', ContactService)
 .service('ControllerModelService', ControllerModelService)

+ 12 - 2
src/services/battery.ts

@@ -15,6 +15,9 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
+import {LogService} from './log';
 import {NotificationService} from './notification';
 
 export class BatteryStatusService {
@@ -31,17 +34,24 @@ export class BatteryStatusService {
     private $translate: ng.translate.ITranslateService;
     private notificationService: NotificationService;
 
-    public static $inject = ['$translate', 'NotificationService'];
+    // Logging
+    private readonly log: Logger;
+
+    public static $inject = ['$translate', 'LogService', 'NotificationService'];
 
-    constructor($translate: ng.translate.ITranslateService, notificationService: NotificationService) {
+    constructor($translate: ng.translate.ITranslateService,
+                logService: LogService, notificationService: NotificationService) {
         this.$translate = $translate;
         this.notificationService = notificationService;
+        this.log = logService.getLogger('BatteryStatus-S');
     }
 
     /**
      * Update the battery status.
      */
     public setStatus(batteryStatus: threema.BatteryStatus): void {
+        this.log.debug('Status:', batteryStatus);
+
         // Handle null percent value. This can happen if the battery status could not be determined.
         if (batteryStatus.percent === null) {
             this.clearStatus();

+ 8 - 10
src/services/browser.ts

@@ -15,23 +15,23 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
 import {BrowserInfo} from '../helpers/browser_info';
+import {LogService} from './log';
 
 import BrowserName = threema.BrowserName;
 
 export class BrowserService {
-    private logTag: string = '[BrowserService]';
-
     private browser: BrowserInfo;
-    private $log: ng.ILogService;
     private $window: ng.IWindowService;
+    private readonly log: Logger;
     private supportsExtendedLocaleCompareCache: boolean;
 
-    public static $inject = ['$log', '$window'];
-    constructor($log: ng.ILogService, $window: ng.IWindowService) {
-        // Angular services
-        this.$log = $log;
+    public static $inject = ['$window', 'LogService'];
+    constructor($window: ng.IWindowService, logService: LogService) {
         this.$window = $window;
+        this.log = logService.getLogger('Browser-S');
     }
 
     public getBrowser(): BrowserInfo {
@@ -158,9 +158,7 @@ export class BrowserService {
 
         const support = getSupport();
         this.supportsExtendedLocaleCompareCache = support;
-        this.$log.debug(this.logTag, 'Browser',
-            support ? 'supports' : 'does not support',
-            'extended locale compare options');
+        this.log.debug(`Browser ${support ? 'supports' : 'does not support'} extended locale compare options`);
         return support;
     }
 }

+ 0 - 7
src/services/controller.ts

@@ -20,13 +20,6 @@
  */
 export class ControllerService {
     private currentController: string;
-    private $log: ng.ILogService;
-    public static $inject = ['$log'];
-
-    constructor($log: ng.ILogService) {
-        // Angular services
-        this.$log = $log;
-    }
 
     public setControllerName(name: string): void {
         this.currentController = name;

+ 17 - 12
src/services/controller_model.ts

@@ -15,10 +15,13 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
 import {ContactControllerModel} from '../controller_model/contact';
 import {DistributionListControllerModel} from '../controller_model/distributionList';
 import {GroupControllerModel} from '../controller_model/group';
 import {MeControllerModel} from '../controller_model/me';
+import {LogService} from './log';
 import {WebClientService} from './webclient';
 
 // Type aliases
@@ -28,18 +31,20 @@ import ControllerModelMode = threema.ControllerModelMode;
  * Factory to create ControllerModels
  */
 export class ControllerModelService {
-    private $log: ng.ILogService;
-    private $translate: ng.translate.ITranslateService;
-    private $mdDialog: ng.material.IDialogService;
-    private webClientService: WebClientService;
+    private readonly $translate: ng.translate.ITranslateService;
+    private readonly $mdDialog: ng.material.IDialogService;
+    private readonly logService: LogService;
+    private readonly webClientService: WebClientService;
+    private readonly log: Logger;
 
-    public static $inject = ['$log', '$translate', '$mdDialog', 'WebClientService'];
-    constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService,
-                $mdDialog: ng.material.IDialogService, webClientService: WebClientService) {
-        this.$log = $log;
+    public static $inject = ['$translate', '$mdDialog', 'LogService', 'WebClientService'];
+    constructor($translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
+                logService: LogService, webClientService: WebClientService) {
         this.$translate = $translate;
         this.$mdDialog = $mdDialog;
+        this.logService = logService;
         this.webClientService = webClientService;
+        this.log = logService.getLogger('ControllerModel-S');
     }
 
     public me(
@@ -47,9 +52,9 @@ export class ControllerModelService {
         mode: ControllerModelMode,
     ): threema.ControllerModel<threema.MeReceiver> {
         return new MeControllerModel(
-            this.$log,
             this.$translate,
             this.$mdDialog,
+            this.logService,
             this.webClientService,
             mode,
             receiver,
@@ -61,9 +66,9 @@ export class ControllerModelService {
         mode: ControllerModelMode,
     ): threema.ControllerModel<threema.ContactReceiver> {
         return new ContactControllerModel(
-            this.$log,
             this.$translate,
             this.$mdDialog,
+            this.logService,
             this.webClientService,
             mode,
             receiver,
@@ -75,9 +80,9 @@ export class ControllerModelService {
         mode: ControllerModelMode,
     ): threema.ControllerModel<threema.GroupReceiver> {
         return new GroupControllerModel(
-            this.$log,
             this.$translate,
             this.$mdDialog,
+            this.logService,
             this.webClientService,
             mode,
             receiver,
@@ -89,9 +94,9 @@ export class ControllerModelService {
         mode: ControllerModelMode,
     ): threema.ControllerModel<threema.DistributionListReceiver> {
         return new DistributionListControllerModel(
-            this.$log,
             this.$translate,
             this.$mdDialog,
+            this.logService,
             this.webClientService,
             mode,
             receiver,

+ 0 - 7
src/services/fingerprint.ts

@@ -18,13 +18,6 @@
 import {sha256} from '../helpers/crypto';
 
 export class FingerPrintService {
-    private $log: ng.ILogService;
-
-    public static $inject = ['$log'];
-    constructor($log: ng.ILogService) {
-        this.$log = $log;
-    }
-
     public async generate(publicKey: ArrayBuffer): Promise<string> {
         if (publicKey !== undefined && publicKey.byteLength === 32) {
             const sha256PublicKey = await sha256(publicKey);

+ 11 - 11
src/services/keystore.ts

@@ -15,9 +15,12 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
 import * as nacl from 'tweetnacl';
 import {hexToU8a, u8aToHex} from '../helpers';
 import {stringToUtf8a, utf8aToString} from '../helpers';
+import {LogService} from './log';
 
 /**
  * This service stores trusted keys in the local browser storage.
@@ -41,24 +44,21 @@ import {stringToUtf8a, utf8aToString} from '../helpers';
 export class TrustedKeyStoreService {
     private STORAGE_KEY = 'trusted';
 
-    private logTag: string = '[TrustedKeyStoreService]';
-
-    private $log: ng.ILogService;
+    private readonly log: Logger;
     private storage: Storage = null;
 
     public blocked = false;
 
-    public static $inject = ['$log', '$window'];
-    constructor($log: ng.ILogService, $window: ng.IWindowService) {
-        this.$log = $log;
-
+    public static $inject = ['$window', 'LogService'];
+    constructor($window: ng.IWindowService, logService: LogService) {
+        this.log = logService.getLogger('TrustedKeyStore-S', 'color: #fff; background-color: #666699');
         try {
             if ($window.localStorage === null) {
                 this.blocked = true;
             }
             this.storage = $window.localStorage;
         } catch (e) {
-            $log.warn(this.logTag, 'LocalStorage blocked:', e);
+            this.log.warn('LocalStorage blocked:', e);
             this.blocked = true;
         }
     }
@@ -119,7 +119,7 @@ export class TrustedKeyStoreService {
         data.set(peerPublicKey, 64);
         data.set(token, 96);
         const encrypted: Uint8Array = nacl.secretbox(data, nonce, this.pwToKey(password));
-        this.$log.debug(this.logTag, 'Storing trusted key');
+        this.log.debug('Storing trusted key');
         this.storage.setItem(this.STORAGE_KEY, u8aToHex(nonce) + ':' + u8aToHex(encrypted));
     }
 
@@ -167,7 +167,7 @@ export class TrustedKeyStoreService {
                     tokenType = threema.PushTokenType.Apns;
                     break;
                 default:
-                    this.$log.error(this.logTag, 'Invalid push token type:', tokenString[0]);
+                    this.log.error('Invalid push token type:', tokenString[0]);
                     return null;
             }
             token = tokenString.slice(2);
@@ -190,7 +190,7 @@ export class TrustedKeyStoreService {
      * Delete any stored trusted keys.
      */
     public clearTrustedKey(): void {
-        this.$log.debug(this.logTag, 'Clearing trusted key');
+        this.log.debug('Clearing trusted key');
         this.storage.removeItem(this.STORAGE_KEY);
     }
 }

+ 88 - 0
src/services/log.ts

@@ -0,0 +1,88 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+import {Logger} from 'ts-log';
+import {ConsoleLogger, LevelLogger, MemoryLogger, TagLogger, TeeLogger, UnveilLogger} from '../helpers/logger';
+import LogLevel = threema.LogLevel;
+
+/**
+ * Initialises logging and hands out Logger instances.
+ */
+export class LogService {
+    private readonly config: threema.Config;
+    public readonly memory: MemoryLogger;
+    private readonly root: TeeLogger;
+
+    public static $inject = ['CONFIG'];
+    constructor(config: threema.Config) {
+        this.config = config;
+
+        // Initialise root logger
+        let logger: Logger;
+        const loggers: Logger[] = [];
+
+        // Initialise console logging
+        logger = new ConsoleLogger();
+        logger = new UnveilLogger(logger);
+        if (config.CONSOLE_LOG_LEVEL !== 'debug') {
+            logger = new LevelLogger(logger, config.CONSOLE_LOG_LEVEL);
+        }
+        loggers.push(logger);
+
+        // Initialise memory logging
+        logger = this.memory = new MemoryLogger(config.REPORT_LOG_LIMIT);
+        if (config.REPORT_LOG_LEVEL !== 'debug') {
+            logger = new LevelLogger(logger, config.REPORT_LOG_LEVEL);
+        }
+        loggers.push(logger);
+
+        // Initialise tee logging
+        this.root = new TeeLogger(loggers);
+    }
+
+    /**
+     * Get a logger.
+     * @param tag The tag prefix for the logger.
+     * @param style Optional CSS style to be included with the tag.
+     * @param level An optional level to be used. Note that loggers higher up
+     *   in the chain will supersede filtering of the log level. Thus, it is
+     *   possible to reduce the amount of logged levels but not increase them.
+     */
+    public getLogger(tag: string, style?: string, level?: LogLevel): Logger {
+        // Style the tag
+        let styledTag: string;
+        if (style !== undefined) {
+            styledTag = `%c[${tag}]`;
+        } else {
+            styledTag = `[${tag}]`;
+        }
+
+        // Pad the styled tag
+        styledTag = styledTag.padStart(this.config.LOG_TAG_PADDING + styledTag.length - tag.length);
+
+        // Create logger instance
+        let logger: Logger;
+        if (style !== undefined) {
+            logger = new TagLogger(this.root, styledTag, style);
+        } else {
+            logger = new TagLogger(this.root, styledTag);
+        }
+        if (level !== undefined) {
+            logger = new LevelLogger(logger, level);
+        }
+        return logger;
+    }
+}

+ 9 - 9
src/services/mediabox.ts

@@ -16,15 +16,15 @@
  */
 
 import {AsyncEvent} from 'ts-events';
+import {Logger} from 'ts-log';
+
+import {LogService} from './log';
 
 /**
  * This service is responsible for showing / hiding the media box.
  */
 export class MediaboxService {
-
-    private logTag: string = '[MediaboxService]';
-
-    private $log: ng.ILogService;
+    private readonly log: Logger;
 
     /**
      * This event is triggered every time the media element changes.
@@ -41,16 +41,16 @@ export class MediaboxService {
     public filename: string = '';
     public mimetype: string = '';
 
-    public static $inject = ['$log'];
-    constructor($log: ng.ILogService) {
-        this.$log = $log;
+    public static $inject = ['LogService'];
+    constructor(logService: LogService) {
+        this.log = logService.getLogger('Mediabox-S');
     }
 
     /**
      * Update media data.
      */
     public setMedia(data: ArrayBuffer, filename: string, mimetype: string, caption: string) {
-        this.$log.debug(this.logTag, 'Media data updated');
+        this.log.debug('Media data updated');
         this.data = data;
         this.filename = filename;
         this.mimetype = mimetype;
@@ -62,7 +62,7 @@ export class MediaboxService {
      * Clear media data.
      */
     public clearMedia() {
-        this.$log.debug(this.logTag, 'Media data cleared');
+        this.log.debug('Media data cleared');
         this.data = null;
         this.filename = '';
         this.mimetype = '';

+ 2 - 7
src/services/message.ts

@@ -29,10 +29,6 @@ export class MessageAccess {
 }
 
 export class MessageService {
-
-    // Angular services
-    private $log: ng.ILogService;
-
     // Own services
     private receiverService: ReceiverService;
     private timeoutService: TimeoutService;
@@ -40,9 +36,8 @@ export class MessageService {
     // Other
     private timeoutDelaySeconds = 30;
 
-    public static $inject = ['$log', 'ReceiverService', 'TimeoutService'];
-    constructor($log: ng.ILogService, receiverService: ReceiverService, timeoutService: TimeoutService) {
-        this.$log = $log;
+    public static $inject = ['ReceiverService', 'TimeoutService'];
+    constructor(receiverService: ReceiverService, timeoutService: TimeoutService) {
         this.receiverService = receiverService;
         this.timeoutService = timeoutService;
     }

+ 2 - 4
src/services/mime.ts

@@ -18,9 +18,8 @@
 import {hasValue} from '../helpers';
 
 export class MimeService {
-    public static $inject = ['$log', '$translate'];
+    public static $inject = ['$translate'];
 
-    private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
 
     private imageMimeTypes: string[] = ['image/png', 'image/jpg', 'image/jpeg'];
@@ -28,8 +27,7 @@ export class MimeService {
     private audioMimeTypesIos: string[] = ['audio/m4a', 'audio/x-m4a', 'audio/mp4'];
     private videoMimeTypes: string[] = ['video/mp4', 'video/mpg', 'video/mpeg'];
 
-    constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService) {
-        this.$log = $log;
+    constructor($translate: ng.translate.ITranslateService) {
         this.$translate = $translate;
     }
 

+ 27 - 24
src/services/notification.ts

@@ -15,8 +15,11 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
 import {StateService as UiStateService} from '@uirouter/angularjs';
 
+import {LogService} from './log';
 import {SettingsService} from './settings';
 
 export class NotificationService {
@@ -26,12 +29,12 @@ export class NotificationService {
     private static SETTINGS_NOTIFICATION_SOUND = 'notificationSound';
     private static NOTIFICATION_SOUND = 'sounds/notification.mp3';
 
-    private $log: ng.ILogService;
     private $window: ng.IWindowService;
     private $state: UiStateService;
 
     private settingsService: SettingsService;
-    private logTag = '[NotificationService]';
+
+    private readonly log: Logger;
 
     // Whether user has granted notification permission
     private notificationPermission: boolean = null;
@@ -47,14 +50,14 @@ export class NotificationService {
     // Cache notifications
     private notificationCache: any = {};
 
-    public static $inject = ['$log', '$window', '$state', 'SettingsService'];
+    public static $inject = ['$window', '$state', 'LogService', 'SettingsService'];
 
-    constructor($log: ng.ILogService, $window: ng.IWindowService,
-                $state: UiStateService, settingsService: SettingsService) {
-        this.$log = $log;
+    constructor($window: ng.IWindowService, $state: UiStateService,
+                logService: LogService, settingsService: SettingsService) {
         this.$window = $window;
         this.$state = $state;
         this.settingsService = settingsService;
+        this.log = logService.getLogger('Notification-S');
     }
 
     public init(): void {
@@ -77,7 +80,7 @@ export class NotificationService {
     private requestNotificationPermission(): void {
         if (this.notificationAPIAvailable) {
             const Notification = this.$window.Notification;
-            this.$log.debug(this.logTag, 'Requesting notification permission...');
+            this.log.debug('Requesting notification permission...');
             Notification.requestPermission((result) => {
                 switch (result) {
                     case 'denied':
@@ -96,7 +99,7 @@ export class NotificationService {
                         this.notificationPermission = false;
                         break;
                 }
-                this.$log.debug(this.logTag, 'Notification permission', this.notificationPermission);
+                this.log.debug('Notification permission', this.notificationPermission);
             });
         }
     }
@@ -109,7 +112,7 @@ export class NotificationService {
      */
     private checkNotificationAPI(): void {
         this.notificationAPIAvailable = ('Notification' in this.$window);
-        this.$log.debug(this.logTag, 'Notification API available:', this.notificationAPIAvailable);
+        this.log.debug('Notification API available:', this.notificationAPIAvailable);
         if (this.notificationAPIAvailable) {
             const Notification = this.$window.Notification;
             switch (Notification.permission) {
@@ -128,47 +131,47 @@ export class NotificationService {
                     break;
             }
         }
-        this.$log.debug(this.logTag, 'Initial notificationPermission', this.notificationPermission);
+        this.log.debug('Initial notificationPermission', this.notificationPermission);
     }
 
     /**
      * Get the initial settings from local storage
      */
     private fetchSettings(): void {
-        this.$log.debug(this.logTag, 'Fetching settings...');
+        this.log.debug('Fetching settings...');
         const notifications = this.retrieveSetting(NotificationService.SETTINGS_NOTIFICATIONS);
         const preview = this.retrieveSetting(NotificationService.SETTINGS_NOTIFICATION_PREVIEW);
         const sound = this.retrieveSetting(NotificationService.SETTINGS_NOTIFICATION_SOUND);
         if (notifications === 'true') {
-            this.$log.debug(this.logTag, 'Desktop notifications:', notifications);
+            this.log.debug('Desktop notifications:', notifications);
             this.desktopNotifications = true;
             // check permission because user may have revoked them
             this.requestNotificationPermission();
         } else if (notifications === 'false') {
-            this.$log.debug(this.logTag, 'Desktop notifications:', notifications);
+            this.log.debug('Desktop notifications:', notifications);
             // user does not want notifications
             this.desktopNotifications = false;
         } else {
-            this.$log.debug(this.logTag, 'Desktop notifications:', notifications, 'Asking user...');
+            this.log.debug('Desktop notifications:', notifications, 'Asking user...');
             // Neither true nor false was in local storage, so we have to ask the user if he wants notifications
             // If he grants (or already has granted) us the permission, we will set the flag true (default setting)
             this.requestNotificationPermission();
         }
         if (preview === 'false') {
-            this.$log.debug(this.logTag, 'Notification preview:', preview);
+            this.log.debug('Notification preview:', preview);
             this.notificationPreview = false;
         } else {
             // set the flag true if true/nothing or sth. else is in local storage (default setting)
-            this.$log.debug(this.logTag, 'Notification preview:', preview, 'Using default value (true)');
+            this.log.debug('Notification preview:', preview, 'Using default value (true)');
             this.notificationPreview = true;
             this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_PREVIEW, 'true');
         }
         if (sound === 'true') {
-            this.$log.debug(this.logTag, 'Notification sound:', sound);
+            this.log.debug('Notification sound:', sound);
             this.notificationSound = true;
         } else {
             // set the flag false if false/nothing or sth. else is in local storage (default setting)
-            this.$log.debug(this.logTag, 'Notification sound:', sound, 'Using default value (false)');
+            this.log.debug('Notification sound:', sound, 'Using default value (false)');
             this.notificationSound = false;
             this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_SOUND, 'false');
         }
@@ -213,7 +216,7 @@ export class NotificationService {
      * Sets if the user wants desktop notifications
      */
     public setWantsNotifications(wantsNotifications: boolean): void {
-        this.$log.debug(this.logTag, 'Requesting notification preference change to', wantsNotifications);
+        this.log.debug('Requesting notification preference change to', wantsNotifications);
         if (wantsNotifications) {
             this.requestNotificationPermission();
         } else {
@@ -226,7 +229,7 @@ export class NotificationService {
      * Sets if the user wants a message preview
      */
     public setWantsPreview(wantsPreview: boolean): void {
-        this.$log.debug(this.logTag, 'Requesting preview preference change to', wantsPreview);
+        this.log.debug('Requesting preview preference change to', wantsPreview);
         this.notificationPreview = wantsPreview;
         this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_PREVIEW, wantsPreview ? 'true' : 'false');
     }
@@ -235,7 +238,7 @@ export class NotificationService {
      * Sets if the user wants sound when a new message arrives
      */
     public setWantsSound(wantsSound: boolean): void {
-        this.$log.debug(this.logTag, 'Requesting sound preference change to', wantsSound);
+        this.log.debug('Requesting sound preference change to', wantsSound);
         this.notificationSound = wantsSound;
         this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_SOUND, wantsSound ? 'true' : 'false');
     }
@@ -365,7 +368,7 @@ export class NotificationService {
         }
 
         // Show notification
-        this.$log.debug(this.logTag, 'Showing notification', tag);
+        this.log.debug('Showing notification', tag);
         const notification = new this.$window.Notification(title, {
             icon: avatar,
             body: body.trim(),
@@ -380,7 +383,7 @@ export class NotificationService {
             if (clickCallback !== undefined) {
                 clickCallback();
             }
-            this.$log.debug(this.logTag, 'Hiding notification', tag, 'on click');
+            this.log.debug('Hiding notification', tag, 'on click');
             notification.close();
             this.clearCache(tag);
         };
@@ -399,7 +402,7 @@ export class NotificationService {
     public hideNotification(tag: string): boolean {
         const notification = this.notificationCache[tag];
         if (notification !== undefined) {
-            this.$log.debug(this.logTag, 'Hiding notification', tag);
+            this.log.debug('Hiding notification', tag);
             notification.close();
             this.clearCache(tag);
             return true;

+ 27 - 54
src/services/peerconnection.ts

@@ -15,18 +15,18 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import * as SDPUtils from 'sdp';
-
 import TaskConnectionState = threema.TaskConnectionState;
+import {Logger} from 'ts-log';
+
+import {ConfidentialIceCandidate} from '../helpers/confidential';
+import {LogService} from './log';
 
 /**
  * Wrapper around the WebRTC PeerConnection.
  */
 export class PeerConnectionHelper {
-    private logTag: string = '[PeerConnectionHelper]';
-
     // Angular services
-    private $log: ng.ILogService;
+    private log: Logger;
     private $q: ng.IQService;
     private $timeout: ng.ITimeoutService;
     private $rootScope: ng.IRootScopeService;
@@ -39,40 +39,32 @@ export class PeerConnectionHelper {
     public connectionState: TaskConnectionState = TaskConnectionState.New;
     public onConnectionStateChange: (state: TaskConnectionState) => void = null;
 
-    // Debugging
-    private censorCandidates: boolean;
-
-    constructor($log: ng.ILogService, $q: ng.IQService,
-                $timeout: ng.ITimeoutService, $rootScope: ng.IRootScopeService,
-                webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask,
-                iceServers: RTCIceServer[],
-                censorCandidates: boolean = true) {
-        this.$log = $log;
-        this.$log.info(this.logTag, 'Initialize WebRTC PeerConnection');
-        this.$log.debug(this.logTag, 'ICE servers used:', [].concat(...iceServers.map((c) => c.urls)).join(', '));
+    constructor($q: ng.IQService, $timeout: ng.ITimeoutService, $rootScope: ng.IRootScopeService,
+                logService: LogService, webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask, iceServers: RTCIceServer[]) {
+        this.log = logService.getLogger('PeerConnection', 'color: #fff; background-color: #3333ff');
+        this.log.info('Initialize WebRTC PeerConnection');
+        this.log.debug('ICE servers used:', [].concat(...iceServers.map((c) => c.urls)));
         this.$q = $q;
         this.$timeout = $timeout;
         this.$rootScope = $rootScope;
 
         this.webrtcTask = webrtcTask;
 
-        this.censorCandidates = censorCandidates;
-
         // Set up peer connection
         this.pc = new RTCPeerConnection({iceServers: iceServers});
         this.pc.onnegotiationneeded = (e: Event) => {
-            this.$log.debug(this.logTag, 'RTCPeerConnection: negotiation needed');
+            this.log.debug('RTCPeerConnection: negotiation needed');
             this.initiatorFlow().then(
-                (_) => this.$log.debug(this.logTag, 'Initiator flow done'),
+                (_) => this.log.debug('Initiator flow done'),
             );
         };
 
         // Handle state changes
         this.pc.onconnectionstatechange = (e: Event) => {
-            $log.debug(this.logTag, 'Connection state change:', this.pc.connectionState);
+            this.log.debug('Connection state change:', this.pc.connectionState);
         };
         this.pc.onsignalingstatechange = (e: Event) => {
-            $log.debug(this.logTag, 'Signaling state change:', this.pc.signalingState);
+            this.log.debug('Signaling state change:', this.pc.signalingState);
         };
 
         // Set up ICE candidate handling
@@ -80,7 +72,7 @@ export class PeerConnectionHelper {
 
         // Log incoming data channels
         this.pc.ondatachannel = (e: RTCDataChannelEvent) => {
-            $log.debug(this.logTag, 'New data channel was created:', e.channel.label);
+            this.log.debug('New data channel was created:', e.channel.label);
         };
     }
 
@@ -95,25 +87,24 @@ export class PeerConnectionHelper {
      * Set up receiving / sending of ICE candidates.
      */
     private setupIceCandidateHandling() {
-        this.$log.debug(this.logTag, 'Setting up ICE candidate handling');
+        this.log.debug('Setting up ICE candidate handling');
         this.pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => {
             if (e.candidate) {
-                this.$log.debug(this.logTag, 'Gathered local ICE candidate:',
-                    this.censorCandidate(e.candidate.candidate));
+                this.log.debug('Gathered local ICE candidate:', new ConfidentialIceCandidate(e.candidate.candidate));
                 this.webrtcTask.sendCandidate({
                     candidate: e.candidate.candidate,
                     sdpMid: e.candidate.sdpMid,
                     sdpMLineIndex: e.candidate.sdpMLineIndex,
                 });
             } else {
-                this.$log.debug(this.logTag, 'No more local ICE candidates');
+                this.log.debug('No more local ICE candidates');
             }
         };
         this.pc.onicecandidateerror = (e: RTCPeerConnectionIceErrorEvent) => {
-            this.$log.error(this.logTag, 'ICE candidate error:', e);
+            this.log.error('ICE candidate error:', e);
         };
         this.pc.oniceconnectionstatechange = (e: Event) => {
-            this.$log.debug(this.logTag, 'ICE connection state change:', this.pc.iceConnectionState);
+            this.log.debug('ICE connection state change:', this.pc.iceConnectionState);
             this.$rootScope.$apply(() => {
                 switch (this.pc.iceConnectionState) {
                     case 'new':
@@ -132,21 +123,21 @@ export class PeerConnectionHelper {
                         this.setConnectionState(TaskConnectionState.Disconnected);
                         break;
                     default:
-                        this.$log.warn(this.logTag, 'Ignored ICE connection state change to',
+                        this.log.warn('Ignored ICE connection state change to',
                                        this.pc.iceConnectionState);
                 }
             });
         };
         this.pc.onicegatheringstatechange = (e: Event) => {
-            this.$log.debug(this.logTag, 'ICE gathering state change:', this.pc.iceGatheringState);
+            this.log.debug('ICE gathering state change:', this.pc.iceGatheringState);
         };
         this.webrtcTask.on('candidates', (e: saltyrtc.tasks.webrtc.CandidatesEvent) => {
             for (const candidateInit of e.data) {
                 if (candidateInit) {
-                    this.$log.debug(this.logTag, 'Adding remote ICE candidate:',
-                        this.censorCandidate(candidateInit.candidate));
+                    this.log.debug('Adding remote ICE candidate:',
+                        new ConfidentialIceCandidate(candidateInit.candidate));
                 } else {
-                    this.$log.debug(this.logTag, 'No more remote ICE candidates');
+                    this.log.debug('No more remote ICE candidates');
                 }
                 this.pc.addIceCandidate(candidateInit);
             }
@@ -157,7 +148,7 @@ export class PeerConnectionHelper {
         // Send offer
         const offer: RTCSessionDescriptionInit = await this.pc.createOffer();
         await this.pc.setLocalDescription(offer);
-        this.$log.debug(this.logTag, 'Created offer, set local description');
+        this.log.debug('Created offer, set local description');
         this.webrtcTask.sendOffer(offer);
 
         // Receive answer
@@ -170,7 +161,7 @@ export class PeerConnectionHelper {
         };
         const answer: RTCSessionDescriptionInit = await receiveAnswer();
         await this.pc.setRemoteDescription(answer);
-        this.$log.debug(this.logTag, 'Received answer, set remote description');
+        this.log.debug('Received answer, set remote description');
     }
 
     /**
@@ -209,22 +200,4 @@ export class PeerConnectionHelper {
         this.pc.ondatachannel = null;
         this.pc.close();
     }
-
-    /**
-     * Censor an ICE candidate's address and port (unless censoring is disabled).
-     *
-     * Return the censored ICE candidate.
-     */
-    private censorCandidate(candidateInit: string): string {
-        const candidate = SDPUtils.parseCandidate(candidateInit);
-        if (this.censorCandidates) {
-            if (candidate.type !== 'relay') {
-                candidate.ip = '***';
-                candidate.port = 1;
-            }
-            candidate.relatedAddress = '***';
-            candidate.relatedPort = 2;
-        }
-        return SDPUtils.writeCandidate(candidate);
-    }
 }

+ 22 - 20
src/services/push.ts

@@ -15,9 +15,12 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
 import {TimeoutError} from '../exceptions';
 import {randomString, sleep} from '../helpers';
 import {sha256} from '../helpers/crypto';
+import {LogService} from './log';
 
 /**
  * A push session will send pushes continuously until an undefined goal has
@@ -44,13 +47,12 @@ import {sha256} from '../helpers/crypto';
  * stored on the push server.
  */
 export class PushSession {
-    private readonly $log: ng.ILogService;
     private readonly service: PushService;
     private readonly session: Uint8Array;
     private readonly config: threema.PushSessionConfig;
     private readonly doneFuture: Future<any> = new Future();
     private readonly affiliation: string = randomString(6);
-    private logTag: string = '[Push]';
+    private log: Logger;
     private running: boolean = false;
     private retryTimeoutMs: number;
     private tries: number = 0;
@@ -99,7 +101,7 @@ export class PushSession {
      * @param config Push session configuration.
      */
     public constructor(service: PushService, session: Uint8Array, config?: threema.PushSessionConfig) {
-        this.$log = service.$log;
+        this.log = service.logService.getLogger(`Push.${this.affiliation}`, 'color: #fff; background-color: #9900cc');
         this.service = service;
         this.session = session;
         this.config = config !== undefined ? config : PushSession.defaultConfig;
@@ -129,7 +131,7 @@ export class PushSession {
         // Start sending
         if (!this.running) {
             this.run().catch((error) => {
-                this.$log.error(this.logTag, 'Push runner failed:', error);
+                this.log.error('Push runner failed:', error);
                 this.doneFuture.reject(error);
             });
             this.running = true;
@@ -143,14 +145,13 @@ export class PushSession {
      * This will resolve all pending promises.
      */
     public done(): void {
-        this.$log.info(this.logTag, 'Push done');
+        this.log.info('Push done');
         this.doneFuture.resolve();
     }
 
     private async run(): Promise<void> {
         // Calculate session hash
         const sessionHash = await sha256(this.session.buffer);
-        this.logTag = `[Push.${sessionHash}]`;
 
         // Prepare data
         const data = new URLSearchParams();
@@ -193,9 +194,9 @@ export class PushSession {
             ++this.tries;
 
             // Send push
-            this.$log.debug(this.logTag, `Sending push ${this.tries}/${this.config.triesMax} (ttl=${timeToLive})`);
-            if (this.service.config.VERBOSE_DEBUGGING) {
-                this.$log.debug(this.logTag, 'Push data:', `${data}`);
+            this.log.debug(`Sending push ${this.tries}/${this.config.triesMax} (ttl=${timeToLive})`);
+            if (this.service.config.ARP_LOG_TRACE) {
+                this.log.debug('Push data:', `${data}`);
             }
             try {
                 const response = await fetch(this.service.url, {
@@ -209,18 +210,18 @@ export class PushSession {
                 // Check if successful
                 if (response.ok) {
                     // Success: Retry
-                    this.$log.debug(this.logTag, 'Push sent successfully');
+                    this.log.debug('Push sent successfully');
                 } else if (response.status >= 400 && response.status < 500) {
                     // Client error: Don't retry
                     const error = `Push rejected (client error), status: ${response.status}`;
-                    this.$log.warn(this.logTag, error);
+                    this.log.warn(error);
                     this.doneFuture.reject(new Error(error));
                 } else {
                     // Server error: Retry
-                    this.$log.warn(this.logTag, `Push rejected (server error), status: ${response.status}`);
+                    this.log.warn(`Push rejected (server error), status: ${response.status}`);
                 }
             } catch (error) {
-                this.$log.warn(this.logTag, 'Sending push failed:', error);
+                this.log.warn('Sending push failed:', error);
             }
 
             // Retry after timeout
@@ -232,7 +233,7 @@ export class PushSession {
             // Maximum tries reached?
             if (!this.doneFuture.done && this.tries === this.config.triesMax) {
                 const error = `Push session timeout after ${this.tries} tries`;
-                this.$log.warn(this.logTag, error);
+                this.log.warn(error);
                 this.doneFuture.reject(new TimeoutError(error));
             }
         }
@@ -240,7 +241,7 @@ export class PushSession {
 }
 
 export class PushService {
-    public static readonly $inject = ['$log', 'CONFIG', 'PROTOCOL_VERSION'];
+    public static readonly $inject = ['CONFIG', 'PROTOCOL_VERSION', 'LogService'];
 
     public static readonly ARG_TYPE = 'type';
     public static readonly ARG_TOKEN = 'token';
@@ -252,19 +253,20 @@ export class PushService {
     public static readonly ARG_TIME_TO_LIVE = 'ttl';
     public static readonly ARG_COLLAPSE_KEY = 'collapse_key';
 
-    private readonly logTag: string = '[PushService]';
-    public readonly $log: ng.ILogService;
     public readonly config: threema.Config;
     public readonly url: string;
     public readonly version: number = null;
+    public readonly logService: LogService;
+    public readonly log: Logger;
     private _pushToken: string = null;
     private _pushType = threema.PushTokenType.Gcm;
 
-    constructor($log: ng.ILogService, CONFIG: threema.Config, PROTOCOL_VERSION: number) {
-        this.$log = $log;
+    constructor(CONFIG: threema.Config, PROTOCOL_VERSION: number, logService: LogService) {
         this.config = CONFIG;
         this.url = CONFIG.PUSH_URL;
         this.version = PROTOCOL_VERSION;
+        this.logService = logService;
+        this.log = logService.getLogger(`Push-S`, 'color: #fff; background-color: #9900ff');
     }
 
     public get pushToken(): string {
@@ -279,7 +281,7 @@ export class PushService {
      * Initiate the push service with a push token.
      */
     public init(pushToken: string, pushTokenType: threema.PushTokenType): void {
-        this.$log.info(this.logTag, 'Initialized with', pushTokenType, 'token');
+        this.log.info('Initialized with', pushTokenType, 'token');
         this._pushToken = pushToken;
         this._pushType = pushTokenType;
     }

+ 11 - 10
src/services/settings.ts

@@ -15,21 +15,22 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
+import {LogService} from './log';
+
 /**
  * The settings service can update variables for settings and persist them to
  * LocalStorage.
  */
 export class SettingsService {
-    private $log: ng.ILogService;
-
     private static STORAGE_KEY_PREFIX = 'settings-';
-    private logTag: string = '[SettingsService]';
-
+    private readonly log: Logger;
     private storage: Storage;
 
-    public static $inject = ['$log', '$window'];
-    constructor($log: ng.ILogService, $window: ng.IWindowService) {
-        this.$log = $log;
+    public static $inject = ['$window', 'LogService'];
+    constructor($window: ng.IWindowService, logService: LogService) {
+        this.log = logService.getLogger('Settings-S');
         this.storage = $window.localStorage;
     }
 
@@ -37,7 +38,7 @@ export class SettingsService {
      * Store settings key-value pair in LocalStorage.
      */
     public storeUntrustedKeyValuePair(key: string, value: string): void {
-        this.$log.debug(this.logTag, 'Storing settings key:', key);
+        this.log.debug('Storing settings key:', key);
         this.storage.setItem(SettingsService.STORAGE_KEY_PREFIX + key, value);
     }
 
@@ -48,7 +49,7 @@ export class SettingsService {
      * with an empty value if it does not yet exist.
      */
     public retrieveUntrustedKeyValuePair(key: string, alwaysCreate: boolean = true): string {
-        this.$log.debug(this.logTag, 'Retrieving settings key:', key);
+        this.log.debug('Retrieving settings key:', key);
         if (this.hasUntrustedKeyValuePair(key)) {
             return this.storage.getItem(SettingsService.STORAGE_KEY_PREFIX + key);
         } else {
@@ -63,7 +64,7 @@ export class SettingsService {
      * Remove settings key-value pair from LocalStorage if it exists.
      */
     public removeUntrustedKeyValuePair(key: string): void {
-        this.$log.debug(this.logTag, 'Removing settings key:', key);
+        this.log.debug('Removing settings key:', key);
         this.storage.removeItem(SettingsService.STORAGE_KEY_PREFIX + key);
     }
 

+ 17 - 15
src/services/state.ts

@@ -16,6 +16,9 @@
  */
 
 import {AsyncEvent} from 'ts-events';
+import {Logger} from 'ts-log';
+
+import {LogService} from './log';
 
 import TaskConnectionState = threema.TaskConnectionState;
 import GlobalConnectionState = threema.GlobalConnectionState;
@@ -27,13 +30,12 @@ const enum Stage {
 }
 
 export class StateService {
-
-    private logTag: string = '[StateService]';
-
     // Angular services
-    private $log: ng.ILogService;
     private $interval: ng.IIntervalService;
 
+    // Logging
+    private readonly log: Logger;
+
     // Events
     public evtConnectionBuildupStateChange = new AsyncEvent<threema.ConnectionBuildupStateChange>();
     public evtGlobalConnectionStateChange = new AsyncEvent<threema.GlobalConnectionStateChange>();
@@ -57,10 +59,10 @@ export class StateService {
     // Unread messages
     private _unreadCount: number = 0;
 
-    public static $inject = ['$log', '$interval'];
-    constructor($log: ng.ILogService, $interval: ng.IIntervalService) {
-        this.$log = $log;
+    public static $inject = ['$interval', 'LogService'];
+    constructor($interval: ng.IIntervalService, logService: LogService) {
         this.$interval = $interval;
+        this.log = logService.getLogger('State-S', 'color: #fff; background-color: #cc9900');
         this.reset();
     }
 
@@ -89,7 +91,7 @@ export class StateService {
         const prevState = this.signalingConnectionState;
         this.signalingConnectionState = state;
         if (!handoverDone) {
-            this.$log.debug(this.logTag, 'Signaling connection state:', prevState, '=>', state);
+            this.log.debug('Signaling connection state:', prevState, '=>', state);
             switch (state) {
                 case 'new':
                 case 'ws-connecting':
@@ -110,10 +112,10 @@ export class StateService {
                     this.state = GlobalConnectionState.Error;
                     break;
                 default:
-                    this.$log.warn(this.logTag, `Unknown signaling connection state: ${state}`);
+                    this.log.warn(`Unknown signaling connection state: ${state}`);
             }
         } else {
-            this.$log.debug(this.logTag, 'Ignored signaling connection state to "' + state + '"');
+            this.log.debug('Ignored signaling connection state to "' + state + '"');
         }
     }
 
@@ -124,7 +126,7 @@ export class StateService {
         const prevState = this.taskConnectionState;
         this.taskConnectionState = state;
         if (this.stage === Stage.Task) {
-            this.$log.debug(this.logTag, 'Task connection state:', prevState, '=>', state);
+            this.log.debug('Task connection state:', prevState, '=>', state);
             switch (state) {
                 case TaskConnectionState.New:
                 case TaskConnectionState.Connecting:
@@ -139,10 +141,10 @@ export class StateService {
                     this.state = GlobalConnectionState.Error;
                     break;
                 default:
-                    this.$log.warn(this.logTag, 'Ignored task connection state change to "' + state + '"');
+                    this.log.warn('Ignored task connection state change to "' + state + '"');
             }
         } else {
-            this.$log.debug(this.logTag, 'Ignored task connection state change to "' + state + '"');
+            this.log.debug('Ignored task connection state change to "' + state + '"');
         }
     }
 
@@ -154,7 +156,7 @@ export class StateService {
             return;
         }
         const prevState = this.connectionBuildupState;
-        this.$log.debug(this.logTag, 'Connection buildup state:', prevState, '=>', state);
+        this.log.debug('Connection buildup state:', prevState, '=>', state);
 
         // Update state
         this.connectionBuildupState = state;
@@ -265,7 +267,7 @@ export class StateService {
      * Reset all states.
      */
     public reset(connectionBuildupState: threema.ConnectionBuildupState = 'new'): void {
-        this.$log.debug(this.logTag, 'Reset states');
+        this.log.debug('Reset states');
 
         // Reset state
         this.signalingConnectionState = 'new';

+ 15 - 20
src/services/timeout.ts

@@ -15,46 +15,42 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-export class TimeoutService {
-    private logTag: string = '[TimeoutService]';
+import {Logger} from 'ts-log';
+
+import {LogService} from './log';
 
+export class TimeoutService {
     // Config
     private config: threema.Config;
 
     // Angular services
-    private $log: ng.ILogService;
     private $timeout: ng.ITimeoutService;
 
+    // Logging
+    private readonly log: Logger;
+
     // List of registered timeouts
     private timeouts: Set<ng.IPromise<any>> = new Set();
 
-    public static $inject = ['CONFIG', '$log', '$timeout'];
-    constructor(config: threema.Config, $log: ng.ILogService, $timeout: ng.ITimeoutService) {
+    public static $inject = ['CONFIG', '$timeout', 'LogService'];
+    constructor(config: threema.Config, $timeout: ng.ITimeoutService, logService: LogService) {
         this.config = config;
-        this.$log = $log;
         this.$timeout = $timeout;
-    }
-
-    /**
-     * Log a message on debug log level, but only if the `DEBUG` flag is enabled.
-     */
-    private logDebug(msg: string): void {
-        if (this.config.VERBOSE_DEBUGGING) {
-            this.$log.debug(this.logTag, msg);
-        }
+        this.log = logService.getLogger(
+            'Timeout-S', 'color: #fff; background-color: #669900', this.config.TIMER_LOG_LEVEL);
     }
 
     /**
      * Register a timeout.
      */
     public register<T>(fn: (...args: any[]) => T, delay: number, invokeApply: boolean, name?: string): ng.IPromise<T> {
-        this.logDebug('Registering timeout' + (name === undefined ? '' : ` (${name})`));
+        this.log.debug(`Registering timeout${name === undefined ? '' : ` (${name})`}`);
         const timeout = this.$timeout(fn, delay, invokeApply);
         timeout
             .then(() => this.timeouts.delete(timeout))
             .catch((reason) => {
                 if (reason !== 'canceled') { // We can safely ignore cancellation
-                    this.$log.error(this.logTag, 'Registered timeout promise rejected:', reason);
+                    this.log.error('Registered timeout promise rejected:', reason);
                 }
             });
 
@@ -75,8 +71,7 @@ export class TimeoutService {
         // Retrieve name from promise for debugging purposes
         // tslint:disable-next-line: no-string-literal
         const name = timeout['_timeout_name'];
-
-        this.logDebug('Cancelling timeout' + (name === undefined ? '' : ` (${name})`));
+        this.log.debug(`Cancelling timeout${name === undefined ? '' : ` (${name})`}`);
         const cancelled = this.$timeout.cancel(timeout);
 
         this.timeouts.delete(timeout);
@@ -87,7 +82,7 @@ export class TimeoutService {
      * Cancel all pending timeouts.
      */
     public cancelAll() {
-        this.$log.debug(this.logTag, 'Cancelling ' + this.timeouts.size + ' timeouts');
+        this.log.debug('Cancelling ' + this.timeouts.size + ' timeouts');
         for (const timeout of this.timeouts) {
             this.$timeout.cancel(timeout);
         }

+ 3 - 6
src/services/title.ts

@@ -14,14 +14,13 @@
  * You should have received a copy of the GNU Affero General Public License
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
+
 import {StateService} from './state';
 
 /**
  * The title service can update the window title.
  */
 export class TitleService {
-
-    private $log: ng.ILogService;
     private $document: ng.IDocumentService;
 
     private stateService: StateService;
@@ -30,9 +29,8 @@ export class TitleService {
     private title: string;
     private unreadCount: number = 0;
 
-    public static $inject = ['$log', '$document', 'StateService'];
-    constructor($log: ng.ILogService, $document: ng.IDocumentService, stateService: StateService) {
-        this.$log = $log;
+    public static $inject = ['$document', 'StateService'];
+    constructor($document: ng.IDocumentService, stateService: StateService) {
         this.$document = $document;
         this.stateService = stateService;
 
@@ -55,5 +53,4 @@ export class TitleService {
         }
         this.$document[0].title = this.title;
     }
-
 }

+ 19 - 17
src/services/version.ts

@@ -15,32 +15,36 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-export class VersionService {
-    private logTag: string = '[VersionService]';
+import {Logger} from 'ts-log';
+
+import {LogService} from './log';
 
-    private $log: ng.ILogService;
+export class VersionService {
     private $http: ng.IHttpService;
     private $mdDialog: ng.material.IDialogService;
     private $translate: ng.translate.ITranslateService;
     private $window: ng.IWindowService;
 
+    private readonly config: threema.Config;
+    private readonly log: Logger;
+
     private version: string;
-    private config: threema.Config;
     private dialogShowing = false;
 
-    public static $inject = ['$log', '$http', '$mdDialog', '$translate', '$window', 'CONFIG'];
-    constructor($log: ng.ILogService,
-                $http: ng.IHttpService,
+    public static $inject = ['$http', '$mdDialog', '$translate', '$window', 'CONFIG', 'LogService'];
+    constructor($http: ng.IHttpService,
                 $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
                 $window: ng.IWindowService,
-                CONFIG: threema.Config) {
-        this.$log = $log;
+                CONFIG: threema.Config,
+                logService: LogService) {
         this.$http = $http;
         this.$mdDialog = $mdDialog;
         this.$translate = $translate;
         this.$window = $window;
+
         this.config = CONFIG;
+        this.log = logService.getLogger('Version-S');
     }
 
     /**
@@ -55,10 +59,10 @@ export class VersionService {
         this.fetchVersion()
             .then((version: string) => {
                 this.version = version;
-                this.$log.info(this.logTag, 'Using Threema Web version', this.version);
+                this.log.info('Using Threema Web version', this.version);
             })
             .catch((error: string) => {
-                this.$log.error(this.logTag, 'Could not fetch version.txt:', error);
+                this.log.error('Could not fetch version.txt:', error);
             });
     }
 
@@ -94,22 +98,20 @@ export class VersionService {
      * Check for a version update. If the version was updated, show a dialog.
      */
     public checkForUpdate(): void {
-        this.$log.debug(this.logTag, 'Checking for version update...');
+        this.log.debug('Checking for version update...');
         if (this.version === undefined) {
-            this.$log.error(this.logTag, 'Cannot check for update, version is not initialized');
+            this.log.error('Cannot check for update, version is not initialized');
             return;
         }
         this.fetchVersion()
             .then((version: string) => {
                 if (version !== this.version) {
-                    this.$log.warn(this.logTag,
-                        'A new version of Threema Web is available:',
-                        this.version, '->', version);
+                    this.log.warn('A new version of Threema Web is available:', this.version, '->', version);
                     this.notifyNewVersion(version);
                 }
             })
             .catch((error: string) => {
-                this.$log.error('Could not fetch version.txt:', error);
+                this.log.error('Could not fetch version.txt:', error);
             });
     }
 

Різницю між файлами не показано, бо вона завелика
+ 138 - 150
src/services/webclient.ts


+ 35 - 7
src/threema.d.ts

@@ -18,6 +18,19 @@
 declare const angular: ng.IAngularStatic;
 
 declare namespace threema {
+    type LogType = 'debug' | 'trace' | 'info' | 'warn' | 'error';
+    type LogLevel = 'none' | 'debug' | 'info' | 'warn' | 'error';
+    type LogRecord = [Date, LogType, any?, ...any[]];
+
+    /**
+     * An object can be marked as confidential in which case it needs to
+     * implement the censor method. This mixin is being used for sanitising log
+     * records when using the report tool.
+     */
+    interface Confidential<U, C> {
+        uncensored: U;
+        censored(): C;
+    }
 
     interface Avatar {
         // Low resolution avatar URI
@@ -624,25 +637,40 @@ declare namespace threema {
     }
 
     interface Config {
+        // General
         SELF_HOSTED: boolean;
-        PREV_PROTOCOL_LAST_VERSION: string | null;
         VERSION_MOUNTAIN: string;
         VERSION_MOUNTAIN_URL: string;
         VERSION_MOUNTAIN_IMAGE_URL: string;
         VERSION_MOUNTAIN_IMAGE_COPYRIGHT: string;
         VERSION_MOUNTAIN_HEIGHT: number;
+        PREV_PROTOCOL_LAST_VERSION: string | null;
         GIT_BRANCH: string;
-        SALTYRTC_PORT: number;
-        SALTYRTC_SERVER_KEY: string | null;
+
+        // SaltyRTC
         SALTYRTC_HOST: string | null;
         SALTYRTC_HOST_PREFIX: string | null;
         SALTYRTC_HOST_SUFFIX: string | null;
-        SALTYRTC_LOG_LEVEL: saltyrtc.LogLevel;
+        SALTYRTC_PORT: number;
+        SALTYRTC_SERVER_KEY: string | null;
+
+        // ICE
         ICE_SERVERS: RTCIceServer[];
+
+        // Push
         PUSH_URL: string;
-        VERBOSE_DEBUGGING: boolean;
-        MSG_DEBUGGING: boolean;
-        MSGPACK_DEBUGGING: boolean;
+
+        // Logging/debugging
+        LOG_TAG_PADDING: number,
+        CONSOLE_LOG_LEVEL: LogLevel;
+        REPORT_LOG_LEVEL: LogLevel;
+        REPORT_LOG_LIMIT: number;
+        COMPOSE_AREA_LOG_LEVEL: LogLevel;
+        SALTYRTC_LOG_LEVEL: saltyrtc.LogLevel;
+        TIMER_LOG_LEVEL: LogLevel;
+        ARP_LOG_LEVEL: LogLevel;
+        ARP_LOG_TRACE: boolean;
+        MSGPACK_LOG_TRACE: boolean;
     }
 
     interface InitialConversationData {

+ 14 - 8
src/threema/container.ts

@@ -15,8 +15,12 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Logger} from 'ts-log';
+
 import {copyShallow, randomString} from '../helpers';
+import {ConfidentialArray, ConfidentialObjectValues} from '../helpers/confidential';
 import {isFirstUnreadStatusMessage} from '../message_helpers';
+import {LogService} from '../services/log';
 import {ReceiverService} from '../services/receiver';
 
 type ContactMap = Map<string, threema.ContactReceiver>;
@@ -408,6 +412,9 @@ class ReceiverMessages {
  * This class manages all messages for the current user.
  */
 class Messages implements threema.Container.Messages {
+    // Logging
+    private readonly log: Logger;
+
     // The messages are stored in date-ascending order,
     // newest messages are appended, older messages are prepended.
     private messages: MessageMap = new Map();
@@ -415,10 +422,8 @@ class Messages implements threema.Container.Messages {
     // Message converter
     public converter: MessageConverter = null;
 
-    private $log: ng.ILogService;
-
-    constructor($log: ng.ILogService) {
-        this.$log = $log;
+    constructor(logService: LogService) {
+        this.log = logService.getLogger('MessagesContainer');
     }
 
     /**
@@ -594,7 +599,8 @@ class Messages implements threema.Container.Messages {
         const lastId = messages[messages.length - 1].id;
         const predicate = (msg: threema.Message) => msg.id === firstId || msg.id === lastId;
         if (receiverMessages.list.findIndex(predicate, receiverMessages.list) !== -1) {
-            this.$log.warn('Messages to be prepended intersect with existing messages:', messages);
+            this.log.warn('Messages to be prepended intersect with existing messages:',
+                new ConfidentialArray(messages.map((message) => new ConfidentialObjectValues(message))));
             return;
         }
 
@@ -866,8 +872,8 @@ class Drafts implements threema.Container.Drafts {
 }
 
 angular.module('3ema.container', [])
-.factory('Container', ['$filter', '$log', 'ReceiverService',
-    function($filter, $log, receiverService: ReceiverService) {
+.factory('Container', ['$filter', 'LogService', 'ReceiverService',
+    function($filter, logService: LogService, receiverService: ReceiverService) {
         class Filters  {
             public static hasData(receivers) {
                 return (obj) => $filter('hasData')(obj, receivers);
@@ -887,7 +893,7 @@ angular.module('3ema.container', [])
             Filters: Filters as threema.Container.Filters,
             createReceivers: () => new Receivers(),
             createConversations: () => new Conversations(receiverService),
-            createMessages: () => new Messages($log),
+            createMessages: () => new Messages(logService),
             createTyping: () => new Typing(),
             createDrafts: () => new Drafts(),
         } as threema.Container.Factory;

+ 6 - 1
tests/bootstrap.ts

@@ -18,8 +18,13 @@
  */
 
 // tslint:disable:no-console
+import config from '../src/config';
 
 // A dependency graph that contains any wasm must all be imported asynchronously.
 import('../src/app')
-    .then(() => console.info('Bundle loaded'))
+    .then(() => {
+        // @ts-ignore
+        window.config = config;
+        console.info('Bundle loaded')
+    })
     .catch((e) => console.error('Could not load bundle', e));

+ 1 - 1
tests/filters.js

@@ -50,7 +50,7 @@ describe('Filters', function() {
         module('3ema.services');
         module('3ema.filters');
 
-        module(function($provide) {
+        module(($provide) => {
             $provide.value('WebClientService', webClientServiceMock);
             $provide.value('$translate', translationMock);
             $provide.constant('$state', null);

+ 12 - 0
tests/init.js

@@ -1,5 +1,17 @@
 // Wait for main application to be fully loaded
 beforeAll((done) => setTimeout(done, 1000));
 
+// Inject common constants
+beforeEach(function() {
+    module(($provide) => {
+        // Provide configuration
+        $provide.constant('CONFIG', window.config);
+
+        // Mock versions
+        $provide.constant('PROTOCOL_VERSION', 1337);
+        $provide.constant('VERSION', 42);
+    });
+});
+
 // Uninstall the mock clock after every test
 afterEach(() => jasmine.clock().uninstall());

+ 1 - 1
tests/service/browser.js

@@ -14,7 +14,7 @@ describe('BrowserService', function() {
     function testUserAgent(agent) {
         let $service;
         module('3ema.services');
-        module(function($provide) {
+        module(($provide) => {
             $provide.value('$window', {
                 navigator: { userAgent: agent }
             });

+ 0 - 9
tests/service/keystore.js

@@ -8,14 +8,6 @@ describe('TrustedKeyStoreService', function() {
     beforeAll(() => window.onbeforeunload = () => null);
 
     beforeEach(function() {
-
-        module(($provide) => {
-            $provide.constant('PROTOCOL_VERSION', 1337);
-            $provide.constant('CONFIG', {
-                'SELF_HOSTED': false,
-            });
-        });
-
         module('3ema.services');
 
         // Inject the service
@@ -24,7 +16,6 @@ describe('TrustedKeyStoreService', function() {
             foo = $service;
             $service.STORAGE_KEY = STORAGE_KEY;
         });
-
     });
 
     it('localstorage is not blocked', () => {

+ 165 - 0
tests/service/log.js

@@ -0,0 +1,165 @@
+describe('LogService', function() {
+    const LOG_TYPES = ['debug', 'trace', 'info', 'warn', 'error'];
+    let backup = {};
+    let records = [];
+    let $service;
+
+    // Mock configuration
+    const config = Object.assign({}, window.config);
+    config.LOG_TAG_PADDING = 20;
+    config.CONSOLE_LOG_LEVEL = 'info';
+    config.REPORT_LOG_LEVEL = 'debug';
+    config.REPORT_LOG_LIMIT = 10;
+
+    // Ignoring page reload request
+    beforeAll(() => window.onbeforeunload = () => null);
+
+    beforeEach(function() {
+        records = [];
+
+        // Store each log level type method of console that we will override
+        for (const type of LOG_TYPES) {
+            backup[type] = console[type];
+        }
+
+        // Overwrite each log level type method of console
+        for (const type of LOG_TYPES) {
+            console[type] = (...args) => records.push(args);
+        }
+
+        // Angular magic
+        module(($provide) => {
+            $provide.constant('CONFIG', config);
+        });
+        module('3ema.services');
+
+        // Inject the service
+        inject(function(LogService) {
+            $service = LogService;
+        });
+    });
+
+    afterEach(() => {
+        // Restore each log level type method of console that we have
+        // previously overridden.
+        for (const type of LOG_TYPES) {
+            console[type] = backup[type];
+        }
+    });
+
+    it('has correct root logger chain', () => {
+        // With the above configuration:
+        //
+        //         TeeLogger
+        //          v     v
+        // LevelLogger   MemoryLogger
+        //      v
+        // UnveilLogger
+        //      v
+        // ConsoleLogger
+
+        // Root logger
+        expect($service.root.constructor.name).toBe('TeeLogger');
+        let [left, right] = $service.root.loggers;
+
+        // Console logger branch
+        expect(left.constructor.name).toBe('LevelLogger');
+        expect(left.level).toBe(config.CONSOLE_LOG_LEVEL);
+        expect(left.logger.constructor.name).toBe('UnveilLogger');
+        expect(left.logger.logger.constructor.name).toBe('ConsoleLogger');
+
+        // Memory (report) logger branch
+        expect(right.constructor.name).toBe('MemoryLogger');
+        expect(right.limit).toBe(config.REPORT_LOG_LIMIT);
+    });
+
+    describe('getLogger', () => {
+        it('log messages propagate to the root logger', () => {
+            const logger = $service.getLogger('test');
+
+            // Log
+            logger.debug('debug');
+            logger.trace('trace');
+            logger.info('info');
+            logger.warn('warn');
+            logger.error('error');
+
+            // Expect the console logger to have been called for 'info' and above
+            expect(records.map((record) => record.slice(1))).toEqual([
+                ['info'],
+                ['warn'],
+                ['error'],
+            ]);
+
+            // Expect the memory logger to have been called for 'debug' and above
+            // (i.e. all log levels).
+            expect(JSON
+                .parse($service.memory.serialize())
+                .map((record) => record.slice(1))
+            ).toEqual([
+                ['debug', '[test]', 'debug'],
+                ['trace', '[test]', 'trace'],
+                ['info', '[test]', 'info'],
+                ['warn', '[test]', 'warn'],
+                ['error', '[test]', 'error'],
+            ]);
+        });
+
+        it('applies a tag (without style)', () => {
+            const logger = $service.getLogger('test');
+            logger.info('test');
+
+            // Expect the console logger tag to be padded
+            expect(records).toEqual([
+                ['                [test]', 'test']
+            ]);
+
+            // Expect the memory logger tag to be unpadded
+            expect(JSON
+                .parse($service.memory.serialize())
+                .map((record) => record.slice(1))
+            ).toEqual([
+                ['info', '[test]', 'test']
+            ]);
+        });
+
+        it('applies a tag (with style)', () => {
+            const style = 'color: #fff';
+            const logger = $service.getLogger('test', style);
+            logger.info('test');
+
+            // Expect the console logger tag to be padded and styled
+            expect(records).toEqual([
+                ['                %c[test]', style, 'test']
+            ]);
+
+            // Expect the memory logger tag to be unpadded and unstyled
+            expect(JSON
+                .parse($service.memory.serialize())
+                .map((record) => record.slice(1))
+            ).toEqual([
+                ['info', '[test]', 'test']
+            ]);
+        });
+
+        it('applies the chosen level', () => {
+            const logger = $service.getLogger('test', undefined, 'info');
+            logger.debug('debug');
+            logger.trace('trace');
+            logger.info('info');
+
+            // Expect the console logger to only contain the 'info' log
+            expect(records.map((record) => record.slice(1))).toEqual([
+                ['info']
+            ]);
+
+            // Expect the memory logger to only contain the 'info' log
+            expect(JSON
+                .parse($service.memory.serialize())
+                .map((record) => record.slice(1))
+            ).toEqual([
+                ['info', '[test]', 'info']
+            ]);
+        });
+    });
+});

+ 0 - 9
tests/service/message.js

@@ -6,14 +6,6 @@ describe('MessageService', function() {
     beforeAll(() => window.onbeforeunload = () => null);
 
     beforeEach(function() {
-
-        // Inject constants
-        module(($provide) => {
-            $provide.constant('CONFIG', {
-                'DEBUG': true,
-            });
-        });
-
         // Load threema services
         module('3ema.services');
 
@@ -21,7 +13,6 @@ describe('MessageService', function() {
         inject(function(MessageService) {
             messageService = MessageService;
         });
-
     });
 
     describe ('getAccess', () => {

+ 0 - 2
tests/service/mime.js

@@ -6,7 +6,6 @@ describe('MimeService', function() {
     beforeAll(() => window.onbeforeunload = () => null);
 
     beforeEach(function() {
-
         module('pascalprecht.translate');
         module('3ema.services');
 
@@ -14,7 +13,6 @@ describe('MimeService', function() {
         inject(function(MimeService) {
             $service = MimeService;
         });
-
     });
 
     it('getLabel', () => {

+ 0 - 1
tests/service/notification.js

@@ -16,7 +16,6 @@ describe('NotificationService', function() {
         inject(function(NotificationService) {
             $service = NotificationService;
         });
-
     });
 
     describe('getAppNotificationSettings', function () {

+ 0 - 9
tests/service/qrcode.js

@@ -6,21 +6,12 @@ describe('QrCodeService', function() {
     beforeAll(() => window.onbeforeunload = () => null);
 
     beforeEach(function() {
-
-        module(($provide) => {
-            $provide.constant('PROTOCOL_VERSION', 1337);
-            $provide.constant('CONFIG', {
-                'SELF_HOSTED': false,
-            });
-        });
-
         module('3ema.services');
 
         // Inject the service
         inject(function(QrCodeService) {
             $service = QrCodeService;
         });
-
     });
 
     it('generates correct payload', () => {

+ 0 - 2
tests/service/receiver.js

@@ -6,14 +6,12 @@ describe('ReceiverService', function () {
     beforeAll(() => window.onbeforeunload = () => null);
 
     beforeEach(function () {
-
         module('3ema.services');
 
         // Inject the service
         inject(function (ReceiverService) {
             $service = ReceiverService;
         });
-
     });
 
     describe('Receiver', () => {

+ 0 - 2
tests/service/string.js

@@ -6,14 +6,12 @@ describe('StringService', function() {
     beforeAll(() => window.onbeforeunload = () => null);
 
     beforeEach(function() {
-
         module('3ema.services');
 
         // Inject the service
         inject(function(StringService) {
             $service = StringService;
         });
-
     });
 
     describe('byteChunkSplit', function() {

+ 0 - 2
tests/service/uri.js

@@ -6,14 +6,12 @@ describe('UriService', function() {
     beforeAll(() => window.onbeforeunload = () => null);
 
     beforeEach(function() {
-
         module('3ema.services');
 
         // Inject the service
         inject(function(UriService) {
             $service = UriService;
         });
-
     });
 
     it('parses query parameters', () => {

+ 0 - 93
tests/service/webclient.js

@@ -1,93 +0,0 @@
-describe('WebClientService', function() {
-
-    let $service;
-
-    beforeEach(function() {
-
-        module(($provide) => {
-            // Provide configuration
-            $provide.constant('CONFIG', {
-                ICE_SERVERS: [
-                    {
-                        urls: [
-                            'turn:turn.threema.ch:443?transport=tcp',
-                            'turn:turn.threema.ch:443?transport=udp',
-                            'turns:turn.threema.ch:443?transport=tcp',
-                        ],
-                        username: 'user',
-                        credential: 'credential',
-                    },
-                ],
-            });
-
-            // Mock some dependencies that we don't really need
-            $provide.constant('PROTOCOL_VERSION', 1337);
-            $provide.constant('$state', null);
-            $provide.constant('$translate', null);
-        });
-
-        // Load modules
-        module('ngAria');
-        module('ngAnimate');
-        module('ngMaterial');
-        module('3ema.services');
-        module('3ema.container');
-
-        // Inject the service to be tested
-        inject(function(WebClientService) {
-            $service = WebClientService;
-        });
-
-    });
-
-    it('can skip ICE TLS hosts if a non-TLS TCP server is available', () => {
-        const allUrlsBefore = [].concat(...$service.config.ICE_SERVERS.map((conf) => conf.urls));
-        expect(allUrlsBefore.indexOf('turns:turn.threema.ch:443?transport=tcp')).not.toEqual(-1);
-
-        $service.skipIceTls();
-
-        const allUrlsAfter = [].concat(...$service.config.ICE_SERVERS.map((conf) => conf.urls));
-        expect(allUrlsAfter.length).toEqual(allUrlsBefore.length - 1);
-        expect(allUrlsAfter.indexOf('turns:turn.threema.ch:443?transport=tcp')).toEqual(-1);
-    });
-
-    it('can skip ICE TLS hosts if a non-TLS TCP server is available in another server object', () => {
-        $service.config.ICE_SERVERS = [
-            {
-                urls: ['turn:turn.threema.ch:443?transport=udp', 'turns:turn.threema.ch:443?transport=tcp'],
-                username: 'user', credential: 'credential',
-            },
-            {
-                urls: ['turn:turn.threema.ch:443?transport=tcp'],
-                username: 'user', credential: 'credential',
-            }
-        ];
-        const allUrlsBefore = [].concat(...$service.config.ICE_SERVERS.map((conf) => conf.urls));
-        expect(allUrlsBefore.indexOf('turns:turn.threema.ch:443?transport=tcp')).not.toEqual(-1);
-
-        $service.skipIceTls();
-
-        const allUrlsAfter = [].concat(...$service.config.ICE_SERVERS.map((conf) => conf.urls));
-        expect(allUrlsAfter.length).toEqual(allUrlsBefore.length - 1);
-        expect(allUrlsAfter.indexOf('turns:turn.threema.ch:443?transport=tcp')).toEqual(-1);
-    });
-
-    it('does not skip ICE TLS hosts if no non-TLS TCP server is available', () => {
-        $service.config.ICE_SERVERS = [
-            {
-                urls: ['turn:turn.threema.ch:443?transport=udp', 'turns:turn.threema.ch:443?transport=tcp'],
-                username: 'user',
-                credential: 'credential',
-            }
-        ];
-        const allUrlsBefore = [].concat(...$service.config.ICE_SERVERS.map((conf) => conf.urls));
-        expect(allUrlsBefore.indexOf('turns:turn.threema.ch:443?transport=tcp')).not.toEqual(-1);
-
-        $service.skipIceTls();
-
-        const allUrlsAfter = [].concat(...$service.config.ICE_SERVERS.map((conf) => conf.urls));
-        expect(allUrlsAfter.length).toEqual(allUrlsBefore.length);
-        expect(allUrlsAfter.indexOf('turns:turn.threema.ch:443?transport=tcp')).not.toEqual(-1);
-    });
-
-});

+ 1 - 1
tests/testsuite.html

@@ -38,10 +38,10 @@
         <script src="service/mime.js"></script>
         <script src="service/qrcode.js"></script>
         <script src="service/uri.js"></script>
-        <script src="service/webclient.js"></script>
         <script src="service/string.js"></script>
         <script src="service/browser.js"></script>
         <script src="service/keystore.js"></script>
+        <script src="service/log.js"></script>
         <script src="service/notification.js"></script>
         <script src="service/receiver.js"></script>
     </head>

+ 303 - 0
tests/ts/confidential_helpers.ts

@@ -0,0 +1,303 @@
+/**
+ * Copyright © 2016-2019 Threema GmbH (https://threema.ch/).
+ *
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+import {
+    censor,
+    BaseConfidential,
+    ConfidentialArray,
+    ConfidentialIceCandidate,
+    ConfidentialObjectValues,
+    ConfidentialWireMessage
+} from '../../src/helpers/confidential';
+
+// tslint:disable:no-reference
+/// <reference path="../../src/threema.d.ts" />
+
+class UnlistedClass {}
+
+/**
+ * A confidential subclass for testing purposes.
+ */
+class TestConfidential extends BaseConfidential<string, string> {
+    public readonly uncensored: string = 'uncensored';
+
+    public censored(): string {
+        return 'censored';
+    }
+}
+
+describe('Confidential Helpers', () => {
+    describe('censor function', () => {
+        it('handles null and undefined', () => {
+            expect(censor(null)).toBe(null);
+            expect(censor(undefined)).toBe(undefined);
+        });
+
+        it('handles an object implementing the Confidential interface', () => {
+            expect(censor(new TestConfidential())).toBe('censored');
+        });
+
+        it('handles booleans', () => {
+            expect(censor(true)).toBe('[Boolean]');
+            expect(censor(false)).toBe('[Boolean]');
+        });
+
+        it('handles numbers', () => {
+            expect(censor(0)).toBe('[Number]');
+            expect(censor(42)).toBe('[Number]');
+            expect(censor(-1337)).toBe('[Number]');
+        });
+
+        it('handles strings', () => {
+            expect(censor('test')).toBe('[String: length=4]');
+            expect(censor('')).toBe('[String: length=0]');
+        });
+
+        it('handles binary types', () => {
+            const buffer = new ArrayBuffer(10);
+            const array = new Uint8Array(buffer, 2, 6);
+            const blob = new Blob([JSON.stringify({ a: 10 })], { type: 'application/json'} );
+            expect(censor(buffer)).toBe('[ArrayBuffer: length=10]');
+            expect(censor(array)).toBe('[Uint8Array: length=6, offset=2]');
+            expect(censor(blob)).toBe(`[Blob: length=${blob.size}, type=application/json]`);
+        });
+
+        it('handles arrays', () => {
+            expect(censor([
+                null,
+                undefined,
+                new TestConfidential(),
+                false,
+                42,
+                'test',
+                new Uint8Array(10),
+            ])).toEqual([
+                null,
+                undefined,
+                'censored',
+                '[Boolean]',
+                '[Number]',
+                '[String: length=4]',
+                '[Uint8Array: length=10, offset=0]',
+            ]);
+        });
+
+        it('handles arrays recursively', () => {
+            expect(censor([
+                'test',
+                [1, false],
+            ])).toEqual([
+                '[String: length=4]',
+                ['[Number]', '[Boolean]'],
+            ]);
+        });
+
+        it('handles objects', () => {
+            expect(censor({
+                null: null,
+                undefined: undefined,
+                confidential: new TestConfidential(),
+                boolean: false,
+                number: 42,
+                string: 'test',
+                uint8array: new Uint8Array(10),
+            })).toEqual({
+                null: null,
+                undefined: undefined,
+                confidential: 'censored',
+                boolean: '[Boolean]',
+                number: '[Number]',
+                string: '[String: length=4]',
+                uint8array: '[Uint8Array: length=10, offset=0]',
+            });
+        });
+
+        it('handles objects recursively', () => {
+            expect(censor({
+                boolean: false,
+                object: {
+                    foo: 'bar',
+                },
+            })).toEqual({
+                boolean: '[Boolean]',
+                object: {
+                    foo: '[String: length=3]',
+                },
+            });
+        });
+
+        it('handles class instances', () => {
+            expect(censor(new UnlistedClass())).toBe('[UnlistedClass]');
+        });
+    });
+
+    describe('ConfidentialArray', () => {
+        it('subclass of BaseConfidential', () => {
+            expect(ConfidentialArray.prototype instanceof BaseConfidential).toBeTruthy();
+        });
+
+        it('sanitises all items', () => {
+            const array = new ConfidentialArray([new TestConfidential(), new TestConfidential()]);
+            expect(array.uncensored).toEqual(['uncensored', 'uncensored']);
+            expect(array.censored()).toEqual(['censored', 'censored']);
+        });
+
+        it('sanitises all items recursively', () => {
+            const array = new ConfidentialArray([
+                new TestConfidential(),
+                new ConfidentialArray([new TestConfidential()]),
+            ]);
+            expect(array.uncensored).toEqual(['uncensored', ['uncensored']]);
+            expect(array.censored()).toEqual(['censored', ['censored']]);
+        });
+    });
+
+    describe('ConfidentialObjectValues', () => {
+        it('subclass of BaseConfidential', () => {
+            expect(ConfidentialObjectValues.prototype instanceof BaseConfidential).toBeTruthy();
+        });
+
+        it('returns underlying object directly when unveiling', () => {
+            const object = {};
+            const confidential = new ConfidentialObjectValues(object);
+            expect(confidential.uncensored).toBe(object);
+        });
+
+        it('sanitises all object values', () => {
+            const object = {
+                boolean: false,
+                object: {
+                    foo: 'bar',
+                },
+            };
+            const confidential = new ConfidentialObjectValues(object);
+            expect(confidential.uncensored).toBe(object);
+            expect(confidential.censored()).toEqual({
+                boolean: '[Boolean]',
+                object: {
+                    foo: '[String: length=3]',
+                },
+            });
+        });
+    });
+
+    describe('ConfidentialWireMessage', () => {
+        it('subclass of BaseConfidential', () => {
+            expect(ConfidentialWireMessage.prototype instanceof BaseConfidential).toBeTruthy();
+        });
+
+        it('returns underlying message directly when unveiling', () => {
+            const message = {
+                type: 'request/food',
+                subType: 'dessert',
+            };
+            const confidential = new ConfidentialWireMessage(message);
+            expect(confidential.uncensored).toBe(message);
+        });
+
+        it("handles 'args' and 'data' being undefined", () => {
+            const message = {
+                type: 'request/food',
+                subType: 'dessert',
+            };
+            const confidential = new ConfidentialWireMessage(message);
+            expect(confidential.censored()).toEqual(message);
+        });
+
+        it("sanitises 'args' and 'data' fields", () => {
+            const message = {
+                type: 'request/food',
+                subType: 'dessert',
+                args: 'arrrrrrgggsss',
+                data: {
+                    preference: ['ice cream', 'chocolate'],
+                    priority: Number.POSITIVE_INFINITY,
+                },
+            };
+            const confidential = new ConfidentialWireMessage(message);
+            expect(confidential.censored()).toEqual({
+                type: 'request/food',
+                subType: 'dessert',
+                args: '[String: length=13]',
+                data: {
+                    preference: ['[String: length=9]', '[String: length=9]'],
+                    priority: '[Number]',
+                },
+            });
+        });
+    });
+
+    describe('ConfidentialIceCandidate', () => {
+        it('subclass of BaseConfidential', () => {
+            expect(ConfidentialIceCandidate.prototype instanceof BaseConfidential).toBeTruthy();
+        });
+
+        it('returns underlying ICE candidate directly when unveiling', () => {
+            const input = 'cannot be bothered to use valid SDP here';
+            const confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.uncensored).toBe(input);
+        });
+
+        it('returns underlying ICE candidate directly if it cannot be parsed', () => {
+            const input = 'certainly invalid';
+            const confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.censored()).toBe(input);
+        });
+
+        it('does not censor mDNS concealed candidates', () => {
+            const input = 'candidate:1 1 UDP 1234 aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.local 1337 typ host';
+            const confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.censored()).toBe(input);
+        });
+
+        it('censors host candidates', () => {
+            // IPv4
+            let input = 'candidate:1 1 UDP 1234 192.168.0.42 1337 typ host';
+            let expected = 'candidate:1 1 UDP 1234 192.168.*.* 1337 typ host';
+            let confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.censored()).toBe(expected);
+
+            // IPv6
+            input = 'candidate:1 1 UDP 1234 fe80::1 1337 typ host';
+            expected = 'candidate:1 1 UDP 1234 fe80::* 1337 typ host';
+            confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.censored()).toBe(expected);
+        });
+
+        it('censors srflx candidates', () => {
+            const input = 'candidate:1 1 UDP 1234 1.2.3.4 42 typ srflx raddr 192.168.0.42 rport 1337';
+            const expected = 'candidate:1 1 UDP 1234 1.2.*.* 42 typ srflx raddr 192.168.*.* rport 1337';
+            const confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.censored()).toBe(expected);
+        });
+
+        it('censors relay candidates', () => {
+            // IPv4
+            let input = 'candidate:1 1 UDP 1234 1.2.3.4 42 typ relay raddr 192.168.0.42 rport 1337';
+            let expected = 'candidate:1 1 UDP 1234 1.2.3.4 42 typ relay raddr 192.168.*.* rport 1337';
+            let confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.censored()).toBe(expected);
+
+            // IPv6
+            input = 'candidate:1 1 UDP 1234 2a02:1:2::3 42 typ relay raddr 2a02:dead:beef::1 rport 1337';
+            expected = 'candidate:1 1 UDP 1234 2a02:1:2::3 42 typ relay raddr 2a02:*:*::* rport 1337';
+            confidential = new ConfidentialIceCandidate(input);
+            expect(confidential.censored()).toBe(expected);
+        });
+    });
+});

+ 511 - 0
tests/ts/logger_helpers.ts

@@ -0,0 +1,511 @@
+/**
+ * Copyright © 2016-2019 Threema GmbH (https://threema.ch/).
+ *
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+// tslint:disable:no-reference
+/// <reference path="../../src/threema.d.ts" />
+
+import {Logger} from 'ts-log';
+
+import {BaseConfidential} from '../../src/helpers/confidential';
+import {ConsoleLogger, LevelLogger, MemoryLogger, TagLogger, TeeLogger, UnveilLogger} from '../../src/helpers/logger';
+
+import LogLevel = threema.LogLevel;
+import LogType = threema.LogType;
+
+interface EnsureLoggedArgs {
+    level: LogLevel,
+    logger: TestLogger | TestLogger[],
+    tag?: string | string[],
+}
+
+type LogRecord = [LogType, any?, ...any[]];
+type LogFunction = (message?: any, ...args: any[]) => void;
+// Supported log (level) types
+const LOG_TYPES: LogType[] = ['debug', 'trace', 'info', 'warn', 'error'];
+
+
+/**
+ * Stores all log records in memory for evaluation.
+ */
+class TestLogger implements Logger {
+    public readonly records: LogRecord[] = [];
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor() {
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.append.bind(this, type);
+        }
+    }
+
+    private append(type: LogType, message?: any, ...args: any[]): void {
+        this.records.push([type, message, ...args])
+    }
+}
+
+/**
+ * A confidential log record for testing purposes.
+ */
+class TestConfidential extends BaseConfidential<string, string> {
+    public readonly uncensored: string = 'uncensored';
+
+    public censored(): string {
+        return 'censored';
+    }
+}
+
+/**
+ * Log a record of each type.
+ */
+function logEachType(...loggers: Logger[]): void {
+    for (const logger of loggers) {
+        for (const type of LOG_TYPES) {
+            logger[type](type);
+        }
+    }
+}
+
+/**
+ * Format a log record. This splices a tag into the expected place.
+ */
+function formatLogRecord(record: LogRecord, tag?: string[]): LogRecord {
+    if (tag === undefined) {
+        return record;
+    }
+
+    // Splice tag into message
+    const [type, ...args] = record;
+    return [type, ...tag.concat(args)];
+}
+
+/**
+ * Ensure a log record of a level equal to or above has been logged.
+ */
+function expectLogged(args: EnsureLoggedArgs): void {
+    // Prepare arguments
+    if (!(args.logger instanceof Array)) {
+        args.logger = [args.logger];
+    }
+    let tag: string[];
+    if (args.tag !== undefined) {
+        tag = args.tag instanceof Array ? args.tag : [args.tag];
+    }
+
+    // Check for log records to be present, depending on the level
+    for (const logger of args.logger) {
+        // noinspection FallThroughInSwitchStatementJS
+        switch (args.level) {
+            case 'debug':
+                expect(logger.records).toContain(formatLogRecord(['debug', 'debug'], tag));
+                expect(logger.records).toContain(formatLogRecord(['trace', 'trace'], tag));
+            case 'info':
+                expect(logger.records).toContain(formatLogRecord(['info', 'info'], tag));
+            case 'warn':
+                expect(logger.records).toContain(formatLogRecord(['warn', 'warn'], tag));
+            case 'error':
+                expect(logger.records).toContain(formatLogRecord(['error', 'error'], tag));
+            default:
+                break;
+        }
+    }
+}
+
+describe('Logger Helpers', () => {
+    describe('TestLogger', () => {
+        it('stores log records', () => {
+            const logger = new TestLogger();
+
+            // Ensure a record of each type is being logged
+            logEachType(logger);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+    });
+
+    describe('TeeLogger', () => {
+        it('forwards log records to each underlying logger', () => {
+            const loggers: TestLogger[] = [
+                new TestLogger(),
+                new TestLogger(),
+                new TestLogger(),
+            ];
+            const root = new TeeLogger(loggers);
+
+            // Ensure a record of each type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: loggers });
+        });
+    });
+
+    describe('LevelLogger', () => {
+        it("'none' level discards everything", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'none');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expect(logger.records.length).toBe(0);
+        });
+
+        it("'debug' level discards nothing", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'debug');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+
+        it("'info' level discards 'debug' and 'trace'", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'info');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'info', logger: logger });
+        });
+
+        it("'warn' level discards 'debug', 'trace' and 'info'", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'warn');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'warn', logger: logger });
+        });
+
+        it("'error' level discards 'debug', 'trace', 'info' and 'warn'", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'error');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'error', logger: logger });
+        });
+    });
+
+    describe('TagLogger', () => {
+        it('applies a tag', () => {
+            const logger = new TestLogger();
+            const root = new TagLogger(logger, 'tag');
+
+            // Ensure a record of each type is being logged with the expected tag
+            logEachType(root);
+            expectLogged({ level: 'debug', tag: 'tag', logger: logger });
+        });
+
+        it('applies multiple tags', () => {
+            const logger = new TestLogger();
+            const root = new TagLogger(logger, 'tag1', 'tag2');
+
+            // Ensure a record of each type is being logged with the expected tag
+            logEachType(root);
+            expectLogged({ level: 'debug', tag: ['tag1', 'tag2'], logger: logger });
+        });
+    });
+
+    describe('UnveilLogger', () => {
+        it('leaves normal log records untouched', () => {
+            const logger = new TestLogger();
+            const root = new UnveilLogger(logger);
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+
+        it('unveils confidential log record', () => {
+            const logger = new TestLogger();
+            const root = new UnveilLogger(logger);
+
+            // Ensure a confidential record is being unveiled
+            root.debug(new TestConfidential());
+            expect(logger.records).toContain(['debug', 'uncensored']);
+        });
+    });
+
+    describe('ConsoleLogger', () => {
+        let backup: Logger = {} as Logger;
+        let logger: TestLogger;
+
+        beforeEach(() => {
+            logger = new TestLogger();
+
+            // Store each log level type method of console that we will override
+            for (const type of LOG_TYPES) {
+                backup[type] = console[type];
+            }
+
+            // Overwrite each log level type method of console
+            for (const type of LOG_TYPES) {
+                console[type] = logger[type];
+            }
+        });
+
+        afterEach(() => {
+            // Restore each log level type method of console that we have
+            // previously overridden.
+            for (const type of LOG_TYPES) {
+                console[type] = backup[type];
+            }
+        });
+
+        it('forwards log records to the console', () => {
+            const root = new ConsoleLogger();
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+
+        it('leaves confidential log record untouched', () => {
+            const root = new ConsoleLogger();
+
+            // Ensure a confidential record is being left untouched
+            const message = new TestConfidential();
+            root.debug(message);
+            expect(logger.records).toContain(['debug', message]);
+        });
+    });
+
+    describe('MemoryLogger', () => {
+        it('serialises each log record with a timestamp', () => {
+            const start = Date.now();
+            const logger = new MemoryLogger();
+
+            // Ensure each log record has a timestamp.
+            for (let i = 0; i < 10; ++i) {
+                logger.debug(i);
+            }
+            const end = Date.now();
+            const timestamps = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry[0]);
+            expect(timestamps.length).toBe(10);
+            for (const timestamp of timestamps) {
+                expect(timestamp).toBeGreaterThanOrEqual(start);
+                expect(timestamp).toBeLessThanOrEqual(end);
+            }
+        });
+
+        it('stores all arguments of the log record', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure all arguments of the log record are being kept.
+            // Note: All of these values need to serialised loss-less.
+            const record = [
+                'debug',
+                null,
+                true,
+                1,
+                'a',
+                [2, 3],
+                { b: 4 },
+            ];
+            logger.debug(...record.slice(1));
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(record);
+        });
+
+        it("strips style formatting of the log record's message (tag)", () => {
+            const logger = new MemoryLogger();
+
+            // Ensure %c CSS style formatting placeholder and the following
+            // argument is being stripped.
+            const args = [
+                null,
+                true,
+                1,
+                'a',
+                [2, 3],
+                { b: 4 },
+            ];
+            logger.debug('  te%cst  ', 'color: #fff', ...args);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual((['debug', 'test'] as any[]).concat(args));
+        });
+
+        it("ignores style formatting beyond the log record's message (args)", () => {
+            const logger = new MemoryLogger();
+
+            // Ensure %c CSS style formatting placeholder and the following
+            // argument are not being touched.
+            const record = [
+                'debug',
+                'test',
+                '  me%cow  ',
+                'color: #fff',
+                null,
+                true,
+                '  ra%cwr  ',
+                'color: #ffa500',
+                1,
+                'a',
+                [2, 3],
+                { b: 4 },
+            ];
+            logger.debug(...record.slice(1));
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(record);
+        });
+
+        it('serialises standard types', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure 'null', 'boolean', 'number' and 'string' are being
+            // represented loss-less.
+            const record = [
+                'debug',
+                null,
+                false,
+                1337,
+                'meow?',
+            ];
+            logger.debug(...record.slice(1));
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(record);
+        });
+
+        it('serialises confidential types sanitised', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure 'Confidential' messages are being sanitised.
+            const confidential = new TestConfidential();
+            logger.debug(confidential, confidential, confidential);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', 'censored', 'censored', 'censored']);
+        });
+
+        it('serialises exceptions', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure exceptions are being represented (in their lossy string form).
+            const error = new Error('WTF!');
+            logger.error(error);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['error', error.toString()]);
+        });
+
+        it('serialises unrepresentable binary types', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure 'ArrayBuffer', 'Uint8Array' and 'Blob' are being
+            // represented with metadata only.
+            const buffer = new ArrayBuffer(10);
+            const array = new Uint8Array(buffer, 2, 6);
+            const blob = new Blob([JSON.stringify({ a: 10 })], { type: 'application/json'} );
+            logger.debug(buffer, array, blob);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual([
+                'debug',
+                '[ArrayBuffer: length=10]',
+                '[Uint8Array: length=6, offset=2]',
+                `[Blob: length=${blob.size}, type=application/json]`,
+            ]);
+        });
+
+        it('serialises class instances', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure instances are being represented with their name.
+            logger.debug(logger);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', '[MemoryLogger]']);
+        });
+
+        it('serialises objects recursively', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure an object's properties are being serialised recursively.
+            const object = {
+                bool: true,
+                inner: {
+                    number: 4,
+                },
+                array: ['a', 'b'],
+            };
+            logger.debug(object);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', object]);
+        });
+
+        it('serialises arrays recursively', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure each item of an array is being serialised recursively.
+            const array = [
+                false,
+                { null: null },
+                ['a', 'b'],
+            ];
+            logger.debug(array);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', array]);
+        });
+
+        it('respects the supplied log record limit', () => {
+            const logger = new MemoryLogger(2);
+
+            // Ensure only the last two log records are being kept
+            for (let i = 0; i < 10; ++i) {
+                logger.debug(i);
+            }
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records).toEqual([
+                ['debug', 8],
+                ['debug', 9],
+            ]);
+        });
+    });
+});

+ 2 - 0
tests/ts/main.ts

@@ -20,9 +20,11 @@
 // tslint:disable:no-reference
 /// <reference path="../../src/threema.d.ts" />
 
+import './confidential_helpers';
 import './containers';
 import './crypto_helpers';
 import './emoji_helpers';
+import './logger_helpers';
 import './helpers';
 import './markup_parser';
 import './receiver_helpers';

+ 1 - 1
tsconfig.json

@@ -1,6 +1,6 @@
 {
     "compilerOptions": {
-        "target": "ES2015",
+        "target": "ES2017",
         "module": "esNext",
         "moduleResolution": "node",
         "removeComments": true

Деякі файли не було показано, через те що забагато файлів було змінено