1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.safetycenter.resources;
18 
19 import static java.util.Objects.requireNonNull;
20 
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.pm.PackageManager;
24 import android.content.pm.ResolveInfo;
25 import android.content.res.Resources;
26 import android.graphics.drawable.Drawable;
27 import android.graphics.drawable.Icon;
28 import android.util.Log;
29 
30 import androidx.annotation.ColorInt;
31 import androidx.annotation.Nullable;
32 import androidx.annotation.StringRes;
33 import androidx.annotation.VisibleForTesting;
34 
35 import java.io.File;
36 import java.io.InputStream;
37 import java.util.List;
38 
39 /**
40  * A class to access Safety Center resources that need to be fetched from a dedicated APK.
41  *
42  * <p>You must check whether Safety Center is enabled or the value returned by {@link #init()} prior
43  * to interacting with this class. Failure to do so may cause an {@link IllegalStateException} if
44  * the resources APK cannot be accessed.
45  *
46  * <p>This class isn't thread safe. Thread safety must be handled by the caller, or this may cause
47  * the resources APK {@link Context} to be initialized multiple times.
48  */
49 public final class SafetyCenterResourcesApk {
50 
51     private static final String TAG = "SafetyCenterResApk";
52 
53     /** Intent action that is used to identify the Safety Center resources APK */
54     private static final String RESOURCES_APK_ACTION =
55             "com.android.safetycenter.intent.action.SAFETY_CENTER_RESOURCES_APK";
56 
57     /** Permission APEX name */
58     private static final String APEX_MODULE_NAME = "com.android.permission";
59 
60     /**
61      * The path where the Permission apex is mounted. Current value = "/apex/com.android.permission"
62      */
63     private static final String APEX_MODULE_PATH =
64             new File("/apex", APEX_MODULE_NAME).getAbsolutePath();
65 
66     /** Raw XML config resource name */
67     private static final String CONFIG_NAME = "safety_center_config";
68 
69     private final Context mContext;
70 
71     /** Intent action that is used to identify the Safety Center resources APK */
72     private final String mResourcesApkAction;
73 
74     /** The path where the Safety Center resources APK is expected to be installed */
75     private final String mResourcesApkPath;
76 
77     /** Specific flags used for retrieving resolve info. */
78     private final int mFlags;
79 
80     /**
81      * Whether we should fallback with an empty string / null values when calling the methods of
82      * this class for a resource that does not exist.
83      */
84     private final boolean mShouldFallbackIfNamedResourceNotFound;
85 
86     // Cached context from the resources APK.
87     @Nullable private Context mResourcesApkContext;
88 
SafetyCenterResourcesApk(Context context)89     public SafetyCenterResourcesApk(Context context) {
90         this(context, /* shouldFallbackIfNamedResourceNotFound */ true);
91     }
92 
SafetyCenterResourcesApk( Context context, boolean shouldFallbackIfNamedResourceNotFound)93     private SafetyCenterResourcesApk(
94             Context context, boolean shouldFallbackIfNamedResourceNotFound) {
95         this(
96                 context,
97                 RESOURCES_APK_ACTION,
98                 APEX_MODULE_PATH,
99                 PackageManager.MATCH_SYSTEM_ONLY,
100                 shouldFallbackIfNamedResourceNotFound);
101     }
102 
103     @VisibleForTesting
SafetyCenterResourcesApk( Context context, String resourcesApkAction, String resourcesApkPath, int flags, boolean shouldFallbackIfNamedResourceNotFound)104     SafetyCenterResourcesApk(
105             Context context,
106             String resourcesApkAction,
107             String resourcesApkPath,
108             int flags,
109             boolean shouldFallbackIfNamedResourceNotFound) {
110         mContext = requireNonNull(context);
111         mResourcesApkAction = requireNonNull(resourcesApkAction);
112         mResourcesApkPath = requireNonNull(resourcesApkPath);
113         mFlags = flags;
114         mShouldFallbackIfNamedResourceNotFound = shouldFallbackIfNamedResourceNotFound;
115     }
116 
117     /** Creates a new {@link SafetyCenterResourcesApk} for testing. */
118     @VisibleForTesting
forTests(Context context)119     public static SafetyCenterResourcesApk forTests(Context context) {
120         return new SafetyCenterResourcesApk(
121                 context, /* shouldFallbackIfNamedResourceNotFound */ false);
122     }
123 
124     /**
125      * Initializes the resources APK {@link Context}, and returns whether this was successful.
126      *
127      * <p>This call is optional as this can also be lazily instantiated. It can be used to ensure
128      * that the resources APK context is loaded prior to interacting with this class. This
129      * initialization code needs to run in the same user as the provided base {@link Context}. This
130      * may not be the case with a binder call, which is why it can be more appropriate to do this
131      * explicitly.
132      */
init()133     public boolean init() {
134         mResourcesApkContext = loadResourcesApkContext();
135         return mResourcesApkContext != null;
136     }
137 
138     /**
139      * Returns the {@link Context} of the Safety Center resources APK.
140      *
141      * <p>Throws an {@link IllegalStateException} if the resources APK is not available
142      */
getContext()143     public Context getContext() {
144         if (mResourcesApkContext != null) {
145             return mResourcesApkContext;
146         }
147 
148         mResourcesApkContext = loadResourcesApkContext();
149         if (mResourcesApkContext == null) {
150             throw new IllegalStateException("Resources APK context not found");
151         }
152 
153         return mResourcesApkContext;
154     }
155 
156     @Nullable
loadResourcesApkContext()157     private Context loadResourcesApkContext() {
158         List<ResolveInfo> resolveInfos =
159                 mContext.getPackageManager()
160                         .queryIntentActivities(new Intent(mResourcesApkAction), mFlags);
161 
162         if (resolveInfos.size() > 1) {
163             // multiple apps found, log a warning, but continue
164             Log.w(TAG, "Found > 1 APK that can resolve Safety Center resources APK intent:");
165             final int resolveInfosSize = resolveInfos.size();
166             for (int i = 0; i < resolveInfosSize; i++) {
167                 ResolveInfo resolveInfo = resolveInfos.get(i);
168                 Log.w(
169                         TAG,
170                         String.format(
171                                 "- pkg:%s at:%s",
172                                 resolveInfo.activityInfo.applicationInfo.packageName,
173                                 resolveInfo.activityInfo.applicationInfo.sourceDir));
174             }
175         }
176 
177         ResolveInfo info = null;
178         // Assume the first good ResolveInfo is the one we're looking for
179         final int resolveInfosSize = resolveInfos.size();
180         for (int i = 0; i < resolveInfosSize; i++) {
181             ResolveInfo resolveInfo = resolveInfos.get(i);
182             if (!resolveInfo.activityInfo.applicationInfo.sourceDir.startsWith(mResourcesApkPath)) {
183                 // skip apps that don't live in the Permission apex
184                 continue;
185             }
186             info = resolveInfo;
187             break;
188         }
189 
190         if (info == null) {
191             // Resource APK not loaded yet, print a stack trace to see where this is called from
192             Log.e(TAG, "Could not find Safety Center resources APK", new IllegalStateException());
193             return null;
194         }
195 
196         String resourcesApkPkgName = info.activityInfo.applicationInfo.packageName;
197         Log.i(TAG, "Found Safety Center resources APK at: " + resourcesApkPkgName);
198         return getPackageContext(resourcesApkPkgName);
199     }
200 
201     @Nullable
getPackageContext(String packageName)202     private Context getPackageContext(String packageName) {
203         try {
204             return mContext.createPackageContext(packageName, 0);
205         } catch (PackageManager.NameNotFoundException e) {
206             Log.e(TAG, "Failed to load package context for: " + packageName, e);
207         }
208         return null;
209     }
210 
211     /** Calls {@link Context#getResources()} for the resources APK {@link Context}. */
getResources()212     public Resources getResources() {
213         return getContext().getResources();
214     }
215 
216     /**
217      * Returns the raw XML resource representing the Safety Center configuration file from the
218      * Safety Center resources APK.
219      */
220     @Nullable
getSafetyCenterConfig()221     public InputStream getSafetyCenterConfig() {
222         return getSafetyCenterConfig(CONFIG_NAME);
223     }
224 
225     @VisibleForTesting
226     @Nullable
getSafetyCenterConfig(String configName)227     InputStream getSafetyCenterConfig(String configName) {
228         int resId = getResIdAndMaybeThrowIfNull(configName, "raw");
229         if (resId == Resources.ID_NULL) {
230             return null;
231         }
232         return getResources().openRawResource(resId);
233     }
234 
235     /** Calls {@link Context#getString(int)} for the resources APK {@link Context}. */
getString(@tringRes int stringId)236     public String getString(@StringRes int stringId) {
237         return getContext().getString(stringId);
238     }
239 
240     /** Same as {@link #getString(int)} but with the given {@code formatArgs}. */
getString(@tringRes int stringId, Object... formatArgs)241     public String getString(@StringRes int stringId, Object... formatArgs) {
242         return getContext().getString(stringId, formatArgs);
243     }
244 
245     /**
246      * Returns the {@link String} with the given resource name.
247      *
248      * <p>If the {@link String} cannot be accessed, returns {@code ""} or throws {@link
249      * Resources.NotFoundException} depending on {@link #mShouldFallbackIfNamedResourceNotFound}.
250      */
getStringByName(String name)251     public String getStringByName(String name) {
252         int resId = getResIdAndMaybeThrowIfNull(name, "string");
253         if (resId == Resources.ID_NULL) {
254             return "";
255         }
256         return getString(resId);
257     }
258 
259     /** Same as {@link #getStringByName(String)} but with the given {@code formatArgs}. */
getStringByName(String name, Object... formatArgs)260     public String getStringByName(String name, Object... formatArgs) {
261         int resId = getResIdAndMaybeThrowIfNull(name, "string");
262         if (resId == Resources.ID_NULL) {
263             return "";
264         }
265         return getString(resId, formatArgs);
266     }
267 
268     /**
269      * Returns an optional {@link String} resource with the given {@code stringId}.
270      *
271      * <p>Returns {@code null} if {@code stringId} is equal to {@link Resources#ID_NULL}. Otherwise,
272      * throws a {@link Resources.NotFoundException}.
273      */
274     @Nullable
getOptionalString(@tringRes int stringId)275     public String getOptionalString(@StringRes int stringId) {
276         if (stringId == Resources.ID_NULL) {
277             return null;
278         }
279         return getString(stringId);
280     }
281 
282     /** Same as {@link #getOptionalString(int)} but with the given resource name rather than ID. */
283     @Nullable
getOptionalStringByName(String name)284     public String getOptionalStringByName(String name) {
285         return getOptionalString(getResId(name, "string"));
286     }
287 
288     /**
289      * Returns the {@link Drawable} with the given resource name.
290      *
291      * <p>If the {@link Drawable} cannot be accessed, returns {@code null} or throws {@link
292      * Resources.NotFoundException} depending on {@link #mShouldFallbackIfNamedResourceNotFound}.
293      *
294      * @param theme the theme used to style the drawable attributes, may be {@code null}
295      */
296     @Nullable
getDrawableByName(String name, @Nullable Resources.Theme theme)297     public Drawable getDrawableByName(String name, @Nullable Resources.Theme theme) {
298         int resId = getResIdAndMaybeThrowIfNull(name, "drawable");
299         if (resId == Resources.ID_NULL) {
300             return null;
301         }
302         return getResources().getDrawable(resId, theme);
303     }
304 
305     /**
306      * Returns an {@link Icon} containing the {@link Drawable} with the given resource name.
307      *
308      * <p>If the {@link Drawable} cannot be accessed, returns {@code null} or throws {@link
309      * Resources.NotFoundException} depending on {@link #mShouldFallbackIfNamedResourceNotFound}.
310      */
311     @Nullable
getIconByDrawableName(String name)312     public Icon getIconByDrawableName(String name) {
313         int resId = getResIdAndMaybeThrowIfNull(name, "drawable");
314         if (resId == Resources.ID_NULL) {
315             return null;
316         }
317         return Icon.createWithResource(getContext().getPackageName(), resId);
318     }
319 
320     /**
321      * Returns the {@link ColorInt} with the given resource name.
322      *
323      * <p>If the {@link ColorInt} cannot be accessed, returns {@code null} or throws {@link
324      * Resources.NotFoundException} depending on {@link #mShouldFallbackIfNamedResourceNotFound}.
325      */
326     @ColorInt
327     @Nullable
getColorByName(String name)328     public Integer getColorByName(String name) {
329         int resId = getResIdAndMaybeThrowIfNull(name, "color");
330         if (resId == Resources.ID_NULL) {
331             return null;
332         }
333         return getResources().getColor(resId, getContext().getTheme());
334     }
335 
getResIdAndMaybeThrowIfNull(String name, String type)336     private int getResIdAndMaybeThrowIfNull(String name, String type) {
337         int resId = getResId(name, type);
338         if (resId != Resources.ID_NULL) {
339             return resId;
340         }
341         if (!mShouldFallbackIfNamedResourceNotFound) {
342             throw new Resources.NotFoundException();
343         }
344         Log.w(TAG, "Named " + type + " resource: " + name + " not found");
345         return resId;
346     }
347 
getResId(String name, String type)348     private int getResId(String name, String type) {
349         // TODO(b/227738283): profile the performance of this operation and consider adding caching
350         //  or finding some alternative solution.
351         return getResources().getIdentifier(name, type, getContext().getPackageName());
352     }
353 }
354