瀏覽代碼

Merge pull request #792 from threema-ch/push-overhaul

Reconnect overhaul
Lennart Grahl 6 年之前
父節點
當前提交
f6b7fefbf7

+ 7 - 0
public/i18n/de.json

@@ -8,6 +8,7 @@
         "PLEASE_SCAN": "Scannen Sie den QR-Code mit Ihrer Threema-App",
         "PLEASE_UNLOCK": "Verbindung wiederaufbauen",
         "WAITING": "Auf Verbindung warten",
+        "RETRY": "Erneut versuchen",
         "PLEASE_RELOAD": "Bitte laden Sie die Seite neu.",
         "RELOAD": "Seite neu laden",
         "PASSWORD": "Passwort",
@@ -59,6 +60,8 @@
         "APP_STARTED": "Ist die Threema-App gestartet?",
         "SESSION_DELETED": "Wurde diese Sitzung in der Threema-App gelöscht?",
         "PHONE_ONLINE": "Ist Ihr Mobilgerät mit dem Internet verbunden?",
+        "UNLOCK_OR_CHARGE": "Es kann helfen, Ihr Mobilgerät zu entsperren oder an ein Ladegerät anzuschliessen.",
+        "PUSH_FAQ": "Möglicherweise liegt ein Problem bei der Verarbeitung von Push-Benachrichtigungen vor. Die FAQ-Artikel für <a target=\"_blank\" href=\"https://threema.ch/faq/push_andr\">Android</a> und <a target=\"_blank\" href=\"https://threema.ch/faq/push_ios\">iOS</a> helfen bei der Fehlersuche.",
         "WEBCLIENT_ENABLED": "Ist Threema Web in der Threema-App aktiviert?",
         "PLUGIN": "Ist in Ihrem Browser ein Plugin zum Blockieren von WebRTC installiert?",
         "ADBLOCKER": "Ist in Ihrem Browser ein Ad-Blocker installiert?",
@@ -290,6 +293,10 @@
             "PLAY_SOUND": "Ton abspielen"
         }
     },
+    "deviceUnreachable": {
+        "DEVICE_UNREACHABLE": "Mobilgerät nicht erreichbar",
+        "UNABLE_TO_CONNECT": "Eine Verbindung mit Ihrem Mobilgerät konnte nicht hergestellt werden …"
+    },
     "version": {
         "NEW_VERSION": "Neue Version Verfügbar",
         "NEW_VERSION_BODY": "Eine neue Version von Threema Web ({version}) ist verfügbar. Mehr Informationen finden Sie im {changelog}. Drücken Sie \"OK\", um das Update zu aktivieren."

+ 7 - 0
public/i18n/en.json

@@ -8,6 +8,7 @@
         "PLEASE_SCAN": "Scan this QR code with your Threema app",
         "PLEASE_UNLOCK": "Reconnecting session",
         "WAITING": "Waiting for connection",
+        "RETRY": "Retry",
         "PLEASE_RELOAD": "Please reload the page to try again.",
         "RELOAD": "Reload page",
         "PASSWORD": "Password",
@@ -59,6 +60,8 @@
         "APP_STARTED": "Is the Threema app started?",
         "SESSION_DELETED": "Did you delete this session on your phone?",
         "PHONE_ONLINE": "Is your phone connected to the internet?",
+        "UNLOCK_OR_CHARGE": "It may help to unlock your device or connect it to a charger.",
+        "PUSH_FAQ": "Your device may be affected by Push Notification issues. See the FAQ articles for <a target=\"_blank\" href=\"https://threema.ch/faq/push_andr\">Android</a> and <a target=\"_blank\" href=\"https://threema.ch/faq/push_ios\">iOS</a> for troubleshooting.",
         "WEBCLIENT_ENABLED": "Is Threema Web enabled in the Threema app?",
         "PLUGIN": "Is a privacy plugin installed in your browser which blocks WebRTC communication?",
         "ADBLOCKER": "Do you use an ad blocker which also blocks WebRTC communication?",
@@ -290,6 +293,10 @@
             "PLAY_SOUND": "Play sound"
         }
     },
+    "deviceUnreachable": {
+        "DEVICE_UNREACHABLE": "Device Unreachable",
+        "UNABLE_TO_CONNECT": "Unable to connect to your device …"
+    },
     "version": {
         "NEW_VERSION": "New Version Available",
         "NEW_VERSION_BODY": "A new version of Threema Web ({version}) is available. Check out the {changelog} for more information. Click \"OK\" to activate the update."

+ 46 - 99
src/controllers/status.ts

@@ -120,21 +120,16 @@ export class StatusController {
                 if (oldValue === 'ok' && isWebrtc) {
                     this.scheduleStatusBar();
                 }
-                if (this.stateService.wasConnected) {
-                    this.webClientService.clearIsTypingFlags();
-                }
-                if (this.stateService.wasConnected && isRelayedData) {
+                this.webClientService.clearIsTypingFlags();
+                if (isRelayedData) {
                     this.reconnectIos();
                 }
                 break;
             case 'error':
-                if (this.stateService.wasConnected && isWebrtc) {
-                    if (oldValue === 'ok') {
-                        this.scheduleStatusBar();
-                    }
+                if (isWebrtc) {
                     this.reconnectAndroid();
                 }
-                if (this.stateService.wasConnected && isRelayedData) {
+                if (this.stateService.attempt === 0 && isRelayedData) {
                     this.reconnectIos();
                 }
                 break;
@@ -166,104 +161,61 @@ export class StatusController {
      * Attempt to reconnect an Android device after a connection loss.
      */
     private reconnectAndroid(): void {
-        this.$log.warn(this.logTag, 'Connection lost (Android). Attempting to reconnect...');
+        this.$log.info(this.logTag, `Connection lost (Android). Reconnect attempt #${this.stateService.attempt + 1}`);
+
+        // Show expanded status bar (if on 'messenger')
+        if (this.$state.includes('messenger')) {
+            this.scheduleStatusBar();
+        }
 
         // Get original keys
         const originalKeyStore = this.webClientService.salty.keyStore;
         const originalPeerPermanentKeyBytes = this.webClientService.salty.peerPermanentKeyBytes;
 
-        // Timeout durations
-        const TIMEOUT1 = 20 * 1000; // Duration per step for first reconnect
-        const TIMEOUT2 = 20 * 1000; // Duration per step for second reconnect
-
-        // Reconnect state
-        let reconnectTry: 1 | 2 = 1;
-
-        // Handler for failed reconnection attempts
-        const reconnectionFailed = () => {
-            // Collapse status bar
-            this.collapseStatusBar();
+        // Soft reconnect: Does not reset the loaded data
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
+            send: true,
+            close: false,
+        });
+        this.webClientService.init({
+            keyStore: originalKeyStore,
+            peerTrustedKey: originalPeerPermanentKeyBytes,
+            resume: true,
+        });
 
-            // Reset connection & state
-            this.webClientService.stop({
-                reason: DisconnectReason.SessionError,
-                send: false,
-                // TODO: Use welcome.error once we have it
-                close: 'welcome',
-                connectionBuildupState: 'reconnect_failed',
-            });
-        };
-
-        // Handlers for reconnecting timeout
-        const reconnect2Timeout = () => {
-            // Give up
-            this.$log.error(this.logTag, 'Reconnect timeout 2. Going back to initial loading screen...');
-            reconnectionFailed();
-        };
-        const reconnect1Timeout = () => {
-            // Could not connect so far.
-            this.$log.error(this.logTag, 'Reconnect timeout 1. Retrying...');
-            reconnectTry = 2;
-            this.reconnectTimeout = this.$timeout(reconnect2Timeout, TIMEOUT2);
-            doSoftReconnect();
-        };
-
-        // Function to soft-reconnect. Does not reset the loaded data.
-        const doSoftReconnect = () => {
-            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
-                    this.$timeout.cancel(this.reconnectTimeout);
+        // Show device unreachable dialog if maximum attempts exceeded
+        // Note: This will not be shown on 'welcome'
+        const pause = this.stateService.attempt >= WebClientService.MAX_CONNECT_ATTEMPTS;
+        if (pause) {
+            this.webClientService.showDeviceUnreachableDialog();
+        }
 
-                    // Hide expanded status bar
-                    this.collapseStatusBar();
-                },
+        // Start
+        this.webClientService.start(pause)
+            .then(
+                () => { /* ignored */ },
                 (error) => {
                     this.$log.error(this.logTag, 'Error state:', error);
-                    this.$timeout.cancel(this.reconnectTimeout);
-                    reconnectionFailed();
+                    // Note: The web client service has already been stopped at
+                    // this point.
                 },
                 (progress: threema.ConnectionBuildupStateChange) => {
-                    if (progress.state === 'peer_handshake' || progress.state === 'loading') {
-                        this.$log.debug(this.logTag, 'Connection buildup advanced, resetting timeout');
-                        // Restart timeout
-                        this.$timeout.cancel(this.reconnectTimeout);
-                        if (reconnectTry === 1) {
-                            this.reconnectTimeout = this.$timeout(reconnect1Timeout, TIMEOUT1);
-                        } else if (reconnectTry === 2) {
-                            this.reconnectTimeout = this.$timeout(reconnect2Timeout, TIMEOUT2);
-                        } else {
-                            throw new Error('Invalid reconnectTry value: ' + reconnectTry);
-                        }
-                    }
+                    this.$log.debug(this.logTag, 'Connection buildup advanced:', progress);
                 },
-            );
-        };
-
-        // Start timeout
-        this.reconnectTimeout = this.$timeout(reconnect1Timeout, TIMEOUT1);
-
-        // Start reconnecting process
-        doSoftReconnect();
-
-        // TODO: Handle server closing state
+            )
+            .finally(() => {
+                // Hide expanded status bar
+                this.collapseStatusBar();
+            });
+        ++this.stateService.attempt;
     }
 
     /**
      * Attempt to reconnect an iOS device after a connection loss.
      */
     private reconnectIos(): void {
-        this.$log.info(this.logTag, 'Connection lost (iOS). Attempting to reconnect...');
+        this.$log.info(this.logTag, `Connection lost (iOS). Reconnect attempt #${++this.stateService.attempt}`);
 
         // Get original keys
         const originalKeyStore = this.webClientService.salty.keyStore;
@@ -305,7 +257,8 @@ export class StatusController {
             };
         })();
 
-        this.$timeout(() => {
+        this.$timeout.cancel(this.reconnectTimeout);
+        this.reconnectTimeout = this.$timeout(() => {
             if (push.send) {
                 this.$log.debug(`Starting new connection with push, reason: ${push.reason}`);
             } else {
@@ -318,18 +271,12 @@ export class StatusController {
             });
 
             this.webClientService.start(!push.send).then(
-                () => { /* ok */ },
+                () => { /* ignored */ },
                 (error) => {
                     this.$log.error(this.logTag, 'Error state:', error);
-                    this.webClientService.stop({
-                        reason: DisconnectReason.SessionError,
-                        send: false,
-                        // TODO: Use welcome.error once we have it
-                        close: 'welcome',
-                        connectionBuildupState: 'reconnect_failed',
-                    });
+                    // Note: The web client service has already been stopped at
+                    // this point.
                 },
-                // Progress
                 (progress: threema.ConnectionBuildupStateChange) => {
                     this.$log.debug(this.logTag, 'Connection buildup advanced:', progress);
                 },

+ 18 - 0
src/exceptions.ts

@@ -0,0 +1,18 @@
+/**
+ * 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/>.
+ */
+
+export class TimeoutError extends Error {}

+ 43 - 0
src/partials/dialog.device-unreachable.html

@@ -0,0 +1,43 @@
+<md-dialog aria-label="Device Unreachable">
+    <form ng-cloak>
+        <md-toolbar>
+            <div class="md-toolbar-tools">
+                <h2 translate>deviceUnreachable.DEVICE_UNREACHABLE</h2>
+            </div>
+        </md-toolbar>
+        <md-dialog-content>
+            <div class="md-dialog-content">
+                <h3 translate>deviceUnreachable.UNABLE_TO_CONNECT</h3>
+                <ul class="material-icons-list">
+                    <li class="help">
+                        <span translate>troubleshooting.PHONE_ONLINE</span>
+                    </li>
+                    <li class="help">
+                        <span translate>troubleshooting.APP_STARTED</span>
+                    </li>
+                    <li class="help">
+                        <span translate>troubleshooting.WEBCLIENT_ENABLED</span>
+                    </li>
+                    <li class="info">
+                        <span translate>troubleshooting.UNLOCK_OR_CHARGE</span>
+                    </li>
+                    <li class="info">
+                        <span translate>troubleshooting.PUSH_FAQ</span>
+                    </li>
+                </ul>
+            </div>
+        </md-dialog-content>
+        <md-dialog-actions layout="row">
+            <span flex></span>
+            <md-button role="button" class="md-primary reload-btn" ng-click="ctrl.reload()" aria-labelledby="aria-label-reload">
+                <span translate id="aria-label-reload">welcome.RELOAD</span>
+            </md-button>
+            <md-button role="button" class="md-primary reload-btn circular-progress-button" ng-click="ctrl.retry()" ng-disabled="ctrl.retrying" aria-labelledby="aria-label-retry">
+                <md-progress-circular ng-if="ctrl.retrying" md-mode="determinate" md-diameter="20" value="{{ctrl.progress}}"></md-progress-circular>
+                <i ng-if="!ctrl.retrying" class="material-icons">refresh</i>
+                <span translate id="aria-label-retry">welcome.RETRY</span>
+            </md-button>
+        </md-dialog-actions>
+    </form>
+</md-dialog>
+

+ 60 - 4
src/partials/messenger.ts

@@ -24,7 +24,7 @@ import {
 } from '@uirouter/angularjs';
 
 import {ContactControllerModel} from '../controller_model/contact';
-import {bufferToUrl, filter, hasValue, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
+import {bufferToUrl, hasValue, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
 import {emojify} from '../helpers/emoji';
 import {ContactService} from '../services/contact';
 import {ControllerService} from '../services/controller';
@@ -61,7 +61,7 @@ class DialogController {
         this.done();
     }
 
-    protected hide(data: any): void {
+    protected hide(data?: any): void {
         this.$mdDialog.hide(data);
         this.done();
     }
@@ -127,11 +127,68 @@ class SendFileController extends DialogController {
     }
 }
 
+/**
+ * Handle device unreachable
+ */
+export class DeviceUnreachableController extends DialogController {
+    public static readonly $inject = [
+        '$rootScope', '$window', '$mdDialog',
+        'CONFIG', 'StateService', 'WebClientService',
+    ];
+    private readonly $rootScope: any;
+    private readonly $window: ng.IWindowService;
+    private readonly stateService: StateService;
+    private readonly webClientService: WebClientService;
+    public retrying: boolean = false;
+    public progress: number = 0;
+
+    constructor(
+        $rootScope: any, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
+        CONFIG: threema.Config, stateService: StateService, webClientService: WebClientService,
+    ) {
+        super($mdDialog, CONFIG);
+        this.$rootScope = $rootScope;
+        this.$window = $window;
+        this.stateService = stateService;
+        this.webClientService = webClientService;
+    }
+
+    /**
+     * Retry wakeup of the device via a push session.
+     */
+    public async retry(): Promise<void> {
+        // Reset attempt counter
+        this.stateService.attempt = 0;
+
+        // Schedule sending a push
+        const [expectedPeriodMaxMs, pushSessionPromise] = this.webClientService.sendPush();
+
+        // Initialise progress circle
+        this.retrying = true;
+        this.progress = 0;
+        const interval = setInterval(() => this.$rootScope.$apply(() => ++this.progress), expectedPeriodMaxMs / 100);
+
+        // Wait for push to succeed/reject and reset the progress circle
+        try {
+            await pushSessionPromise;
+        } finally {
+            clearInterval(interval);
+            this.$rootScope.$apply(() => this.retrying = false);
+        }
+    }
+
+    /**
+     * Reload the page.
+     */
+    public reload(): void {
+        this.$window.location.reload();
+    }
+}
+
 /**
  * Handle settings
  */
 class SettingsController {
-
     public static $inject = ['$mdDialog', '$window', 'SettingsService', 'NotificationService'];
 
     public $mdDialog: ng.material.IDialogService;
@@ -190,7 +247,6 @@ class SettingsController {
     public setWantsSound(notificationSound: boolean) {
         this.notificationService.setWantsSound(notificationSound);
     }
-
 }
 
 interface ConversationStateParams extends UiStateParams {

+ 13 - 13
src/partials/welcome.html

@@ -66,29 +66,29 @@
                     <p ng-if="ctrl.state === 'loading' || ctrl.state === 'done'" translate>welcome.LOADING_INITIAL_DATA</p>
                     <div class="troubleshoot" ng-if="ctrl.slowConnect">
                         <h3 translate>troubleshooting.SLOW_CONNECT</h3>
-                        <ul>
-                            <li>
-                                <i class="material-icons md-dark md-14">help</i>
+                        <ul class="material-icons-list">
+                            <li class="help">
                                 <span translate>troubleshooting.PHONE_ONLINE</span>
                             </li>
-                            <li ng-if="ctrl.state === 'push'">
-                                <i class="material-icons md-dark md-14">help</i>
+                            <li ng-if="ctrl.state === 'push'" class="help">
                                 <span translate>troubleshooting.APP_STARTED</span>
                             </li>
-                            <li ng-if="ctrl.state === 'push'">
-                                <i class="material-icons md-dark md-14">help</i>
+                            <li ng-if="ctrl.state === 'push'" class="help">
                                 <span translate>troubleshooting.WEBCLIENT_ENABLED</span>
                             </li>
-                            <li ng-if="ctrl.state === 'push'">
-                                <i class="material-icons md-dark md-14">help</i>
+                            <li ng-if="ctrl.state === 'push'" class="help">
                                 <span translate>troubleshooting.SESSION_DELETED</span>
                             </li>
-                            <li ng-if="ctrl.state === 'peer_handshake' && ctrl.showWebrtcTroubleshooting">
-                                <i class="material-icons md-dark md-14">help</i>
+                            <li ng-if="ctrl.state === 'push'" class="info">
+                                <span translate>troubleshooting.UNLOCK_OR_CHARGE</span>
+                            </li>
+                            <li ng-if="ctrl.state === 'push'" class="info">
+                                <span translate>troubleshooting.PUSH_FAQ</span>
+                            </li>
+                            <li ng-if="ctrl.state === 'peer_handshake' && ctrl.showWebrtcTroubleshooting" class="help">
                                 <span translate>troubleshooting.PLUGIN</span>
                             </li>
-                            <li ng-if="ctrl.state === 'peer_handshake' && ctrl.showWebrtcTroubleshooting">
-                                <i class="material-icons md-dark md-14">help</i>
+                            <li ng-if="ctrl.state === 'peer_handshake' && ctrl.showWebrtcTroubleshooting" class="help">
                                 <span translate>troubleshooting.ADBLOCKER</span>
                             </li>
                         </ul>

+ 3 - 10
src/partials/welcome.ts

@@ -582,19 +582,12 @@ class WelcomeController {
             // If an error occurs...
             (error) => {
                 this.$log.error(this.logTag, 'Error state:', error);
-                // TODO: should probably show an error message instead
-                this.timeoutService.register(
-                    () => this.$state.reload(),
-                    WelcomeController.REDIRECT_DELAY,
-                    true,
-                    'reloadStateError',
-                );
+                // Note: On rejection, the web client service will already
+                //       redirect to 'welcome' and show a protocol error.
             },
 
             // State updates
-            (progress: threema.ConnectionBuildupStateChange) => {
-                // Do nothing
-            },
+            (progress: threema.ConnectionBuildupStateChange) => { /* ignored */ },
         );
     }
 

+ 32 - 0
src/sass/layout/_main.scss

@@ -250,7 +250,32 @@
             opacity: 1;
         }
     }
+}
+
+ul.material-icons-list {
+    list-style-type: none;
+
+    li {
+        $list-style-width: 1.3em;
+        margin-left: $list-style-width;
+        text-indent: -$list-style-width;
+
+        &::before {
+            font-family: 'Material Icons';
+        }
+
+        &.help {
+            &::before {
+                content: 'help';
+            }
+        }
 
+        &.info {
+            &::before {
+                content: 'info';
+            }
+        }
+    }
 }
 
 .md-dialog-content {
@@ -259,6 +284,13 @@
     }
 }
 
+.circular-progress-button {
+    md-progress-circular {
+        float: left;
+        margin: 8px 4px;
+    }
+}
+
 input.threema-id {
     text-transform: uppercase;
 }

+ 10 - 33
src/sass/sections/_welcome.scss

@@ -112,10 +112,13 @@
     }
 
     .loading {
+        $progress-height: 250px;
+        $progress-overlap: 70px;
         margin-top: 48px;
 
         md-progress-circular {
-            margin: 0 auto;
+            margin: 0 auto calc(-#{$progress-height} + #{$progress-overlap});
+            height: $progress-height;
 
             svg path {
                 stroke-width: 12px !important;
@@ -123,13 +126,6 @@
         }
 
         .info {
-            display: flex;
-            position: relative;
-            top: -250px;
-            flex-direction: column;
-            justify-content: center;
-            height: 250px;
-
             .percentage {
                 margin-bottom: 8px;
                 vertical-align: center;
@@ -140,11 +136,7 @@
         }
 
         .troubleshoot {
-            $troubleshoot-height: 190px;
-            position: absolute;
-            bottom: -$troubleshoot-height - 32px;
-            width: 100%;
-            height: $troubleshoot-height;
+            margin-top: calc(#{$progress-overlap} + 40px);
 
             h3 {
                 margin-bottom: 8px;
@@ -152,13 +144,13 @@
             }
 
             ul {
+                text-align: left;
                 font-size: .9em;
-                list-style-type: none;
-            }
 
-            li {
-                padding-bottom: .3em;
-                line-height: 1.2em;
+                li {
+                    padding: 0 1em .3em;
+                    line-height: 1.2em;
+                }
             }
 
             .forget {
@@ -172,19 +164,4 @@
             }
         }
     }
-
-    .notification {
-        flex-direction: horizontal;
-        margin-bottom: 16px;
-        background-color: #ff9800;
-        padding: 8px;
-
-        p {
-            width: 100%;
-            text-align: center;
-            line-height: 1.4em;
-            font-size: .8em;
-            font-weight: bold;
-        }
-    }
 }

+ 264 - 87
src/services/push.ts

@@ -15,123 +15,300 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {TimeoutError} from '../exceptions';
+import {randomString, sleep} from '../helpers';
 import {sha256} from '../helpers/crypto';
 
+/**
+ * A push session will send pushes continuously until an undefined goal has
+ * been achieved which needs to call the `.done` method to stop pushes.
+ *
+ * The push session will stop and reject the returned promise in case the
+ * push relay determined a client error (e.g. an invalid push token). In any
+ * other case, it will continue sending pushes. Thus, it is crucial to call
+ * `.done` eventually!
+ *
+ * With default settings, the push session will send a push in the following
+ * intervals: 0s, 2s, 4s, 8s, 16s, 30s (maximum), 30s, ...
+ *
+ * The first push will use a TTL (time to live) of 0, the second push a TTL of
+ * 15s, and all subsequent pushes will use a TTL of 90s.
+ *
+ * The default settings intend to wake up the app immediately by the first push
+ * which uses a TTL of 0, indicating the push server to deliver *now or never*.
+ * The mid TTL tries to work around issues with FCM clients interpreting the
+ * TTL as *don't need to dispatch until expired*. And the TTL of 90s acts as a
+ * last resort mechanism to wake up the app eventually.
+ *
+ * Furthermore, the collapse key ensures that only one push per session will be
+ * 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 running: boolean = false;
+    private retryTimeoutMs: number;
+    private tries: number = 0;
+
+    /**
+     * Return the default configuration.
+     */
+    public static get defaultConfig(): threema.PushSessionConfig {
+        return {
+            retryTimeoutInitMs: 2000,
+            retryTimeoutMaxMs: 30000,
+            triesMax: 3,
+            timeToLiveRange: [0, 15, 90],
+        };
+    }
+
+    /**
+     * Return the expected maximum period until the session will be forcibly
+     * rejected.
+     *
+     * Note: The actual maximum period will usually be larger since the HTTP
+     *       request itself can take an arbitrary amount of time.
+     */
+    public static expectedPeriodMaxMs(config?: threema.PushSessionConfig): number {
+        if (config === undefined) {
+            config = PushSession.defaultConfig;
+        }
+        if (config.triesMax === Number.POSITIVE_INFINITY) {
+            return Number.POSITIVE_INFINITY;
+        }
+        let retryTimeoutMs = config.retryTimeoutInitMs;
+        let sumMs = 0;
+        for (let i = 0; i < config.triesMax; ++i) {
+            sumMs += retryTimeoutMs;
+            retryTimeoutMs = Math.min(retryTimeoutMs * 2, config.retryTimeoutMaxMs);
+        }
+        return sumMs;
+    }
+
+    /**
+     * Create a push session.
+     *
+     * @param service The associated `PushService` instance.
+     * @param session Session identifier (public permanent key of the
+     *   initiator)
+     * @param config Push session configuration.
+     */
+    public constructor(service: PushService, session: Uint8Array, config?: threema.PushSessionConfig) {
+        this.$log = service.$log;
+        this.service = service;
+        this.session = session;
+        this.config = config !== undefined ? config : PushSession.defaultConfig;
+        this.retryTimeoutMs = this.config.retryTimeoutInitMs;
+
+        // Sanity checks
+        if (this.config.timeToLiveRange.length === 0) {
+            throw new Error('timeToLiveRange must not be an empty array');
+        }
+        if (this.config.triesMax < 1) {
+            throw new Error('triesMax must be >= 1');
+        }
+    }
+
+    /**
+     * The promise resolves once the session has been marked as *done*.
+     *
+     * It will reject in case the server indicated a bad request or the maximum
+     * amount of retransmissions have been reached.
+     *
+     * @throws TimeoutError in case the maximum amount of retries has been
+     *   reached.
+     * @throws Error in case of an unrecoverable error which prevents further
+     *   pushes.
+     */
+    public start(): Promise<void> {
+        // Start sending
+        if (!this.running) {
+            this.run().catch((error) => {
+                this.$log.error(this.logTag, 'Push runner failed:', error);
+                this.doneFuture.reject(error);
+            });
+            this.running = true;
+        }
+        return this.doneFuture;
+    }
+
+    /**
+     * Mark as done and stop sending push messages.
+     *
+     * This will resolve all pending promises.
+     */
+    public done(): void {
+        this.$log.info(this.logTag, '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();
+        data.set(PushService.ARG_TYPE, this.service.pushType);
+        data.set(PushService.ARG_SESSION, sessionHash);
+        data.set(PushService.ARG_VERSION, `${this.service.version}`);
+        data.set(PushService.ARG_AFFILIATION, this.affiliation);
+        if (this.service.pushType === threema.PushTokenType.Apns) {
+            // APNS token format: "<hex-deviceid>;<endpoint>;<bundle-id>"
+            const parts = this.service.pushToken.split(';');
+            if (parts.length < 3) {
+                throw new Error(`APNS push token contains ${parts.length} parts, but at least 3 are required`);
+            }
+            data.set(PushService.ARG_TOKEN, parts[0]);
+            data.set(PushService.ARG_ENDPOINT, parts[1]);
+            data.set(PushService.ARG_BUNDLE_ID, parts[2]);
+        } else if (this.service.pushType === threema.PushTokenType.Gcm) {
+            data.set(PushService.ARG_TOKEN, this.service.pushToken);
+        } else {
+            throw new Error(`Invalid push type: ${this.service.pushType}`);
+        }
+
+        // Push until done or unrecoverable error
+        while (!this.doneFuture.done) {
+            // Determine TTL
+            let timeToLive = this.config.timeToLiveRange[this.tries];
+            if (timeToLive === undefined) {
+                timeToLive = this.config.timeToLiveRange[this.config.timeToLiveRange.length - 1];
+            }
+
+            // Set/Remove collapse key
+            if (timeToLive === 0) {
+                data.delete(PushService.ARG_COLLAPSE_KEY);
+            } else {
+                data.set(PushService.ARG_COLLAPSE_KEY, sessionHash.slice(0, 6));
+            }
+
+            // Modify data
+            data.set(PushService.ARG_TIME_TO_LIVE, `${timeToLive}`);
+            ++this.tries;
+
+            // Send push
+            this.$log.debug(this.logTag, `Sending push ${this.tries}/${this.config.triesMax} (ttl=${timeToLive})`);
+            if (this.service.config.DEBUG) {
+                this.$log.debug(this.logTag, 'Push data:', `${data}`);
+            }
+            try {
+                const response = await fetch(this.service.url, {
+                    method: 'POST',
+                    headers: {
+                        'Content-Type': 'application/x-www-form-urlencoded',
+                    },
+                    body: data,
+                });
+
+                // Check if successful
+                if (response.ok) {
+                    // Success: Retry
+                    this.$log.debug(this.logTag, '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.doneFuture.reject(new Error(error));
+                } else {
+                    // Server error: Retry
+                    this.$log.warn(this.logTag, `Push rejected (server error), status: ${response.status}`);
+                }
+            } catch (error) {
+                this.$log.warn(this.logTag, 'Sending push failed:', error);
+            }
+
+            // Retry after timeout
+            await sleep(this.retryTimeoutMs);
+
+            // Apply RTO backoff
+            this.retryTimeoutMs = Math.min(this.retryTimeoutMs * 2, this.config.retryTimeoutMaxMs);
+
+            // 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.doneFuture.reject(new TimeoutError(error));
+            }
+        }
+    }
+}
+
 export class PushService {
-    private static ARG_TYPE = 'type';
-    private static ARG_TOKEN = 'token';
-    private static ARG_SESSION = 'session';
-    private static ARG_VERSION = 'version';
-    private static ARG_ENDPOINT = 'endpoint';
-    private static ARG_BUNDLE_ID = 'bundleid';
-
-    private logTag: string = '[PushService]';
-
-    private $http: ng.IHttpService;
-    private $log: ng.ILogService;
-    private $httpParamSerializerJQLike;
-
-    private url: string;
-    private pushToken: string = null;
-    private pushType = threema.PushTokenType.Gcm;
-    private version: number = null;
-
-    public static $inject = ['$http', '$log', '$httpParamSerializerJQLike', 'CONFIG', 'PROTOCOL_VERSION'];
-    constructor($http: ng.IHttpService, $log: ng.ILogService, $httpParamSerializerJQLike,
-                CONFIG: threema.Config, PROTOCOL_VERSION: number) {
-        this.$http = $http;
+    public static readonly $inject = ['$log', 'CONFIG', 'PROTOCOL_VERSION'];
+
+    public static readonly ARG_TYPE = 'type';
+    public static readonly ARG_TOKEN = 'token';
+    public static readonly ARG_SESSION = 'session';
+    public static readonly ARG_VERSION = 'version';
+    public static readonly ARG_AFFILIATION = 'affiliation';
+    public static readonly ARG_ENDPOINT = 'endpoint';
+    public static readonly ARG_BUNDLE_ID = 'bundleid';
+    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;
+    private _pushToken: string = null;
+    private _pushType = threema.PushTokenType.Gcm;
+
+    constructor($log: ng.ILogService, CONFIG: threema.Config, PROTOCOL_VERSION: number) {
         this.$log = $log;
-        this.$httpParamSerializerJQLike = $httpParamSerializerJQLike;
+        this.config = CONFIG;
         this.url = CONFIG.PUSH_URL;
         this.version = PROTOCOL_VERSION;
     }
 
+    public get pushToken(): string {
+        return this._pushToken;
+    }
+
+    public get pushType(): string {
+        return this._pushType;
+    }
+
     /**
      * 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.pushToken = pushToken;
-        this.pushType = pushTokenType;
+        this._pushToken = pushToken;
+        this._pushType = pushTokenType;
     }
 
     /**
      * Reset the push service, remove stored push tokens.
      */
     public reset(): void {
-        this.pushToken = null;
+        this._pushToken = null;
     }
 
     /**
-     * Return true if service has been initialized with a push token.
+     * Return whether the service has been initialized with a push token.
      */
     public isAvailable(): boolean {
-        return this.pushToken != null;
+        return this._pushToken != null;
     }
 
     /**
-     * Send a push notification for the specified session (public permanent key
-     * of the initiator). The promise is always resolved to a boolean.
-     *
-     * If something goes wrong, the promise is rejected with an `Error` instance.
+     * Create a push session for a specific session (public permanent key of
+     * the initiator) which will repeatedly send push messages until the
+     * session is marked as established.
      */
-    public async sendPush(session: Uint8Array): Promise<boolean> {
+    public createSession(session: Uint8Array, config?: threema.PushSessionConfig): PushSession {
         if (!this.isAvailable()) {
-            return false;
+            throw new Error('Push service unavailable');
         }
 
-        // Calculate session hash
-        const sessionHash = await sha256(session.buffer);
-
-        // Prepare request
-        const data = {
-            [PushService.ARG_TYPE]: this.pushType,
-            [PushService.ARG_SESSION]: sessionHash,
-            [PushService.ARG_VERSION]: this.version,
-        };
-        if (this.pushType === threema.PushTokenType.Apns) {
-            // APNS token format: "<hex-deviceid>;<endpoint>;<bundle-id>"
-            const parts = this.pushToken.split(';');
-            if (parts.length < 3) {
-                this.$log.warn(this.logTag, 'APNS push token contains', parts.length, 'parts, at least 3 are required');
-                return false;
-            }
-            data[PushService.ARG_TOKEN] = parts[0];
-            data[PushService.ARG_ENDPOINT] = parts[1];
-            data[PushService.ARG_BUNDLE_ID] = parts[2];
-        } else if (this.pushType === threema.PushTokenType.Gcm) {
-            data[PushService.ARG_TOKEN] = this.pushToken;
-        } else {
-            this.$log.warn(this.logTag, 'Invalid push type');
-            return false;
-        }
-
-        const request = {
-            method: 'POST',
-            url: this.url,
-            headers: {
-                'Content-Type': 'application/x-www-form-urlencoded',
-            },
-            data: this.$httpParamSerializerJQLike(data),
-        };
-
-        // Send push
-        return new Promise((resolve) => {
-            this.$http(request).then(
-                (successResponse) => {
-                    if (successResponse.status === 204) {
-                        this.$log.debug(this.logTag, 'Sent push');
-                        resolve(true);
-                    } else {
-                        this.$log.warn(this.logTag, 'Sending push failed: HTTP ' + successResponse.status);
-                        resolve(false);
-                    }
-                },
-                (errorResponse) => {
-                    this.$log.warn(this.logTag, 'Sending push failed:', errorResponse);
-                    resolve(false);
-                },
-            );
-        }) as Promise<boolean>;
+        // Create push instance
+        return new PushSession(this, session, config);
     }
 }

+ 2 - 3
src/services/state.ts

@@ -52,7 +52,7 @@ export class StateService {
     // Global connection state
     private stage: Stage;
     private _state: threema.GlobalConnectionState;
-    public wasConnected: boolean;
+    public attempt: number = 0;
 
     // Unread messages
     private _unreadCount: number = 0;
@@ -132,7 +132,7 @@ export class StateService {
                     break;
                 case TaskConnectionState.Connected:
                     this.state = GlobalConnectionState.Ok;
-                    this.wasConnected = true;
+                    this.attempt = 0;
                     break;
                 case TaskConnectionState.Disconnected:
                     this.state = GlobalConnectionState.Error;
@@ -271,7 +271,6 @@ export class StateService {
         this.taskConnectionState = TaskConnectionState.New;
         this.stage = Stage.Signaling;
         this.state = GlobalConnectionState.Error;
-        this.wasConnected = false;
         this.connectionBuildupState = connectionBuildupState;
         this.progress = 0;
         this.unreadCount = 0;

+ 175 - 29
src/services/webclient.ts

@@ -37,7 +37,7 @@ import {MessageService} from './message';
 import {MimeService} from './mime';
 import {NotificationService} from './notification';
 import {PeerConnectionHelper} from './peerconnection';
-import {PushService} from './push';
+import {PushService, PushSession} from './push';
 import {QrCodeService} from './qrcode';
 import {ReceiverService} from './receiver';
 import {StateService} from './state';
@@ -45,6 +45,8 @@ import {TimeoutService} from './timeout';
 import {TitleService} from './title';
 import {VersionService} from './version';
 
+import {TimeoutError} from '../exceptions';
+import {DeviceUnreachableController} from '../partials/messenger';
 import {ChunkCache} from '../protocol/cache';
 import {SequenceNumber} from '../protocol/sequence_number';
 
@@ -52,6 +54,7 @@ import {SequenceNumber} from '../protocol/sequence_number';
 import InitializationStep = threema.InitializationStep;
 import ContactReceiverFeature = threema.ContactReceiverFeature;
 import DisconnectReason = threema.DisconnectReason;
+import PushSessionConfig = threema.PushSessionConfig;
 
 /**
  * Payload of a connectionInfo message.
@@ -75,6 +78,7 @@ const fakeConnectionId = Uint8Array.from([
  * This service handles everything related to the communication with the peer.
  */
 export class WebClientService {
+    public static readonly MAX_CONNECT_ATTEMPTS = 3;
     private static CHUNK_SIZE = 64 * 1024;
     private static SEQUENCE_NUMBER_MIN = 0;
     private static SEQUENCE_NUMBER_MAX = (2 ** 32) - 1;
@@ -186,7 +190,6 @@ export class WebClientService {
     private pendingInitializationStepRoutines: Set<threema.InitializationStepRoutine> = new Set();
     private initialized: Set<threema.InitializationStep> = new Set();
     private stateService: StateService;
-    private lastPush: Date = null;
 
     // Session connection
     private saltyRtcHost: string = null;
@@ -215,8 +218,17 @@ export class WebClientService {
     public conversations: threema.Container.Conversations;
     public receivers: threema.Container.Receivers;
     public alerts: threema.Alert[] = [];
+
+    // Push
     private pushToken: string = null;
     private pushTokenType: threema.PushTokenType = null;
+    private pushSession: PushSession | null = null;
+    private readonly pushSessionConfig: PushSessionConfig;
+    private readonly pushSessionExpectedPeriodMaxMs: number;
+    private pushPromise: Promise<any> | null = null;
+    private deviceUnreachableDialog: ng.IPromise<any> | null = null;
+    private pushTimer: number | null = null;
+    private schedulePushAfterCooldown: boolean = false;
 
     // Timeouts
     private batteryStatusTimeout: ng.IPromise<void> = null;
@@ -310,6 +322,11 @@ export class WebClientService {
         // State
         this.stateService = stateService;
 
+        // Push session configuration
+        this.pushSessionConfig = PushSession.defaultConfig;
+        this.pushSessionConfig.triesMax = WebClientService.MAX_CONNECT_ATTEMPTS;
+        this.pushSessionExpectedPeriodMaxMs = PushSession.expectedPeriodMaxMs(this.pushSessionConfig);
+
         // Other properties
         this.container = container;
         this.trustedKeyStore = trustedKeyStore;
@@ -484,6 +501,9 @@ export class WebClientService {
         // We want to know about new responders.
         this.salty.on('new-responder', () => {
             if (!this.startupDone) {
+                // Pushing complete
+                this.resetPushSession(true);
+
                 // Peer handshake
                 this.stateService.updateConnectionBuildupState('peer_handshake');
             }
@@ -529,7 +549,6 @@ export class WebClientService {
         // Once the connection is established, if this is a WebRTC connection,
         // initiate the peer connection and start the handover.
         this.salty.once('state-change:task', () => {
-
             // Determine chosen task
             const task = this.salty.getTask();
             if (task.getName().indexOf('webrtc.tasks.saltyrtc.org') !== -1) {
@@ -806,6 +825,51 @@ export class WebClientService {
         }
     }
 
+    /**
+     * Schedule a push to be sent if there is no network activity within a
+     * specified interval.
+     */
+    private schedulePush(timeoutMs: number = 3000): void {
+        if (this.pushTimer !== null) {
+            this.schedulePushAfterCooldown = true;
+            return;
+        }
+
+        // Send a push after the timeout
+        this.pushTimer = self.setTimeout(() => {
+            this.pushTimer = null;
+            this.schedulePushAfterCooldown = false;
+            this.$log.debug(this.logTag, 'Connection appears to be lost, sending push');
+            this.sendPush();
+        }, timeoutMs);
+
+        // Send a connection ack.
+        // Note: This acts as a *ping* but also helps us to keep the caches
+        //       clean.
+        this._requestConnectionAck();
+    }
+
+    /**
+     * Cancel a scheduled push.
+     */
+    private cancelPush(cooldownMs: number = 10000): void {
+        if (this.pushTimer !== null) {
+            self.clearTimeout(this.pushTimer);
+            this.pushTimer = null;
+        }
+        this.schedulePushAfterCooldown = false;
+
+        // Start the cooldown of the push timeout (if required)
+        if (cooldownMs > 0) {
+            this.pushTimer = self.setTimeout(() => {
+                this.pushTimer = null;
+                if (this.schedulePushAfterCooldown) {
+                    this.schedulePush();
+                }
+            }, cooldownMs);
+        }
+    }
+
     /**
      * For the WebRTC task, this is called when the DataChannel is open.
      * For the relayed data task, this is called once the connection is established.
@@ -1018,28 +1082,77 @@ export class WebClientService {
 
     /**
      * Send a push message to wake up the peer.
-     * The push message will only be sent if the last push is less than 2 seconds ago.
-     */
-    private sendPush(): void {
-        // Make sure not to flood the target device with pushes
-        const minPushInterval = 2000;
-        const now = new Date();
-        if (this.lastPush !== null && (now.getTime() - this.lastPush.getTime()) < minPushInterval) {
-            this.$log.debug(this.logTag,
-                'Skipping push, last push was requested less than ' + (minPushInterval / 1000) + 's ago');
-            return;
+     *
+     * Returns the maximum expected period until the promise will be resolved,
+     * and the promise itself.
+     */
+    public sendPush(): [number, Promise<void>] {
+        // Create new session
+        if (this.pushSession === null) {
+            this.pushSession = this.pushService.createSession(this.salty.permanentKeyBytes, this.pushSessionConfig);
+
+            // Start and handle success/error
+            this.pushPromise = this.pushSession.start()
+                .then(() => this.resetPushSession(true))
+                .catch((error) => {
+                    // Reset push session
+                    this.resetPushSession(false);
+
+                    // Handle error
+                    if (error instanceof TimeoutError) {
+                        this.showDeviceUnreachableDialog();
+                    } else {
+                        this.failSession();
+                    }
+                });
+
+            // Update state
+            if (!this.$rootScope.$$phase) {
+                this.$rootScope.$apply(() => this.stateService.updateConnectionBuildupState('push'));
+            } else {
+                this.stateService.updateConnectionBuildupState('push');
+            }
         }
-        this.lastPush = now;
 
-        // Actually send the push notification
-        this.pushService.sendPush(this.salty.permanentKeyBytes)
-            .then(() => {
-                this.$log.debug(this.logTag, 'Requested app wakeup via', this.pushTokenType, 'push');
-                this.$rootScope.$apply(() => {
-                    this.stateService.updateConnectionBuildupState('push');
-                });
+        // Retrieve the expected maximum period
+        return [this.pushSessionExpectedPeriodMaxMs, this.pushPromise];
+    }
+
+    /**
+     * Reset push session (if any) and hide the *device unreachable* dialog
+     * (if any and if requested).
+     */
+    private resetPushSession(hideDeviceUnreachableDialog: boolean = true): void {
+        // Hide unreachable dialog (if any)
+        if (hideDeviceUnreachableDialog && this.deviceUnreachableDialog !== null) {
+            this.$mdDialog.hide();
+        }
+
+        // Reset push session (if any)
+        if (this.pushSession !== null) {
+            this.pushSession.done();
+            this.pushSession = null;
+            this.pushPromise = null;
+        }
+    }
+
+    /**
+     * Show the *device unreachable* dialog.
+     */
+    public showDeviceUnreachableDialog(): void {
+        // Show device unreachable dialog (if we were already
+        // connected and if not already visible).
+        if (this.pushService.isAvailable() && this.$state.includes('messenger')
+            && this.deviceUnreachableDialog === null) {
+            this.deviceUnreachableDialog = this.$mdDialog.show({
+                controller: DeviceUnreachableController,
+                controllerAs: 'ctrl',
+                templateUrl: 'partials/dialog.device-unreachable.html',
+                parent: angular.element(document.body),
+                escapeToClose: false,
             })
-            .catch((e: Error) => this.$log.error(this.logTag, 'Could not send wakeup push to app: ' + e.message));
+                .finally(() => this.deviceUnreachableDialog = null);
+        }
     }
 
     /**
@@ -1062,10 +1175,12 @@ export class WebClientService {
         this.salty.connect();
 
         // If push service is available, notify app
-        if (skipPush === true) {
-            this.$log.debug(this.logTag, 'start(): Skipping push notification');
-        } else if (this.pushService.isAvailable()) {
-            this.sendPush();
+        if (this.pushService.isAvailable()) {
+            if (skipPush === true) {
+                this.$log.debug(this.logTag, 'start(): Skipping push notification');
+            } else {
+                this.sendPush();
+            }
         } else if (this.trustedKeyStore.hasTrustedKey()) {
             this.$log.debug(this.logTag, 'Push service not available');
             this.stateService.updateConnectionBuildupState('manual_start');
@@ -1101,6 +1216,9 @@ export class WebClientService {
         let close = args.close !== false;
         let remove = false;
 
+        // Stop push session
+        this.resetPushSession(true);
+
         // Session deleted: Force close and delete
         if (args.reason === DisconnectReason.SessionDeleted) {
             close = true;
@@ -1119,11 +1237,15 @@ export class WebClientService {
                 {reason: args.reason});
         }
 
-        // Stop ack timer
+        // Stop timer
         if (this.ackTimer !== null) {
             self.clearTimeout(this.ackTimer);
             this.ackTimer = null;
         }
+        if (this.pushTimer !== null) {
+            this.cancelPush(0);
+        }
+        this.$log.debug(this.logTag, 'Timer stopped');
 
         // Reset states
         this.stateService.reset(args.connectionBuildupState);
@@ -1143,6 +1265,11 @@ export class WebClientService {
             this.previousIncomingChunkSequenceNumber = null;
             this.previousChunkCache = null;
 
+            // Remove chosen task
+            // Note: This implicitly prevents automatic connection attempts
+            //       from the status controller.
+            this.chosenTask = threema.ChosenTask.None;
+
             // Reset general client information
             this.clientInfo = null;
 
@@ -1204,6 +1331,13 @@ export class WebClientService {
 
         // Done, redirect now if session closed
         if (close) {
+            // Reject startup promise (if any)
+            if (this.startupPromise !== null) {
+                this.startupPromise.reject();
+                this.startupPromise = null;
+                this._resetInitializationSteps();
+            }
+
             // Translate close flag
             const state = args.close !== false ? args.close : 'welcome';
             this.$state.go(state);
@@ -3121,7 +3255,7 @@ export class WebClientService {
                     this.$log.error(this.logTag, 'Invalid operating system in client info');
             }
         }
-        if (this.pushToken && this.pushTokenType) {
+        if (this.pushToken !== null && this.pushTokenType !== null) {
             this.pushService.init(this.pushToken, this.pushTokenType);
         }
 
@@ -3834,7 +3968,15 @@ export class WebClientService {
             if (this.config.DEBUG && this.config.MSG_DEBUGGING) {
                 this.$log.debug(`[Chunk] Sending chunk (retransmit/push=${retransmit}:`, chunk);
             }
+
+            // Send chunk
             this.relayedDataTask.sendMessage(chunk.buffer);
+
+            // Send a push if no incoming chunks within the next two seconds.
+            // Note: This has a cooldown phase of 10 seconds.
+            if (retransmit && this.startupDone) {
+                this.schedulePush();
+            }
         }
     }
 
@@ -3858,6 +4000,9 @@ export class WebClientService {
         // Schedule the periodic ack timer
         this.scheduleConnectionAck();
 
+        // Cancel scheduled push since data has been received
+        this.cancelPush();
+
         // Process chunk
         // Warning: Nothing should be called after the unchunker has processed
         //          the chunk since the message event is synchronous and can
@@ -3954,7 +4099,8 @@ export class WebClientService {
             try {
                 messageHandler.apply(this, [message.subType, message]);
             } catch (error) {
-                this.$log.error(this.logTag, `Unable to handle incoming wire message: ${error}`, error.stack);
+                this.$log.error(this.logTag, 'Unable to handle incoming wire message:', error);
+                console.trace(error); // tslint:disable-line:no-console
                 return;
             }
         }

+ 7 - 0
src/threema.d.ts

@@ -487,6 +487,13 @@ declare namespace threema {
         text: string;
     }
 
+    interface PushSessionConfig {
+        retryTimeoutInitMs: number;
+        retryTimeoutMaxMs: number;
+        triesMax: number;
+        timeToLiveRange: number[];
+    }
+
     const enum PushTokenType {
         Gcm = 'gcm',
         Apns = 'apns',