1// Copyright (c) 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.sync.notifier;
6
7import android.accounts.Account;
8import android.app.PendingIntent;
9import android.content.ContentResolver;
10import android.content.Intent;
11import android.os.Bundle;
12import android.util.Log;
13
14import com.google.common.annotations.VisibleForTesting;
15import com.google.common.collect.Lists;
16import com.google.common.collect.Sets;
17import com.google.ipc.invalidation.external.client.InvalidationListener.RegistrationState;
18import com.google.ipc.invalidation.external.client.contrib.AndroidListener;
19import com.google.ipc.invalidation.external.client.types.ErrorInfo;
20import com.google.ipc.invalidation.external.client.types.Invalidation;
21import com.google.ipc.invalidation.external.client.types.ObjectId;
22import com.google.protos.ipc.invalidation.Types.ClientType;
23
24import org.chromium.base.ActivityStatus;
25import org.chromium.sync.internal_api.pub.base.ModelType;
26import org.chromium.sync.notifier.InvalidationController.IntentProtocol;
27import org.chromium.sync.notifier.InvalidationPreferences.EditContext;
28import org.chromium.sync.signin.AccountManagerHelper;
29import org.chromium.sync.signin.ChromeSigninController;
30
31import java.util.Collection;
32import java.util.Collections;
33import java.util.List;
34import java.util.Random;
35import java.util.Set;
36
37import javax.annotation.Nullable;
38
39/**
40 * Service that controls notifications for sync.
41 * <p>
42 * This service serves two roles. On the one hand, it is a client for the notification system
43 * used to trigger sync. It receives invalidations and converts them into
44 * {@link ContentResolver#requestSync} calls, and it supplies the notification system with the set
45 * of desired registrations when requested.
46 * <p>
47 * On the other hand, this class is controller for the notification system. It starts it and stops
48 * it, and it requests that it perform (un)registrations as the set of desired sync types changes.
49 * <p>
50 * This class is an {@code IntentService}. All methods are assumed to be executing on its single
51 * execution thread.
52 *
53 * @author dsmyers@google.com
54 */
55public class InvalidationService extends AndroidListener {
56    /* This class must be public because it is exposed as a service. */
57
58    /** Notification client typecode. */
59    @VisibleForTesting
60    static final int CLIENT_TYPE = ClientType.Type.CHROME_SYNC_ANDROID_VALUE;
61
62    private static final String TAG = "InvalidationService";
63
64    private static final Random RANDOM = new Random();
65
66    /**
67     * Whether the underlying notification client has been started. This boolean is updated when a
68     * start or stop intent is issued to the underlying client, not when the intent is actually
69     * processed.
70     */
71    private static boolean sIsClientStarted;
72
73    /**
74     * The id of the client in use, if any. May be {@code null} if {@link #sIsClientStarted} is
75     * true if the client has not yet gone ready.
76     */
77    @Nullable private static byte[] sClientId;
78
79    @Override
80    public void onHandleIntent(Intent intent) {
81        // Ensure that a client is or is not running, as appropriate, and that it is for the
82        // correct account. ensureAccount will stop the client if account is non-null and doesn't
83        // match the stored account. Then, if a client should be running, ensureClientStartState
84        // will start a new one if needed. I.e., these two functions work together to restart the
85        // client when the account changes.
86        Account account = intent.hasExtra(IntentProtocol.EXTRA_ACCOUNT) ?
87                (Account) intent.getParcelableExtra(IntentProtocol.EXTRA_ACCOUNT) : null;
88        ensureAccount(account);
89        ensureClientStartState();
90
91        // Handle the intent.
92        if (IntentProtocol.isStop(intent) && sIsClientStarted) {
93            // If the intent requests that the client be stopped, stop it.
94            stopClient();
95        } else if (IntentProtocol.isRegisteredTypesChange(intent)) {
96            // If the intent requests a change in registrations, change them.
97            List<String> regTypes =
98                    intent.getStringArrayListExtra(IntentProtocol.EXTRA_REGISTERED_TYPES);
99            setRegisteredTypes(Sets.newHashSet(regTypes));
100        } else {
101            // Otherwise, we don't recognize the intent. Pass it to the notification client service.
102            super.onHandleIntent(intent);
103        }
104    }
105
106    @Override
107    public void invalidate(Invalidation invalidation, byte[] ackHandle) {
108        byte[] payload = invalidation.getPayload();
109        String payloadStr = (payload == null) ? null : new String(payload);
110        requestSync(invalidation.getObjectId(), invalidation.getVersion(), payloadStr);
111        acknowledge(ackHandle);
112    }
113
114    @Override
115    public void invalidateUnknownVersion(ObjectId objectId, byte[] ackHandle) {
116        requestSync(objectId, null, null);
117        acknowledge(ackHandle);
118    }
119
120    @Override
121    public void invalidateAll(byte[] ackHandle) {
122        requestSync(null, null, null);
123        acknowledge(ackHandle);
124    }
125
126    @Override
127    public void informRegistrationFailure(
128            byte[] clientId, ObjectId objectId, boolean isTransient, String errorMessage) {
129        Log.w(TAG, "Registration failure on " + objectId + " ; transient = " + isTransient
130                + ": " + errorMessage);
131        if (isTransient) {
132          // Retry immediately on transient failures. The base AndroidListener will handle
133          // exponential backoff if there are repeated failures.
134          List<ObjectId> objectIdAsList = Lists.newArrayList(objectId);
135          if (readRegistrationsFromPrefs().contains(objectId)) {
136              register(clientId, objectIdAsList);
137          } else {
138              unregister(clientId, objectIdAsList);
139          }
140        }
141    }
142
143    @Override
144    public void informRegistrationStatus(
145            byte[] clientId, ObjectId objectId, RegistrationState regState) {
146        Log.d(TAG, "Registration status for " + objectId + ": " + regState);
147        List<ObjectId> objectIdAsList = Lists.newArrayList(objectId);
148        boolean registrationisDesired = readRegistrationsFromPrefs().contains(objectId);
149        if (regState == RegistrationState.REGISTERED) {
150          if (!registrationisDesired) {
151            Log.i(TAG, "Unregistering for object we're no longer interested in");
152            unregister(clientId, objectIdAsList);
153          }
154        } else {
155          if (registrationisDesired) {
156            Log.i(TAG, "Registering for an object");
157            register(clientId, objectIdAsList);
158          }
159        }
160    }
161
162    @Override
163    public void informError(ErrorInfo errorInfo) {
164        Log.w(TAG, "Invalidation client error:" + errorInfo);
165        if (!errorInfo.isTransient() && sIsClientStarted) {
166            // It is important not to stop the client if it is already stopped. Otherwise, the
167            // possibility exists to go into an infinite loop if the stop call itself triggers an
168            // error (e.g., because no client actually exists).
169            stopClient();
170        }
171    }
172
173    @Override
174    public void ready(byte[] clientId) {
175        setClientId(clientId);
176
177        // We might have accumulated some registrations to do while we were waiting for the client
178        // to become ready.
179        reissueRegistrations(clientId);
180    }
181
182    @Override
183    public void reissueRegistrations(byte[] clientId) {
184        Set<ObjectId> desiredRegistrations = readRegistrationsFromPrefs();
185        if (!desiredRegistrations.isEmpty()) {
186            register(clientId, desiredRegistrations);
187        }
188    }
189
190    @Override
191    public void requestAuthToken(final PendingIntent pendingIntent,
192            @Nullable String invalidAuthToken) {
193        @Nullable Account account = ChromeSigninController.get(this).getSignedInUser();
194        if (account == null) {
195            // This should never happen, because this code should only be run if a user is
196            // signed-in.
197            Log.w(TAG, "No signed-in user; cannot send message to data center");
198            return;
199        }
200
201        // Attempt to retrieve a token for the user. This method will also invalidate
202        // invalidAuthToken if it is non-null.
203        AccountManagerHelper.get(this).getNewAuthTokenFromForeground(
204                account, invalidAuthToken, SyncStatusHelper.AUTH_TOKEN_TYPE_SYNC,
205                new AccountManagerHelper.GetAuthTokenCallback() {
206                    @Override
207                    public void tokenAvailable(String token) {
208                        if (token != null) {
209                            InvalidationService.setAuthToken(
210                                    InvalidationService.this.getApplicationContext(), pendingIntent,
211                                    token, SyncStatusHelper.AUTH_TOKEN_TYPE_SYNC);
212                        }
213                    }
214                });
215    }
216
217    @Override
218    public void writeState(byte[] data) {
219        InvalidationPreferences invPreferences = new InvalidationPreferences(this);
220        EditContext editContext = invPreferences.edit();
221        invPreferences.setInternalNotificationClientState(editContext, data);
222        invPreferences.commit(editContext);
223    }
224
225    @Override
226    @Nullable public byte[] readState() {
227        return new InvalidationPreferences(this).getInternalNotificationClientState();
228    }
229
230    /**
231     * Ensures that the client is running or not running as appropriate, based on the value of
232     * {@link #shouldClientBeRunning}.
233     */
234    private void ensureClientStartState() {
235        final boolean shouldClientBeRunning = shouldClientBeRunning();
236        if (!shouldClientBeRunning && sIsClientStarted) {
237            // Stop the client if it should not be running and is.
238            stopClient();
239        } else if (shouldClientBeRunning && !sIsClientStarted) {
240            // Start the client if it should be running and isn't.
241            startClient();
242        }
243    }
244
245    /**
246     * If {@code intendedAccount} is non-{@null} and differs from the account stored in preferences,
247     * then stops the existing client (if any) and updates the stored account.
248     */
249    private void ensureAccount(@Nullable Account intendedAccount) {
250        if (intendedAccount == null) {
251            return;
252        }
253        InvalidationPreferences invPrefs = new InvalidationPreferences(this);
254        if (!intendedAccount.equals(invPrefs.getSavedSyncedAccount())) {
255            if (sIsClientStarted) {
256                stopClient();
257            }
258            setAccount(intendedAccount);
259        }
260    }
261
262    /**
263     * Starts a new client, destroying any existing client. {@code owningAccount} is the account
264     * of the user for which the client is being created; it will be persisted using
265     * {@link InvalidationPreferences#setAccount}.
266     */
267    private void startClient() {
268        Intent startIntent = AndroidListener.createStartIntent(this, CLIENT_TYPE, getClientName());
269        startService(startIntent);
270        setIsClientStarted(true);
271    }
272
273    /** Stops the notification client. */
274    private void stopClient() {
275        startService(AndroidListener.createStopIntent(this));
276        setIsClientStarted(false);
277        setClientId(null);
278    }
279
280    /** Sets the saved sync account in {@link InvalidationPreferences} to {@code owningAccount}. */
281    private void setAccount(Account owningAccount) {
282        InvalidationPreferences invPrefs = new InvalidationPreferences(this);
283        EditContext editContext = invPrefs.edit();
284        invPrefs.setAccount(editContext, owningAccount);
285        invPrefs.commit(editContext);
286    }
287
288    /**
289     * Reads the saved sync types from storage (if any) and returns a set containing the
290     * corresponding object ids.
291     */
292    @VisibleForTesting
293    Set<ObjectId> readRegistrationsFromPrefs() {
294        Set<String> savedTypes = new InvalidationPreferences(this).getSavedSyncedTypes();
295        if (savedTypes == null) return Collections.emptySet();
296        else return ModelType.syncTypesToObjectIds(savedTypes);
297    }
298
299    /**
300     * Sets the types for which notifications are required to {@code syncTypes}. {@code syncTypes}
301     * is either a list of specific types or the special wildcard type
302     * {@link ModelType#ALL_TYPES_TYPE}.
303     * <p>
304     * @param syncTypes
305     */
306    private void setRegisteredTypes(Set<String> syncTypes) {
307        // If we have a ready client and will be making registration change calls on it, then
308        // read the current registrations from preferences before we write the new values, so that
309        // we can take the diff of the two registration sets and determine which registration change
310        // calls to make.
311        Set<ObjectId> existingRegistrations = (sClientId == null) ?
312                null : readRegistrationsFromPrefs();
313
314        // Write the new sync types to preferences. We do not expand the syncTypes to take into
315        // account the ALL_TYPES_TYPE at this point; we want to persist the wildcard unexpanded.
316        InvalidationPreferences prefs = new InvalidationPreferences(this);
317        EditContext editContext = prefs.edit();
318        prefs.setSyncTypes(editContext, syncTypes);
319        prefs.commit(editContext);
320
321        // If we do not have a ready invalidation client, we cannot change its registrations, so
322        // return. Later, when the client is ready, we will supply the new registrations.
323        if (sClientId == null) {
324            return;
325        }
326
327        // We do have a ready client. Unregister any existing registrations not present in the
328        // new set and register any elements in the new set not already present. This call does
329        // expansion of the ALL_TYPES_TYPE wildcard.
330        // NOTE: syncTypes MUST NOT be used below this line, since it contains an unexpanded
331        // wildcard.
332        List<ObjectId> unregistrations = Lists.newArrayList();
333        List<ObjectId> registrations = Lists.newArrayList();
334        computeRegistrationOps(existingRegistrations,
335                ModelType.syncTypesToObjectIds(syncTypes),
336                registrations, unregistrations);
337        unregister(sClientId, unregistrations);
338        register(sClientId, registrations);
339    }
340
341    /**
342     * Computes the set of (un)registrations to perform so that the registrations active in the
343     * Ticl will be {@code desiredRegs}, given that {@existingRegs} already exist.
344     *
345     * @param regAccumulator registrations to perform
346     * @param unregAccumulator unregistrations to perform.
347     */
348    @VisibleForTesting
349    static void computeRegistrationOps(Set<ObjectId> existingRegs, Set<ObjectId> desiredRegs,
350            Collection<ObjectId> regAccumulator, Collection<ObjectId> unregAccumulator) {
351        // Registrations to do are elements in the new set but not the old set.
352        regAccumulator.addAll(Sets.difference(desiredRegs, existingRegs));
353
354        // Unregistrations to do are elements in the old set but not the new set.
355        unregAccumulator.addAll(Sets.difference(existingRegs, desiredRegs));
356    }
357
358    /**
359     * Requests that the sync system perform a sync.
360     *
361     * @param objectId the object that changed, if known.
362     * @param version the version of the object that changed, if known.
363     * @param payload the payload of the change, if known.
364     */
365    private void requestSync(@Nullable ObjectId objectId, @Nullable Long version,
366            @Nullable String payload) {
367        // Construct the bundle to supply to the native sync code.
368        Bundle bundle = new Bundle();
369        if (objectId == null && version == null && payload == null) {
370            // Use an empty bundle in this case for compatibility with the v1 implementation.
371        } else {
372            if (objectId != null) {
373                bundle.putString("objectId", new String(objectId.getName()));
374            }
375            // We use "0" as the version if we have an unknown-version invalidation. This is OK
376            // because the native sync code special-cases zero and always syncs for invalidations at
377            // that version (Tango defines a special UNKNOWN_VERSION constant with this value).
378            bundle.putLong("version", (version == null) ? 0 : version);
379            bundle.putString("payload", (payload == null) ? "" : payload);
380        }
381        Account account = ChromeSigninController.get(this).getSignedInUser();
382        String contractAuthority = InvalidationController.get(this).getContractAuthority();
383        requestSyncFromContentResolver(bundle, account, contractAuthority);
384    }
385
386    /**
387     * Calls {@link ContentResolver#requestSync(Account, String, Bundle)} to trigger a sync. Split
388     * into a separate method so that it can be overriden in tests.
389     */
390    @VisibleForTesting
391    void requestSyncFromContentResolver(
392            Bundle bundle, Account account, String contractAuthority) {
393        Log.d(TAG, "Request sync: " + account + " / " + contractAuthority + " / "
394            + bundle.keySet());
395        ContentResolver.requestSync(account, contractAuthority, bundle);
396    }
397
398    /**
399     * Returns whether the notification client should be running, i.e., whether Chrome is in the
400     * foreground and sync is enabled.
401     */
402    @VisibleForTesting
403    boolean shouldClientBeRunning() {
404        return isSyncEnabled() && isChromeInForeground();
405    }
406
407    /** Returns whether sync is enabled. LLocal method so it can be overridden in tests. */
408    @VisibleForTesting
409    boolean isSyncEnabled() {
410        return SyncStatusHelper.get(getApplicationContext()).isSyncEnabled();
411    }
412
413    /**
414     * Returns whether Chrome is in the foreground. Local method so it can be overridden in tests.
415     */
416    @VisibleForTesting
417    boolean isChromeInForeground() {
418        switch (ActivityStatus.getState()) {
419            case ActivityStatus.CREATED:
420            case ActivityStatus.STARTED:
421            case ActivityStatus.RESUMED:
422                return true;
423            default:
424                return false;
425        }
426    }
427
428    /** Returns whether the notification client has been started, for tests. */
429    @VisibleForTesting
430    static boolean getIsClientStartedForTest() {
431        return sIsClientStarted;
432    }
433
434    /** Returns the notification client id, for tests. */
435    @VisibleForTesting
436    @Nullable static byte[] getClientIdForTest() {
437        return sClientId;
438    }
439
440    /** Returns the client name used for the notification client. */
441    private static byte[] getClientName() {
442        // TODO(dsmyers): we should use the same client name as the native sync code.
443        // Bug: https://code.google.com/p/chromium/issues/detail?id=172391
444        return Long.toString(RANDOM.nextLong()).getBytes();
445    }
446
447    private static void setClientId(byte[] clientId) {
448        sClientId = clientId;
449    }
450
451    private static void setIsClientStarted(boolean isStarted) {
452        sIsClientStarted = isStarted;
453    }
454}
455