summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSetup Wizard Team <android-setup-team-eng@google.com>2018-12-13 14:30:13 +0800
committercnchen <cnchen@google.com>2018-12-14 11:41:21 +0800
commit1d79d004a05309c98637e35c27a37625025d0f9d (patch)
tree103cf3e797883e89d8d07d3bdacb9614a9986774
parent2bac0bd0c8121bc67a8efa2f811dbfb16c640f21 (diff)
downloadsetupcompat-1d79d004a05309c98637e35c27a37625025d0f9d.tar.gz
Import updated Android SetupCompat Library 225313891
Test: mm PiperOrigin-RevId: 225313891 Change-Id: I3e44ddfa512f55c2a5cd7361777bb9e538c1565c
-rw-r--r--Android.bp3
-rw-r--r--main/aidl/com/google/android/setupcompat/ISetupCompatService.aidl4
-rw-r--r--main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java42
-rw-r--r--main/java/com/google/android/setupcompat/internal/ExecutorProvider.java84
-rw-r--r--main/java/com/google/android/setupcompat/internal/SetupCompatServiceProvider.java330
-rw-r--r--main/java/com/google/android/setupcompat/item/FooterButton.java44
-rw-r--r--main/java/com/google/android/setupcompat/logging/internal/ButtonFooterMixinMetrics.java114
-rw-r--r--main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java58
-rw-r--r--main/java/com/google/android/setupcompat/logging/internal/SetupMetricsLoggingConstants.java6
-rw-r--r--main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java127
-rw-r--r--main/java/com/google/android/setupcompat/util/PartnerConfig.java8
-rw-r--r--main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java9
-rw-r--r--main/java/com/google/android/setupcompat/util/PartnerConfigKey.java6
-rw-r--r--main/res/values/attrs.xml2
14 files changed, 759 insertions, 78 deletions
diff --git a/Android.bp b/Android.bp
index 7fe886a..0da14a6 100644
--- a/Android.bp
+++ b/Android.bp
@@ -8,12 +8,13 @@ android_library {
resource_dirs: [
"main/res",
],
- sdk_version: "current",
srcs: [
"main/java/**/*.java",
+ "main/aidl/**/*.aidl",
],
static_libs: [
"androidx.annotation_annotation",
+ "androidx.core_core",
"guava",
],
min_sdk_version: "14",
diff --git a/main/aidl/com/google/android/setupcompat/ISetupCompatService.aidl b/main/aidl/com/google/android/setupcompat/ISetupCompatService.aidl
index 6441b10..cc95e03 100644
--- a/main/aidl/com/google/android/setupcompat/ISetupCompatService.aidl
+++ b/main/aidl/com/google/android/setupcompat/ISetupCompatService.aidl
@@ -15,9 +15,11 @@
*/
package com.google.android.setupcompat;
+import android.os.Bundle;
+
/**
* Declares the interface for compat related service methods.
*/
interface ISetupCompatService {
oneway void logMetric(int metricType, in Bundle arguments, in Bundle extras);
-} \ No newline at end of file
+}
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.
+ *
+ * <p>It allows the executors to be mocked in Robolectric, redirecting to Robolectric's schedulers
+ * rather than using real threads.
+ */
+public final class ExecutorProvider<T extends Executor> {
+
+ 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<ExecutorService> 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<CountDownLatch> 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.
*
* <p>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";
diff --git a/main/res/values/attrs.xml b/main/res/values/attrs.xml
index 7466586..1a45522 100644
--- a/main/res/values/attrs.xml
+++ b/main/res/values/attrs.xml
@@ -60,6 +60,8 @@
<enum name="none" value="0" />
<enum name="next" value="1" />
<enum name="skip" value="2" />
+ <enum name="cancel" value="3" />
+ <enum name="stop" value="4" />
</attr>
</declare-styleable>