diff options
author | Setup Wizard Team <android-setup-team-eng@google.com> | 2018-11-28 13:33:48 +0800 |
---|---|---|
committer | Cn Chen <cnchen@google.com> | 2018-11-29 07:12:59 +0000 |
commit | 8ccc9e66eeabe7510f2175bc18deb2000245f64c (patch) | |
tree | c64f7870ff975cc1680caaab3ee0af407eb2f1bd /main/java/com/google | |
parent | a4e3b960b3331ddd425844d2e7d4f980275d3fea (diff) | |
download | setupcompat-8ccc9e66eeabe7510f2175bc18deb2000245f64c.tar.gz |
Import updated Android SetupCompat Library 223108899
Test: mm
Bug: 119924155
PiperOrigin-RevId: 223108899
Change-Id: I82ed82e884f07d0a8828e665119a541b53144c7f
Diffstat (limited to 'main/java/com/google')
26 files changed, 3124 insertions, 0 deletions
diff --git a/main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java b/main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java new file mode 100644 index 0000000..885f01d --- /dev/null +++ b/main/java/com/google/android/setupcompat/PartnerCustomizationLayout.java @@ -0,0 +1,169 @@ +/* + * Copyright 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; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.res.TypedArray; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import androidx.annotation.LayoutRes; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.WindowManager; +import com.google.android.setupcompat.lifecycle.LifecycleFragment; +import com.google.android.setupcompat.template.ButtonFooterMixin; +import com.google.android.setupcompat.template.StatusBarMixin; +import com.google.android.setupcompat.template.SystemNavBarMixin; +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 Activity activity; + + public PartnerCustomizationLayout(Context context) { + this(context, 0, 0); + } + + public PartnerCustomizationLayout(Context context, int template) { + this(context, template, 0); + } + + public PartnerCustomizationLayout(Context context, int template, int containerId) { + super(context, template, containerId); + init(null, R.attr.sucLayoutTheme); + } + + public PartnerCustomizationLayout(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs, R.attr.sucLayoutTheme); + } + + @TargetApi(VERSION_CODES.HONEYCOMB) + public PartnerCustomizationLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs, defStyleAttr); + } + + private void init(AttributeSet attrs, int defStyleAttr) { + activity = lookupActivityFromContext(getContext()); + + boolean isSetupFlow = WizardManagerHelper.isAnySetupWizard(activity.getIntent()); + registerMixin( + StatusBarMixin.class, + new StatusBarMixin( + this, + activity.getWindow(), + attrs, + defStyleAttr, + /* applyPartnerResources= */ isSetupFlow)); + registerMixin( + SystemNavBarMixin.class, + new SystemNavBarMixin( + this, + activity.getWindow(), + attrs, + defStyleAttr, + /* applyPartnerResources= */ isSetupFlow)); + + TypedArray a = + getContext() + .obtainStyledAttributes( + attrs, R.styleable.SucPartnerCustomizationLayout, defStyleAttr, 0); + + final int primaryBtn = + a.getResourceId(R.styleable.SucPartnerCustomizationLayout_sucPrimaryFooterButton, 0); + final int secondaryBtn = + a.getResourceId(R.styleable.SucPartnerCustomizationLayout_sucSecondaryFooterButton, 0); + + registerMixin( + ButtonFooterMixin.class, + new ButtonFooterMixin( + this, primaryBtn, secondaryBtn, /* applyPartnerResources= */ isSetupFlow)); + + final int footer = a.getResourceId(R.styleable.SucPartnerCustomizationLayout_sucFooter, 0); + if (footer != 0) { + inflateFooter(footer); + } + + boolean layoutFullscreen = + a.getBoolean(R.styleable.SucPartnerCustomizationLayout_sucLayoutFullscreen, true); + a.recycle(); + + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && layoutFullscreen) { + setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + } + + // Override the FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS, FLAG_TRANSLUCENT_STATUS, + // FLAG_TRANSLUCENT_NAVIGATION and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN attributes of window forces + // showing status bar and navigation bar. + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + } + + @Override + protected View onInflateTemplate(LayoutInflater inflater, int template) { + if (template == 0) { + template = R.layout.partner_customization_layout; + } + return inflateTemplate(inflater, 0, template); + } + + @Override + protected ViewGroup findContainer(int containerId) { + if (containerId == 0) { + containerId = R.id.suc_layout_content; + } + return super.findContainer(containerId); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + LifecycleFragment.attachNow(lookupActivityFromContext(getContext())); + } + + private static Activity lookupActivityFromContext(Context context) { + if (context instanceof Activity) { + return (Activity) context; + } else if (context instanceof ContextWrapper) { + return lookupActivityFromContext(((ContextWrapper) context).getBaseContext()); + } else { + throw new IllegalArgumentException("Cannot find instance of Activity in parent tree"); + } + } + + /** + * Sets the footer of the layout, which is at the bottom of the content area outside the scrolling + * container. The footer can only be inflated once per instance of this layout. + * + * @param footer The layout to be inflated as footer. + * @return The root of the inflated footer view. + */ + public View inflateFooter(@LayoutRes int footer) { + ViewStub footerStub = findManagedViewById(R.id.suc_layout_footer); + footerStub.setLayoutResource(footer); + return footerStub.inflate(); + } +} diff --git a/main/java/com/google/android/setupcompat/TemplateLayout.java b/main/java/com/google/android/setupcompat/TemplateLayout.java new file mode 100644 index 0000000..644ab78 --- /dev/null +++ b/main/java/com/google/android/setupcompat/TemplateLayout.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2015 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; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build.VERSION_CODES; +import androidx.annotation.Keep; +import androidx.annotation.LayoutRes; +import androidx.annotation.StyleRes; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.FrameLayout; +import com.google.android.setupcompat.template.Mixin; +import com.google.android.setupcompat.util.FallbackThemeWrapper; +import java.util.HashMap; +import java.util.Map; + +/** + * A generic template class that inflates a template, provided in the constructor or in {@code + * android:layout} through XML, and adds its children to a "container" in the template. When + * inflating this layout from XML, the {@code android:layout} and {@code suwContainer} attributes + * are required. + */ +public class TemplateLayout extends FrameLayout { + + /** + * The container of the actual content. This will be a view in the template, which child views + * will be added to when {@link #addView(View)} is called. + */ + private ViewGroup container; + + private final Map<Class<? extends Mixin>, Mixin> mixins = new HashMap<>(); + + public TemplateLayout(Context context, int template, int containerId) { + super(context); + init(template, containerId, null, R.attr.sucLayoutTheme); + } + + public TemplateLayout(Context context, AttributeSet attrs) { + super(context, attrs); + init(0, 0, attrs, R.attr.sucLayoutTheme); + } + + @TargetApi(VERSION_CODES.HONEYCOMB) + public TemplateLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(0, 0, attrs, defStyleAttr); + } + + // All the constructors delegate to this init method. The 3-argument constructor is not + // available in LinearLayout before v11, so call super with the exact same arguments. + private void init(int template, int containerId, AttributeSet attrs, int defStyleAttr) { + final TypedArray a = + getContext().obtainStyledAttributes(attrs, R.styleable.SucTemplateLayout, defStyleAttr, 0); + if (template == 0) { + template = a.getResourceId(R.styleable.SucTemplateLayout_android_layout, 0); + } + if (containerId == 0) { + containerId = a.getResourceId(R.styleable.SucTemplateLayout_sucContainer, 0); + } + inflateTemplate(template, containerId); + + a.recycle(); + } + + /** + * Registers a mixin with a given class. This method should be called in the constructor. + * + * @param cls The class to register the mixin. In most cases, {@code cls} is the same as {@code + * mixin.getClass()}, but {@code cls} can also be a super class of that. In the latter case + * the mixin must be retrieved using {@code cls} in {@link #getMixin(Class)}, not the + * subclass. + * @param mixin The mixin to be registered. + * @param <M> The class of the mixin to register. This is the same as {@code cls} + */ + protected <M extends Mixin> void registerMixin(Class<M> cls, M mixin) { + mixins.put(cls, mixin); + } + + /** + * Same as {@link View#findViewById(int)}, but may include views that are managed by this view but + * not currently added to the view hierarchy. e.g. recycler view or list view headers that are not + * currently shown. + */ + // Returning generic type is the common pattern used for findViewBy* methods + @SuppressWarnings("TypeParameterUnusedInFormals") + public <T extends View> T findManagedViewById(int id) { + return findViewById(id); + } + + /** + * Get a {@link Mixin} from this template registered earlier in {@link #registerMixin(Class, + * Mixin)}. + * + * @param cls The class marker of Mixin being requested. The actual Mixin returned may be a + * subclass of this marker. Note that this must be the same class as registered in {@link + * #registerMixin(Class, Mixin)}, which is not necessarily the same as the concrete class of + * the instance returned by this method. + * @param <M> The type of the class marker. + * @return The mixin marked by {@code cls}, or null if the template does not have a matching + * mixin. + */ + @SuppressWarnings("unchecked") + public <M extends Mixin> M getMixin(Class<M> cls) { + return (M) mixins.get(cls); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + container.addView(child, index, params); + } + + private void addViewInternal(View child) { + super.addView(child, -1, generateDefaultLayoutParams()); + } + + private void inflateTemplate(int templateResource, int containerId) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + final View templateRoot = onInflateTemplate(inflater, templateResource); + addViewInternal(templateRoot); + + container = findContainer(containerId); + if (container == null) { + throw new IllegalArgumentException("Container cannot be null in TemplateLayout"); + } + onTemplateInflated(); + } + + /** + * Inflate the template using the given inflater and theme. The fallback theme will be applied to + * the theme without overriding the values already defined in the theme, but simply providing + * default values for values which have not been defined. This allows templates to add additional + * required theme attributes without breaking existing clients. + * + * <p>In general, clients should still set the activity theme to the corresponding theme in setup + * wizard lib, so that the content area gets the correct styles as well. + * + * @param inflater A LayoutInflater to inflate the template. + * @param fallbackTheme A fallback theme to apply to the template. If the values defined in the + * fallback theme is already defined in the original theme, the value in the original theme + * takes precedence. + * @param template The layout template to be inflated. + * @return Root of the inflated layout. + * @see FallbackThemeWrapper + */ + protected final View inflateTemplate( + LayoutInflater inflater, @StyleRes int fallbackTheme, @LayoutRes int template) { + if (template == 0) { + throw new IllegalArgumentException("android:layout not specified for TemplateLayout"); + } + if (fallbackTheme != 0) { + inflater = + LayoutInflater.from(new FallbackThemeWrapper(inflater.getContext(), fallbackTheme)); + } + return inflater.inflate(template, this, false); + } + + /** + * This method inflates the template. Subclasses can override this method to customize the + * template inflation, or change to a different default template. The root of the inflated layout + * should be returned, and not added to the view hierarchy. + * + * @param inflater A LayoutInflater to inflate the template. + * @param template The resource ID of the template to be inflated, or 0 if no template is + * specified. + * @return Root of the inflated layout. + */ + protected View onInflateTemplate(LayoutInflater inflater, @LayoutRes int template) { + return inflateTemplate(inflater, 0, template); + } + + protected ViewGroup findContainer(int containerId) { + if (containerId == 0) { + // Maintain compatibility with the deprecated way of specifying container ID. + containerId = getContainerId(); + } + return (ViewGroup) findViewById(containerId); + } + + /** + * This is called after the template has been inflated and added to the view hierarchy. Subclasses + * can implement this method to modify the template as necessary, such as caching views retrieved + * from findViewById, or other view operations that need to be done in code. You can think of this + * as {@link View#onFinishInflate()} but for inflation of the template instead of for child views. + */ + protected void onTemplateInflated() {} + + /** + * @return ID of the default container for this layout. This will be used to find the container + * ViewGroup, which all children views of this layout will be placed in. + * @deprecated Override {@link #findContainer(int)} instead. + */ + @Deprecated + protected int getContainerId() { + return 0; + } + + /* Animator support */ + + private float xFraction; + private ViewTreeObserver.OnPreDrawListener preDrawListener; + + /** + * Set the X translation as a fraction of the width of this view. Make sure this method is not + * stripped out by proguard when using this with {@link android.animation.ObjectAnimator}. You may + * need to add <code> + * -keep @androidx.annotation.Keep class * + * </code> to your proguard configuration if you are seeing mysterious {@link NoSuchMethodError} + * at runtime. + */ + @Keep + @TargetApi(VERSION_CODES.HONEYCOMB) + public void setXFraction(float fraction) { + xFraction = fraction; + final int width = getWidth(); + if (width != 0) { + setTranslationX(width * fraction); + } else { + // If we haven't done a layout pass yet, wait for one and then set the fraction before + // the draw occurs using an OnPreDrawListener. Don't call translationX until we know + // getWidth() has a reliable, non-zero value or else we will see the fragment flicker on + // screen. + if (preDrawListener == null) { + preDrawListener = + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(preDrawListener); + setXFraction(xFraction); + return true; + } + }; + getViewTreeObserver().addOnPreDrawListener(preDrawListener); + } + } + } + + /** + * Return the X translation as a fraction of the width, as previously set in {@link + * #setXFraction(float)}. + * + * @see #setXFraction(float) + */ + @Keep + @TargetApi(VERSION_CODES.HONEYCOMB) + public float getXFraction() { + return xFraction; + } +} diff --git a/main/java/com/google/android/setupcompat/internal/ClockProvider.java b/main/java/com/google/android/setupcompat/internal/ClockProvider.java new file mode 100644 index 0000000..c54ac2e --- /dev/null +++ b/main/java/com/google/android/setupcompat/internal/ClockProvider.java @@ -0,0 +1,57 @@ +/* + * 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.VisibleForTesting; +import com.google.common.base.Supplier; +import com.google.common.base.Ticker; +import java.util.concurrent.TimeUnit; + +/** Provider for time in nanos and millis. Allows overriding time during tests. */ +public class ClockProvider extends Ticker { + + public static long timeInNanos() { + return ticker.read(); + } + + public static long timeInMillis() { + return TimeUnit.NANOSECONDS.toMillis(timeInNanos()); + } + + @VisibleForTesting + public static void resetInstance() { + ticker = Ticker.systemTicker(); + } + + @VisibleForTesting + public static void setInstance(Supplier<Long> nanoSecondSupplier) { + ticker = + new Ticker() { + @Override + public long read() { + return nanoSecondSupplier.get(); + } + }; + } + + @Override + public long read() { + return ticker.read(); + } + + private static Ticker ticker = Ticker.systemTicker(); +} diff --git a/main/java/com/google/android/setupcompat/item/FooterButton.java b/main/java/com/google/android/setupcompat/item/FooterButton.java new file mode 100644 index 0000000..31c6e93 --- /dev/null +++ b/main/java/com/google/android/setupcompat/item/FooterButton.java @@ -0,0 +1,95 @@ +/* + * Copyright 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.item; + +import android.content.Context; +import android.content.res.TypedArray; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.annotation.StyleRes; +import androidx.annotation.VisibleForTesting; +import android.util.AttributeSet; +import android.view.View.OnClickListener; +import com.google.android.setupcompat.R; +import com.google.android.setupcompat.template.ButtonFooterMixin; + +/** + * Definition of a footer button. Clients can use this class to customize attributes like text and + * click listener, and ButtonFooterMixin will inflate a corresponding Button view. + */ +public class FooterButton { + private final String text; + private final OnClickListener listener; + private int theme; + + public FooterButton(Context context, AttributeSet attrs) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SucFooterButton); + this.text = a.getString(R.styleable.SucFooterButton_android_text); + this.listener = null; + this.theme = a.getResourceId(R.styleable.SucFooterButton_android_theme, 0); + a.recycle(); + } + + /** + * Allows client customize text, click listener and theme for footer button + * before Button has been created. The {@link ButtonFooterMixin} will inflate a corresponding + * Button view. + * + * @param context The context of application. + * @param text The text for button. + * @param listener The listener for button. + * @param theme The theme for button. + */ + public FooterButton( + Context context, + @StringRes int text, + @Nullable OnClickListener listener, + @StyleRes int theme) { + this(context.getString(text), listener, theme); + } + + public FooterButton( + String text, @Nullable OnClickListener listener, @StyleRes int theme) { + this.text = text; + this.listener = listener; + this.theme = theme; + } + + public String getText() { + return text; + } + + public OnClickListener getListener() { + return listener; + } + + @StyleRes + public int getTheme() { + return theme; + } + + /** + * Sets the default theme for footer button, the method only for internal use in {@link + * ButtonFooterMixin} and there will have no influence during setup wizard flow. + * + * @param theme The theme for footer button. + */ + @VisibleForTesting + public void setTheme(@StyleRes int theme) { + this.theme = theme; + } +} diff --git a/main/java/com/google/android/setupcompat/item/FooterButtonInflater.java b/main/java/com/google/android/setupcompat/item/FooterButtonInflater.java new file mode 100644 index 0000000..cc354ab --- /dev/null +++ b/main/java/com/google/android/setupcompat/item/FooterButtonInflater.java @@ -0,0 +1,102 @@ +/* + * 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.item; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; +import androidx.annotation.NonNull; +import android.util.AttributeSet; +import android.util.Xml; +import android.view.InflateException; +import java.io.IOException; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +public class FooterButtonInflater { + protected final Context context; + + /** + * Creates a new inflater instance associated with a particular Resources bundle. + * + * @param context The Context using to get Resources and Generate FooterButton Object + */ + public FooterButtonInflater(@NonNull Context context) { + this.context = context; + } + + public Resources getResources() { + return context.getResources(); + } + + /** + * Inflates a new hierarchy from the specified XML resource. Throws InflaterException if there is + * an error. + * + * @param resId ID for an XML resource to load (e.g. <code>R.xml.my_xml</code>) + * @return The root of the inflated hierarchy. + */ + public FooterButton inflate(int resId) { + XmlResourceParser parser = getResources().getXml(resId); + try { + return inflate(parser); + } finally { + parser.close(); + } + } + + /** + * Inflates a new hierarchy from the specified XML node. Throws InflaterException if there is an + * error. + * + * <p><em><strong>Important</strong></em> For performance reasons, inflation + * relies heavily on pre-processing of XML files that is done at build time. Therefore, it is not + * currently possible to use inflater with an XmlPullParser over a plain XML file at runtime. + * + * @param parser XML dom node containing the description of the hierarchy. + * @return The root of the inflated hierarchy. + */ + private FooterButton inflate(XmlPullParser parser) { + final AttributeSet attrs = Xml.asAttributeSet(parser); + FooterButton button; + + try { + // Look for the root node. + int type; + while ((type = parser.next()) != XmlPullParser.START_TAG + && type != XmlPullParser.END_DOCUMENT) { + // continue + } + + if (type != XmlPullParser.START_TAG) { + throw new InflateException(parser.getPositionDescription() + ": No start tag found!"); + } + + if (!parser.getName().equals("FooterButton")) { + throw new InflateException(parser.getPositionDescription() + ": not a FooterButton"); + } + + button = new FooterButton(context, attrs); + } catch (XmlPullParserException e) { + throw new InflateException(e.getMessage(), e); + } catch (IOException e) { + throw new InflateException(parser.getPositionDescription() + ": " + e.getMessage(), e); + } + + return button; + } +} diff --git a/main/java/com/google/android/setupcompat/lifecycle/LifecycleFragment.java b/main/java/com/google/android/setupcompat/lifecycle/LifecycleFragment.java new file mode 100644 index 0000000..0acec8c --- /dev/null +++ b/main/java/com/google/android/setupcompat/lifecycle/LifecycleFragment.java @@ -0,0 +1,95 @@ +/* + * 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.lifecycle; + +import android.app.Activity; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.Context; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.util.Log; +import com.google.android.setupcompat.internal.ClockProvider; +import com.google.android.setupcompat.logging.MetricKey; +import com.google.android.setupcompat.logging.SetupMetricsLogger; +import com.google.android.setupcompat.util.WizardManagerHelper; + +/** Fragment used to detect lifecycle of an activity for metrics logging. */ +public class LifecycleFragment extends Fragment { + private static final String LOG_TAG = LifecycleFragment.class.getSimpleName(); + private static final String FRAGMENT_ID = "lifecycle_monitor"; + + private MetricKey metricKey; + private long startInNanos; + private long durationInNanos = 0; + + public LifecycleFragment() { + setRetainInstance(true); + } + + /** + * Attaches the lifecycle fragment if it is not attached yet. + * + * @param activity the activity to detect lifecycle for. + * @return fragment to monitor life cycle. + */ + public static LifecycleFragment attachNow(Activity activity) { + if (WizardManagerHelper.isAnySetupWizard(activity.getIntent())) { + if (VERSION.SDK_INT > VERSION_CODES.M) { + FragmentManager fragmentManager = activity.getFragmentManager(); + Fragment fragment = activity.getFragmentManager().findFragmentByTag(FRAGMENT_ID); + if (fragment == null) { + LifecycleFragment lifeCycleFragment = new LifecycleFragment(); + fragmentManager.beginTransaction().add(lifeCycleFragment, FRAGMENT_ID).commitNow(); + fragment = lifeCycleFragment; + } else if (!(fragment instanceof LifecycleFragment)) { + Log.wtf( + LOG_TAG, + activity.getClass().getSimpleName() + " Incorrect instance on lifecycle fragment."); + } + + return (LifecycleFragment) fragment; + } + } + + return null; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + metricKey = MetricKey.get("ScreenDuration", getActivity().getClass().getSimpleName()); + } + + @Override + public void onDetach() { + super.onDetach(); + SetupMetricsLogger.logDuration(getContext(), metricKey, durationInNanos); + } + + @Override + public void onResume() { + super.onResume(); + startInNanos = ClockProvider.timeInNanos(); + } + + @Override + public void onPause() { + super.onPause(); + durationInNanos += (ClockProvider.timeInNanos() - startInNanos); + } +} diff --git a/main/java/com/google/android/setupcompat/logging/CustomEvent.java b/main/java/com/google/android/setupcompat/logging/CustomEvent.java new file mode 100644 index 0000000..363c3ef --- /dev/null +++ b/main/java/com/google/android/setupcompat/logging/CustomEvent.java @@ -0,0 +1,138 @@ +/* + * 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.logging; + +import android.os.Parcel; +import android.os.Parcelable; +import android.os.PersistableBundle; +import com.google.android.setupcompat.internal.ClockProvider; +import com.google.common.base.Preconditions; +import java.util.Objects; + +/** + * This class represents a interesting event at a particular point in time. The event is identified + * by {@link MetricKey} along with {@code timestamp}. It can include additional key-value pairs + * providing more attributes associated with the given event. Only primitive values are supported + * for now (int, long, double, float, String). + */ +public final class CustomEvent implements Parcelable { + + /** Creates a new instance of {@code CustomEvent}. Null arguments are not allowed. */ + public static CustomEvent create( + MetricKey metricKey, PersistableBundle bundle, PersistableBundle piiValues) { + return new CustomEvent(ClockProvider.timeInMillis(), metricKey, bundle, piiValues); + } + + /** Creates a new instance of {@code CustomEvent}. Null arguments are not allowed. */ + public static CustomEvent create(MetricKey metricKey, PersistableBundle bundle) { + return create(metricKey, bundle, PersistableBundle.EMPTY); + } + + public static final Creator<CustomEvent> CREATOR = + new Creator<CustomEvent>() { + @Override + public CustomEvent createFromParcel(Parcel in) { + return new CustomEvent( + in.readLong(), + in.readParcelable(MetricKey.class.getClassLoader()), + in.readPersistableBundle(), + in.readPersistableBundle()); + } + + @Override + public CustomEvent[] newArray(int size) { + return new CustomEvent[size]; + } + }; + + /** Returns the timestamp of when the event occurred. */ + public long timestampMillis() { + return timestampMillis; + } + + /** Returns the identifier of the event. */ + public MetricKey metricKey() { + return this.metricKey; + } + + /** Returns the non PII values describing the event. Only primitive values are supported. */ + public PersistableBundle values() { + return this.persistableBundle; + } + + /** + * Returns the PII(Personally identifiable information) values describing the event. These values + * will not be included in the aggregated logs. Only primitive values are supported. + */ + public PersistableBundle piiValues() { + return this.piiValues; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeLong(timestampMillis); + parcel.writeParcelable(metricKey, i); + parcel.writePersistableBundle(persistableBundle); + parcel.writePersistableBundle(piiValues); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CustomEvent)) { + return false; + } + CustomEvent that = (CustomEvent) o; + return timestampMillis == that.timestampMillis + && Objects.equals(metricKey, that.metricKey) + && Objects.equals(persistableBundle, that.persistableBundle) + && Objects.equals(piiValues, that.piiValues); + } + + @Override + public int hashCode() { + return Objects.hash(timestampMillis, metricKey, persistableBundle, piiValues); + } + + private CustomEvent( + long timestampMillis, + MetricKey metricKey, + PersistableBundle bundle, + PersistableBundle piiValues) { + Preconditions.checkArgument(timestampMillis >= 0, "Timestamp cannot be negative."); + Preconditions.checkNotNull(metricKey, "MetricKey cannot be null."); + Preconditions.checkNotNull(bundle, "Bundle cannot be null."); + Preconditions.checkArgument(!bundle.isEmpty(), "Bundle cannot be empty."); + Preconditions.checkNotNull(piiValues, "piiValues cannot be null."); + this.timestampMillis = timestampMillis; + this.metricKey = metricKey; + this.persistableBundle = bundle.deepCopy(); + this.piiValues = piiValues.deepCopy(); + } + + private final long timestampMillis; + private final MetricKey metricKey; + private final PersistableBundle persistableBundle; + private final PersistableBundle piiValues; +} diff --git a/main/java/com/google/android/setupcompat/logging/MetricKey.java b/main/java/com/google/android/setupcompat/logging/MetricKey.java new file mode 100644 index 0000000..a74a2a6 --- /dev/null +++ b/main/java/com/google/android/setupcompat/logging/MetricKey.java @@ -0,0 +1,137 @@ +/* + * 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.logging; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import com.google.common.base.Preconditions; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A metric key represents a validated “string key” and a "screen name" that is associated with the + * values reported by the API consumer. + */ +public final class MetricKey implements Parcelable { + + /** + * Creates a new instance of MetricKey. + * + * <p>NOTE: + * + * <ul> + * <li>Length of {@code name} should be in range of 5-30 characters, only alpha numeric + * characters are allowed. + * <li>Length of {@code screenName} should be in range of 5-50 characters, only alpha numeric + * characters are allowed. + * </ul> + */ + public static MetricKey get(@NonNull String name, @NonNull String screenName) { + Preconditions.checkNotNull(name); + Preconditions.checkNotNull(screenName); + assertLengthInRange( + name.length(), "MetricKey.name", MIN_METRIC_KEY_LENGTH, MAX_METRIC_KEY_LENGTH); + assertLengthInRange( + screenName.length(), + "MetricKey.screenName", + MIN_SCREEN_NAME_LENGTH, + MAX_SCREEN_NAME_LENGTH); + Preconditions.checkArgument( + METRIC_KEY_PATTERN.matcher(name).matches(), + "Invalid MetricKey, only alpha numeric characters are allowed."); + Preconditions.checkArgument( + METRIC_KEY_PATTERN.matcher(screenName).matches(), + "Invalid MetricKey, only alpha numeric characters are allowed."); + return new MetricKey(name, screenName); + } + + public static final Creator<MetricKey> CREATOR = + new Creator<MetricKey>() { + @Override + public MetricKey createFromParcel(Parcel in) { + return new MetricKey(in.readString(), in.readString()); + } + + @Override + public MetricKey[] newArray(int size) { + return new MetricKey[size]; + } + }; + + /** Returns the name of the metric key. */ + public String name() { + return name; + } + + /** Returns the name of the metric key. */ + public String screenName() { + return screenName; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeString(name); + parcel.writeString(screenName); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MetricKey)) { + return false; + } + MetricKey metricKey = (MetricKey) o; + return Objects.equals(name, metricKey.name) && Objects.equals(screenName, metricKey.screenName); + } + + @Override + public int hashCode() { + return Objects.hash(name, screenName); + } + + private MetricKey(String name, String screenName) { + this.name = name; + this.screenName = screenName; + } + + private final String name; + private final String screenName; + + private static void assertLengthInRange( + int foundLength, String name, int minLength, int maxLength) { + Preconditions.checkArgument( + foundLength <= maxLength && foundLength >= minLength, + "Length of %s should be in the range [%s-%s]", + name, + minLength, + maxLength); + } + + private static final int MIN_SCREEN_NAME_LENGTH = 5; + private static final int MIN_METRIC_KEY_LENGTH = 5; + private static final int MAX_SCREEN_NAME_LENGTH = 50; + private static final int MAX_METRIC_KEY_LENGTH = 30; + private static final Pattern METRIC_KEY_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9_]+"); +} diff --git a/main/java/com/google/android/setupcompat/logging/SetupMetricsLogger.java b/main/java/com/google/android/setupcompat/logging/SetupMetricsLogger.java new file mode 100644 index 0000000..e0c9371 --- /dev/null +++ b/main/java/com/google/android/setupcompat/logging/SetupMetricsLogger.java @@ -0,0 +1,75 @@ +/* + * 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.logging; + +import android.content.Context; +import android.os.Bundle; +import androidx.annotation.NonNull; +import com.google.android.setupcompat.logging.internal.DefaultSetupMetricsLogger; +import com.google.android.setupcompat.logging.internal.SetupMetricsLoggingConstants.MetricBundleKeys; +import com.google.android.setupcompat.logging.internal.SetupMetricsLoggingConstants.MetricType; +import com.google.common.base.Preconditions; +import java.util.concurrent.TimeUnit; + +/** SetupMetricsLogger provides an easy way to log custom metrics to SetupWizard. */ +public class SetupMetricsLogger { + + /** Logs an instance of {@link CustomEvent} to SetupWizard. */ + public static void logCustomEvent(@NonNull Context context, @NonNull CustomEvent customEvent) { + Preconditions.checkNotNull(context, "Context cannot be null."); + Preconditions.checkNotNull(customEvent, "CustomEvent cannot be null."); + Bundle bundle = new Bundle(); + bundle.putParcelable(MetricBundleKeys.CUSTOM_EVENT, customEvent); + DefaultSetupMetricsLogger.get(context).logEventSafely(MetricType.CUSTOM_EVENT, bundle); + } + + /** Increments the counter value with the name {@code counterName} by {@code times}. */ + public static void logCounter( + @NonNull Context context, @NonNull MetricKey counterName, int times) { + Preconditions.checkNotNull(context, "Context cannot be null."); + Preconditions.checkNotNull(counterName, "CounterName cannot be null."); + Preconditions.checkArgument(times > 0, "Counter cannot be negative."); + Bundle bundle = new Bundle(); + bundle.putParcelable(MetricBundleKeys.METRIC_KEY, counterName); + bundle.putInt(MetricBundleKeys.COUNTER_INT, times); + DefaultSetupMetricsLogger.get(context).logEventSafely(MetricType.COUNTER_EVENT, bundle); + } + + /** + * Logs the {@link Timer}'s duration by calling {@link #logDuration(Context, MetricKey, long)}. + */ + public static void logDuration(@NonNull Context context, @NonNull Timer timer) { + Preconditions.checkNotNull(context, "Context cannot be null."); + Preconditions.checkNotNull(timer, "Timer cannot be null."); + Preconditions.checkArgument( + timer.isStopped(), "Timer should be stopped before calling logDuration."); + logDuration( + context, timer.getMetricKey(), TimeUnit.NANOSECONDS.toMillis(timer.getDurationInNanos())); + } + + /** Logs a duration event to SetupWizard. */ + public static void logDuration( + @NonNull Context context, @NonNull MetricKey timerName, long timeInMillis) { + Preconditions.checkNotNull(context, "Context cannot be null."); + Preconditions.checkNotNull(timerName, "Timer name cannot be null."); + Preconditions.checkArgument(timeInMillis >= 0, "Duration cannot be negative."); + Bundle bundle = new Bundle(); + bundle.putParcelable(MetricBundleKeys.METRIC_KEY, timerName); + bundle.putLong(MetricBundleKeys.TIME_MILLIS_LONG, timeInMillis); + DefaultSetupMetricsLogger.get(context).logEventSafely(MetricType.DURATION_EVENT, bundle); + } +} diff --git a/main/java/com/google/android/setupcompat/logging/Timer.java b/main/java/com/google/android/setupcompat/logging/Timer.java new file mode 100644 index 0000000..7d2a2c9 --- /dev/null +++ b/main/java/com/google/android/setupcompat/logging/Timer.java @@ -0,0 +1,85 @@ +/* + * 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.logging; + +import android.util.Log; +import com.google.android.setupcompat.internal.ClockProvider; +import com.google.common.base.Preconditions; + +/** Convenience utility to log duration events. Please note that this class is not thread-safe. */ +public final class Timer { + /** Creates a new instance of timer for the given {@code metricKey}. */ + public Timer(MetricKey metricKey) { + this.metricKey = metricKey; + } + + /** + * Starts the timer and notes the current clock time. + * + * @throws IllegalStateException if the timer was stopped. + */ + public void start() { + Preconditions.checkState(!isStopped(), "Timer cannot be started once stopped."); + if (isStarted()) { + Log.wtf( + TAG, + String.format( + "Timer instance was already started for: %s at [%s].", metricKey, startInNanos)); + return; + } + startInNanos = ClockProvider.timeInNanos(); + } + + /** + * Stops the watch and the current clock time is noted. + * + * @throws IllegalStateException if the watch was not started. + */ + public void stop() { + Preconditions.checkState(isStarted(), "Timer must be started before it can be stopped."); + if (isStopped()) { + Log.wtf( + TAG, + String.format( + "Timer instance was already stopped for: %s at [%s]", metricKey, stopInNanos)); + return; + } + stopInNanos = ClockProvider.timeInNanos(); + } + + boolean isStopped() { + return stopInNanos != 0; + } + + private boolean isStarted() { + return startInNanos != 0; + } + + long getDurationInNanos() { + return stopInNanos - startInNanos; + } + + MetricKey getMetricKey() { + return metricKey; + } + + private long startInNanos; + private long stopInNanos; + private final MetricKey metricKey; + + private static final String TAG = "SetupCompat.Timer"; +} diff --git a/main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java b/main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java new file mode 100644 index 0000000..12e9f98 --- /dev/null +++ b/main/java/com/google/android/setupcompat/logging/internal/DefaultSetupMetricsLogger.java @@ -0,0 +1,96 @@ +/* + * 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.logging.internal; + +import android.content.Context; +import android.os.Bundle; +import androidx.annotation.VisibleForTesting; +import android.util.Log; +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; + +/** + * 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. + * + * <p>NOTE: This class is not meant to be used directly. Please use {@link + * com.google.android.setupcompat.logging.SetupMetricsLogger} for publishing metric events. + */ +public class DefaultSetupMetricsLogger { + + @SuppressWarnings("FutureReturnValueIgnored") + public void logEventSafely(@MetricType int metricType, Bundle args) { + try { + executorService.submit(() -> invokeService(metricType, args)); + } catch (RejectedExecutionException e) { + Log.e(TAG, String.format("Metric of type %d dropped since queue is full.", metricType), e); + } + } + + 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)); + } + + private DefaultSetupMetricsLogger(Context context) { + this(context, MAX_QUEUED); + } + + private DefaultSetupMetricsLogger(Context context, ExecutorService executorService) { + this.context = context; + this.executorService = executorService; + } + + @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")); + } + + public static synchronized DefaultSetupMetricsLogger get(Context context) { + if (instance == null) { + instance = new DefaultSetupMetricsLogger(context); + } + + return instance; + } + + private static DefaultSetupMetricsLogger instance; + private static final int MAX_QUEUED = 50; + 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 new file mode 100644 index 0000000..fd6f450 --- /dev/null +++ b/main/java/com/google/android/setupcompat/logging/internal/SetupMetricsLoggingConstants.java @@ -0,0 +1,86 @@ +/* + * 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.logging.internal; + +import android.content.Context; +import androidx.annotation.IntDef; +import androidx.annotation.StringDef; +import com.google.android.setupcompat.logging.MetricKey; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Constant values used by {@link com.google.android.setupcompat.logging.SetupMetricsLogger}. */ +public interface SetupMetricsLoggingConstants { + + /** Enumeration of supported metric types logged to SetupWizard. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({MetricType.CUSTOM_EVENT, MetricType.COUNTER_EVENT, MetricType.DURATION_EVENT}) + @interface MetricType { + /** + * MetricType constant used when logging {@link + * com.google.android.setupcompat.logging.CustomEvent}. + */ + int CUSTOM_EVENT = 0; + /** + * MetricType constant used when logging {@link com.google.android.setupcompat.logging.Timer}. + */ + int DURATION_EVENT = 1; + + /** + * MetricType constant used when logging counter value using {@link + * com.google.android.setupcompat.logging.SetupMetricsLogger#logCounter(Context, MetricKey, + * int)}. + */ + int COUNTER_EVENT = 2; + } + + /** Keys of the bundle used while logging data to SetupWizard. */ + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + MetricBundleKeys.METRIC_KEY, + MetricBundleKeys.CUSTOM_EVENT, + MetricBundleKeys.TIME_MILLIS_LONG, + MetricBundleKeys.COUNTER_INT + }) + @interface MetricBundleKeys { + /** + * {@link MetricKey} of the data being logged. This will be set when {@code metricType} is + * either {@link MetricType#COUNTER_EVENT} or {@link MetricType#DURATION_EVENT}. + */ + String METRIC_KEY = "MetricKey"; + + /** + * This key will be used when {@code metricType} is {@link MetricType#CUSTOM_EVENT} with the + * value being a parcelable of type {@link com.google.android.setupcompat.logging.CustomEvent}. + */ + String CUSTOM_EVENT = "CustomEvent"; + + /** + * This key will be set when {@code metricType} is {@link MetricType#DURATION_EVENT} with the + * value of type {@code long} representing the {@code duration} in milliseconds for the given + * {@link MetricKey}. + */ + String TIME_MILLIS_LONG = "timeMillis"; + + /** + * This key will be set when {@code metricType} is {@link MetricType#COUNTER_EVENT} with the + * value of type {@code int} representing the {@code counter} value logged for the given {@link + * MetricKey}. + */ + String COUNTER_INT = "counter"; + } +} diff --git a/main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java b/main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java new file mode 100644 index 0000000..c122f07 --- /dev/null +++ b/main/java/com/google/android/setupcompat/template/ButtonFooterMixin.java @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2017 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.template; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff.Mode; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.InsetDrawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.drawable.RippleDrawable; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import androidx.annotation.ColorInt; +import androidx.annotation.IdRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.XmlRes; +import android.util.TypedValue; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewStub; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.LinearLayout.LayoutParams; +import com.google.android.setupcompat.R; +import com.google.android.setupcompat.TemplateLayout; +import com.google.android.setupcompat.item.FooterButton; +import com.google.android.setupcompat.item.FooterButtonInflater; +import com.google.android.setupcompat.util.PartnerConfig; +import com.google.android.setupcompat.util.PartnerConfigHelper; + +/** + * A {@link Mixin} for managing buttons. By default, the button bar expects that buttons on the + * start (left for LTR) are "secondary" borderless buttons, while buttons on the end (right for LTR) + * are "primary" accent-colored buttons. + */ +public class ButtonFooterMixin implements Mixin { + + private final Context context; + + @Nullable private final ViewStub footerStub; + + private LinearLayout buttonContainer; + + @VisibleForTesting final boolean applyPartnerResources; + + /** + * Creates a mixin for managing buttons on the footer. + * + * @param layout The {@link TemplateLayout} containing this mixin. + * @param applyPartnerResources determine applies partner resources or not. + */ + public ButtonFooterMixin( + TemplateLayout layout, + @XmlRes int attrPrimaryButton, + @XmlRes int attrSecondaryButton, + boolean applyPartnerResources) { + context = layout.getContext(); + footerStub = (ViewStub) layout.findManagedViewById(R.id.suc_layout_footer); + this.applyPartnerResources = applyPartnerResources; + + FooterButtonInflater inflater = new FooterButtonInflater(context); + + if (attrPrimaryButton != 0) { + setPrimaryButton(inflater.inflate(attrPrimaryButton)); + } + + if (attrSecondaryButton != 0) { + setSecondaryButton(inflater.inflate(attrSecondaryButton)); + } + } + + // 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()); + space.setLayoutParams(new LayoutParams(0, 0, 1.0f)); + space.setVisibility(View.INVISIBLE); + buttonContainer.addView(space); + return space; + } + + @NonNull + private LinearLayout ensureFooterInflated() { + if (buttonContainer == null) { + if (footerStub == null) { + throw new IllegalStateException("Footer stub is not found in this template"); + } + footerStub.setLayoutResource(R.layout.suc_footer_button_bar); + buttonContainer = (LinearLayout) footerStub.inflate(); + } + return buttonContainer; + } + + @SuppressLint("InflateParams") + private Button createThemedButton(Context context, @StyleRes int theme) { + // Inflate a single button from XML, which when using support lib, will take advantage of + // the injected layout inflater and give us AppCompatButton instead. + LayoutInflater inflater = LayoutInflater.from(new ContextThemeWrapper(context, theme)); + return (Button) inflater.inflate(R.layout.suc_button, null, false); + } + + /** Sets primary button for footer. */ + public void setPrimaryButton(FooterButton footerButton) { + addSpace(); + LinearLayout buttonContainer = ensureFooterInflated(); + + // Set the default theme if theme is not set, or when running in setup flow. + if (footerButton.getTheme() == 0 || applyPartnerResources) { + footerButton.setTheme(R.style.SucPartnerCustomizationButton_Primary); + } + // TODO(b/120055778): Make sure customize attributes in theme can be applied during setup flow. + // If sets background color to full transparent, the button changes to colored borderless ink + // button style. + if (applyPartnerResources + && PartnerConfigHelper.get(context) + .getColor(context, PartnerConfig.CONFIG_FOOTER_PRIMARY_BUTTON_BG_COLOR) + == Color.TRANSPARENT) { + footerButton.setTheme(R.style.SucPartnerCustomizationButton_Secondary); + } + + Button button = inflateButton(footerButton, R.id.suc_customization_primary_button); + buttonContainer.addView(button); + } + + public Button getPrimaryButton() { + return buttonContainer.findViewById(R.id.suc_customization_primary_button); + } + + /** Sets secondary button for footer. */ + public void setSecondaryButton(FooterButton footerButton) { + LinearLayout buttonContainer = ensureFooterInflated(); + + // Set the default theme if theme is not set, or when running in setup flow. + if (footerButton.getTheme() == 0 || applyPartnerResources) { + footerButton.setTheme(R.style.SucPartnerCustomizationButton_Secondary); + } + // TODO(b/120055778): Make sure customize attributes in theme can be applied during setup flow. + // If doesn't set background color to full transparent, the button changes to colored bordered + // ink button style. + if (applyPartnerResources + && PartnerConfigHelper.get(context) + .getColor(context, PartnerConfig.CONFIG_FOOTER_SECONDARY_BUTTON_BG_COLOR) + != Color.TRANSPARENT) { + footerButton.setTheme(R.style.SucPartnerCustomizationButton_Primary); + } + + Button button = inflateButton(footerButton, R.id.suc_customization_secondary_button); + buttonContainer.addView(button); + addSpace(); + } + + public Button getSecondaryButton() { + return buttonContainer.findViewById(R.id.suc_customization_secondary_button); + } + + private Button inflateButton(FooterButton footerButton, @IdRes int id) { + Button button = createThemedButton(context, footerButton.getTheme()); + button.setId(id); + button.setText(footerButton.getText()); + button.setOnClickListener(footerButton.getListener()); + if (applyPartnerResources) { + updateButtonAttrsWithPartnerConfig(button, id); + } + return button; + } + + // TODO(b/120055778): Make sure customize attributes in theme can be applied during setup flow. + private void updateButtonAttrsWithPartnerConfig(Button button, @IdRes int id) { + updateButtonTextColorWithPartnerConfig(button, id); + updateButtonTextSizeWithPartnerConfig(button, id); + updateButtonTypeFaceWithPartnerConfig(button); + updateButtonBackgroundWithPartnerConfig(button, id); + updateButtonRadiusWithPartnerConfig(button); + } + + private void updateButtonTextColorWithPartnerConfig(Button button, @IdRes int id) { + @ColorInt int color = 0; + if (id == R.id.suc_customization_primary_button) { + color = + PartnerConfigHelper.get(context) + .getColor(context, PartnerConfig.CONFIG_FOOTER_PRIMARY_BUTTON_TEXT_COLOR); + } else if (id == R.id.suc_customization_secondary_button) { + color = + PartnerConfigHelper.get(context) + .getColor(context, PartnerConfig.CONFIG_FOOTER_SECONDARY_BUTTON_TEXT_COLOR); + } + button.setTextColor(color); + } + + private void updateButtonTextSizeWithPartnerConfig(Button button, @IdRes int id) { + float size = 0.0f; + if (id == R.id.suc_customization_primary_button) { + size = + PartnerConfigHelper.get(context) + .getDimension(context, PartnerConfig.CONFIG_FOOTER_PRIMARY_BUTTON_TEXT_SIZE); + } else if (id == R.id.suc_customization_secondary_button) { + size = + PartnerConfigHelper.get(context) + .getDimension(context, PartnerConfig.CONFIG_FOOTER_SECONDARY_BUTTON_TEXT_SIZE); + } + button.setTextSize(TypedValue.COMPLEX_UNIT_PX, size); + } + + private void updateButtonTypeFaceWithPartnerConfig(Button button) { + String fontFamilyName = + PartnerConfigHelper.get(context) + .getString(context, PartnerConfig.CONFIG_FOOTER_BUTTON_FONT_FAMILY); + Typeface font = Typeface.create(fontFamilyName, Typeface.NORMAL); + if (font != null) { + button.setTypeface(font); + } + } + + private void updateButtonBackgroundWithPartnerConfig(Button button, @IdRes int id) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { + if (id == R.id.suc_customization_primary_button) { + int color = + PartnerConfigHelper.get(context) + .getColor(context, PartnerConfig.CONFIG_FOOTER_PRIMARY_BUTTON_BG_COLOR); + if (color != Color.TRANSPARENT) { + button.getBackground().setColorFilter(color, Mode.MULTIPLY); + } + } else if (id == R.id.suc_customization_secondary_button) { + int color = + PartnerConfigHelper.get(context) + .getColor(context, PartnerConfig.CONFIG_FOOTER_SECONDARY_BUTTON_BG_COLOR); + if (color != Color.TRANSPARENT) { + button.getBackground().setColorFilter(color, Mode.MULTIPLY); + } + } + } + } + + private void updateButtonRadiusWithPartnerConfig(Button button) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.N) { + float defaultRadius = + context.getResources().getDimension(R.dimen.suc_customization_button_corner_radius); + float radius = + PartnerConfigHelper.get(context) + .getDimension(context, PartnerConfig.CONFIG_FOOTER_BUTTON_RADIUS, defaultRadius); + GradientDrawable gradientDrawable = getGradientDrawable(button); + if (gradientDrawable != null) { + gradientDrawable.setCornerRadius(radius); + } + } + } + + GradientDrawable getGradientDrawable(Button button) { + Drawable drawable = button.getBackground(); + if (drawable instanceof InsetDrawable) { + LayerDrawable layerDrawable = (LayerDrawable) ((InsetDrawable) drawable).getDrawable(); + return (GradientDrawable) layerDrawable.getDrawable(0); + } else if (drawable instanceof RippleDrawable) { + InsetDrawable insetDrawable = (InsetDrawable) ((RippleDrawable) drawable).getDrawable(0); + return (GradientDrawable) insetDrawable.getDrawable(); + } + return null; + } + + protected View inflateFooter(@LayoutRes int footer) { + footerStub.setLayoutResource(footer); + return footerStub.inflate(); + } +} diff --git a/main/java/com/google/android/setupcompat/template/Mixin.java b/main/java/com/google/android/setupcompat/template/Mixin.java new file mode 100644 index 0000000..473ba6f --- /dev/null +++ b/main/java/com/google/android/setupcompat/template/Mixin.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2017 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.template; + +/** + * Marker interface to indicate Mixin classes. + * + * @see com.google.android.setupcompat.TemplateLayout#registerMixin(Class, Mixin) + * @see com.google.android.setupcompat.TemplateLayout#getMixin(Class) + */ +public interface Mixin {} diff --git a/main/java/com/google/android/setupcompat/template/StatusBarMixin.java b/main/java/com/google/android/setupcompat/template/StatusBarMixin.java new file mode 100644 index 0000000..ef482bf --- /dev/null +++ b/main/java/com/google/android/setupcompat/template/StatusBarMixin.java @@ -0,0 +1,162 @@ +/* + * 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.template; + +import static android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import android.util.AttributeSet; +import android.view.View; +import android.view.Window; +import com.google.android.setupcompat.PartnerCustomizationLayout; +import com.google.android.setupcompat.R; +import com.google.android.setupcompat.util.PartnerConfig; +import com.google.android.setupcompat.util.PartnerConfigHelper; +import com.google.android.setupcompat.view.StatusBarBackgroundLayout; + +/** + * A {@link Mixin} for setting and getting background color, and window compatible light/dark theme + * of status bar. + */ +public class StatusBarMixin implements Mixin { + + private final PartnerCustomizationLayout partnerCustomizationLayout; + private final StatusBarBackgroundLayout statusBarLayout; + private final View decorView; + @VisibleForTesting final boolean applyPartnerResources; + + /** + * Creates a mixin for managing status bar. + * + * @param layout The layout this Mixin belongs to. + * @param window The window this activity of Mixin belongs to. + * @param attrs XML attributes given to the layout. + * @param defStyleAttr The default style attribute as given to the constructor of the layout. + * @param applyPartnerResources determine applies partner resources or not. + */ + public StatusBarMixin( + @NonNull PartnerCustomizationLayout layout, + @NonNull Window window, + @Nullable AttributeSet attrs, + @AttrRes int defStyleAttr, + boolean applyPartnerResources) { + partnerCustomizationLayout = layout; + statusBarLayout = partnerCustomizationLayout.findManagedViewById(R.id.suc_layout_status); + decorView = window.getDecorView(); + this.applyPartnerResources = applyPartnerResources; + + if (statusBarLayout == null) { + throw new NullPointerException("StatusBarBackgroundLayout cannot be null in StatusBarMixin"); + } + + // Override the color of status bar to transparent such that the color of + // StatusBarBackgroundLayout can be seen. + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + window.setStatusBarColor(Color.TRANSPARENT); + } + + TypedArray a = + layout + .getContext() + .obtainStyledAttributes(attrs, R.styleable.SucStatusBarMixin, defStyleAttr, 0); + setStatusBarWindowLight( + a.getBoolean( + R.styleable.SucStatusBarMixin_sucStatusBarWindowLight, isStatusBarWindowLight())); + setStatusBarBackground(a.getDrawable(R.styleable.SucStatusBarMixin_sucStatusBarBackground)); + a.recycle(); + } + + /** + * Sets the background color of status bar. The color will be overridden by partner resource if + * the activity is running in setup wizard flow. + * + * @param color The background color of status bar. + */ + public void setStatusBarBackground(int color) { + setStatusBarBackground(new ColorDrawable(color)); + } + + /** + * Sets the background image of status bar. The drawable will be overridden by partner resource if + * the activity is running in setup wizard flow. + * + * @param background The drawable of status bar. + */ + public void setStatusBarBackground(Drawable background) { + if (applyPartnerResources) { + Context context = partnerCustomizationLayout.getContext(); + background = + PartnerConfigHelper.get(context) + .getDrawable(context, PartnerConfig.CONFIG_STATUS_BAR_BACKGROUND); + } + + statusBarLayout.setStatusBarBackground(background); + } + + /** Returns the background of status bar. */ + public Drawable getStatusBarBackground() { + return statusBarLayout.getStatusBarBackground(); + } + + /** + * Sets the status bar to draw in a mode that is compatible with light or dark status bar + * backgrounds. The status bar drawing mode will be overridden by partner resource if the activity + * is running in setup wizard flow. + * + * @param isLight true means compatible with light theme, otherwise compatible with dark theme + */ + public void setStatusBarWindowLight(boolean isLight) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { + if (applyPartnerResources) { + Context context = partnerCustomizationLayout.getContext(); + isLight = + PartnerConfigHelper.get(context) + .getBoolean(context, PartnerConfig.CONFIG_WINDOW_LIGHT_STATUS_BAR, false); + } + + if (isLight) { + decorView.setSystemUiVisibility( + decorView.getSystemUiVisibility() | SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); + } else { + decorView.setSystemUiVisibility( + decorView.getSystemUiVisibility() & ~SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); + } + } + } + + /** + * Returns true if status bar icons should be drawn on light background, false if the icons should + * be light-on-dark. + */ + public boolean isStatusBarWindowLight() { + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { + return (decorView.getSystemUiVisibility() & SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) + == SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + return true; + } +} diff --git a/main/java/com/google/android/setupcompat/template/SystemNavBarMixin.java b/main/java/com/google/android/setupcompat/template/SystemNavBarMixin.java new file mode 100644 index 0000000..e9d7799 --- /dev/null +++ b/main/java/com/google/android/setupcompat/template/SystemNavBarMixin.java @@ -0,0 +1,147 @@ +/* + * 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.template; + +import static android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import android.util.AttributeSet; +import android.view.View; +import android.view.Window; +import com.google.android.setupcompat.PartnerCustomizationLayout; +import com.google.android.setupcompat.R; +import com.google.android.setupcompat.util.PartnerConfig; +import com.google.android.setupcompat.util.PartnerConfigHelper; + +/** + * A {@link Mixin} for setting and getting background color and window compatible with light theme + * of system navigation bar. + */ +public class SystemNavBarMixin implements Mixin { + + private final PartnerCustomizationLayout partnerCustomizationLayout; + private final Window windowOfActivity; + private final View decorView; + @VisibleForTesting final boolean applyPartnerResources; + + /** + * Creates a mixin for managing system navigation bar. + * + * @param layout The layout this Mixin belongs to. + * @param window The window this activity of Mixin belongs to. + * @param attrs XML attributes given to the layout. + * @param defStyleAttr The default style attribute as given to the constructor of the layout. + * @param applyPartnerResources determine applies partner resources or not. + */ + public SystemNavBarMixin( + @NonNull PartnerCustomizationLayout layout, + @NonNull Window window, + @Nullable AttributeSet attrs, + @AttrRes int defStyleAttr, + boolean applyPartnerResources) { + partnerCustomizationLayout = layout; + windowOfActivity = window; + decorView = window.getDecorView(); + this.applyPartnerResources = applyPartnerResources; + + TypedArray a = + partnerCustomizationLayout + .getContext() + .obtainStyledAttributes(attrs, R.styleable.SucSystemNavBarMixin, defStyleAttr, 0); + int navigationBarBackground = + a.getColor(R.styleable.SucSystemNavBarMixin_sucSystemNavBarBackgroundColor, 0); + setSystemNavBarBackground(navigationBarBackground); + setSystemNavBarWindowLight( + a.getBoolean( + R.styleable.SucSystemNavBarMixin_sucSystemNavBarWindowLight, + isSystemNavBarWindowLight())); + a.recycle(); + } + + /** + * Sets the background color of navigation bar. The color will be overridden by partner resource + * if the activity is running in setup wizard flow. + * + * @param color The background color of navigation bar. + */ + public void setSystemNavBarBackground(int color) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + if (applyPartnerResources) { + Context context = partnerCustomizationLayout.getContext(); + color = + PartnerConfigHelper.get(context) + .getColor(context, PartnerConfig.CONFIG_NAVIGATION_BAR_BG_COLOR); + } + + windowOfActivity.setNavigationBarColor(color); + } + } + + /** Returns the background color of navigation bar. */ + public int getSystemNavBarBackground() { + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + return windowOfActivity.getNavigationBarColor(); + } + return Color.BLACK; + } + + /** + * Sets the navigation bar to draw in a mode that is compatible with light or dark navigation bar + * backgrounds. The navigation bar drawing mode will be overridden by partner resource if the + * activity is running in setup wizard flow. + * + * @param isLight true means compatible with light theme, otherwise compatible with dark theme + */ + public void setSystemNavBarWindowLight(boolean isLight) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.O) { + if (applyPartnerResources) { + Context context = partnerCustomizationLayout.getContext(); + isLight = + PartnerConfigHelper.get(context) + .getBoolean(context, PartnerConfig.CONFIG_WINDOW_LIGHT_NAVIGATION_BAR, false); + } + + if (isLight) { + decorView.setSystemUiVisibility( + decorView.getSystemUiVisibility() | SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); + } else { + decorView.setSystemUiVisibility( + decorView.getSystemUiVisibility() & ~SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); + } + } + } + + /** + * Returns true if the navigation bar icon should be drawn on light background, false if the icons + * should be drawn light-on-dark. + */ + public boolean isSystemNavBarWindowLight() { + if (Build.VERSION.SDK_INT >= VERSION_CODES.O) { + return (decorView.getSystemUiVisibility() & SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR) + == SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + } + return true; + } +} diff --git a/main/java/com/google/android/setupcompat/util/FallbackThemeWrapper.java b/main/java/com/google/android/setupcompat/util/FallbackThemeWrapper.java new file mode 100644 index 0000000..917b9d7 --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/FallbackThemeWrapper.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2017 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.util; + +import android.content.Context; +import android.content.res.Resources.Theme; +import androidx.annotation.StyleRes; +import android.view.ContextThemeWrapper; + +/** + * Same as {@link ContextThemeWrapper}, but the base context's theme attributes take precedence over + * the wrapper context's. This is used to provide default values for theme attributes referenced in + * layouts, to remove the risk of crashing the client because of using the wrong theme. + */ +public class FallbackThemeWrapper extends ContextThemeWrapper { + + /** + * Creates a new context wrapper with the specified theme. + * + * <p>The specified theme will be applied as fallbacks to the base context's theme. Any attributes + * defined in the base context's theme will retain their original values. Otherwise values in + * {@code themeResId} will be used. + * + * @param base The base context. + * @param themeResId The theme to use as fallback. + */ + public FallbackThemeWrapper(Context base, @StyleRes int themeResId) { + super(base, themeResId); + } + + /** {@inheritDoc} */ + @Override + protected void onApplyThemeResource(Theme theme, int resId, boolean first) { + theme.applyStyle(resId, false /* force */); + } +} diff --git a/main/java/com/google/android/setupcompat/util/FlagHelper.java b/main/java/com/google/android/setupcompat/util/FlagHelper.java new file mode 100644 index 0000000..b2b700f --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/FlagHelper.java @@ -0,0 +1,39 @@ +/* + * Copyright 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.util; + +import android.content.Context; +import android.content.res.TypedArray; +import com.google.android.setupcompat.R; + +/** Helper utilities to get flags that enable/disable features of this library. */ +public final class FlagHelper { + + private FlagHelper() {} + + /** + * Returns whether customization styles should be loaded from the partner overlay APK. Default + * value is currently false, but will be changed to true once the feature is ready for general + * consumption. Outside of setup wizard flow, this flag should default to false. + */ + public static final boolean isPartnerStyleEnabled(Context context) { + TypedArray typedArray = context.obtainStyledAttributes(new int[] {R.attr.sucEnablePartnerStyle}); + boolean enablePartnerStyle = typedArray.getBoolean(0, false); + typedArray.recycle(); + return enablePartnerStyle; + } +} diff --git a/main/java/com/google/android/setupcompat/util/PartnerConfig.java b/main/java/com/google/android/setupcompat/util/PartnerConfig.java new file mode 100644 index 0000000..bf888f3 --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/PartnerConfig.java @@ -0,0 +1,108 @@ +/* + * Copyright 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.util; + +/** Resources that can be customized by partner overlay APK. */ +public enum PartnerConfig { + + // Status bar background color or illustration. + CONFIG_STATUS_BAR_BACKGROUND(PartnerConfigKey.KEY_STATUS_BAR_BACKGROUND, ResourceType.DRAWABLE), + + // The same as "WindowLightStatusBar". If set true, the status bar icons will be drawn such + // that it is compatible with a light status bar background + CONFIG_WINDOW_LIGHT_STATUS_BAR(PartnerConfigKey.KEY_WINDOW_LIGHT_STATUS_BAR, ResourceType.BOOL), + + // Navigation bar background color + CONFIG_NAVIGATION_BAR_BG_COLOR(PartnerConfigKey.KEY_NAVIGATION_BAR_BG_COLOR, ResourceType.COLOR), + + // The same as "windowLightNavigationBar". If set true, the navigation bar icons will be drawn + // such that it is compatible with a light navigation bar background. + CONFIG_WINDOW_LIGHT_NAVIGATION_BAR( + PartnerConfigKey.KEY_WINDOW_LIGHT_NAVIGATION_BAR, ResourceType.BOOL), + + // The font face used in footer buttons. This must be a string reference to a font that is + // available in the system. Font references (@font or @xml) are not allowed. + CONFIG_FOOTER_BUTTON_FONT_FAMILY( + PartnerConfigKey.KEY_FOOTER_BUTTON_FONT_FAMILY, ResourceType.STRING), + + // The icon for "next" action. Can be "@null" for no icon. + CONFIG_FOOTER_BUTTON_ICON_NEXT( + PartnerConfigKey.KEY_FOOTER_BUTTON_ICON_NEXT, ResourceType.DRAWABLE), + + // The icon for "skip" action. Can be "@null" for no icon. + CONFIG_FOOTER_BUTTON_ICON_SKIP( + PartnerConfigKey.KEY_FOOTER_BUTTON_ICON_SKIP, ResourceType.DRAWABLE), + + // Top padding of the footer buttons + CONFIG_FOOTER_BUTTON_PADDING_TOP( + PartnerConfigKey.KEY_FOOTER_BUTTON_PADDING_TOP, ResourceType.DIMENSION), + + // Bottom padding of the footer buttons + CONFIG_FOOTER_BUTTON_PADDING_BOTTOM( + PartnerConfigKey.KEY_FOOTER_BUTTON_PADDING_BOTTOM, ResourceType.DIMENSION), + + // Corner radius of the footer buttons + CONFIG_FOOTER_BUTTON_RADIUS(PartnerConfigKey.KEY_FOOTER_BUTTON_RADIUS, ResourceType.DIMENSION), + + // Background color of the primary footer button + CONFIG_FOOTER_PRIMARY_BUTTON_BG_COLOR( + PartnerConfigKey.KEY_FOOTER_PRIMARY_BUTTON_BG_COLOR, ResourceType.COLOR), + + // Text color of the primary footer button + CONFIG_FOOTER_PRIMARY_BUTTON_TEXT_COLOR( + PartnerConfigKey.KEY_FOOTER_PRIMARY_BUTTON_TEXT_COLOR, ResourceType.COLOR), + + // Text size of the primary footer button + CONFIG_FOOTER_PRIMARY_BUTTON_TEXT_SIZE( + PartnerConfigKey.KEY_FOOTER_PRIMARY_BUTTON_TEXT_SIZE, ResourceType.DIMENSION), + + // Background color of the secondary footer button + CONFIG_FOOTER_SECONDARY_BUTTON_BG_COLOR( + PartnerConfigKey.KEY_FOOTER_SECONDARY_BUTTON_BG_COLOR, ResourceType.COLOR), + + // Text color of the secondary footer button + CONFIG_FOOTER_SECONDARY_BUTTON_TEXT_COLOR( + PartnerConfigKey.KEY_FOOTER_SECONDARY_BUTTON_TEXT_COLOR, ResourceType.COLOR), + + // Text size of the secondary footer button + CONFIG_FOOTER_SECONDARY_BUTTON_TEXT_SIZE( + PartnerConfigKey.KEY_FOOTER_SECONDARY_BUTTON_TEXT_SIZE, ResourceType.DIMENSION); + + public enum ResourceType { + BOOL, + COLOR, + DRAWABLE, + STRING, + DIMENSION; + } + + private final String resourceName; + private final ResourceType resourceType; + + public ResourceType getResourceType() { + return resourceType; + } + + public String getResourceName() { + return resourceName; + } + + PartnerConfig(@PartnerConfigKey String resourceName, ResourceType type) { + this.resourceName = resourceName; + this.resourceType = type; + } +} diff --git a/main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java b/main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java new file mode 100644 index 0000000..87a3706 --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/PartnerConfigHelper.java @@ -0,0 +1,272 @@ +/* + * Copyright 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.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import android.util.Log; +import com.google.android.setupcompat.util.PartnerConfig.ResourceType; +import java.util.EnumMap; + +/** The helper reads and caches the partner configurations from SUW. */ +public class PartnerConfigHelper { + + private static final String TAG = PartnerConfigHelper.class.getSimpleName(); + + @VisibleForTesting + public static final String SUW_AUTHORITY = "com.google.android.setupwizard.partner"; + + @VisibleForTesting public static final String SUW_GET_PARTNER_CONFIG_METHOD = "getOverlayConfig"; + private static PartnerConfigHelper instance = null; + + @VisibleForTesting Bundle resultBundle = null; + + @VisibleForTesting + final EnumMap<PartnerConfig, Object> partnerResourceCache = new EnumMap<>(PartnerConfig.class); + + public static synchronized PartnerConfigHelper get(@NonNull Context context) { + if (instance == null) { + instance = new PartnerConfigHelper(context); + } + return instance; + } + + private PartnerConfigHelper(Context context) { + getPartnerConfigBundle(context); + } + + /** + * Returns the color of given {@code resourceConfig}, or 0 if the given {@code resourceConfig} is + * not found. If the {@code ResourceType} of the given {@code resourceConfig} is not color, + * IllegalArgumentException will be thrown. + * + * @param context The context of client activity + * @param resourceConfig The {@code PartnerConfig} of target resource + */ + @ColorInt + public int getColor(@NonNull Context context, PartnerConfig resourceConfig) { + if (resourceConfig.getResourceType() != ResourceType.COLOR) { + throw new IllegalArgumentException("Not a color resource"); + } + + if (partnerResourceCache.containsKey(resourceConfig)) { + return (int) partnerResourceCache.get(resourceConfig); + } + + int result = 0; + try { + String resourceName = resourceConfig.getResourceName(); + ResourceEntry resourceEntry = getResourceEntryFromKey(resourceName); + Resources resource = getResourcesByPackageName(context, resourceEntry.getPackageName()); + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { + result = resource.getColor(resourceEntry.getResourceId(), null); + } else { + result = resource.getColor(resourceEntry.getResourceId()); + } + partnerResourceCache.put(resourceConfig, result); + } catch (PackageManager.NameNotFoundException | NullPointerException exception) { + // fall through + } + return result; + } + + /** + * Returns the {@code Drawable} of given {@code resourceConfig}, or {@code null} if the given + * {@code resourceConfig} is not found. If the {@code ResourceType} of the given {@code + * resourceConfig} is not drawable, IllegalArgumentException will be thrown. + * + * @param context The context of client activity + * @param resourceConfig The {@code PartnerConfig} of target resource + */ + @Nullable + public Drawable getDrawable(@NonNull Context context, PartnerConfig resourceConfig) { + if (resourceConfig.getResourceType() != ResourceType.DRAWABLE) { + throw new IllegalArgumentException("Not a drawable resource"); + } + + if (partnerResourceCache.containsKey(resourceConfig)) { + return (Drawable) partnerResourceCache.get(resourceConfig); + } + + Drawable result = null; + try { + ResourceEntry resourceEntry = getResourceEntryFromKey(resourceConfig.getResourceName()); + Resources resource = getResourcesByPackageName(context, resourceEntry.getPackageName()); + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + result = resource.getDrawable(resourceEntry.getResourceId(), null); + } else { + result = resource.getDrawable(resourceEntry.getResourceId()); + } + partnerResourceCache.put(resourceConfig, result); + } catch (PackageManager.NameNotFoundException | NullPointerException exception) { + // fall through + } + return result; + } + + /** + * Returns the string of the given {@code resourceConfig}, or {@code null} if the given {@code + * resourceConfig} is not found. If the {@code ResourceType} of the given {@code resourceConfig} + * is not string, IllegalArgumentException will be thrown. + * + * @param context The context of client activity + * @param resourceConfig The {@code PartnerConfig} of target resource + */ + @Nullable + public String getString(@NonNull Context context, PartnerConfig resourceConfig) { + if (resourceConfig.getResourceType() != ResourceType.STRING) { + throw new IllegalArgumentException("Not a string resource"); + } + + if (partnerResourceCache.containsKey(resourceConfig)) { + return (String) partnerResourceCache.get(resourceConfig); + } + + String result = null; + try { + ResourceEntry resourceEntry = getResourceEntryFromKey(resourceConfig.getResourceName()); + Resources resource = getResourcesByPackageName(context, resourceEntry.getPackageName()); + result = resource.getString(resourceEntry.getResourceId()); + partnerResourceCache.put(resourceConfig, result); + } catch (PackageManager.NameNotFoundException | NullPointerException exception) { + // fall through + } + return result; + } + + /** + * Returns the boolean of given {@code resourceConfig}, or {@code defaultValue} if the given + * {@code resourceName} is not found. If the {@code ResourceType} of the given {@code + * resourceConfig} is not boolean, IllegalArgumentException will be thrown. + * + * @param context The context of client activity + * @param resourceConfig The {@code PartnerConfig} of target resource + * @param defaultValue The default value + */ + public boolean getBoolean( + @NonNull Context context, PartnerConfig resourceConfig, boolean defaultValue) { + if (resourceConfig.getResourceType() != ResourceType.BOOL) { + throw new IllegalArgumentException("Not a bool resource"); + } + + if (partnerResourceCache.containsKey(resourceConfig)) { + return (boolean) partnerResourceCache.get(resourceConfig); + } + + boolean result = defaultValue; + try { + ResourceEntry resourceEntry = getResourceEntryFromKey(resourceConfig.getResourceName()); + Resources resource = getResourcesByPackageName(context, resourceEntry.getPackageName()); + result = resource.getBoolean(resourceEntry.getResourceId()); + partnerResourceCache.put(resourceConfig, result); + } catch (PackageManager.NameNotFoundException | NullPointerException exception) { + // fall through + } + return result; + } + + /** + * Returns the dimension of given {@code resourceConfig}. The default return value is 0. + * + * @param context The context of client activity + * @param resourceConfig The {@code PartnerConfig} of target resource + */ + public float getDimension(@NonNull Context context, PartnerConfig resourceConfig) { + return getDimension(context, resourceConfig, 0); + } + + /** + * Returns the dimension of given {@code resourceConfig}. If the given {@code resourceConfig} not + * found, will return {@code defaultValue}. If the {@code ResourceType} of given {@code + * resourceConfig} is not dimension, will throw IllegalArgumentException. + * + * @param context The context of client activity + * @param resourceConfig The {@code PartnerConfig} of target resource + * @param defaultValue The default value + */ + public float getDimension( + @NonNull Context context, PartnerConfig resourceConfig, float defaultValue) { + if (resourceConfig.getResourceType() != ResourceType.DIMENSION) { + throw new IllegalArgumentException("Not a dimension resource"); + } + + if (partnerResourceCache.containsKey(resourceConfig)) { + return (float) partnerResourceCache.get(resourceConfig); + } + + float result = defaultValue; + try { + ResourceEntry resourceEntry = getResourceEntryFromKey(resourceConfig.getResourceName()); + Resources resource = getResourcesByPackageName(context, resourceEntry.getPackageName()); + result = resource.getDimension(resourceEntry.getResourceId()); + partnerResourceCache.put(resourceConfig, result); + } catch (PackageManager.NameNotFoundException | NullPointerException exception) { + // fall through + } + return result; + } + + private void getPartnerConfigBundle(Context context) { + if (resultBundle == null || resultBundle.isEmpty()) { + try { + Uri contentUri = + new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(SUW_AUTHORITY) + .appendPath(SUW_GET_PARTNER_CONFIG_METHOD) + .build(); + resultBundle = + context + .getContentResolver() + .call( + contentUri, SUW_GET_PARTNER_CONFIG_METHOD, /* arg= */ null, /* extras= */ null); + partnerResourceCache.clear(); + } catch (IllegalArgumentException exception) { + Log.w(TAG, "Fail to get config from suw provider"); + } + } + } + + private Resources getResourcesByPackageName(Context context, String packageName) + throws PackageManager.NameNotFoundException { + PackageManager manager = context.getPackageManager(); + return manager.getResourcesForApplication(packageName); + } + + private ResourceEntry getResourceEntryFromKey(String resourceName) { + if (resultBundle == null) { + return null; + } + return ResourceEntry.fromBundle(resultBundle.getBundle(resourceName)); + } + + @VisibleForTesting + public static synchronized void resetForTesting() { + instance = null; + } +} diff --git a/main/java/com/google/android/setupcompat/util/PartnerConfigKey.java b/main/java/com/google/android/setupcompat/util/PartnerConfigKey.java new file mode 100644 index 0000000..dfd4add --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/PartnerConfigKey.java @@ -0,0 +1,94 @@ +/* + * Copyright 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.util; + +import androidx.annotation.StringDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Resource names that can be customized by partner overlay APK. */ +@Retention(RetentionPolicy.SOURCE) +@StringDef({ + PartnerConfigKey.KEY_STATUS_BAR_BACKGROUND, + PartnerConfigKey.KEY_WINDOW_LIGHT_STATUS_BAR, + PartnerConfigKey.KEY_NAVIGATION_BAR_BG_COLOR, + PartnerConfigKey.KEY_WINDOW_LIGHT_NAVIGATION_BAR, + PartnerConfigKey.KEY_FOOTER_BUTTON_FONT_FAMILY, + PartnerConfigKey.KEY_FOOTER_BUTTON_ICON_NEXT, + PartnerConfigKey.KEY_FOOTER_BUTTON_ICON_SKIP, + PartnerConfigKey.KEY_FOOTER_BUTTON_PADDING_TOP, + PartnerConfigKey.KEY_FOOTER_BUTTON_PADDING_BOTTOM, + PartnerConfigKey.KEY_FOOTER_BUTTON_RADIUS, + PartnerConfigKey.KEY_FOOTER_PRIMARY_BUTTON_BG_COLOR, + PartnerConfigKey.KEY_FOOTER_PRIMARY_BUTTON_TEXT_COLOR, + PartnerConfigKey.KEY_FOOTER_PRIMARY_BUTTON_TEXT_SIZE, + PartnerConfigKey.KEY_FOOTER_SECONDARY_BUTTON_BG_COLOR, + PartnerConfigKey.KEY_FOOTER_SECONDARY_BUTTON_TEXT_COLOR, + PartnerConfigKey.KEY_FOOTER_SECONDARY_BUTTON_TEXT_SIZE, +}) +public @interface PartnerConfigKey { + // Status bar background color or illustration. + String KEY_STATUS_BAR_BACKGROUND = "setup_compat_status_bar_background"; + + // The same as "WindowLightStatusBar". If set true, the status bar icons will be drawn such + // that it is compatible with a light status bar background + String KEY_WINDOW_LIGHT_STATUS_BAR = "setup_compat_window_light_status_bar"; + + // Navigation bar background color + String KEY_NAVIGATION_BAR_BG_COLOR = "setup_compat_navigation_bar_bg_color"; + + // The same as "windowLightNavigationBar". If set true, the navigation bar icons will be drawn + // such that it is compatible with a light navigation bar background. + String KEY_WINDOW_LIGHT_NAVIGATION_BAR = "setup_compat_window_light_navigation_bar"; + + // The font face used in footer buttons. This must be a string reference to a font that is + // available in the system. Font references (@font or @xml) are not allowed. + String KEY_FOOTER_BUTTON_FONT_FAMILY = "setup_compat_footer_button_font_family"; + + // The icon for "next" action. Can be "@null" for no icon. + String KEY_FOOTER_BUTTON_ICON_NEXT = "setup_compat_footer_button_icon_next"; + + // The icon for "skip" action. Can be "@null" for no icon. + String KEY_FOOTER_BUTTON_ICON_SKIP = "setup_compat_footer_button_icon_skip"; + + // Top padding of the footer buttons + String KEY_FOOTER_BUTTON_PADDING_TOP = "setup_compat_footer_button_padding_top"; + + // Bottom padding of the footer buttons + String KEY_FOOTER_BUTTON_PADDING_BOTTOM = "setup_compat_footer_button_padding_bottom"; + + // Corner radius of the footer buttons + String KEY_FOOTER_BUTTON_RADIUS = "setup_compat_footer_button_radius"; + + // Background color of the primary footer button + String KEY_FOOTER_PRIMARY_BUTTON_BG_COLOR = "setup_compat_footer_primary_button_bg_color"; + + // Text color of the primary footer button + String KEY_FOOTER_PRIMARY_BUTTON_TEXT_COLOR = "setup_compat_footer_primary_button_text_color"; + + // Text size of the primary footer button + String KEY_FOOTER_PRIMARY_BUTTON_TEXT_SIZE = "setup_compat_footer_primary_button_text_size"; + + // Background color of the secondary footer button + String KEY_FOOTER_SECONDARY_BUTTON_BG_COLOR = "setup_compat_footer_secondary_button_bg_color"; + + // Text color of the secondary footer button + String KEY_FOOTER_SECONDARY_BUTTON_TEXT_COLOR = "setup_compat_footer_secondary_button_text_color"; + + // Text size of the secondary footer button + String KEY_FOOTER_SECONDARY_BUTTON_TEXT_SIZE = "setup_compat_footer_secondary_button_text_size"; +} diff --git a/main/java/com/google/android/setupcompat/util/ResourceEntry.java b/main/java/com/google/android/setupcompat/util/ResourceEntry.java new file mode 100644 index 0000000..0cf7fc3 --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/ResourceEntry.java @@ -0,0 +1,86 @@ +/* + * Copyright 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.util; + +import android.os.Bundle; +import androidx.annotation.VisibleForTesting; + +/** + * A potentially cross-package resource entry, which can then be retrieved using {@link + * PackageManager#getApplicationForResources}. This class can also be sent across to other packages + * on IPC via the Bundle representation. + */ +public final class ResourceEntry { + @VisibleForTesting static final String KEY_PACKAGE_NAME = "packageName"; + @VisibleForTesting static final String KEY_RESOURCE_NAME = "resourceName"; + @VisibleForTesting static final String KEY_RESOURCE_ID = "resourceId"; + + private final String packageName; + private final String resourceName; + private final int resourceId; + + /** + * Creates a {@code ResourceEntry} object from a provided bundle. + * + * @param bundle the source bundle needs to have all the information for a {@code ResourceEntry} + */ + public static ResourceEntry fromBundle(Bundle bundle) { + String packageName; + String resourceName; + int resourceId; + if (!bundle.containsKey(KEY_PACKAGE_NAME) + || !bundle.containsKey(KEY_RESOURCE_NAME) + || !bundle.containsKey(KEY_RESOURCE_ID)) { + return null; + } + packageName = bundle.getString(KEY_PACKAGE_NAME); + resourceName = bundle.getString(KEY_RESOURCE_NAME); + resourceId = bundle.getInt(KEY_RESOURCE_ID); + return new ResourceEntry(packageName, resourceName, resourceId); + } + + public ResourceEntry(String packageName, @PartnerConfigKey String resourceName, int resourceId) { + this.packageName = packageName; + this.resourceName = resourceName; + this.resourceId = resourceId; + } + + public String getPackageName() { + return this.packageName; + } + + public String getResourceName() { + return this.resourceName; + } + + public int getResourceId() { + return this.resourceId; + } + + /** + * Returns a bundle representation of this resource entry, which can then be sent over IPC. + * + * @see #fromBundle(Bundle) + */ + public Bundle toBundle() { + Bundle result = new Bundle(); + result.putString(KEY_PACKAGE_NAME, packageName); + result.putString(KEY_RESOURCE_NAME, resourceName); + result.putInt(KEY_RESOURCE_ID, resourceId); + return result; + } +} diff --git a/main/java/com/google/android/setupcompat/util/ResultCodes.java b/main/java/com/google/android/setupcompat/util/ResultCodes.java new file mode 100644 index 0000000..3934b21 --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/ResultCodes.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2015 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.util; + +import static android.app.Activity.RESULT_FIRST_USER; + +/** Result codes for activities to return to Wizard Manager. */ +public final class ResultCodes { + + public static final int RESULT_SKIP = RESULT_FIRST_USER; + public static final int RESULT_RETRY = RESULT_FIRST_USER + 1; + public static final int RESULT_ACTIVITY_NOT_FOUND = RESULT_FIRST_USER + 2; + + public static final int RESULT_FIRST_SETUP_USER = RESULT_FIRST_USER + 100; +} diff --git a/main/java/com/google/android/setupcompat/util/WizardManagerHelper.java b/main/java/com/google/android/setupcompat/util/WizardManagerHelper.java new file mode 100644 index 0000000..780105d --- /dev/null +++ b/main/java/com/google/android/setupcompat/util/WizardManagerHelper.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2015 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.util; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.Settings; +import androidx.annotation.VisibleForTesting; +import java.util.Arrays; + +/** + * Helper to interact with Wizard Manager in setup wizard, which should be used when a screen is + * shown inside the setup flow. This includes things like parsing extras passed by Wizard Manager, + * and invoking Wizard Manager to start the next action. + */ +public class WizardManagerHelper { + + private static final String ACTION_NEXT = "com.android.wizard.NEXT"; + + // EXTRA_SCRIPT_URI and EXTRA_ACTION_ID are used in setup wizard in versions before M and are + // kept for backwards compatibility. + @VisibleForTesting static final String EXTRA_SCRIPT_URI = "scriptUri"; + @VisibleForTesting static final String EXTRA_ACTION_ID = "actionId"; + + @VisibleForTesting static final String EXTRA_WIZARD_BUNDLE = "wizardBundle"; + private static final String EXTRA_RESULT_CODE = "com.android.setupwizard.ResultCode"; + @VisibleForTesting public static final String EXTRA_IS_FIRST_RUN = "firstRun"; + @VisibleForTesting static final String EXTRA_IS_DEFERRED_SETUP = "deferredSetup"; + @VisibleForTesting static final String EXTRA_IS_PRE_DEFERRED_SETUP = "preDeferredSetup"; + @VisibleForTesting public static final String EXTRA_IS_SETUP_FLOW = "isSetupFlow"; + + public static final String EXTRA_THEME = "theme"; + public static final String EXTRA_USE_IMMERSIVE_MODE = "useImmersiveMode"; + + public static final String SETTINGS_GLOBAL_DEVICE_PROVISIONED = "device_provisioned"; + public static final String SETTINGS_SECURE_USER_SETUP_COMPLETE = "user_setup_complete"; + + /** + * Gets an intent that will invoke the next step of setup wizard. + * + * @param originalIntent The original intent that was used to start the step, usually via {@link + * Activity#getIntent()}. + * @param resultCode The result code of the step. See {@link ResultCodes}. + * @return A new intent that can be used with {@link Activity#startActivityForResult(Intent, int)} + * to start the next step of the setup flow. + */ + public static Intent getNextIntent(Intent originalIntent, int resultCode) { + return getNextIntent(originalIntent, resultCode, null); + } + + /** + * Gets an intent that will invoke the next step of setup wizard. + * + * @param originalIntent The original intent that was used to start the step, usually via {@link + * Activity#getIntent()}. + * @param resultCode The result code of the step. See {@link ResultCodes}. + * @param data An intent containing extra result data. + * @return A new intent that can be used with {@link Activity#startActivityForResult(Intent, int)} + * to start the next step of the setup flow. + */ + public static Intent getNextIntent(Intent originalIntent, int resultCode, Intent data) { + Intent intent = new Intent(ACTION_NEXT); + copyWizardManagerExtras(originalIntent, intent); + intent.putExtra(EXTRA_RESULT_CODE, resultCode); + if (data != null && data.getExtras() != null) { + intent.putExtras(data.getExtras()); + } + intent.putExtra(EXTRA_THEME, originalIntent.getStringExtra(EXTRA_THEME)); + + return intent; + } + + /** + * Copies the internal extras used by setup wizard from one intent to another. For low-level use + * only, such as when using {@link Intent#FLAG_ACTIVITY_FORWARD_RESULT} to relay to another + * intent. + * + * @param srcIntent Intent to get the wizard manager extras from. + * @param dstIntent Intent to copy the wizard manager extras to. + */ + public static void copyWizardManagerExtras(Intent srcIntent, Intent dstIntent) { + dstIntent.putExtra(EXTRA_WIZARD_BUNDLE, srcIntent.getBundleExtra(EXTRA_WIZARD_BUNDLE)); + for (String key : + Arrays.asList(EXTRA_IS_FIRST_RUN, EXTRA_IS_DEFERRED_SETUP, EXTRA_IS_PRE_DEFERRED_SETUP)) { + dstIntent.putExtra(key, srcIntent.getBooleanExtra(key, false)); + } + + for (String key : Arrays.asList(EXTRA_THEME, EXTRA_SCRIPT_URI, EXTRA_ACTION_ID)) { + dstIntent.putExtra(key, srcIntent.getStringExtra(key)); + } + } + + /** + * Checks whether an intent is intended to be used within the setup wizard flow. + * + * @param intent The intent to be checked, usually from {@link Activity#getIntent()}. + * @return true if the intent passed in was intended to be used with setup wizard. + */ + public static boolean isSetupWizardIntent(Intent intent) { + return intent.getBooleanExtra(EXTRA_IS_FIRST_RUN, false); + } + + /** + * Checks whether the current user has completed Setup Wizard. This is true if the current user + * has gone through Setup Wizard. The current user may or may not be the device owner and the + * device owner may have already completed setup wizard. + * + * @param context The context to retrieve the settings. + * @return true if the current user has completed Setup Wizard. + * @see #isDeviceProvisioned(Context) + */ + public static boolean isUserSetupComplete(Context context) { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { + return Settings.Secure.getInt( + context.getContentResolver(), SETTINGS_SECURE_USER_SETUP_COMPLETE, 0) + == 1; + } else { + // For versions below JB MR1, there are no user profiles. Just return the global device + // provisioned state. + return Settings.Secure.getInt( + context.getContentResolver(), SETTINGS_GLOBAL_DEVICE_PROVISIONED, 0) + == 1; + } + } + + /** + * Checks whether the device is provisioned. This means that the device has gone through Setup + * Wizard at least once. Note that the user can still be in Setup Wizard even if this is true, for + * a secondary user profile triggered through Settings > Add account. + * + * @param context The context to retrieve the settings. + * @return true if the device is provisioned. + * @see #isUserSetupComplete(Context) + */ + public static boolean isDeviceProvisioned(Context context) { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { + return Settings.Global.getInt( + context.getContentResolver(), SETTINGS_GLOBAL_DEVICE_PROVISIONED, 0) + == 1; + } else { + return Settings.Secure.getInt( + context.getContentResolver(), SETTINGS_GLOBAL_DEVICE_PROVISIONED, 0) + == 1; + } + } + + /** + * Checks whether an intent is running in the deferred setup wizard flow. + * + * @param originalIntent The original intent that was used to start the step, usually via {@link + * Activity#getIntent()}. + * @return true if the intent passed in was running in deferred setup wizard. + */ + public static boolean isDeferredSetupWizard(Intent originalIntent) { + return originalIntent != null && originalIntent.getBooleanExtra(EXTRA_IS_DEFERRED_SETUP, false); + } + + /** + * Checks whether an intent is running in "pre-deferred" setup wizard flow. + * + * @param originalIntent The original intent that was used to start the step, usually via {@link + * Activity#getIntent()}. + * @return true if the intent passed in was running in "pre-deferred" setup wizard. + */ + public static boolean isPreDeferredSetupWizard(Intent originalIntent) { + return originalIntent != null + && originalIntent.getBooleanExtra(EXTRA_IS_PRE_DEFERRED_SETUP, false); + } + + /** + * Returns true if the intent passed in indicates that it is running in any setup wizard flow, + * including initial setup and deferred setup etc. + * + * @param originalIntent The original intent that was used to start the step, usually via {@link + * Activity#getIntent()}. + */ + public static boolean isAnySetupWizard(Intent originalIntent) { + // TODO(b/119455685): change this to >= VERSION_CODES.Q when VERSION_CODES.Q is available + if (Build.VERSION.SDK_INT > VERSION_CODES.P) { + return originalIntent.getBooleanExtra(EXTRA_IS_SETUP_FLOW, false); + } else { + return originalIntent != null + && (isSetupWizardIntent(originalIntent) + || isPreDeferredSetupWizard(originalIntent) + || isDeferredSetupWizard(originalIntent)); + } + } +} diff --git a/main/java/com/google/android/setupcompat/view/ButtonBarLayout.java b/main/java/com/google/android/setupcompat/view/ButtonBarLayout.java new file mode 100644 index 0000000..da1ab34 --- /dev/null +++ b/main/java/com/google/android/setupcompat/view/ButtonBarLayout.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2017 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.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import com.google.android.setupcompat.R; + +/** + * An extension of LinearLayout that automatically switches to vertical orientation when it can't + * fit its child views horizontally. + * + * <p>Modified from {@code com.android.internal.widget.ButtonBarLayout} + */ +public class ButtonBarLayout extends LinearLayout { + + private boolean stacked = false; + private int originalPaddingLeft; + private int originalPaddingRight; + + public ButtonBarLayout(Context context) { + super(context); + } + + public ButtonBarLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int widthSize = MeasureSpec.getSize(widthMeasureSpec); + + setStacked(false); + + boolean needsRemeasure = false; + + int initialWidthMeasureSpec = widthMeasureSpec; + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + // Measure with WRAP_CONTENT, so that we can compare the measured size with the + // available size to see if we need to stack. + initialWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + + // We'll need to remeasure again to fill excess space. + needsRemeasure = true; + } + + super.onMeasure(initialWidthMeasureSpec, heightMeasureSpec); + + if (getMeasuredWidth() > widthSize) { + setStacked(true); + + // Measure again in the new orientation. + needsRemeasure = true; + } + + if (needsRemeasure) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + private void setStacked(boolean stacked) { + if (this.stacked == stacked) { + return; + } + this.stacked = stacked; + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + LayoutParams childParams = (LayoutParams) child.getLayoutParams(); + if (stacked) { + child.setTag(R.id.suc_customization_original_weight, childParams.weight); + childParams.weight = 0; + } else { + Float weight = (Float) child.getTag(R.id.suc_customization_original_weight); + if (weight != null) { + childParams.weight = weight; + } + } + child.setLayoutParams(childParams); + } + + setOrientation(stacked ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); + + // Reverse the child order, so that the primary button is towards the top when vertical + for (int i = childCount - 1; i >= 0; i--) { + bringChildToFront(getChildAt(i)); + } + + if (stacked) { + // HACK: In the default button bar style, the left and right paddings are not + // balanced to compensate for different alignment for borderless (left) button and + // the raised (right) button. When it's stacked, we want the buttons to be centered, + // so we balance out the paddings here. + originalPaddingLeft = getPaddingLeft(); + originalPaddingRight = getPaddingRight(); + int paddingHorizontal = Math.max(originalPaddingLeft, originalPaddingRight); + setPadding(paddingHorizontal, getPaddingTop(), paddingHorizontal, getPaddingBottom()); + } else { + setPadding(originalPaddingLeft, getPaddingTop(), originalPaddingRight, getPaddingBottom()); + } + } +} diff --git a/main/java/com/google/android/setupcompat/view/StatusBarBackgroundLayout.java b/main/java/com/google/android/setupcompat/view/StatusBarBackgroundLayout.java new file mode 100644 index 0000000..9cee894 --- /dev/null +++ b/main/java/com/google/android/setupcompat/view/StatusBarBackgroundLayout.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2015 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.view; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.util.AttributeSet; +import android.view.WindowInsets; +import android.widget.FrameLayout; + +/** + * A FrameLayout subclass that will responds to onApplyWindowInsets to draw a drawable in the top + * inset area, making a background effect for the navigation bar. To make use of this layout, + * specify the system UI visibility {@link android.view.View#SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN} and + * set specify fitsSystemWindows. + * + * <p>This view is a normal FrameLayout if either of those are not set, or if the platform version + * is lower than Lollipop. + */ +public class StatusBarBackgroundLayout extends FrameLayout { + + private Drawable statusBarBackground; + private Object lastInsets; // Use generic Object type for compatibility + + public StatusBarBackgroundLayout(Context context) { + super(context); + } + + public StatusBarBackgroundLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @TargetApi(VERSION_CODES.HONEYCOMB) + public StatusBarBackgroundLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + if (lastInsets == null) { + requestApplyInsets(); + } + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + if (lastInsets != null) { + final int insetTop = ((WindowInsets) lastInsets).getSystemWindowInsetTop(); + if (insetTop > 0) { + statusBarBackground.setBounds(0, 0, getWidth(), insetTop); + statusBarBackground.draw(canvas); + } + } + } + } + + public void setStatusBarBackground(Drawable background) { + statusBarBackground = background; + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + setWillNotDraw(background == null); + setFitsSystemWindows(background != null); + invalidate(); + } + } + + public Drawable getStatusBarBackground() { + return statusBarBackground; + } + + @Override + public WindowInsets onApplyWindowInsets(WindowInsets insets) { + lastInsets = insets; + return super.onApplyWindowInsets(insets); + } +} |