1 /*
2  * Copyright (C) 2014 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.support.v7.app;
18 
19 import android.content.Context;
20 import android.content.ContextWrapper;
21 import android.content.res.TypedArray;
22 import android.os.Build;
23 import android.support.annotation.NonNull;
24 import android.support.annotation.Nullable;
25 import android.support.v4.util.ArrayMap;
26 import android.support.v4.view.ViewCompat;
27 import android.support.v7.appcompat.R;
28 import android.support.v7.view.ContextThemeWrapper;
29 import android.support.v7.widget.AppCompatAutoCompleteTextView;
30 import android.support.v7.widget.AppCompatButton;
31 import android.support.v7.widget.AppCompatCheckBox;
32 import android.support.v7.widget.AppCompatCheckedTextView;
33 import android.support.v7.widget.AppCompatEditText;
34 import android.support.v7.widget.AppCompatImageButton;
35 import android.support.v7.widget.AppCompatImageView;
36 import android.support.v7.widget.AppCompatMultiAutoCompleteTextView;
37 import android.support.v7.widget.AppCompatRadioButton;
38 import android.support.v7.widget.AppCompatRatingBar;
39 import android.support.v7.widget.AppCompatSeekBar;
40 import android.support.v7.widget.AppCompatSpinner;
41 import android.support.v7.widget.AppCompatTextView;
42 import android.support.v7.widget.TintContextWrapper;
43 import android.util.AttributeSet;
44 import android.util.Log;
45 import android.view.InflateException;
46 import android.view.View;
47 
48 import java.lang.reflect.Constructor;
49 import java.lang.reflect.InvocationTargetException;
50 import java.lang.reflect.Method;
51 import java.util.Map;
52 
53 /**
54  * This class is responsible for manually inflating our tinted widgets which are used on devices
55  * running {@link android.os.Build.VERSION_CODES#KITKAT KITKAT} or below. As such, this class
56  * should only be used when running on those devices.
57  * <p>This class two main responsibilities: the first is to 'inject' our tinted views in place of
58  * the framework versions in layout inflation; the second is backport the {@code android:theme}
59  * functionality for any inflated widgets. This include theme inheritance from its parent.
60  */
61 class AppCompatViewInflater {
62 
63     private static final Class<?>[] sConstructorSignature = new Class[]{
64             Context.class, AttributeSet.class};
65     private static final int[] sOnClickAttrs = new int[]{android.R.attr.onClick};
66 
67     private static final String[] sClassPrefixList = {
68             "android.widget.",
69             "android.view.",
70             "android.webkit."
71     };
72 
73     private static final String LOG_TAG = "AppCompatViewInflater";
74 
75     private static final Map<String, Constructor<? extends View>> sConstructorMap
76             = new ArrayMap<>();
77 
78     private final Object[] mConstructorArgs = new Object[2];
79 
createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext)80     public final View createView(View parent, final String name, @NonNull Context context,
81             @NonNull AttributeSet attrs, boolean inheritContext,
82             boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
83         final Context originalContext = context;
84 
85         // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
86         // by using the parent's context
87         if (inheritContext && parent != null) {
88             context = parent.getContext();
89         }
90         if (readAndroidTheme || readAppTheme) {
91             // We then apply the theme on the context, if specified
92             context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
93         }
94         if (wrapContext) {
95             context = TintContextWrapper.wrap(context);
96         }
97 
98         View view = null;
99 
100         // We need to 'inject' our tint aware Views in place of the standard framework versions
101         switch (name) {
102             case "TextView":
103                 view = new AppCompatTextView(context, attrs);
104                 break;
105             case "ImageView":
106                 view = new AppCompatImageView(context, attrs);
107                 break;
108             case "Button":
109                 view = new AppCompatButton(context, attrs);
110                 break;
111             case "EditText":
112                 view = new AppCompatEditText(context, attrs);
113                 break;
114             case "Spinner":
115                 view = new AppCompatSpinner(context, attrs);
116                 break;
117             case "ImageButton":
118                 view = new AppCompatImageButton(context, attrs);
119                 break;
120             case "CheckBox":
121                 view = new AppCompatCheckBox(context, attrs);
122                 break;
123             case "RadioButton":
124                 view = new AppCompatRadioButton(context, attrs);
125                 break;
126             case "CheckedTextView":
127                 view = new AppCompatCheckedTextView(context, attrs);
128                 break;
129             case "AutoCompleteTextView":
130                 view = new AppCompatAutoCompleteTextView(context, attrs);
131                 break;
132             case "MultiAutoCompleteTextView":
133                 view = new AppCompatMultiAutoCompleteTextView(context, attrs);
134                 break;
135             case "RatingBar":
136                 view = new AppCompatRatingBar(context, attrs);
137                 break;
138             case "SeekBar":
139                 view = new AppCompatSeekBar(context, attrs);
140                 break;
141         }
142 
143         if (view == null && originalContext != context) {
144             // If the original context does not equal our themed context, then we need to manually
145             // inflate it using the name so that android:theme takes effect.
146             view = createViewFromTag(context, name, attrs);
147         }
148 
149         if (view != null) {
150             // If we have created a view, check its android:onClick
151             checkOnClickListener(view, attrs);
152         }
153 
154         return view;
155     }
156 
createViewFromTag(Context context, String name, AttributeSet attrs)157     private View createViewFromTag(Context context, String name, AttributeSet attrs) {
158         if (name.equals("view")) {
159             name = attrs.getAttributeValue(null, "class");
160         }
161 
162         try {
163             mConstructorArgs[0] = context;
164             mConstructorArgs[1] = attrs;
165 
166             if (-1 == name.indexOf('.')) {
167                 for (int i = 0; i < sClassPrefixList.length; i++) {
168                     final View view = createView(context, name, sClassPrefixList[i]);
169                     if (view != null) {
170                         return view;
171                     }
172                 }
173                 return null;
174             } else {
175                 return createView(context, name, null);
176             }
177         } catch (Exception e) {
178             // We do not want to catch these, lets return null and let the actual LayoutInflater
179             // try
180             return null;
181         } finally {
182             // Don't retain references on context.
183             mConstructorArgs[0] = null;
184             mConstructorArgs[1] = null;
185         }
186     }
187 
188     /**
189      * android:onClick doesn't handle views with a ContextWrapper context. This method
190      * backports new framework functionality to traverse the Context wrappers to find a
191      * suitable target.
192      */
checkOnClickListener(View view, AttributeSet attrs)193     private void checkOnClickListener(View view, AttributeSet attrs) {
194         final Context context = view.getContext();
195 
196         if (!(context instanceof ContextWrapper) ||
197                 (Build.VERSION.SDK_INT >= 15 && !ViewCompat.hasOnClickListeners(view))) {
198             // Skip our compat functionality if: the Context isn't a ContextWrapper, or
199             // the view doesn't have an OnClickListener (we can only rely on this on API 15+ so
200             // always use our compat code on older devices)
201             return;
202         }
203 
204         final TypedArray a = context.obtainStyledAttributes(attrs, sOnClickAttrs);
205         final String handlerName = a.getString(0);
206         if (handlerName != null) {
207             view.setOnClickListener(new DeclaredOnClickListener(view, handlerName));
208         }
209         a.recycle();
210     }
211 
createView(Context context, String name, String prefix)212     private View createView(Context context, String name, String prefix)
213             throws ClassNotFoundException, InflateException {
214         Constructor<? extends View> constructor = sConstructorMap.get(name);
215 
216         try {
217             if (constructor == null) {
218                 // Class not found in the cache, see if it's real, and try to add it
219                 Class<? extends View> clazz = context.getClassLoader().loadClass(
220                         prefix != null ? (prefix + name) : name).asSubclass(View.class);
221 
222                 constructor = clazz.getConstructor(sConstructorSignature);
223                 sConstructorMap.put(name, constructor);
224             }
225             constructor.setAccessible(true);
226             return constructor.newInstance(mConstructorArgs);
227         } catch (Exception e) {
228             // We do not want to catch these, lets return null and let the actual LayoutInflater
229             // try
230             return null;
231         }
232     }
233 
234     /**
235      * Allows us to emulate the {@code android:theme} attribute for devices before L.
236      */
themifyContext(Context context, AttributeSet attrs, boolean useAndroidTheme, boolean useAppTheme)237     private static Context themifyContext(Context context, AttributeSet attrs,
238             boolean useAndroidTheme, boolean useAppTheme) {
239         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.View, 0, 0);
240         int themeId = 0;
241         if (useAndroidTheme) {
242             // First try reading android:theme if enabled
243             themeId = a.getResourceId(R.styleable.View_android_theme, 0);
244         }
245         if (useAppTheme && themeId == 0) {
246             // ...if that didn't work, try reading app:theme (for legacy reasons) if enabled
247             themeId = a.getResourceId(R.styleable.View_theme, 0);
248 
249             if (themeId != 0) {
250                 Log.i(LOG_TAG, "app:theme is now deprecated. "
251                         + "Please move to using android:theme instead.");
252             }
253         }
254         a.recycle();
255 
256         if (themeId != 0 && (!(context instanceof ContextThemeWrapper)
257                 || ((ContextThemeWrapper) context).getThemeResId() != themeId)) {
258             // If the context isn't a ContextThemeWrapper, or it is but does not have
259             // the same theme as we need, wrap it in a new wrapper
260             context = new ContextThemeWrapper(context, themeId);
261         }
262         return context;
263     }
264 
265     /**
266      * An implementation of OnClickListener that attempts to lazily load a
267      * named click handling method from a parent or ancestor context.
268      */
269     private static class DeclaredOnClickListener implements View.OnClickListener {
270         private final View mHostView;
271         private final String mMethodName;
272 
273         private Method mResolvedMethod;
274         private Context mResolvedContext;
275 
DeclaredOnClickListener(@onNull View hostView, @NonNull String methodName)276         public DeclaredOnClickListener(@NonNull View hostView, @NonNull String methodName) {
277             mHostView = hostView;
278             mMethodName = methodName;
279         }
280 
281         @Override
onClick(@onNull View v)282         public void onClick(@NonNull View v) {
283             if (mResolvedMethod == null) {
284                 resolveMethod(mHostView.getContext(), mMethodName);
285             }
286 
287             try {
288                 mResolvedMethod.invoke(mResolvedContext, v);
289             } catch (IllegalAccessException e) {
290                 throw new IllegalStateException(
291                         "Could not execute non-public method for android:onClick", e);
292             } catch (InvocationTargetException e) {
293                 throw new IllegalStateException(
294                         "Could not execute method for android:onClick", e);
295             }
296         }
297 
298         @NonNull
resolveMethod(@ullable Context context, @NonNull String name)299         private void resolveMethod(@Nullable Context context, @NonNull String name) {
300             while (context != null) {
301                 try {
302                     if (!context.isRestricted()) {
303                         final Method method = context.getClass().getMethod(mMethodName, View.class);
304                         if (method != null) {
305                             mResolvedMethod = method;
306                             mResolvedContext = context;
307                             return;
308                         }
309                     }
310                 } catch (NoSuchMethodException e) {
311                     // Failed to find method, keep searching up the hierarchy.
312                 }
313 
314                 if (context instanceof ContextWrapper) {
315                     context = ((ContextWrapper) context).getBaseContext();
316                 } else {
317                     // Can't search up the hierarchy, null out and fail.
318                     context = null;
319                 }
320             }
321 
322             final int id = mHostView.getId();
323             final String idText = id == View.NO_ID ? "" : " with id '"
324                     + mHostView.getContext().getResources().getResourceEntryName(id) + "'";
325             throw new IllegalStateException("Could not find method " + mMethodName
326                     + "(View) in a parent or ancestor Context for android:onClick "
327                     + "attribute defined on view " + mHostView.getClass() + idText);
328         }
329     }
330 }
331