diff --git a/public/i18n/de.json b/public/i18n/de.json
index 975942248..44ac6a9b4 100644
--- a/public/i18n/de.json
+++ b/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 Android und iOS 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."
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 8efc3c1da..a4b40c4bf 100644
--- a/public/i18n/en.json
+++ b/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 Android and iOS 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."
diff --git a/src/controllers/status.ts b/src/controllers/status.ts
index 854d793ed..66d28bc39 100644
--- a/src/controllers/status.ts
+++ b/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);
},
diff --git a/src/exceptions.ts b/src/exceptions.ts
new file mode 100644
index 000000000..11808fd2e
--- /dev/null
+++ b/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 .
+ */
+
+export class TimeoutError extends Error {}
diff --git a/src/partials/dialog.device-unreachable.html b/src/partials/dialog.device-unreachable.html
new file mode 100644
index 000000000..3df0241f8
--- /dev/null
+++ b/src/partials/dialog.device-unreachable.html
@@ -0,0 +1,43 @@
+
+
+
+
diff --git a/src/partials/messenger.ts b/src/partials/messenger.ts
index 3a6db20f9..89cf1e5f0 100644
--- a/src/partials/messenger.ts
+++ b/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 {
+ // 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 {
diff --git a/src/partials/welcome.html b/src/partials/welcome.html
index 7fd50b896..1309814f5 100644
--- a/src/partials/welcome.html
+++ b/src/partials/welcome.html
@@ -66,29 +66,29 @@
welcome.CONNECTING
welcome.LOADING_INITIAL_DATA
troubleshooting.SLOW_CONNECT
-
-
- help
+
+
troubleshooting.PHONE_ONLINE
-
- help
+
troubleshooting.APP_STARTED
-
- help
+
troubleshooting.WEBCLIENT_ENABLED
-
- help
+
troubleshooting.SESSION_DELETED
-
- help
+
+ troubleshooting.UNLOCK_OR_CHARGE
+
+
+ troubleshooting.PUSH_FAQ
+
+
troubleshooting.PLUGIN
-
- help
+
troubleshooting.ADBLOCKER
diff --git a/src/partials/welcome.ts b/src/partials/welcome.ts
index 5db8be5e7..d3433b357 100644
--- a/src/partials/welcome.ts
+++ b/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 */ },
);
}
diff --git a/src/sass/layout/_main.scss b/src/sass/layout/_main.scss
index e4a1419f8..79e8c1a07 100644
--- a/src/sass/layout/_main.scss
+++ b/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;
}
diff --git a/src/sass/sections/_welcome.scss b/src/sass/sections/_welcome.scss
index 054617394..a0d702efc 100644
--- a/src/sass/sections/_welcome.scss
+++ b/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;
- }
- }
}
diff --git a/src/services/push.ts b/src/services/push.ts
index fe87ffda7..28de52580 100644
--- a/src/services/push.ts
+++ b/src/services/push.ts
@@ -15,123 +15,300 @@
* along with Threema Web. If not, see .
*/
+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 = 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 {
+ // 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 {
+ // 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: ";;"
+ 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 {
+ 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: ";;"
- 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;
+ // Create push instance
+ return new PushSession(this, session, config);
}
}
diff --git a/src/services/state.ts b/src/services/state.ts
index e285c33b2..e46132be1 100644
--- a/src/services/state.ts
+++ b/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;
diff --git a/src/services/webclient.ts b/src/services/webclient.ts
index aa8941d5e..4fa7e7f21 100644
--- a/src/services/webclient.ts
+++ b/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 = new Set();
private initialized: Set = 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 | null = null;
+ private deviceUnreachableDialog: ng.IPromise | null = null;
+ private pushTimer: number | null = null;
+ private schedulePushAfterCooldown: boolean = false;
// Timeouts
private batteryStatusTimeout: ng.IPromise = 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] {
+ // 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;
}
}
diff --git a/src/threema.d.ts b/src/threema.d.ts
index 5d692c75d..eae0f3edf 100644
--- a/src/threema.d.ts
+++ b/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',