瀏覽代碼

Merge pull request #551 from threema-ch/ack-protocol

Handshake & ACK protocol
Danilo Bargen 7 年之前
父節點
當前提交
30d67fa6cd

+ 1 - 0
index.html

@@ -121,6 +121,7 @@
     <script src="libs/emojione/emojione.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/angularjs-scroll-glue/src/scrollglue.js?v=[[VERSION]]"></script>
     <script src="libs/angular-inview/angular-inview.js?v=[[VERSION]]"></script>
+    <script src="libs/future.js?v=[[VERSION]]"></script>
 
     <!-- Translation -->
     <script src="node_modules/messageformat/messageformat.min.js?v=[[VERSION]]"></script>

+ 4 - 2
public/i18n/de.json

@@ -52,7 +52,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services nicht installiert. Bitte starten Sie die Sitzung manuell.",
         "CONNECTING_TO_SERVER": "Verbinden mit Server\u2026",
         "CONNECTING_TO_APP": "Verbindung zu App wird aufgebaut\u2026",
-        "CONNECTION_CLOSED": "Server-Verbindung wurde geschlossen."
+        "CONNECTION_CLOSED": "Server-Verbindung wurde geschlossen.",
+        "RECONNECT_FAILED": "Verbindung zur App fehlgeschlagen."
     },
     "troubleshooting": {
         "SLOW_CONNECT": "Verbindungsaufbau scheint länger zu dauern<br>als normal …",
@@ -326,6 +327,7 @@
         "SESSION_STOPPED": "Die Sitzung wurde auf Ihrem Gerät gestoppt.",
         "SESSION_DELETED": "Die Sitzung wurde auf Ihrem Gerät gelöscht.",
         "WEBCLIENT_DISABLED": "Threema Web wurde auf Ihrem Gerät deaktiviert.",
-        "SESSION_REPLACED": "Die Sitzung wurde beendet, weil Sie eine andere Sitzung gestartet haben."
+        "SESSION_REPLACED": "Die Sitzung wurde beendet, weil Sie eine andere Sitzung gestartet haben.",
+        "SESSION_ERROR": "Die Sitzung wurde aufgrund eines Protokollfehlers beendet."
     }
 }

+ 4 - 2
public/i18n/en.json

@@ -52,7 +52,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services not installed. Please start the session manually.",
         "CONNECTING_TO_SERVER": "Connecting to server\u2026",
         "CONNECTING_TO_APP": "Connection to app is being established\u2026",
-        "CONNECTION_CLOSED": "Connection to server has been closed."
+        "CONNECTION_CLOSED": "Connection to server has been closed.",
+        "RECONNECT_FAILED": "Connecting to app failed."
     },
     "troubleshooting": {
         "SLOW_CONNECT": "Connecting seems to take longer than usual …",
@@ -325,6 +326,7 @@
         "SESSION_STOPPED": "The session was stopped on your device.",
         "SESSION_DELETED": "The session was deleted on your device.",
         "WEBCLIENT_DISABLED": "Threema Web was disabled on your device.",
-        "SESSION_REPLACED": "This session was stopped because you started a Threema Web session in another browser window."
+        "SESSION_REPLACED": "This session was stopped because you started a Threema Web session in another browser window.",
+        "SESSION_ERROR": "The session was stopped due to a protocol error."
     }
 }

+ 45 - 0
public/libs/future.js

@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * A future similar to Python's asyncio.Future. Allows to resolve or reject
+ * outside of the executor and query the current status.
+ */
+class Future extends Promise {
+    constructor(executor) {
+        let resolve, reject;
+        super((resolve_, reject_) => {
+            resolve = resolve_;
+            reject = reject_;
+            if (executor) {
+                return executor(resolve_, reject_);
+            }
+        });
+
+        this._done = false;
+        this._resolve = resolve;
+        this._reject = reject;
+    }
+
+    /**
+     * Return whether the future is done (resolved or rejected).
+     */
+    get done() {
+        return this._done;
+    }
+
+    /**
+     * Resolve the future.
+     */
+    resolve(...args) {
+        this._done = true;
+        return this._resolve(...args);
+    }
+
+    /**
+     * Reject the future.
+     */
+    reject(...args) {
+        this._done = true;
+        return this._reject(...args);
+    }
+}

+ 87 - 39
src/controllers/status.ts

@@ -22,13 +22,14 @@ import {StateService} from '../services/state';
 import {WebClientService} from '../services/webclient';
 
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 /**
  * This controller handles state changes globally.
  *
  * It also controls auto-reconnecting and the connection status indicator bar.
  *
- * Status updates should be done through the status service.
+ * Status updates should be done through the state service.
  */
 export class StatusController {
 
@@ -55,8 +56,10 @@ export class StatusController {
     private webClientService: WebClientService;
     private controllerService: ControllerService;
 
-    public static $inject = ['$scope', '$timeout', '$log', '$state', 'StateService',
-        'WebClientService', 'ControllerService'];
+    public static $inject = [
+        '$scope', '$timeout', '$log', '$state', 'StateService',
+        'WebClientService', 'ControllerService',
+    ];
     constructor($scope, $timeout: ng.ITimeoutService, $log: ng.ILogService, $state: UiStateService,
                 stateService: StateService, webClientService: WebClientService,
                 controllerService: ControllerService) {
@@ -169,15 +172,13 @@ export class StatusController {
             // Collapse status bar
             this.collapseStatusBar();
 
-            // Reset state
-            this.stateService.reset();
-
-            // Redirect to welcome page
-            this.$state.go('welcome', {
-                initParams: {
-                    keyStore: originalKeyStore,
-                    peerTrustedKey: originalPeerPermanentKeyBytes,
-                },
+            // Reset connection & state
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionError,
+                send: false,
+                // TODO: Use welcome.error once we have it
+                close: 'welcome',
+                connectionBuildupState: 'reconnect_failed',
             });
         };
 
@@ -197,10 +198,16 @@ export class StatusController {
 
         // Function to soft-reconnect. Does not reset the loaded data.
         const doSoftReconnect = () => {
-            const resetPush = false;
-            const redirect = false;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionStopped,
+                send: true,
+                close: false,
+            });
+            this.webClientService.init({
+                keyStore: originalKeyStore,
+                peerTrustedKey: originalPeerPermanentKeyBytes,
+                resume: true,
+            });
             this.webClientService.start().then(
                 () => {
                     // Cancel timeout
@@ -250,34 +257,75 @@ export class StatusController {
         const originalKeyStore = this.webClientService.salty.keyStore;
         const originalPeerPermanentKeyBytes = this.webClientService.salty.peerPermanentKeyBytes;
 
-        // Handler for failed reconnection attempts
-        const reconnectionFailed = () => {
-            // Reset state
-            this.stateService.reset();
-
-            // Redirect to welcome page
-            this.$state.go('welcome', {
-                initParams: {
-                    keyStore: originalKeyStore,
-                    peerTrustedKey: originalPeerPermanentKeyBytes,
-                },
-            });
-        };
-
-        const resetPush = false;
-        const skipPush = true;
-        const redirect = false;
-        const startTimeout = 500; // Delay connecting a bit to wait for old websocket to close
+        // 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.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
+            send: true,
+            close: false,
+            connectionBuildupState: 'push',
+        });
+
+        // Only send a push...
+        const push = ((): { send: boolean, reason?: string } => {
+            // ... if never left the 'welcome' page.
+            if (this.$state.includes('welcome')) {
+                return {
+                    send: true,
+                    reason: 'still on welcome page',
+                };
+            }
+
+            // ... if there is at least one pending request.
+            const pendingRequests = this.webClientService.pendingRequests;
+            if (pendingRequests > 0) {
+                return {
+                    send: true,
+                    reason: `${pendingRequests} pending requests`,
+                };
+            }
+
+            // ... if there are one or more cached chunks that require immediate
+            //     sending.
+            const immediateChunksPending = this.webClientService.immediateChunksPending;
+            if (immediateChunksPending > 0) {
+                return {
+                    send: true,
+                    reason: `${immediateChunksPending} chunks that require acknowledgement`,
+                };
+            }
+
+            // ... otherwise, don't push!
+            return {
+                send: false,
+            };
+        })();
+
         this.$timeout(() => {
-            this.$log.debug(this.logTag, 'Starting new connection');
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
-            this.webClientService.start(skipPush).then(
+            if (push.send) {
+                this.$log.debug(`Starting new connection with push, reason: ${push.reason}`);
+            } else {
+                this.$log.debug('Starting new connection without push');
+            }
+            this.webClientService.init({
+                keyStore: originalKeyStore,
+                peerTrustedKey: originalPeerPermanentKeyBytes,
+                resume: true,
+            });
+
+            this.webClientService.start(!push.send).then(
                 () => { /* ok */ },
                 (error) => {
                     this.$log.error(this.logTag, 'Error state:', error);
-                    reconnectionFailed();
+                    this.webClientService.stop({
+                        reason: DisconnectReason.SessionError,
+                        send: false,
+                        // TODO: Use welcome.error once we have it
+                        close: 'welcome',
+                        connectionBuildupState: 'reconnect_failed',
+                    });
                 },
                 // Progress
                 (progress: threema.ConnectionBuildupStateChange) => {

+ 17 - 1
src/helpers.ts

@@ -258,7 +258,7 @@ export function escapeRegExp(str: string) {
  * msgpack encoded data.
  */
 export function msgpackVisualizer(bytes: Uint8Array): string {
-    return 'https://msgpack.dbrgn.ch#base64=' + encodeURIComponent(btoa(bytes as any));
+    return 'https://msgpack.dbrgn.ch#base64=' + encodeURIComponent(btoa(String.fromCharCode.apply(null, bytes)));
 }
 
 /**
@@ -338,3 +338,19 @@ export function hasValue(val: any): boolean {
 export function sleep(ms: number): Promise<void> {
     return new Promise((resolve) => setTimeout(resolve, ms));
 }
+
+/**
+ * Compare two Uint8Array instances. Return true if all elements are equal
+ * (compared using ===).
+ */
+export function arraysAreEqual(a1: Uint8Array, a2: Uint8Array): boolean {
+    if (a1.length !== a2.length) {
+        return false;
+    }
+    for (let i = 0; i < a1.length; i++) {
+        if (a1[i] !== a2[i]) {
+            return false;
+        }
+    }
+    return true;
+}

+ 22 - 10
src/partials/messenger.ts

@@ -585,6 +585,9 @@ class ConversationController {
                         `,
                         // tslint:enable:max-line-length
                     }).then((data) => {
+                        // TODO: This should probably be moved into the
+                        //       WebClientService as a specific method for the
+                        //       type.
                         const caption = data.caption;
                         const sendAsFile = data.sendAsFile;
                         contents.forEach((msg: threema.FileMessageData, index: number) => {
@@ -592,7 +595,7 @@ class ConversationController {
                                 msg.caption = caption;
                             }
                             msg.sendAsFile = sendAsFile;
-                            this.webClientService.sendMessage(this.$stateParams, type, msg)
+                            this.webClientService.sendMessage(this.$stateParams, type, true, msg)
                                 .then(() => {
                                     nextCallback(index);
                                 })
@@ -612,7 +615,10 @@ class ConversationController {
                         // remove quote
                         this.webClientService.setQuote(this.receiver);
                         // send message
-                        this.webClientService.sendMessage(this.$stateParams, type, msg)
+                        // TODO: This should probably be moved into the
+                        //       WebClientService as a specific method for the
+                        //       type.
+                        this.webClientService.sendMessage(this.$stateParams, type, true, msg)
                             .then(() => {
                                 nextCallback(index);
                             })
@@ -1003,10 +1009,13 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const resetPush = true;
-            const redirect = true;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
-            this.receiverService.setActive(undefined);
+            this.webClientService.stop({
+                reason: threema.DisconnectReason.SessionStopped,
+                send: true,
+                // TODO: Use welcome.stopped once we have it
+                close: 'welcome',
+                connectionBuildupState: 'closed',
+            });
         }, () => {
             // do nothing
         });
@@ -1023,10 +1032,13 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const resetPush = true;
-            const redirect = true;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
-            this.receiverService.setActive(undefined);
+            this.webClientService.stop({
+                reason: threema.DisconnectReason.SessionDeleted,
+                send: true,
+                // TODO: Use welcome.deleted once we have it
+                close: 'welcome',
+                connectionBuildupState: 'closed',
+            });
         }, () => {
             // do nothing
         });

+ 12 - 1
src/partials/welcome.html

@@ -31,7 +31,7 @@
             </div>
         </div>
 
-        <div ng-if="ctrl.state === 'connecting' && ctrl.mode === 'unlock'" class="unlock">
+        <div ng-if="(ctrl.state === 'new' || ctrl.state === 'connecting') && ctrl.mode === 'unlock'" class="unlock">
             <h2 class="instructions" translate>welcome.PLEASE_UNLOCK</h2>
             <div class="password-entry">
                 <label>
@@ -138,5 +138,16 @@
             </md-button>
         </div>
 
+        <div ng-if="ctrl.state === 'reconnect_failed'">
+            <p class="state error">
+                <strong><span translate>common.ERROR</span>:</strong> <span translate>connecting.RECONNECT_FAILED</span><br>
+                <span translate>welcome.PLEASE_RELOAD</span>
+            </p>
+            <br>
+            <md-button class="md-raised md-primary" ng-click="ctrl.reload()">
+                <i class="material-icons">refresh</i> <span translate>welcome.RELOAD</span>
+            </md-button>
+        </div>
+
     </div>
 </div>

+ 47 - 35
src/partials/welcome.ts

@@ -20,7 +20,6 @@
 /// <reference path="../types/broadcastchannel.d.ts" />
 
 import {
-    StateParams as UiStateParams,
     StateProvider as UiStateProvider,
     StateService as UiStateService,
 } from '@uirouter/angularjs';
@@ -36,6 +35,7 @@ import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 class DialogController {
     // TODO: This is also used in partials/messenger.ts. We could somehow
@@ -53,10 +53,6 @@ class DialogController {
     }
 }
 
-interface WelcomeStateParams extends UiStateParams {
-    initParams: null | {keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array};
-}
-
 class WelcomeController {
 
     private static REDIRECT_DELAY = 500;
@@ -94,12 +90,12 @@ class WelcomeController {
     private browserWarningShown: boolean = false;
 
     public static $inject = [
-        '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
+        '$scope', '$state', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
         'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
         'VersionService', 'SettingsService', 'ControllerService',
         'BROWSER_MIN_VERSIONS', 'CONFIG',
     ];
-    constructor($scope: ng.IScope, $state: UiStateService, $stateParams: WelcomeStateParams,
+    constructor($scope: ng.IScope, $state: UiStateService,
                 $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
@@ -130,6 +126,8 @@ class WelcomeController {
         this.settingsService = settingsService;
         this.config = config;
 
+        // 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;
@@ -198,12 +196,7 @@ class WelcomeController {
         }
 
         // Determine connection mode
-        if ($stateParams.initParams !== null) {
-            this.mode = 'unlock';
-            const keyStore = $stateParams.initParams.keyStore;
-            const peerTrustedKey = $stateParams.initParams.peerTrustedKey;
-            this.reconnect(keyStore, peerTrustedKey);
-        } else if (hasTrustedKey) {
+        if (hasTrustedKey) {
             this.mode = 'unlock';
             this.unlock();
         } else {
@@ -257,11 +250,19 @@ class WelcomeController {
     /**
      * Initiate a new session by scanning a new QR code.
      */
-    private scan(): void {
+    private scan(stopArguments?: threema.WebClientServiceStopArguments): void {
         this.$log.info(this.logTag, 'Initialize session by scanning QR code...');
 
         // Initialize webclient with new keystore
-        this.webClientService.init();
+        this.webClientService.stop(stopArguments !== undefined ? stopArguments : {
+            reason: DisconnectReason.SessionStopped,
+            send: false,
+            close: 'welcome',
+            connectionBuildupState: this.stateService.connectionBuildupState,
+        });
+        this.webClientService.init({
+            resume: false,
+        });
 
         // Set up the broadcast channel that checks whether we're already connected in another tab
         this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);
@@ -280,6 +281,7 @@ class WelcomeController {
      * Initiate a new session by unlocking a trusted key.
      */
     private unlock(): void {
+        this.stateService.reset('new');
         this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...');
     }
 
@@ -302,14 +304,8 @@ class WelcomeController {
         // Set up the broadcast channel that checks whether we're already connected in another tab
         this.setupBroadcastChannel(keyStore.publicKeyHex);
 
-        // Initialize push service
-        if (decrypted.pushToken !== null && decrypted.pushTokenType !== null) {
-            this.webClientService.updatePushToken(decrypted.pushToken, decrypted.pushTokenType);
-            this.pushService.init(decrypted.pushToken, decrypted.pushTokenType);
-        }
-
         // Reconnect
-        this.reconnect(keyStore, decrypted.peerPublicKey);
+        this.reconnect(keyStore, decrypted);
     }
 
     /**
@@ -381,10 +377,30 @@ class WelcomeController {
     }
 
     /**
-     * Reconnect using a specific keypair and peer public key.
+     * Reconnect using a specific keypair and the decrypted data from the trusted keystore.
      */
-    private reconnect(keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array): void {
-        this.webClientService.init(keyStore, peerTrustedKey);
+    private reconnect(keyStore: saltyrtc.KeyStore, decrypted: threema.TrustedKeyStoreData): void {
+        // Reset state
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
+            send: false,
+            close: 'welcome',
+            connectionBuildupState: this.stateService.connectionBuildupState,
+        });
+
+        // Initialize push service
+        if (decrypted.pushToken !== null && decrypted.pushTokenType !== null) {
+            this.webClientService.updatePushToken(decrypted.pushToken, decrypted.pushTokenType);
+            this.pushService.init(decrypted.pushToken, decrypted.pushTokenType);
+        }
+
+        // Initialize webclient service
+        this.webClientService.init({
+            keyStore: keyStore,
+            peerTrustedKey: decrypted.peerPublicKey,
+            resume: false,
+        });
+
         this.start();
     }
 
@@ -475,21 +491,17 @@ class WelcomeController {
              .cancel(this.$translate.instant('common.CANCEL'));
 
         this.$mdDialog.show(confirm).then(() =>  {
-            // Force-stop the webclient
-            const resetPush = true;
-            const redirect = false;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
-
-            // Reset state
-            this.stateService.updateConnectionBuildupState('new');
-
             // Go back to scan mode
             this.mode = 'scan';
             this.password = '';
             this.formLocked = false;
 
-            // Initiate scan
-            this.scan();
+            // Force-stop the webclient and initiate scan
+            this.scan({
+                reason: DisconnectReason.SessionDeleted,
+                send: true,
+                close: 'welcome',
+            });
         }, () => {
             // do nothing
         });

+ 115 - 0
src/protocol/cache.ts

@@ -0,0 +1,115 @@
+/**
+ * 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 {SequenceNumber} from './sequence_number';
+
+export type CachedChunk = Uint8Array | null;
+
+/**
+ * Contains chunks that have not yet been acknowledged.
+ */
+export class ChunkCache {
+    private _sequenceNumber: SequenceNumber;
+    private _byteLength = 0;
+    private cache: CachedChunk[] = [];
+
+    constructor(sequenceNumber: SequenceNumber) {
+        this._sequenceNumber = sequenceNumber;
+    }
+
+    /**
+     * Get the current sequence number (e.g. of the **next** chunk to be added).
+     */
+    public get sequenceNumber(): SequenceNumber {
+        return this._sequenceNumber;
+    }
+
+    /**
+     * Get the total size of currently cached chunks in bytes.
+     */
+    public get byteLength(): number {
+        return this._byteLength;
+    }
+
+    /**
+     * Get a reference to the currently cached chunks.
+     *
+     * Note: Blacklisted chunks will be filtered automatically.
+     */
+    public get chunks(): CachedChunk[] {
+        return this.cache.filter((chunk) => chunk !== null);
+    }
+
+    /**
+     * Transfer an array of cached chunks to this cache instance and return the
+     * amount of chunks that have been transferred.
+     */
+    public transfer(cache: CachedChunk[]): number {
+        // Add chunks but remove all which should not be retransmitted
+        cache = cache.filter((chunk) => chunk !== null);
+        const count = cache.length;
+        for (const chunk of cache) {
+            this.append(chunk);
+        }
+        return count;
+    }
+
+    /**
+     * Append a chunk to the chunk cache.
+     */
+    public append(chunk: CachedChunk): void {
+        // Update sequence number, update size & append chunk
+        this._sequenceNumber.increment();
+        if (chunk !== null) {
+            this._byteLength += chunk.byteLength;
+        }
+        this.cache.push(chunk);
+    }
+
+    /**
+     * Prune cached chunks that have been acknowledged. Return the
+     * amount of chunks which have been acknowledged and the amount of
+     * chunks left in the cache.
+     */
+    public prune(theirSequenceNumber: number): { acknowledged: number, left: number } {
+        try {
+            this._sequenceNumber.validate(theirSequenceNumber);
+        } catch (error) {
+            throw new Error(`Remote sent us an invalid sequence number: ${theirSequenceNumber}`);
+        }
+
+        // Calculate the slice start index for the chunk cache
+        // Important: Our sequence number is one chunk ahead!
+        const beginOffset = theirSequenceNumber - this._sequenceNumber.get();
+        if (beginOffset > 0) {
+            throw new Error('Remote travelled through time and acknowledged a chunk which is in the future');
+        } else if (-beginOffset > this.cache.length) {
+            throw new Error('Remote travelled back in time and acknowledged a chunk it has already acknowledged');
+        }
+
+        // Slice our cache & recalculate size
+        const chunkCountBefore = this.cache.length;
+        this.cache = beginOffset === 0 ? [] : this.cache.slice(beginOffset);
+        this._byteLength = this.cache
+            .filter((chunk) => chunk !== null)
+            .reduce((sum, chunk) => sum + chunk.byteLength, 0);
+        return {
+            acknowledged: chunkCountBefore + beginOffset,
+            left: this.cache.length,
+        };
+    }
+}

+ 73 - 0
src/protocol/sequence_number.ts

@@ -0,0 +1,73 @@
+/**
+ * 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/>.
+ */
+
+/**
+ * A generic sequence number with specific boundaries.
+ *
+ * Does not allow for wrapping.
+ */
+export class SequenceNumber {
+    private readonly minValue: number;
+    private readonly maxValue: number;
+    private value: number;
+
+    constructor(initialValue: number = 0, minValue: number, maxValue: number) {
+        this.minValue = minValue;
+        this.maxValue = maxValue;
+        this.value = initialValue;
+    }
+
+    /**
+     * Validate a specific sequence number.
+     */
+    public validate(other: number) {
+        if (other < this.minValue) {
+            throw new Error(`Invalid sequence number: ${other} < 0`);
+        }
+        if (other > this.maxValue) {
+            throw new Error(`Invalid sequence number: ${other} > ${this.maxValue}`);
+        }
+    }
+
+    /**
+     * Get the current value of the sequence number.
+     */
+    public get(): number {
+        return this.value;
+    }
+
+    /**
+     * Set the new value of the sequence number.
+     */
+    public set(value: number): void {
+        this.validate(value);
+        this.value = value;
+    }
+
+    /**
+     * Increment the sequence number and return the sequence number as it was
+     * before it has been incremented.
+     */
+    public increment(by: number = 1): number {
+        if (by < 0) {
+            throw new Error('Cannot decrement the sequence number');
+        }
+        const value = this.value;
+        this.set(value + by);
+        return value;
+    }
+}

+ 14 - 54
src/services/peerconnection.ts

@@ -39,9 +39,6 @@ export class PeerConnectionHelper {
     public connectionState: TaskConnectionState = TaskConnectionState.New;
     public onConnectionStateChange: (state: TaskConnectionState) => void = null;
 
-    // Internal callback when connection closes
-    private onConnectionClosed: () => void = null;
-
     // Debugging
     private censorCandidates: boolean;
 
@@ -179,14 +176,10 @@ export class PeerConnectionHelper {
     /**
      * Create a new secure data channel.
      */
-    public createSecureDataChannel(label: string, onopenHandler?): saltyrtc.tasks.webrtc.SecureDataChannel {
+    public createSecureDataChannel(label: string): saltyrtc.tasks.webrtc.SecureDataChannel {
         const dc: RTCDataChannel = this.pc.createDataChannel(label);
         dc.binaryType = 'arraybuffer';
-        const sdc: saltyrtc.tasks.webrtc.SecureDataChannel = this.webrtcTask.wrapDataChannel(dc);
-        if (onopenHandler !== undefined) {
-            sdc.onopen = onopenHandler;
-        }
-        return sdc;
+        return this.webrtcTask.wrapDataChannel(dc);
     }
 
     /**
@@ -198,56 +191,23 @@ export class PeerConnectionHelper {
             if (this.onConnectionStateChange !== null) {
                 this.$timeout(() => this.onConnectionStateChange(state), 0);
             }
-            if (this.onConnectionClosed !== null && state === TaskConnectionState.Disconnected) {
-                this.$timeout(() => this.onConnectionClosed(), 0);
-            }
         }
     }
 
     /**
-     * Close the peer connection.
-     *
-     * Return a promise that resolves once the connection is actually closed.
+     * Unbind all event handler and abruptly close the peer connection.
      */
-    public close(): ng.IPromise<{}> {
-        return this.$q((resolve, reject) => {
-            const signalingClosed = this.pc.signalingState as string === 'closed'; // Legacy
-            const connectionClosed = this.pc.connectionState === 'closed';
-            if (!signalingClosed && !connectionClosed) {
-
-                // If connection state is not yet "disconnected", register a callback
-                // for the disconnect event.
-                if (this.connectionState !== 'disconnected') {
-                    // Disconnect timeout
-                    let timeout: ng.IPromise<any>;
-
-                    // Handle connection closed event
-                    this.onConnectionClosed = () => {
-                        this.$timeout.cancel(timeout);
-                        this.onConnectionClosed = null;
-                        resolve();
-                    };
-
-                    // Launch timeout
-                    timeout = this.$timeout(() => {
-                        this.onConnectionClosed = null;
-                        reject('Timeout');
-                    }, 2000);
-                }
-
-                // Close connection
-                setTimeout(() => {
-                    this.pc.close();
-                }, 0);
-
-                // If connection state is already "disconnected", resolve immediately.
-                if (this.connectionState === 'disconnected') {
-                    resolve();
-                }
-            } else {
-                resolve();
-            }
-        });
+    public close(): void {
+        this.webrtcTask.off();
+        this.pc.onnegotiationneeded = null;
+        this.pc.onconnectionstatechange = null;
+        this.pc.onsignalingstatechange = null;
+        this.pc.onicecandidate = null;
+        this.pc.onicecandidateerror = null;
+        this.pc.oniceconnectionstatechange = null;
+        this.pc.onicegatheringstatechange = null;
+        this.pc.ondatachannel = null;
+        this.pc.close();
     }
 
     /**

+ 3 - 2
src/services/push.ts

@@ -74,7 +74,7 @@ export class PushService {
      * Send a push notification for the specified session (public permanent key
      * of the initiator). The promise is always resolved to a boolean.
      */
-    public async sendPush(session: Uint8Array, wakeupType: threema.WakeupType): Promise<boolean> {
+    public async sendPush(session: Uint8Array): Promise<boolean> {
         if (!this.isAvailable()) {
             return false;
         }
@@ -87,7 +87,8 @@ export class PushService {
             [PushService.ARG_TYPE]: this.pushType,
             [PushService.ARG_SESSION]: sessionHash,
             [PushService.ARG_VERSION]: this.version,
-            [PushService.ARG_WAKEUP_TYPE]: wakeupType,
+            // Note: Wakeup type has been obsoleted by connectionInfo
+            [PushService.ARG_WAKEUP_TYPE]: '0',
         };
         if (this.pushType === threema.PushTokenType.Apns) {
             // APNS token format: "<hex-deviceid>;<endpoint>;<bundle-id>"

+ 5 - 4
src/services/state.ts

@@ -43,7 +43,7 @@ export class StateService {
     public taskConnectionState: TaskConnectionState;
 
     // Connection buildup state
-    public connectionBuildupState: threema.ConnectionBuildupState = 'connecting';
+    public connectionBuildupState: threema.ConnectionBuildupState;
     public progress = 0;
     private progressInterval: ng.IPromise<any> = null;
     public slowConnect = false;
@@ -238,8 +238,8 @@ export class StateService {
     /**
      * Reset all states.
      */
-    public reset(): void {
-        this.$log.debug(this.logTag, 'Reset');
+    public reset(connectionBuildupState: threema.ConnectionBuildupState = 'new'): void {
+        this.$log.debug(this.logTag, 'Reset states');
 
         // Reset state
         this.signalingConnectionState = 'new';
@@ -247,6 +247,7 @@ export class StateService {
         this.stage = Stage.Signaling;
         this.state = GlobalConnectionState.Error;
         this.wasConnected = false;
-        this.connectionBuildupState = 'connecting';
+        this.connectionBuildupState = connectionBuildupState;
+        this.progress = 0;
     }
 }

File diff suppressed because it is too large
+ 541 - 136
src/services/webclient.ts


+ 10 - 6
src/threema.d.ts

@@ -401,10 +401,11 @@ declare namespace threema {
      * - loading: Loading initial data
      * - done: Initial loading is finished
      * - closed: Connection is closed
+     * - reconnect_failed: Reconnecting failed after several attempts
      *
      */
     type ConnectionBuildupState = 'new' | 'connecting' | 'push' | 'manual_start' | 'already_connected'
-        | 'waiting' | 'peer_handshake' | 'loading' | 'done' | 'closed';
+        | 'waiting' | 'peer_handshake' | 'loading' | 'done' | 'closed' | 'reconnect_failed';
 
     interface ConnectionBuildupStateChange {
         state: ConnectionBuildupState;
@@ -528,11 +529,6 @@ declare namespace threema {
         textInfo?: string;
     }
 
-    interface PromiseCallbacks {
-        resolve: (arg: any) => void;
-        reject: (arg: any) => void;
-    }
-
     interface PromiseRequestResult<T> {
         success: boolean;
         error?: string;
@@ -742,6 +738,13 @@ declare namespace threema {
         realLength: number;
     }
 
+    interface WebClientServiceStopArguments {
+        reason: DisconnectReason,
+        send: boolean,
+        close: boolean | string,
+        connectionBuildupState?: ConnectionBuildupState,
+    }
+
     const enum ChosenTask {
         None = 'none',
         WebRTC = 'webrtc',
@@ -753,6 +756,7 @@ declare namespace threema {
         SessionDeleted = 'delete',
         WebclientDisabled = 'disable',
         SessionReplaced = 'replace',
+        SessionError = 'error',
     }
 
     namespace Container {

+ 45 - 0
src/types/future.d.ts

@@ -0,0 +1,45 @@
+/**
+ * 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/>.
+ */
+
+/**
+ * A future similar to Python's asyncio.Future. Allows to resolve or reject
+ * outside of the executor and query the current status.
+ */
+interface Future<T> extends Promise<T> {
+    /**
+     * Return whether the future is done (resolved or rejected).
+     */
+    readonly done: boolean;
+
+    /**
+     * Resolve the future.
+     */
+    resolve(value?: T | PromiseLike<T>): void;
+
+    /**
+     * Reject the future.
+     */
+    reject(reason?: any): void;
+}
+
+interface FutureStatic {
+    new<T>(executor?: (resolveFn: (value?: T | PromiseLike<T>) => void,
+                       rejectFn: (reason?: any) => void) => void,
+    ): Future<T>
+}
+
+declare var Future: FutureStatic;

Some files were not shown because too many files changed in this diff