1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.google.android.setupcompat.internal;
18 
19 import android.annotation.SuppressLint;
20 import android.content.ComponentName;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.ServiceConnection;
24 import android.os.IBinder;
25 import android.os.Looper;
26 import androidx.annotation.NonNull;
27 import androidx.annotation.Nullable;
28 import androidx.annotation.VisibleForTesting;
29 import android.util.Log;
30 import com.google.android.setupcompat.ISetupCompatService;
31 import java.util.concurrent.CountDownLatch;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
34 import java.util.concurrent.atomic.AtomicReference;
35 import java.util.function.UnaryOperator;
36 
37 /**
38  * This class provides an instance of {@link ISetupCompatService}. It keeps track of the connection
39  * state and reconnects if necessary.
40  */
41 public class SetupCompatServiceProvider {
42 
43   /**
44    * Returns an instance of {@link ISetupCompatService} if one already exists. If not, attempts to
45    * rebind if the current state allows such an operation and waits until {@code waitTime} for
46    * receiving the stub reference via {@link ServiceConnection#onServiceConnected(ComponentName,
47    * IBinder)}.
48    *
49    * @throws IllegalStateException if called from the main thread since this is a blocking
50    *     operation.
51    * @throws TimeoutException if timed out waiting for {@code waitTime}.
52    */
get(Context context, long waitTime, @NonNull TimeUnit timeUnit)53   public static ISetupCompatService get(Context context, long waitTime, @NonNull TimeUnit timeUnit)
54       throws TimeoutException, InterruptedException {
55     return getInstance(context).getService(waitTime, timeUnit);
56   }
57 
58   @VisibleForTesting
getService(long timeout, TimeUnit timeUnit)59   public ISetupCompatService getService(long timeout, TimeUnit timeUnit)
60       throws TimeoutException, InterruptedException {
61     Preconditions.checkState(
62         disableLooperCheckForTesting || Looper.getMainLooper() != Looper.myLooper(),
63         "getService blocks and should not be called from the main thread.");
64     ServiceContext serviceContext = getCurrentServiceState();
65     switch (serviceContext.state) {
66       case CONNECTED:
67         return serviceContext.compatService;
68 
69       case SERVICE_NOT_USABLE:
70       case BIND_FAILED:
71         // End states, no valid connection can be obtained ever.
72         return null;
73 
74       case DISCONNECTED:
75       case BINDING:
76         return waitForConnection(timeout, timeUnit);
77 
78       case REBIND_REQUIRED:
79         requestServiceBind();
80         return waitForConnection(timeout, timeUnit);
81 
82       case NOT_STARTED:
83         throw new IllegalStateException(
84             "NOT_STARTED state only possible before instance is created.");
85     }
86     throw new IllegalStateException("Unknown state = " + serviceContext.state);
87   }
88 
waitForConnection(long timeout, TimeUnit timeUnit)89   private ISetupCompatService waitForConnection(long timeout, TimeUnit timeUnit)
90       throws TimeoutException, InterruptedException {
91     ServiceContext currentServiceState = getCurrentServiceState();
92     if (currentServiceState.state == State.CONNECTED) {
93       return currentServiceState.compatService;
94     }
95 
96     CountDownLatch connectedStateLatch = getConnectedCondition();
97     Log.i(TAG, "Waiting for service to get connected");
98     boolean stateChanged = connectedStateLatch.await(timeout, timeUnit);
99     if (!stateChanged) {
100       // Even though documentation states that disconnected service should connect again,
101       // requesting rebind reduces the wait time to acquire a new connection.
102       requestServiceBind();
103       throw new TimeoutException(
104           String.format("Failed to acquire connection after [%s %s]", timeout, timeUnit));
105     }
106     currentServiceState = getCurrentServiceState();
107     if (Log.isLoggable(TAG, Log.INFO)) {
108       Log.i(
109           TAG,
110           String.format(
111               "Finished waiting for service to get connected. Current state = %s",
112               currentServiceState.state));
113     }
114     return currentServiceState.compatService;
115   }
116 
117   /**
118    * This method is being overwritten by {@link SetupCompatServiceProviderTest} for injecting an
119    * instance of {@link CountDownLatch}.
120    */
121   @VisibleForTesting
createCountDownLatch()122   protected CountDownLatch createCountDownLatch() {
123     return new CountDownLatch(1);
124   }
125 
requestServiceBind()126   private synchronized void requestServiceBind() {
127     ServiceContext currentServiceState = getCurrentServiceState();
128     if (currentServiceState.state == State.CONNECTED) {
129       Log.i(TAG, "Refusing to rebind since current state is already connected");
130       return;
131     }
132     if (currentServiceState.state != State.NOT_STARTED) {
133       Log.i(TAG, "Unbinding existing service connection.");
134       context.unbindService(serviceConnection);
135     }
136 
137     boolean bindAllowed;
138     try {
139       bindAllowed =
140           context.bindService(COMPAT_SERVICE_INTENT, serviceConnection, Context.BIND_AUTO_CREATE);
141     } catch (SecurityException e) {
142       Log.e(TAG, "Unable to bind to compat service", e);
143       bindAllowed = false;
144     }
145 
146     if (bindAllowed) {
147       // Robolectric calls ServiceConnection#onServiceConnected inline during Context#bindService.
148       // This check prevents us from overriding connected state which usually arrives much later
149       // in the normal world
150       if (getCurrentState() != State.CONNECTED) {
151         swapServiceContextAndNotify(new ServiceContext(State.BINDING));
152         Log.i(TAG, "Context#bindService went through, now waiting for service connection");
153       }
154     } else {
155       // SetupWizard is not installed/calling app does not have permissions to bind.
156       swapServiceContextAndNotify(new ServiceContext(State.BIND_FAILED));
157       Log.e(TAG, "Context#bindService did not succeed.");
158     }
159   }
160 
161   @VisibleForTesting
162   static final Intent COMPAT_SERVICE_INTENT =
163       new Intent()
164           .setPackage("com.google.android.setupwizard")
165           .setAction("com.google.android.setupcompat.SetupCompatService.BIND");
166 
167   @VisibleForTesting
getCurrentState()168   State getCurrentState() {
169     return serviceContext.state;
170   }
171 
getCurrentServiceState()172   private ServiceContext getCurrentServiceState() {
173     return serviceContext;
174   }
175 
swapServiceContextAndNotify(ServiceContext latestServiceContext)176   private void swapServiceContextAndNotify(ServiceContext latestServiceContext) {
177     if (Log.isLoggable(TAG, Log.INFO)) {
178       Log.i(
179           TAG,
180           String.format(
181               "State changed: %s -> %s", serviceContext.state, latestServiceContext.state));
182     }
183     serviceContext = latestServiceContext;
184     CountDownLatch countDownLatch = getAndClearConnectedCondition();
185     if (countDownLatch != null) {
186       countDownLatch.countDown();
187     }
188   }
189 
getAndClearConnectedCondition()190   private CountDownLatch getAndClearConnectedCondition() {
191     return connectedConditionRef.getAndSet(/* newValue= */ null);
192   }
193 
194   /**
195    * Cannot use {@link AtomicReference#updateAndGet(UnaryOperator)} to fix null reference since the
196    * library needs to be compatible with legacy android devices.
197    */
getConnectedCondition()198   private CountDownLatch getConnectedCondition() {
199     CountDownLatch countDownLatch;
200     // Loop until either count down latch is found or successfully able to update atomic reference.
201     do {
202       countDownLatch = connectedConditionRef.get();
203       if (countDownLatch != null) {
204         return countDownLatch;
205       }
206       countDownLatch = createCountDownLatch();
207     } while (!connectedConditionRef.compareAndSet(/* expect= */ null, countDownLatch));
208     return countDownLatch;
209   }
210 
211   @VisibleForTesting
SetupCompatServiceProvider(Context context)212   SetupCompatServiceProvider(Context context) {
213     this.context = context.getApplicationContext();
214   }
215 
216   @VisibleForTesting
217   final ServiceConnection serviceConnection =
218       new ServiceConnection() {
219         @Override
220         public void onServiceConnected(ComponentName componentName, IBinder binder) {
221           State state = State.CONNECTED;
222           if (binder == null) {
223             state = State.DISCONNECTED;
224             Log.w(TAG, "Binder is null when onServiceConnected was called!");
225           }
226           swapServiceContextAndNotify(
227               new ServiceContext(state, ISetupCompatService.Stub.asInterface(binder)));
228         }
229 
230         @Override
231         public void onServiceDisconnected(ComponentName componentName) {
232           swapServiceContextAndNotify(new ServiceContext(State.DISCONNECTED));
233         }
234 
235         @Override
236         public void onBindingDied(ComponentName name) {
237           swapServiceContextAndNotify(new ServiceContext(State.REBIND_REQUIRED));
238         }
239 
240         @Override
241         public void onNullBinding(ComponentName name) {
242           swapServiceContextAndNotify(new ServiceContext(State.SERVICE_NOT_USABLE));
243         }
244       };
245 
246   private volatile ServiceContext serviceContext = new ServiceContext(State.NOT_STARTED);
247   private final Context context;
248   private final AtomicReference<CountDownLatch> connectedConditionRef = new AtomicReference<>();
249 
250   @VisibleForTesting
251   enum State {
252     /** Initial state of the service instance is completely created. */
253     NOT_STARTED,
254 
255     /**
256      * Attempt to call {@link Context#bindService(Intent, ServiceConnection, int)} failed because,
257      * either Setupwizard is not installed or the app does not have permission to bind. This is an
258      * unrecoverable situation.
259      */
260     BIND_FAILED,
261 
262     /**
263      * Call to bind with the service went through, now waiting for {@link
264      * ServiceConnection#onServiceConnected(ComponentName, IBinder)}.
265      */
266     BINDING,
267 
268     /** Provider is connected to the service and can call the API(s). */
269     CONNECTED,
270 
271     /**
272      * Not connected since provider received the call {@link
273      * ServiceConnection#onServiceDisconnected(ComponentName)}, and waiting for {@link
274      * ServiceConnection#onServiceConnected(ComponentName, IBinder)}.
275      */
276     DISCONNECTED,
277 
278     /**
279      * Similar to {@link #BIND_FAILED}, the bind call went through but we received a "null" binding
280      * via {@link ServiceConnection#onNullBinding(ComponentName)}. This is an unrecoverable
281      * situation.
282      */
283     SERVICE_NOT_USABLE,
284 
285     /**
286      * The provider has requested rebind via {@link Context#bindService(Intent, ServiceConnection,
287      * int)} and is waiting for a service connection.
288      */
289     REBIND_REQUIRED
290   }
291 
292   private static final class ServiceContext {
293     final State state;
294     @Nullable final ISetupCompatService compatService;
295 
ServiceContext(State state, @Nullable ISetupCompatService compatService)296     private ServiceContext(State state, @Nullable ISetupCompatService compatService) {
297       this.state = state;
298       this.compatService = compatService;
299       if (state == State.CONNECTED) {
300         Preconditions.checkNotNull(
301             compatService, "CompatService cannot be null when state is connected");
302       }
303     }
304 
ServiceContext(State state)305     private ServiceContext(State state) {
306       this(state, /* compatService= */ null);
307     }
308   }
309 
310   @VisibleForTesting
getInstance(@onNull Context context)311   static SetupCompatServiceProvider getInstance(@NonNull Context context) {
312     Preconditions.checkNotNull(context, "Context object cannot be null.");
313     SetupCompatServiceProvider result = instance;
314     if (result == null) {
315       synchronized (SetupCompatServiceProvider.class) {
316         result = instance;
317         if (result == null) {
318           instance = result = new SetupCompatServiceProvider(context.getApplicationContext());
319           instance.requestServiceBind();
320         }
321       }
322     }
323     return result;
324   }
325 
326   @VisibleForTesting
setInstanceForTesting(SetupCompatServiceProvider testInstance)327   public static void setInstanceForTesting(SetupCompatServiceProvider testInstance) {
328     instance = testInstance;
329   }
330 
331   @VisibleForTesting static boolean disableLooperCheckForTesting = false;
332 
333   // The instance is coming from Application context which alive during the application activate and
334   // it's not depend on the activities life cycle, so we can avoid memory leak. However linter
335   // cannot distinguish Application context or activity context, so we add @SuppressLint to avoid
336   // lint error.
337   @SuppressLint("StaticFieldLeak")
338   private static volatile SetupCompatServiceProvider instance;
339 
340   private static final String TAG = "SucServiceProvider";
341 }
342