From 1d79d004a05309c98637e35c27a37625025d0f9d Mon Sep 17 00:00:00 2001 From: Setup Wizard Team Date: Thu, 13 Dec 2018 14:30:13 +0800 Subject: Import updated Android SetupCompat Library 225313891 Test: mm PiperOrigin-RevId: 225313891 Change-Id: I3e44ddfa512f55c2a5cd7361777bb9e538c1565c --- .../setupcompat/PartnerCustomizationLayout.java | 42 ++- .../setupcompat/internal/ExecutorProvider.java | 84 ++++++ .../internal/SetupCompatServiceProvider.java | 330 +++++++++++++++++++++ .../android/setupcompat/item/FooterButton.java | 44 ++- .../logging/internal/ButtonFooterMixinMetrics.java | 114 +++++++ .../internal/DefaultSetupMetricsLogger.java | 58 ++-- .../internal/SetupMetricsLoggingConstants.java | 6 +- .../setupcompat/template/ButtonFooterMixin.java | 127 ++++++-- .../android/setupcompat/util/PartnerConfig.java | 8 + .../setupcompat/util/PartnerConfigHelper.java | 9 + .../android/setupcompat/util/PartnerConfigKey.java | 6 + 11 files changed, 752 insertions(+), 76 deletions(-) create mode 100644 main/java/com/google/android/setupcompat/internal/ExecutorProvider.java create mode 100644 main/java/com/google/android/setupcompat/internal/SetupCompatServiceProvider.java create mode 100644 main/java/com/google/android/setupcompat/logging/internal/ButtonFooterMixinMetrics.java (limited to 'main/java/com/google/android') diff --git a/main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java b/main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java index e2f7b89..477097f 100644 --- a/main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java +++ b/main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java @@ -23,6 +23,7 @@ import android.content.ContextWrapper; import android.content.res.TypedArray; import android.os.Build; import android.os.Build.VERSION_CODES; +import android.os.PersistableBundle; import androidx.annotation.LayoutRes; import android.util.AttributeSet; import android.view.LayoutInflater; @@ -31,6 +32,9 @@ import android.view.ViewGroup; import android.view.ViewStub; import android.view.WindowManager; import com.google.android.setupcompat.lifecycle.LifecycleFragment; +import com.google.android.setupcompat.logging.CustomEvent; +import com.google.android.setupcompat.logging.MetricKey; +import com.google.android.setupcompat.logging.SetupMetricsLogger; import com.google.android.setupcompat.template.ButtonFooterMixin; import com.google.android.setupcompat.template.StatusBarMixin; import com.google.android.setupcompat.template.SystemNavBarMixin; @@ -39,6 +43,8 @@ import com.google.android.setupcompat.util.WizardManagerHelper; /** A templatization layout with consistent style used in Setup Wizard or app itself. */ public class PartnerCustomizationLayout extends TemplateLayout { + private final boolean suwVersionSupportPartnerResource = + Build.VERSION.SDK_INT > VERSION_CODES.P; private Activity activity; public PartnerCustomizationLayout(Context context) { @@ -69,25 +75,17 @@ public class PartnerCustomizationLayout extends TemplateLayout { activity = lookupActivityFromContext(getContext()); boolean isSetupFlow = WizardManagerHelper.isAnySetupWizard(activity.getIntent()); + boolean applyPartnerResources = suwVersionSupportPartnerResource && isSetupFlow; registerMixin( StatusBarMixin.class, - new StatusBarMixin( - this, - activity.getWindow(), - attrs, - defStyleAttr, - /* applyPartnerResources= */ isSetupFlow)); + new StatusBarMixin(this, activity.getWindow(), attrs, defStyleAttr, applyPartnerResources)); registerMixin( SystemNavBarMixin.class, new SystemNavBarMixin( - this, - activity.getWindow(), - attrs, - defStyleAttr, - /* applyPartnerResources= */ isSetupFlow)); + this, activity.getWindow(), attrs, defStyleAttr, applyPartnerResources)); registerMixin( ButtonFooterMixin.class, - new ButtonFooterMixin(this, attrs, defStyleAttr, /* applyPartnerResources= */ isSetupFlow)); + new ButtonFooterMixin(this, attrs, defStyleAttr, applyPartnerResources)); TypedArray a = getContext() @@ -134,7 +132,25 @@ public class PartnerCustomizationLayout extends TemplateLayout { @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); - LifecycleFragment.attachNow(lookupActivityFromContext(getContext())); + LifecycleFragment.attachNow(activity); + getMixin(ButtonFooterMixin.class).onAttachedToWindow(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (WizardManagerHelper.isAnySetupWizard(activity.getIntent())) { + ButtonFooterMixin buttonFooterMixin = getMixin(ButtonFooterMixin.class); + buttonFooterMixin.onDetachedFromWindow(); + PersistableBundle persistableBundle = new PersistableBundle(); + persistableBundle.putPersistableBundle( + "FooterButtonVisibilityMetrics", buttonFooterMixin.getLoggingMetrics()); + SetupMetricsLogger.logCustomEvent( + getContext(), + CustomEvent.create( + MetricKey.get("SetupCompatMetrics", activity.getClass().getSimpleName()), + persistableBundle)); + } } private static Activity lookupActivityFromContext(Context context) { diff --git a/main/java/com/google/android/setupcompat/internal/ExecutorProvider.java b/main/java/com/google/android/setupcompat/internal/ExecutorProvider.java new file mode 100644 index 0000000..4477ec4 --- /dev/null +++ b/main/java/com/google/android/setupcompat/internal/ExecutorProvider.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.setupcompat.internal; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Utility class to provide executors. + * + *

It allows the executors to be mocked in Robolectric, redirecting to Robolectric's schedulers + * rather than using real threads. + */ +public final class ExecutorProvider { + + private static final int SETUP_METRICS_LOGGER_MAX_QUEUED = 50; + /** + * Creates a single threaded {@link ExecutorService} with a maximum pool size {@code maxSize}. + * Jobs submitted when the pool is full causes {@link + * java.util.concurrent.RejectedExecutionException} to be thrown. + */ + public static final ExecutorProvider setupMetricsLoggerExecutor = + new ExecutorProvider<>( + createSizeBoundedExecutor("DefaultSetupMetricsLogger", SETUP_METRICS_LOGGER_MAX_QUEUED)); + + private final T executor; + + @Nullable private T injectedExecutor; + + private ExecutorProvider(T executor) { + this.executor = executor; + } + + public T get() { + if (injectedExecutor != null) { + return injectedExecutor; + } + return executor; + } + + /** + * Injects an executor for testing use for this provider. Subsequent calls to {@link #get} will + * return this instance instead, until {@link #resetExecutors()} is called. + */ + @VisibleForTesting + public void injectExecutor(T executor) { + this.injectedExecutor = executor; + } + + @VisibleForTesting + public static void resetExecutors() { + setupMetricsLoggerExecutor.injectedExecutor = null; + } + + @VisibleForTesting + public static ExecutorService createSizeBoundedExecutor(String threadName, int maxSize) { + return new ThreadPoolExecutor( + /* corePoolSize= */ 1, + /* maximumPoolSize= */ 1, + /* keepAliveTime= */ 0, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(maxSize), + runnable -> new Thread(runnable, threadName)); + } +} diff --git a/main/java/com/google/android/setupcompat/internal/SetupCompatServiceProvider.java b/main/java/com/google/android/setupcompat/internal/SetupCompatServiceProvider.java new file mode 100644 index 0000000..0538450 --- /dev/null +++ b/main/java/com/google/android/setupcompat/internal/SetupCompatServiceProvider.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.setupcompat.internal; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import android.util.Log; +import com.google.android.setupcompat.ISetupCompatService; +import com.google.common.base.Preconditions; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; + +/** + * This class provides an instance of {@link ISetupCompatService}. It keeps track of the connection + * state and reconnects if necessary. + */ +public class SetupCompatServiceProvider { + + /** + * Returns an instance of {@link ISetupCompatService} if one already exists. If not, attempts to + * rebind if the current state allows such an operation and waits until {@code waitTime} for + * receiving the stub reference via {@link ServiceConnection#onServiceConnected(ComponentName, + * IBinder)}. + * + * @throws IllegalStateException if called from the main thread since this is a blocking + * operation. + * @throws TimeoutException if timed out waiting for {@code waitTime}. + */ + public static ISetupCompatService get(Context context, long waitTime, @NonNull TimeUnit timeUnit) + throws TimeoutException, InterruptedException { + return getInstance(context).getService(waitTime, timeUnit); + } + + @VisibleForTesting + public ISetupCompatService getService(long timeout, TimeUnit timeUnit) + throws TimeoutException, InterruptedException { + Preconditions.checkState( + disableLooperCheckForTesting || Looper.getMainLooper() != Looper.myLooper(), + "getService blocks and should not be called from the main thread."); + ServiceContext serviceContext = getCurrentServiceState(); + switch (serviceContext.state) { + case CONNECTED: + return serviceContext.compatService; + + case SERVICE_NOT_USABLE: + case BIND_FAILED: + // End states, no valid connection can be obtained ever. + return null; + + case DISCONNECTED: + case BINDING: + return waitForConnection(timeout, timeUnit); + + case REBIND_REQUIRED: + requestServiceBind(); + return waitForConnection(timeout, timeUnit); + + case NOT_STARTED: + throw new IllegalStateException( + "NOT_STARTED state only possible before instance is created."); + } + throw new IllegalStateException("Unknown state = " + serviceContext.state); + } + + private ISetupCompatService waitForConnection(long timeout, TimeUnit timeUnit) + throws TimeoutException, InterruptedException { + ServiceContext currentServiceState = getCurrentServiceState(); + if (currentServiceState.state == State.CONNECTED) { + return currentServiceState.compatService; + } + + CountDownLatch connectedStateLatch = getConnectedCondition(); + Log.i(TAG, "Waiting for service to get connected"); + boolean stateChanged = connectedStateLatch.await(timeout, timeUnit); + if (!stateChanged) { + // Even though documentation states that disconnected service should connect again, + // requesting rebind reduces the wait time to acquire a new connection. + requestServiceBind(); + throw new TimeoutException( + String.format("Failed to acquire connection after [%s %s]", timeout, timeUnit)); + } + currentServiceState = getCurrentServiceState(); + Log.i( + TAG, + String.format( + "Finished waiting for service to get connected. Current state = %s", + currentServiceState.state)); + return currentServiceState.compatService; + } + + /** + * This method is being overwritten by {@link SetupCompatServiceProviderTest} for injecting an + * instance of {@link CountDownLatch}. + */ + @VisibleForTesting + protected CountDownLatch createCountDownLatch() { + return new CountDownLatch(1); + } + + private synchronized void requestServiceBind() { + ServiceContext currentServiceState = getCurrentServiceState(); + if (currentServiceState.state == State.CONNECTED) { + Log.i(TAG, "Refusing to rebind since current state is already connected"); + return; + } + if (currentServiceState.state != State.NOT_STARTED) { + Log.i(TAG, "Unbinding existing service connection."); + context.unbindService(serviceConnection); + } + + boolean bindAllowed; + try { + bindAllowed = + context.bindService(COMPAT_SERVICE_INTENT, serviceConnection, Context.BIND_AUTO_CREATE); + } catch (SecurityException e) { + Log.e(TAG, "Unable to bind to compat service", e); + bindAllowed = false; + } + + if (bindAllowed) { + // Robolectric calls ServiceConnection#onServiceConnected inline during Context#bindService. + // This check prevents us from overriding connected state which usually arrives much later + // in the normal world + if (getCurrentState() != State.CONNECTED) { + swapServiceContextAndNotify(new ServiceContext(State.BINDING)); + Log.i(TAG, "Context#bindService went through, now waiting for service connection"); + } + } else { + // SetupWizard is not installed/calling app does not have permissions to bind. + swapServiceContextAndNotify(new ServiceContext(State.BIND_FAILED)); + Log.e( + TAG, + String.format( + "Context#bindService did not succeed, is the manifest missing %s permission?", + COMPAT_PERMISSION)); + } + } + + @VisibleForTesting + static final Intent COMPAT_SERVICE_INTENT = + new Intent() + .setPackage("com.google.android.setupwizard") + .setAction("com.google.android.setupcompat.SetupCompatService.BIND"); + + @VisibleForTesting + State getCurrentState() { + return serviceContext.state; + } + + private ServiceContext getCurrentServiceState() { + return serviceContext; + } + + private void swapServiceContextAndNotify(ServiceContext latestServiceContext) { + Log.i( + TAG, + String.format("State changed: %s -> %s", serviceContext.state, latestServiceContext.state)); + serviceContext = latestServiceContext; + CountDownLatch countDownLatch = getAndClearConnectedCondition(); + if (countDownLatch != null) { + countDownLatch.countDown(); + } + } + + private CountDownLatch getAndClearConnectedCondition() { + return connectedConditionRef.getAndSet(/* newValue= */ null); + } + + /** + * Cannot use {@link AtomicReference#updateAndGet(UnaryOperator)} to fix null reference since the + * library needs to be compatible with legacy android devices. + */ + private CountDownLatch getConnectedCondition() { + CountDownLatch countDownLatch; + // Loop until either count down latch is found or successfully able to update atomic reference. + do { + countDownLatch = connectedConditionRef.get(); + if (countDownLatch != null) { + return countDownLatch; + } + countDownLatch = createCountDownLatch(); + } while (!connectedConditionRef.compareAndSet(/* expect= */ null, countDownLatch)); + return countDownLatch; + } + + @VisibleForTesting + SetupCompatServiceProvider(Context context) { + this.context = context; + } + + @VisibleForTesting + final ServiceConnection serviceConnection = + new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder binder) { + swapServiceContextAndNotify( + new ServiceContext(State.CONNECTED, ISetupCompatService.Stub.asInterface(binder))); + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + swapServiceContextAndNotify(new ServiceContext(State.DISCONNECTED)); + } + + @Override + public void onBindingDied(ComponentName name) { + swapServiceContextAndNotify(new ServiceContext(State.REBIND_REQUIRED)); + } + + @Override + public void onNullBinding(ComponentName name) { + swapServiceContextAndNotify(new ServiceContext(State.SERVICE_NOT_USABLE)); + } + }; + + private volatile ServiceContext serviceContext = new ServiceContext(State.NOT_STARTED); + private final Context context; + private final AtomicReference connectedConditionRef = new AtomicReference<>(); + + @VisibleForTesting + enum State { + /** Initial state of the service instance is completely created. */ + NOT_STARTED, + + /** + * Attempt to call {@link Context#bindService(Intent, ServiceConnection, int)} failed because, + * either Setupwizard is not installed or the app does not have permission to bind. This is an + * unrecoverable situation. + */ + BIND_FAILED, + + /** + * Call to bind with the service went through, now waiting for {@link + * ServiceConnection#onServiceConnected(ComponentName, IBinder)}. + */ + BINDING, + + /** Provider is connected to the service and can call the API(s). */ + CONNECTED, + + /** + * Not connected since provider received the call {@link + * ServiceConnection#onServiceDisconnected(ComponentName)}, and waiting for {@link + * ServiceConnection#onServiceConnected(ComponentName, IBinder)}. + */ + DISCONNECTED, + + /** + * Similar to {@link #BIND_FAILED}, the bind call went through but we received a "null" binding + * via {@link ServiceConnection#onNullBinding(ComponentName)}. This is an unrecoverable + * situation. + */ + SERVICE_NOT_USABLE, + + /** + * The provider has requested rebind via {@link Context#bindService(Intent, ServiceConnection, + * int)} and is waiting for a service connection. + */ + REBIND_REQUIRED + } + + private static final class ServiceContext { + final State state; + @Nullable final ISetupCompatService compatService; + + private ServiceContext(State state, @Nullable ISetupCompatService compatService) { + this.state = state; + this.compatService = compatService; + if (state == State.CONNECTED) { + Preconditions.checkNotNull( + compatService, "CompatService cannot be null when state is connected"); + } + } + + private ServiceContext(State state) { + this(state, /* compatService= */ null); + } + } + + @VisibleForTesting + static SetupCompatServiceProvider getInstance(@NonNull Context context) { + Preconditions.checkNotNull(context, "Context object cannot be null."); + SetupCompatServiceProvider result = instance; + if (result == null) { + synchronized (SetupCompatServiceProvider.class) { + result = instance; + if (result == null) { + instance = result = new SetupCompatServiceProvider(context.getApplicationContext()); + instance.requestServiceBind(); + } + } + } + return result; + } + + @VisibleForTesting + public static void setInstanceForTesting(SetupCompatServiceProvider testInstance) { + instance = testInstance; + } + + @VisibleForTesting static boolean disableLooperCheckForTesting = false; + private static volatile SetupCompatServiceProvider instance; + private static final String COMPAT_PERMISSION = + "com.google.android.setupwizard.SETUP_COMPAT_SERVICE"; + private static final String TAG = "SetupCompat.SetupCompatServiceProvider"; +} diff --git a/main/java/com/google/android/setupcompat/item/FooterButton.java b/main/java/com/google/android/setupcompat/item/FooterButton.java index e2a2818..4601ba0 100644 --- a/main/java/com/google/android/setupcompat/item/FooterButton.java +++ b/main/java/com/google/android/setupcompat/item/FooterButton.java @@ -35,8 +35,10 @@ import com.google.android.setupcompat.template.ButtonFooterMixin; public class FooterButton { private static final int BUTTON_TYPE_NONE = 0; - private final String text; private final ButtonType buttonType; + private CharSequence text; + private boolean enabled; + private int visibility; private int theme; @IdRes private int id; private OnClickListener onClickListener; @@ -76,11 +78,11 @@ public class FooterButton { @StringRes int text, @Nullable OnClickListener listener, @StyleRes int theme) { - this(context.getString(text), listener, ButtonType.NONE, theme); + this(context.getString(text), listener, ButtonType.OTHER, theme); } public FooterButton(String text, @Nullable OnClickListener listener, @StyleRes int theme) { - this(text, listener, ButtonType.NONE, theme); + this(text, listener, ButtonType.OTHER, theme); } public FooterButton( @@ -101,7 +103,7 @@ public class FooterButton { } /** Returns the text that this footer button is displaying. */ - public String getText() { + public CharSequence getText() { return text; } @@ -145,17 +147,41 @@ public class FooterButton { } } + /** Returns the enabled status for this footer button. */ + public boolean isEnabled() { + return enabled; + } + /** * Sets the visibility state of this footer button. * * @param visibility one of {@link #VISIBLE}, {@link #INVISIBLE}, or {@link #GONE}. */ public void setVisibility(int visibility) { + this.visibility = visibility; if (buttonListener != null && id != 0) { buttonListener.onVisibilityChanged(visibility, id); } } + /** Returns the visibility status for this footer button. */ + public int getVisibility() { + return visibility; + } + + /** Sets the text to be displayed using a string resource identifier. */ + public void setText(Context context, @IdRes int resid) { + setText(context.getText(resid)); + } + + /** Sets the text to be displayed on footer button. */ + public void setText(CharSequence text) { + this.text = text; + if (buttonListener != null && id != 0) { + buttonListener.onTextChanged(text, id); + } + } + /** * Registers a callback to be invoked when footer button API has set. * @@ -177,6 +203,8 @@ public class FooterButton { void onEnabledChanged(boolean enabled, @IdRes int id); void onVisibilityChanged(int visibility, @IdRes int id); + + void onTextChanged(CharSequence text, @IdRes int id); } /** @@ -193,10 +221,14 @@ public class FooterButton { /** Types for footer button. The button appearance and behavior may change based on its type. */ public enum ButtonType { /** A type of button that doesn't fit into any other categories. */ - NONE, + OTHER, /** A type of button that will go to the next screen, or next step in the flow when clicked. */ NEXT, /** A type of button that will skip the current step when clicked. */ - SKIP + SKIP, + /** A type of button that will cancel the ongoing setup step(s) and exit setup when clicked. */ + CANCEL, + /** A type of button that will stop the ongoing setup step(s) and skip forward when clicked. */ + STOP } } diff --git a/main/java/com/google/android/setupcompat/logging/internal/ButtonFooterMixinMetrics.java b/main/java/com/google/android/setupcompat/logging/internal/ButtonFooterMixinMetrics.java new file mode 100644 index 0000000..b6d58d0 --- /dev/null +++ b/main/java/com/google/android/setupcompat/logging/internal/ButtonFooterMixinMetrics.java @@ -0,0 +1,114 @@ +package com.google.android.setupcompat.logging.internal; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.os.PersistableBundle; +import androidx.annotation.StringDef; +import androidx.annotation.VisibleForTesting; +import java.lang.annotation.Retention; + +/** Uses to log internal event footer button metric */ +public class ButtonFooterMixinMetrics { + @VisibleForTesting + public static final String EXTRA_PRIMARY_BUTTON_VISIBILITY = "PrimaryButtonVisibility"; + + @VisibleForTesting + public static final String EXTRA_SECONDARY_BUTTON_VISIBILITY = "SecondaryButtonVisibility"; + + @Retention(SOURCE) + @StringDef({ + FooterButtonVisibility.UNKNOW, + FooterButtonVisibility.VISIBLE_USING_XML, + FooterButtonVisibility.VISIBLE, + FooterButtonVisibility.VISIBLE_USING_XML_TO_INVISIBLE, + FooterButtonVisibility.VISIBLE_TO_INVISIBLE, + FooterButtonVisibility.INVISIBLE_TO_VISIBLE, + FooterButtonVisibility.INVISIBLE, + }) + @VisibleForTesting + public @interface FooterButtonVisibility { + String UNKNOW = "Unknow"; + String VISIBLE_USING_XML = "VisibileUsingXml"; + String VISIBLE = "Visible"; + String VISIBLE_USING_XML_TO_INVISIBLE = "VisibileUsingXml_to_Invisible"; + String VISIBLE_TO_INVISIBLE = "Visible_to_Invisible"; + String INVISIBLE_TO_VISIBLE = "Invisible_to_Visible"; + String INVISIBLE = "Invisible"; + } + + @FooterButtonVisibility String primaryButtonVisibility = FooterButtonVisibility.UNKNOW; + + @FooterButtonVisibility String secondaryButtonVisibility = FooterButtonVisibility.UNKNOW; + + /** Creates a metric object for metric logging */ + public ButtonFooterMixinMetrics() {} + + /** Gets initial state visibility */ + @FooterButtonVisibility + public String getInitialStateVisibility(boolean isVisible, boolean isUsingXml) { + @FooterButtonVisibility String visibility; + + if (isVisible) { + visibility = + isUsingXml ? FooterButtonVisibility.VISIBLE_USING_XML : FooterButtonVisibility.VISIBLE; + } else { + visibility = FooterButtonVisibility.INVISIBLE; + } + + return visibility; + } + + /** Saves primary footer button visibility when initial state */ + public void logPrimaryButtonInitialStateVisibility(boolean isVisible, boolean isUsingXml) { + primaryButtonVisibility = + primaryButtonVisibility.equals(FooterButtonVisibility.UNKNOW) + ? getInitialStateVisibility(isVisible, isUsingXml) + : primaryButtonVisibility; + } + + /** Saves secondary footer button visibility when initial state */ + public void logSecondaryButtonInitialStateVisibility(boolean isVisible, boolean isUsingXml) { + secondaryButtonVisibility = + secondaryButtonVisibility.equals(FooterButtonVisibility.UNKNOW) + ? getInitialStateVisibility(isVisible, isUsingXml) + : secondaryButtonVisibility; + } + + /** Saves footer button visibility when finish state */ + public void updateButtonVisibility( + boolean isPrimaryButtonVisiable, boolean isSecondaryButtonVisible) { + primaryButtonVisibility = + updateButtonVisibilityState(primaryButtonVisibility, isPrimaryButtonVisiable); + secondaryButtonVisibility = + updateButtonVisibilityState(secondaryButtonVisibility, isSecondaryButtonVisible); + } + + @FooterButtonVisibility + static String updateButtonVisibilityState( + @FooterButtonVisibility String origionalVisibility, boolean isVisible) { + if (!origionalVisibility.equals(FooterButtonVisibility.VISIBLE_USING_XML) + && !origionalVisibility.equals(FooterButtonVisibility.VISIBLE) + && !origionalVisibility.equals(FooterButtonVisibility.INVISIBLE)) { + throw new IllegalStateException("Illegal visibility state:" + origionalVisibility); + } + + if (isVisible && origionalVisibility.equals(FooterButtonVisibility.INVISIBLE)) { + return FooterButtonVisibility.INVISIBLE_TO_VISIBLE; + } else if (!isVisible) { + if (origionalVisibility.equals(FooterButtonVisibility.VISIBLE_USING_XML)) { + return FooterButtonVisibility.VISIBLE_USING_XML_TO_INVISIBLE; + } else if (origionalVisibility.equals(FooterButtonVisibility.VISIBLE)) { + return FooterButtonVisibility.VISIBLE_TO_INVISIBLE; + } + } + return origionalVisibility; + } + + /** Returns metrics data for logging */ + public PersistableBundle getMetrics() { + PersistableBundle persistableBundle = new PersistableBundle(); + persistableBundle.putString(EXTRA_PRIMARY_BUTTON_VISIBILITY, primaryButtonVisibility); + persistableBundle.putString(EXTRA_SECONDARY_BUTTON_VISIBILITY, secondaryButtonVisibility); + return persistableBundle; + } +} diff --git a/main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java b/main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java index 12e9f98..79d3f86 100644 --- a/main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java +++ b/main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java @@ -18,19 +18,23 @@ package com.google.android.setupcompat.logging.internal; import android.content.Context; import android.os.Bundle; +import android.os.RemoteException; import androidx.annotation.VisibleForTesting; import android.util.Log; +import com.google.android.setupcompat.ISetupCompatService; +import com.google.android.setupcompat.internal.ExecutorProvider; +import com.google.android.setupcompat.internal.SetupCompatServiceProvider; import com.google.android.setupcompat.logging.internal.SetupMetricsLoggingConstants.MetricType; -import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; /** * This class is responsible for safely publishing log events to SetupWizard. To avoid memory issues - * due to backed up queues, an upper bound of {@link #MAX_QUEUED} is set on the executor service's - * queue. Once the upper bound is reached, metrics published after this event are dropped silently. + * due to backed up queues, an upper bound of {@link + * ExecutorProvider#SETUP_METRICS_LOGGER_MAX_QUEUED} is set on the executor service's queue. Once + * the upper bound is reached, metrics published after this event are dropped silently. * *

NOTE: This class is not meant to be used directly. Please use {@link * com.google.android.setupcompat.logging.SetupMetricsLogger} for publishing metric events. @@ -47,40 +51,31 @@ public class DefaultSetupMetricsLogger { } private void invokeService(@MetricType int metricType, @SuppressWarnings("unused") Bundle args) { - // TODO(b/117984473): Invoke service. - Log.w( - TAG, - String.format("invokeService not implemented yet. No action taken for: %d", metricType)); - } - - @VisibleForTesting - DefaultSetupMetricsLogger(Context context, int maxSize) { - this(context, createBoundedExecutor(maxSize)); + try { + ISetupCompatService setupCompatService = + SetupCompatServiceProvider.get( + context, waitTimeInMillisForServiceConnection, TimeUnit.MILLISECONDS); + if (setupCompatService != null) { + setupCompatService.logMetric(metricType, args, Bundle.EMPTY); + } else { + Log.w(TAG, "logMetric failed since service reference is null. Are the permissions valid?"); + } + } catch (InterruptedException | TimeoutException | RemoteException e) { + Log.e(TAG, String.format("Exception occurred while trying to log metric = [%s]", args), e); + } } private DefaultSetupMetricsLogger(Context context) { - this(context, MAX_QUEUED); - } - - private DefaultSetupMetricsLogger(Context context, ExecutorService executorService) { this.context = context; - this.executorService = executorService; + this.executorService = ExecutorProvider.setupMetricsLoggerExecutor.get(); + this.waitTimeInMillisForServiceConnection = MAX_WAIT_TIME_FOR_CONNECTION_MS; } @SuppressWarnings("unused") private final Context context; private final ExecutorService executorService; - - private static ExecutorService createBoundedExecutor(int maxSize) { - return new ThreadPoolExecutor( - /* corePoolSize= */ 1, - /* maximumPoolSize= */ 1, - /* keepAliveTime= */ 0, - TimeUnit.SECONDS, - new ArrayBlockingQueue<>(maxSize), - runnable -> new Thread(runnable, "DefaultSetupMetricsLogger")); - } + private final long waitTimeInMillisForServiceConnection; public static synchronized DefaultSetupMetricsLogger get(Context context) { if (instance == null) { @@ -90,7 +85,12 @@ public class DefaultSetupMetricsLogger { return instance; } + @VisibleForTesting + static void setInstanceForTesting(DefaultSetupMetricsLogger testInstance) { + instance = testInstance; + } + private static DefaultSetupMetricsLogger instance; - private static final int MAX_QUEUED = 50; + private static final long MAX_WAIT_TIME_FOR_CONNECTION_MS = TimeUnit.SECONDS.toMillis(10); private static final String TAG = "SetupCompat.SetupMetricsLogger"; } diff --git a/main/java/com/google/android/setupcompat/logging/internal/SetupMetricsLoggingConstants.java b/main/java/com/google/android/setupcompat/logging/internal/SetupMetricsLoggingConstants.java index a06ee76..9d70056 100644 --- a/main/java/com/google/android/setupcompat/logging/internal/SetupMetricsLoggingConstants.java +++ b/main/java/com/google/android/setupcompat/logging/internal/SetupMetricsLoggingConstants.java @@ -34,18 +34,18 @@ public interface SetupMetricsLoggingConstants { * MetricType constant used when logging {@link * com.google.android.setupcompat.logging.CustomEvent}. */ - int CUSTOM_EVENT = 0; + int CUSTOM_EVENT = 1; /** * MetricType constant used when logging {@link com.google.android.setupcompat.logging.Timer}. */ - int DURATION_EVENT = 1; + int DURATION_EVENT = 2; /** * MetricType constant used when logging counter value using {@link * com.google.android.setupcompat.logging.SetupMetricsLogger#logCounter(Context, MetricKey, * int)}. */ - int COUNTER_EVENT = 2; + int COUNTER_EVENT = 3; /** MetricType constant used for internal logging purposes. */ int INTERNAL = 100; diff --git a/main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java b/main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java index ff28f16..9effade 100644 --- a/main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java +++ b/main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java @@ -18,7 +18,6 @@ package com.google.android.setupcompat.template; import android.annotation.SuppressLint; import android.content.Context; -import android.content.res.Configuration; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.PorterDuff.Mode; @@ -30,6 +29,7 @@ import android.graphics.drawable.LayerDrawable; import android.graphics.drawable.RippleDrawable; import android.os.Build; import android.os.Build.VERSION_CODES; +import android.os.PersistableBundle; import androidx.annotation.AttrRes; import androidx.annotation.ColorInt; import androidx.annotation.IdRes; @@ -54,6 +54,7 @@ import com.google.android.setupcompat.item.FooterButton; import com.google.android.setupcompat.item.FooterButton.ButtonType; import com.google.android.setupcompat.item.FooterButton.OnButtonEventListener; import com.google.android.setupcompat.item.FooterButtonInflater; +import com.google.android.setupcompat.logging.internal.ButtonFooterMixinMetrics; import com.google.android.setupcompat.util.PartnerConfig; import com.google.android.setupcompat.util.PartnerConfigHelper; import java.util.concurrent.atomic.AtomicInteger; @@ -69,19 +70,21 @@ public class ButtonFooterMixin implements Mixin { @Nullable private final ViewStub footerStub; + @VisibleForTesting final boolean applyPartnerResources; + private LinearLayout buttonContainer; private FooterButton primaryButton; private FooterButton secondaryButton; @IdRes private int primaryButtonId; @IdRes private int secondaryButtonId; - @VisibleForTesting final boolean applyPartnerResources; - private int footerBarPaddingTop; private int footerBarPaddingBottom; private static final AtomicInteger nextGeneratedId = new AtomicInteger(1); + @VisibleForTesting public final ButtonFooterMixinMetrics metrics = new ButtonFooterMixinMetrics(); + private final OnButtonEventListener onButtonEventListener = new OnButtonEventListener() { @Override @@ -113,6 +116,16 @@ public class ButtonFooterMixin implements Mixin { } } } + + @Override + public void onTextChanged(CharSequence text, @IdRes int id) { + if (buttonContainer != null && id != 0) { + Button button = buttonContainer.findViewById(id); + if (button != null) { + button.setText(text); + } + } + } }; /** @@ -149,18 +162,18 @@ public class ButtonFooterMixin implements Mixin { FooterButtonInflater inflater = new FooterButtonInflater(context); - // If there are both PrimaryButton & SecondaryButton; setSecondaryButton() need to be called - // first. The button will be added from left to right in LTR, right to left in RTL. if (secondaryBtn != 0) { setSecondaryButton(inflater.inflate(secondaryBtn)); + metrics.logPrimaryButtonInitialStateVisibility(/* isVisible= */ true, /* isUsingXml= */ true); } if (primaryBtn != 0) { setPrimaryButton(inflater.inflate(primaryBtn)); + metrics.logSecondaryButtonInitialStateVisibility( + /* isVisible= */ true, /* isUsingXml= */ true); } } - // TODO(b/119537553): The button position abnormal due to set button order different. private View addSpace() { LinearLayout buttonContainer = ensureFooterInflated(); View space = new View(buttonContainer.getContext()); @@ -221,6 +234,12 @@ public class ButtonFooterMixin implements Mixin { footerButton.setId(primaryButtonId); footerButton.setOnButtonEventListener(onButtonEventListener); primaryButton = footerButton; + + // Check secondary button has been set before primary button or not. If set, will re-populate + // buttons to make sure the position of buttons are correctly. + if (getSecondaryButton() != null) { + repopulateButtons(); + } } /** Returns the {@link FooterButton} of primary button. */ @@ -230,7 +249,12 @@ public class ButtonFooterMixin implements Mixin { @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) public Button getPrimaryButtonView() { - return buttonContainer.findViewById(primaryButtonId); + return buttonContainer == null ? null : buttonContainer.findViewById(primaryButtonId); + } + + @VisibleForTesting + boolean isPrimaryButtonVisible() { + return getPrimaryButtonView() != null && getPrimaryButtonView().getVisibility() == View.VISIBLE; } /** Sets secondary button for footer. */ @@ -264,6 +288,27 @@ public class ButtonFooterMixin implements Mixin { footerButton.setOnButtonEventListener(onButtonEventListener); secondaryButton = footerButton; addSpace(); + + // Check primary button has been set before secondary button or not. If set, will re-populate + // buttons to make sure the position of buttons are correctly. + if (getPrimaryButton() != null) { + repopulateButtons(); + } + } + + public void repopulateButtons() { + LinearLayout buttonContainer = ensureFooterInflated(); + Button tempPrimaryButton = getPrimaryButtonView(); + Button tempSecondaryButton = getSecondaryButtonView(); + buttonContainer.removeAllViews(); + buttonContainer.addView(tempSecondaryButton); + addSpace(); + buttonContainer.addView(tempPrimaryButton); + } + + @VisibleForTesting + LinearLayout getButtonContainer() { + return buttonContainer; } /** Returns the {@link FooterButton} of secondary button. */ @@ -273,7 +318,13 @@ public class ButtonFooterMixin implements Mixin { @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) public Button getSecondaryButtonView() { - return buttonContainer.findViewById(secondaryButtonId); + return buttonContainer == null ? null : buttonContainer.findViewById(secondaryButtonId); + } + + @VisibleForTesting + boolean isSecondaryButtonVisible() { + return getSecondaryButtonView() != null + && getSecondaryButtonView().getVisibility() == View.VISIBLE; } private static int generateViewId() { @@ -402,7 +453,17 @@ public class ButtonFooterMixin implements Mixin { PartnerConfigHelper.get(context) .getDrawable(context, PartnerConfig.CONFIG_FOOTER_BUTTON_ICON_SKIP); break; - case NONE: + case CANCEL: + icon = + PartnerConfigHelper.get(context) + .getDrawable(context, PartnerConfig.CONFIG_FOOTER_BUTTON_ICON_CANCEL); + break; + case STOP: + icon = + PartnerConfigHelper.get(context) + .getDrawable(context, PartnerConfig.CONFIG_FOOTER_BUTTON_ICON_STOP); + break; + case OTHER: default: icon = null; break; @@ -411,32 +472,28 @@ public class ButtonFooterMixin implements Mixin { } private void setButtonIcon(Button button, Drawable icon) { - if (button == null || icon == null) { + if (button == null) { return; } - int h = icon.getIntrinsicHeight(); - int w = icon.getIntrinsicWidth(); - icon.setBounds(0, 0, w, h); - boolean isRtl = false; - if (Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { - Configuration config = context.getResources().getConfiguration(); - isRtl = config.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + if (icon != null) { + // TODO(b/120488979): restrict the icons to a reasonable size + int h = icon.getIntrinsicHeight(); + int w = icon.getIntrinsicWidth(); + icon.setBounds(0, 0, w, h); } - Drawable iconLeft = null; - Drawable iconRight = null; + Drawable iconStart = null; + Drawable iconEnd = null; if (button.getId() == primaryButtonId) { - iconRight = icon; + iconEnd = icon; } else if (button.getId() == secondaryButtonId) { - iconLeft = icon; + iconStart = icon; } if (Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { - button.setCompoundDrawablesRelative( - isRtl ? iconRight : iconLeft, null, isRtl ? iconLeft : iconRight, null); + button.setCompoundDrawablesRelative(iconStart, null, iconEnd, null); } else { - button.setCompoundDrawables( - isRtl ? iconRight : iconLeft, null, isRtl ? iconLeft : iconRight, null); + button.setCompoundDrawables(iconStart, null, iconEnd, null); } } @@ -499,4 +556,24 @@ public class ButtonFooterMixin implements Mixin { ? buttonContainer.getPaddingBottom() : footerStub.getPaddingBottom(); } + + /** Uses for notify mixin the view already attached to window. */ + public void onAttachedToWindow() { + metrics.logPrimaryButtonInitialStateVisibility( + /* isVisible= */ isPrimaryButtonVisible(), /* isUsingXml= */ false); + metrics.logSecondaryButtonInitialStateVisibility( + /* isVisible= */ isSecondaryButtonVisible(), /* isUsingXml= */ false); + } + + /** Uses for notify mixin the view already detached from window. */ + public void onDetachedFromWindow() { + metrics.updateButtonVisibility(isPrimaryButtonVisible(), isSecondaryButtonVisible()); + } + + /** + * Assigns logging metrics to bundle for PartnerCustomizationLayout to log metrics to SetupWizard. + */ + public PersistableBundle getLoggingMetrics() { + return metrics.getMetrics(); + } } diff --git a/main/java/com/google/android/setupcompat/util/PartnerConfig.java b/main/java/com/google/android/setupcompat/util/PartnerConfig.java index bf888f3..083479d 100644 --- a/main/java/com/google/android/setupcompat/util/PartnerConfig.java +++ b/main/java/com/google/android/setupcompat/util/PartnerConfig.java @@ -47,6 +47,14 @@ public enum PartnerConfig { CONFIG_FOOTER_BUTTON_ICON_SKIP( PartnerConfigKey.KEY_FOOTER_BUTTON_ICON_SKIP, ResourceType.DRAWABLE), + // The icon for "cancel" action. Can be "@null" for no icon. + CONFIG_FOOTER_BUTTON_ICON_CANCEL( + PartnerConfigKey.KEY_FOOTER_BUTTON_ICON_CANCEL, ResourceType.DRAWABLE), + + // The icon for "stop" action. Can be "@null" for no icon. + CONFIG_FOOTER_BUTTON_ICON_STOP( + PartnerConfigKey.KEY_FOOTER_BUTTON_ICON_STOP, ResourceType.DRAWABLE), + // Top padding of the footer buttons CONFIG_FOOTER_BUTTON_PADDING_TOP( PartnerConfigKey.KEY_FOOTER_BUTTON_PADDING_TOP, ResourceType.DIMENSION), diff --git a/main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java b/main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java index 78ac626..09dddb3 100644 --- a/main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java +++ b/main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java @@ -31,6 +31,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import android.util.Log; +import android.util.TypedValue; import com.google.android.setupcompat.util.PartnerConfig.ResourceType; import java.util.EnumMap; @@ -118,6 +119,14 @@ public class PartnerConfigHelper { try { ResourceEntry resourceEntry = getResourceEntryFromKey(resourceConfig.getResourceName()); Resources resource = getResourcesByPackageName(context, resourceEntry.getPackageName()); + + // for @null + TypedValue outValue = new TypedValue(); + resource.getValue(resourceEntry.getResourceId(), outValue, true); + if (outValue.type == TypedValue.TYPE_REFERENCE && outValue.data == 0) { + return result; + } + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { result = resource.getDrawable(resourceEntry.getResourceId(), null); } else { diff --git a/main/java/com/google/android/setupcompat/util/PartnerConfigKey.java b/main/java/com/google/android/setupcompat/util/PartnerConfigKey.java index dfd4add..d9bdbb2 100644 --- a/main/java/com/google/android/setupcompat/util/PartnerConfigKey.java +++ b/main/java/com/google/android/setupcompat/util/PartnerConfigKey.java @@ -65,6 +65,12 @@ public @interface PartnerConfigKey { // The icon for "skip" action. Can be "@null" for no icon. String KEY_FOOTER_BUTTON_ICON_SKIP = "setup_compat_footer_button_icon_skip"; + // The icon for "skip" action. Can be "@null" for no icon. + String KEY_FOOTER_BUTTON_ICON_CANCEL = "setup_compat_footer_button_icon_cancel"; + + // The icon for "skip" action. Can be "@null" for no icon. + String KEY_FOOTER_BUTTON_ICON_STOP = "setup_compat_footer_button_icon_stop"; + // Top padding of the footer buttons String KEY_FOOTER_BUTTON_PADDING_TOP = "setup_compat_footer_button_padding_top"; -- cgit v1.2.3