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