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 @@ + +
+ +
+

deviceUnreachable.DEVICE_UNREACHABLE

+
+
+ +
+

deviceUnreachable.UNABLE_TO_CONNECT

+
    +
  • + troubleshooting.PHONE_ONLINE +
  • +
  • + troubleshooting.APP_STARTED +
  • +
  • + troubleshooting.WEBCLIENT_ENABLED +
  • +
  • + troubleshooting.UNLOCK_OR_CHARGE +
  • +
  • + troubleshooting.PUSH_FAQ +
  • +
+
+
+ + + + welcome.RELOAD + + + + refresh + welcome.RETRY + + +
+
+ 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',