summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
blob: 42ef60b61904631226bf939a5d95e9597a2b34e4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.push;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;

import org.json.JSONObject;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.gcm.GcmTokenClient;
import org.mozilla.gecko.push.autopush.AutopushClientException;
import org.mozilla.gecko.util.ThreadUtils;

import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

/**
 * The push manager advances push registrations, ensuring that the upstream autopush endpoint has
 * a fresh GCM token.  It brokers channel subscription requests to the upstream and maintains
 * local state.
 * <p/>
 * This class is not thread safe.  An individual instance should be accessed on a single
 * (background) thread.
 */
public class PushManager {
    public static final long TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS = 7 * 24 * 60 * 60 * 1000L; // One week.

    public static class ProfileNeedsConfigurationException extends Exception {
        private static final long serialVersionUID = 3326738888L;

        public ProfileNeedsConfigurationException() {
            super();
        }
    }

    private static final String LOG_TAG = "GeckoPushManager";

    protected final @NonNull PushState state;
    protected final @NonNull GcmTokenClient gcmClient;
    protected final @NonNull PushClientFactory pushClientFactory;

    // For testing only.
    public interface PushClientFactory {
        PushClient getPushClient(String autopushEndpoint, boolean debug);
    }

    public PushManager(@NonNull PushState state, @NonNull GcmTokenClient gcmClient, @NonNull PushClientFactory pushClientFactory) {
        this.state = state;
        this.gcmClient = gcmClient;
        this.pushClientFactory = pushClientFactory;
    }

    public PushRegistration registrationForSubscription(String chid) {
        // chids are globally unique, so we're not concerned about finding a chid associated to
        // any particular profile.
        for (Map.Entry<String, PushRegistration> entry : state.getRegistrations().entrySet()) {
            final PushSubscription subscription = entry.getValue().getSubscription(chid);
            if (subscription != null) {
                return entry.getValue();
            }
        }
        return null;
    }

    public Map<String, PushSubscription> allSubscriptionsForProfile(String profileName) {
        final PushRegistration registration = state.getRegistration(profileName);
        if (registration == null) {
            return Collections.emptyMap();
        }
        return Collections.unmodifiableMap(registration.subscriptions);
    }

    public PushRegistration registerUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
        Log.i(LOG_TAG, "Registering user agent for profile named: " + profileName);
        return advanceRegistration(profileName, now);
    }

    public PushRegistration unregisterUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException {
        Log.i(LOG_TAG, "Unregistering user agent for profile named: " + profileName);

        final PushRegistration registration = state.getRegistration(profileName);
        if (registration == null) {
            Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote uaid for profileName: " + profileName);
            return null;
        }

        final String uaid = registration.uaid.value;
        final String secret = registration.secret;
        if (uaid == null || secret == null) {
            Log.e(LOG_TAG, "Cannot unregisterUserAgent with null registration uaid or secret!");
            return null;
        }

        unregisterUserAgentOnBackgroundThread(registration);
        return registration;
    }

    public PushSubscription subscribeChannel(final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
        Log.i(LOG_TAG, "Subscribing to channel for service: " + service + "; for profile named: " + profileName);
        final PushRegistration registration = advanceRegistration(profileName, now);
        final PushSubscription subscription = subscribeChannel(registration, profileName, service, serviceData, appServerKey, System.currentTimeMillis());
        return subscription;
    }

    protected PushSubscription subscribeChannel(final @NonNull PushRegistration registration, final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws AutopushClientException, PushClient.LocalException {
        final String uaid = registration.uaid.value;
        final String secret = registration.secret;
        if (uaid == null || secret == null) {
            throw new IllegalStateException("Cannot subscribeChannel with null uaid or secret!");
        }

        // Verify endpoint is not null?
        final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);

        final SubscribeChannelResponse result = pushClient.subscribeChannel(uaid, secret, appServerKey);
        if (registration.debug) {
            Log.i(LOG_TAG, "Got chid: " + result.channelID + " and endpoint: " + result.endpoint);
        } else {
            Log.i(LOG_TAG, "Got chid and endpoint.");
        }

        final PushSubscription subscription = new PushSubscription(result.channelID, profileName, result.endpoint, service, serviceData);
        registration.putSubscription(result.channelID, subscription);
        state.checkpoint();

        return subscription;
    }

    public PushSubscription unsubscribeChannel(final @NonNull String chid) {
        Log.i(LOG_TAG, "Unsubscribing from channel with chid: " + chid);

        final PushRegistration registration = registrationForSubscription(chid);
        if (registration == null) {
            Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote subscription: " + chid);
            return null;
        }

        // We remove the local subscription before the remote subscription:  without the local
        // subscription we'll ignoring incoming messages, and after some amount of time the
        // server will expire the channel due to non-activity.  This is also Desktop's approach.
        final PushSubscription subscription = registration.removeSubscription(chid);
        state.checkpoint();

        if (subscription == null) {
            // This should never happen.
            Log.e(LOG_TAG, "Subscription did not exist: " + chid);
            return null;
        }

        final String uaid = registration.uaid.value;
        final String secret = registration.secret;
        if (uaid == null || secret == null) {
            Log.e(LOG_TAG, "Cannot unsubscribeChannel with null registration uaid or secret!");
            return null;
        }

        final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
        // Fire and forget.
        ThreadUtils.postToBackgroundThread(new Runnable() {
            @Override
            public void run() {
                try {
                    pushClient.unsubscribeChannel(registration.uaid.value, registration.secret, chid);
                    Log.i(LOG_TAG, "Unsubscribed from channel with chid: " + chid);
                } catch (PushClient.LocalException | AutopushClientException e) {
                    Log.w(LOG_TAG, "Failed to unsubscribe from channel with chid; ignoring: " + chid, e);
                }
            }
        });

        return subscription;
    }

    public PushRegistration configure(final @NonNull String profileName, final @NonNull String endpoint, final boolean debug, final long now) {
        Log.i(LOG_TAG, "Updating configuration.");
        final PushRegistration registration = state.getRegistration(profileName);
        final PushRegistration newRegistration;
        if (registration != null) {
            if (!endpoint.equals(registration.autopushEndpoint)) {
                if (debug) {
                    Log.i(LOG_TAG, "Push configuration autopushEndpoint changed! Was: " + registration.autopushEndpoint + "; now: " + endpoint);
                } else {
                    Log.i(LOG_TAG, "Push configuration autopushEndpoint changed!");
                }

                newRegistration = new PushRegistration(endpoint, debug, Fetched.now(null), null);

                if (registration.uaid.value != null) {
                    // New endpoint!  All registrations and subscriptions have been dropped, and
                    // should be removed remotely.
                    unregisterUserAgentOnBackgroundThread(registration);
                }
            } else if (debug != registration.debug) {
                Log.i(LOG_TAG, "Push configuration debug changed: " + debug);
                newRegistration = registration.withDebug(debug);
            } else {
                newRegistration = registration;
            }
        } else {
            if (debug) {
                Log.i(LOG_TAG, "Push configuration set: " + endpoint + "; debug: " + debug);
            } else {
                Log.i(LOG_TAG, "Push configuration set!");
            }
            newRegistration = new PushRegistration(endpoint, debug, new Fetched(null, now), null);
        }

        if (newRegistration != registration) {
            state.putRegistration(profileName, newRegistration);
            state.checkpoint();
        }

        return newRegistration;
    }

    private void unregisterUserAgentOnBackgroundThread(final PushRegistration registration) {
        ThreadUtils.postToBackgroundThread(new Runnable() {
            @Override
            public void run() {
                try {
                    pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug).unregisterUserAgent(registration.uaid.value, registration.secret);
                    Log.i(LOG_TAG, "Unregistered user agent with uaid: " + registration.uaid.value);
                } catch (PushClient.LocalException | AutopushClientException e) {
                    Log.w(LOG_TAG, "Failed to unregister user agent with uaid; ignoring: " + registration.uaid.value, e);
                }
            }
        });
    }

    protected @NonNull PushRegistration advanceRegistration(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
        final PushRegistration registration = state.getRegistration(profileName);
        if (registration == null || registration.autopushEndpoint == null) {
            Log.i(LOG_TAG, "Cannot advance to registered: registration needs configuration.");
            throw new ProfileNeedsConfigurationException();
        }
        return advanceRegistration(registration, profileName, now);
    }

    protected @NonNull PushRegistration advanceRegistration(final PushRegistration registration, final @NonNull String profileName, final long now) throws AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
        final Fetched gcmToken = gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, registration.debug);

        final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);

        if (registration.uaid.value == null) {
            if (registration.debug) {
                Log.i(LOG_TAG, "No uaid; requesting from autopush endpoint: " + registration.autopushEndpoint);
            } else {
                Log.i(LOG_TAG, "No uaid: requesting from autopush endpoint.");
            }
            final RegisterUserAgentResponse result = pushClient.registerUserAgent(gcmToken.value);
            if (registration.debug) {
                Log.i(LOG_TAG, "Got uaid: " + result.uaid + " and secret: " + result.secret);
            } else {
                Log.i(LOG_TAG, "Got uaid and secret.");
            }
            final long nextNow = System.currentTimeMillis();
            final PushRegistration nextRegistration = registration.withUserAgentID(result.uaid, result.secret, nextNow);
            state.putRegistration(profileName, nextRegistration);
            state.checkpoint();
            return advanceRegistration(nextRegistration, profileName, nextNow);
        }

        if (registration.uaid.timestamp + TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS < now
                || registration.uaid.timestamp < gcmToken.timestamp) {
            if (registration.debug) {
                Log.i(LOG_TAG, "Stale uaid; re-registering with autopush endpoint: " + registration.autopushEndpoint);
            } else {
                Log.i(LOG_TAG, "Stale uaid: re-registering with autopush endpoint.");
            }

            pushClient.reregisterUserAgent(registration.uaid.value, registration.secret, gcmToken.value);

            Log.i(LOG_TAG, "Re-registered uaid and secret.");
            final long nextNow = System.currentTimeMillis();
            final PushRegistration nextRegistration = registration.withUserAgentID(registration.uaid.value, registration.secret, nextNow);
            state.putRegistration(profileName, nextRegistration);
            state.checkpoint();
            return advanceRegistration(nextRegistration, profileName, nextNow);
        }

        Log.d(LOG_TAG, "Existing uaid is fresh; no need to request from autopush endpoint.");
        return registration;
    }

    public void invalidateGcmToken() {
        gcmClient.invalidateToken();
    }

    public void startup(long now) {
        try {
            Log.i(LOG_TAG, "Startup: requesting GCM token.");
            gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, false); // For side-effects.
        } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
            // Requires user intervention.  At App startup, we don't want to address this.  In
            // response to user activity, we do want to try to have the user address this.
            Log.w(LOG_TAG, "Startup: needs Google Play Services.  Ignoring until GCM is requested in response to user activity.");
            return;
        } catch (IOException e) {
            // We're temporarily unable to get a GCM token.  There's nothing to be done; we'll
            // try to advance the App's state in response to user activity or at next startup.
            Log.w(LOG_TAG, "Startup: Google Play Services is available, but we can't get a token; ignoring.", e);
            return;
        }

        Log.i(LOG_TAG, "Startup: advancing all registrations.");
        final Map<String, PushRegistration> registrations = state.getRegistrations();

        // Now advance all registrations.
        try {
            final Iterator<Map.Entry<String, PushRegistration>> it = registrations.entrySet().iterator();
            while (it.hasNext()) {
                final Map.Entry<String, PushRegistration> entry = it.next();
                final String profileName = entry.getKey();
                final PushRegistration registration = entry.getValue();
                if (registration.subscriptions.isEmpty()) {
                    Log.i(LOG_TAG, "Startup: no subscriptions for profileName; not advancing registration: " + profileName);
                    continue;
                }

                try {
                    advanceRegistration(profileName, now); // For side-effects.
                    Log.i(LOG_TAG, "Startup: advanced registration for profileName: " + profileName);
                } catch (ProfileNeedsConfigurationException e) {
                    Log.i(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; profile needs configuration from Gecko.");
                } catch (AutopushClientException e) {
                    if (e.isTransientError()) {
                        Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got transient autopush error.  Ignoring; will advance on demand.", e);
                    } else {
                        Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got permanent autopush error.  Removing registration entirely.", e);
                        it.remove();
                    }
                } catch (PushClient.LocalException e) {
                    Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got local exception.  Ignoring; will advance on demand.", e);
                }
            }
        } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
            Log.w(LOG_TAG, "Startup: cannot advance any registrations; need Google Play Services!", e);
            return;
        } catch (IOException e) {
            Log.w(LOG_TAG, "Startup: cannot advance any registrations; intermittent Google Play Services exception; ignoring, will advance on demand.", e);
            return;
        }

        // We may have removed registrations above.  Checkpoint just to be safe!
        state.checkpoint();
    }
}