1 /* 2 * Copyright (C) 2019 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 package com.android.customization.model.theme; 17 18 import static com.android.customization.model.ResourceConstants.OVERLAY_CATEGORY_COLOR; 19 import static com.android.customization.model.ResourceConstants.OVERLAY_CATEGORY_FONT; 20 import static com.android.customization.model.ResourceConstants.OVERLAY_CATEGORY_ICON_ANDROID; 21 import static com.android.customization.model.ResourceConstants.OVERLAY_CATEGORY_SHAPE; 22 import static com.android.customization.model.ResourceConstants.PATH_SIZE; 23 24 import android.content.Context; 25 import android.content.pm.PackageManager; 26 import android.content.res.Configuration; 27 import android.content.res.Resources; 28 import android.graphics.Path; 29 import android.graphics.Typeface; 30 import android.graphics.drawable.AdaptiveIconDrawable; 31 import android.graphics.drawable.Drawable; 32 import android.graphics.drawable.ShapeDrawable; 33 import android.graphics.drawable.shapes.PathShape; 34 import android.text.TextUtils; 35 import android.util.Log; 36 import android.view.View; 37 import android.widget.ImageView; 38 import android.widget.TextView; 39 40 import androidx.annotation.ColorInt; 41 import androidx.annotation.Dimension; 42 import androidx.annotation.Nullable; 43 import androidx.core.graphics.PathParser; 44 45 import com.android.customization.model.CustomizationManager; 46 import com.android.customization.model.CustomizationOption; 47 import com.android.customization.model.theme.ThemeBundle.PreviewInfo.ShapeAppIcon; 48 import com.android.customization.widget.DynamicAdaptiveIconDrawable; 49 import com.android.wallpaper.R; 50 import com.android.wallpaper.asset.Asset; 51 import com.android.wallpaper.asset.BitmapCachingAsset; 52 import com.android.wallpaper.model.WallpaperInfo; 53 import com.android.wallpaper.util.ResourceUtils; 54 55 import org.json.JSONException; 56 import org.json.JSONObject; 57 58 import java.util.ArrayList; 59 import java.util.Collections; 60 import java.util.HashMap; 61 import java.util.HashSet; 62 import java.util.Iterator; 63 import java.util.List; 64 import java.util.Map; 65 import java.util.Set; 66 import java.util.stream.Collectors; 67 68 /** 69 * Represents a Theme component available in the system as a "persona" bundle. 70 * Note that in this context a Theme is not related to Android's Styles, but it's rather an 71 * abstraction representing a series of overlays to be applied to the system. 72 */ 73 public class ThemeBundle implements CustomizationOption<ThemeBundle> { 74 75 private static final String TAG = "ThemeBundle"; 76 private final static String EMPTY_JSON = "{}"; 77 private final static String TIMESTAMP_FIELD = "_applied_timestamp"; 78 79 private final String mTitle; 80 private final PreviewInfo mPreviewInfo; 81 private final boolean mIsDefault; 82 protected final Map<String, String> mPackagesByCategory; 83 private WallpaperInfo mOverrideWallpaper; 84 private Asset mOverrideWallpaperAsset; 85 private CharSequence mContentDescription; 86 ThemeBundle(String title, Map<String, String> overlayPackages, boolean isDefault, PreviewInfo previewInfo)87 protected ThemeBundle(String title, Map<String, String> overlayPackages, 88 boolean isDefault, PreviewInfo previewInfo) { 89 mTitle = title; 90 mIsDefault = isDefault; 91 mPreviewInfo = previewInfo; 92 mPackagesByCategory = Collections.unmodifiableMap(removeNullValues(overlayPackages)); 93 } 94 95 @Override getTitle()96 public String getTitle() { 97 return mTitle; 98 } 99 100 @Override bindThumbnailTile(View view)101 public void bindThumbnailTile(View view) { 102 Resources res = view.getContext().getResources(); 103 104 ((TextView) view.findViewById(R.id.theme_option_font)).setTypeface( 105 mPreviewInfo.headlineFontFamily); 106 if (mPreviewInfo.shapeDrawable != null) { 107 ((ShapeDrawable) mPreviewInfo.shapeDrawable).getPaint().setColor( 108 mPreviewInfo.resolveAccentColor(res)); 109 ((ImageView) view.findViewById(R.id.theme_option_shape)).setImageDrawable( 110 mPreviewInfo.shapeDrawable); 111 } 112 if (!mPreviewInfo.icons.isEmpty()) { 113 Drawable icon = mPreviewInfo.icons.get(0).getConstantState().newDrawable().mutate(); 114 icon.setTint(ResourceUtils.getColorAttr( 115 view.getContext(), android.R.attr.textColorSecondary)); 116 ((ImageView) view.findViewById(R.id.theme_option_icon)).setImageDrawable( 117 icon); 118 } 119 view.setContentDescription(getContentDescription(view.getContext())); 120 } 121 122 @Override isActive(CustomizationManager<ThemeBundle> manager)123 public boolean isActive(CustomizationManager<ThemeBundle> manager) { 124 ThemeManager themeManager = (ThemeManager) manager; 125 126 if (mIsDefault) { 127 String serializedOverlays = themeManager.getStoredOverlays(); 128 return TextUtils.isEmpty(serializedOverlays) || EMPTY_JSON.equals(serializedOverlays); 129 } else { 130 Map<String, String> currentOverlays = themeManager.getCurrentOverlays(); 131 return mPackagesByCategory.equals(currentOverlays); 132 } 133 } 134 135 @Override getLayoutResId()136 public int getLayoutResId() { 137 return R.layout.theme_option; 138 } 139 140 /** 141 * This is similar to #equals() but it only compares this theme's packages with the other, that 142 * is, it will return true if applying this theme has the same effect of applying the given one. 143 */ isEquivalent(ThemeBundle other)144 public boolean isEquivalent(ThemeBundle other) { 145 if (other == null) { 146 return false; 147 } 148 if (mIsDefault) { 149 return other.isDefault() || TextUtils.isEmpty(other.getSerializedPackages()) 150 || EMPTY_JSON.equals(other.getSerializedPackages()); 151 } 152 // Map#equals ensures keys and values are compared. 153 return mPackagesByCategory.equals(other.mPackagesByCategory); 154 } 155 getPreviewInfo()156 public PreviewInfo getPreviewInfo() { 157 return mPreviewInfo; 158 } 159 setOverrideThemeWallpaper(WallpaperInfo homeWallpaper)160 public void setOverrideThemeWallpaper(WallpaperInfo homeWallpaper) { 161 mOverrideWallpaper = homeWallpaper; 162 mOverrideWallpaperAsset = null; 163 } 164 getOverrideWallpaperAsset(Context context)165 private Asset getOverrideWallpaperAsset(Context context) { 166 if (mOverrideWallpaperAsset == null) { 167 mOverrideWallpaperAsset = new BitmapCachingAsset(context, 168 mOverrideWallpaper.getThumbAsset(context)); 169 } 170 return mOverrideWallpaperAsset; 171 } 172 isDefault()173 boolean isDefault() { 174 return mIsDefault; 175 } 176 getPackagesByCategory()177 public Map<String, String> getPackagesByCategory() { 178 return mPackagesByCategory; 179 } 180 getSerializedPackages()181 public String getSerializedPackages() { 182 return getJsonPackages(false).toString(); 183 } 184 getSerializedPackagesWithTimestamp()185 public String getSerializedPackagesWithTimestamp() { 186 return getJsonPackages(true).toString(); 187 } 188 getJsonPackages(boolean insertTimestamp)189 JSONObject getJsonPackages(boolean insertTimestamp) { 190 if (isDefault()) { 191 return new JSONObject(); 192 } 193 JSONObject json = new JSONObject(mPackagesByCategory); 194 // Remove items with null values to avoid deserialization issues. 195 removeNullValues(json); 196 if (insertTimestamp) { 197 try { 198 json.put(TIMESTAMP_FIELD, System.currentTimeMillis()); 199 } catch (JSONException e) { 200 Log.e(TAG, "Couldn't add timestamp to serialized themebundle"); 201 } 202 } 203 return json; 204 } 205 removeNullValues(JSONObject json)206 private void removeNullValues(JSONObject json) { 207 Iterator<String> keys = json.keys(); 208 Set<String> keysToRemove = new HashSet<>(); 209 while(keys.hasNext()) { 210 String key = keys.next(); 211 if (json.isNull(key)) { 212 keysToRemove.add(key); 213 } 214 } 215 for (String key : keysToRemove) { 216 json.remove(key); 217 } 218 } 219 removeNullValues(Map<String, String> map)220 private Map<String, String> removeNullValues(Map<String, String> map) { 221 return map.entrySet() 222 .stream() 223 .filter(entry -> entry.getValue() != null) 224 .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); 225 } 226 getContentDescription(Context context)227 protected CharSequence getContentDescription(Context context) { 228 if (mContentDescription == null) { 229 CharSequence defaultName = context.getString(R.string.default_theme_title); 230 if (isDefault()) { 231 mContentDescription = defaultName; 232 } else { 233 PackageManager pm = context.getPackageManager(); 234 CharSequence fontName = getOverlayName(pm, OVERLAY_CATEGORY_FONT); 235 CharSequence iconName = getOverlayName(pm, OVERLAY_CATEGORY_ICON_ANDROID); 236 CharSequence shapeName = getOverlayName(pm, OVERLAY_CATEGORY_SHAPE); 237 CharSequence colorName = getOverlayName(pm, OVERLAY_CATEGORY_COLOR); 238 mContentDescription = context.getString(R.string.theme_description, 239 TextUtils.isEmpty(fontName) ? defaultName : fontName, 240 TextUtils.isEmpty(iconName) ? defaultName : iconName, 241 TextUtils.isEmpty(shapeName) ? defaultName : shapeName, 242 TextUtils.isEmpty(colorName) ? defaultName : colorName); 243 } 244 } 245 return mContentDescription; 246 } 247 getOverlayName(PackageManager pm, String overlayCategoryFont)248 private CharSequence getOverlayName(PackageManager pm, String overlayCategoryFont) { 249 try { 250 return pm.getApplicationInfo( 251 mPackagesByCategory.get(overlayCategoryFont), 0).loadLabel(pm); 252 } catch (PackageManager.NameNotFoundException e) { 253 return ""; 254 } 255 } 256 257 public static class PreviewInfo { 258 public final Typeface bodyFontFamily; 259 public final Typeface headlineFontFamily; 260 @ColorInt public final int colorAccentLight; 261 @ColorInt public final int colorAccentDark; 262 public final List<Drawable> icons; 263 public final Drawable shapeDrawable; 264 public final List<ShapeAppIcon> shapeAppIcons; 265 @Dimension public final int bottomSheeetCornerRadius; 266 267 /** A class to represent an App icon and its name. */ 268 public static class ShapeAppIcon { 269 private Drawable mIconDrawable; 270 private CharSequence mAppName; 271 ShapeAppIcon(Drawable icon, CharSequence appName)272 public ShapeAppIcon(Drawable icon, CharSequence appName) { 273 mIconDrawable = icon; 274 mAppName = appName; 275 } 276 277 /** Returns a copy of app icon drawable. */ getDrawableCopy()278 public Drawable getDrawableCopy() { 279 return mIconDrawable.getConstantState().newDrawable().mutate(); 280 } 281 282 /** Returns the app name. */ getAppName()283 public CharSequence getAppName() { 284 return mAppName; 285 } 286 } 287 PreviewInfo(Context context, Typeface bodyFontFamily, Typeface headlineFontFamily, int colorAccentLight, int colorAccentDark, List<Drawable> icons, Drawable shapeDrawable, @Dimension int cornerRadius, List<ShapeAppIcon> shapeAppIcons)288 private PreviewInfo(Context context, Typeface bodyFontFamily, Typeface headlineFontFamily, 289 int colorAccentLight, int colorAccentDark, List<Drawable> icons, 290 Drawable shapeDrawable, @Dimension int cornerRadius, 291 List<ShapeAppIcon> shapeAppIcons) { 292 this.bodyFontFamily = bodyFontFamily; 293 this.headlineFontFamily = headlineFontFamily; 294 this.colorAccentLight = colorAccentLight; 295 this.colorAccentDark = colorAccentDark; 296 this.icons = icons; 297 this.shapeDrawable = shapeDrawable; 298 this.bottomSheeetCornerRadius = cornerRadius; 299 this.shapeAppIcons = shapeAppIcons; 300 } 301 302 /** 303 * Returns the accent color to be applied corresponding with the current configuration's 304 * UI mode. 305 * @return one of {@link #colorAccentDark} or {@link #colorAccentLight} 306 */ 307 @ColorInt resolveAccentColor(Resources res)308 public int resolveAccentColor(Resources res) { 309 return (res.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) 310 == Configuration.UI_MODE_NIGHT_YES ? colorAccentDark : colorAccentLight; 311 } 312 } 313 314 public static class Builder { 315 protected String mTitle; 316 private Typeface mBodyFontFamily; 317 private Typeface mHeadlineFontFamily; 318 @ColorInt private int mColorAccentLight = -1; 319 @ColorInt private int mColorAccentDark = -1; 320 private List<Drawable> mIcons = new ArrayList<>(); 321 private String mPathString; 322 private Path mShapePath; 323 private boolean mIsDefault; 324 @Dimension private int mCornerRadius; 325 protected Map<String, String> mPackages = new HashMap<>(); 326 private List<ShapeAppIcon> mAppIcons = new ArrayList<>(); 327 build(Context context)328 public ThemeBundle build(Context context) { 329 return new ThemeBundle(mTitle, mPackages, mIsDefault, createPreviewInfo(context)); 330 } 331 createPreviewInfo(Context context)332 public PreviewInfo createPreviewInfo(Context context) { 333 ShapeDrawable shapeDrawable = null; 334 List<ShapeAppIcon> shapeIcons = new ArrayList<>(); 335 Path path = mShapePath; 336 if (!TextUtils.isEmpty(mPathString)) { 337 path = PathParser.createPathFromPathData(mPathString); 338 } 339 if (path != null) { 340 PathShape shape = new PathShape(path, PATH_SIZE, PATH_SIZE); 341 shapeDrawable = new ShapeDrawable(shape); 342 shapeDrawable.setIntrinsicHeight((int) PATH_SIZE); 343 shapeDrawable.setIntrinsicWidth((int) PATH_SIZE); 344 for (ShapeAppIcon icon : mAppIcons) { 345 Drawable drawable = icon.mIconDrawable; 346 if (drawable instanceof AdaptiveIconDrawable) { 347 AdaptiveIconDrawable adaptiveIcon = (AdaptiveIconDrawable) drawable; 348 shapeIcons.add(new ShapeAppIcon( 349 new DynamicAdaptiveIconDrawable(adaptiveIcon.getBackground(), 350 adaptiveIcon.getForeground(), path), 351 icon.getAppName())); 352 } else if (drawable instanceof DynamicAdaptiveIconDrawable) { 353 shapeIcons.add(icon); 354 } 355 // TODO: add iconloader library's legacy treatment helper methods for 356 // non-adaptive icons 357 } 358 } 359 return new PreviewInfo(context, mBodyFontFamily, mHeadlineFontFamily, mColorAccentLight, 360 mColorAccentDark, mIcons, shapeDrawable, mCornerRadius, shapeIcons); 361 } 362 getPackages()363 public Map<String, String> getPackages() { 364 return Collections.unmodifiableMap(mPackages); 365 } 366 getTitle()367 public String getTitle() { 368 return mTitle; 369 } 370 setTitle(String title)371 public Builder setTitle(String title) { 372 mTitle = title; 373 return this; 374 } 375 setBodyFontFamily(@ullable Typeface bodyFontFamily)376 public Builder setBodyFontFamily(@Nullable Typeface bodyFontFamily) { 377 mBodyFontFamily = bodyFontFamily; 378 return this; 379 } 380 setHeadlineFontFamily(@ullable Typeface headlineFontFamily)381 public Builder setHeadlineFontFamily(@Nullable Typeface headlineFontFamily) { 382 mHeadlineFontFamily = headlineFontFamily; 383 return this; 384 } 385 setColorAccentLight(@olorInt int colorAccentLight)386 public Builder setColorAccentLight(@ColorInt int colorAccentLight) { 387 mColorAccentLight = colorAccentLight; 388 return this; 389 } 390 setColorAccentDark(@olorInt int colorAccentDark)391 public Builder setColorAccentDark(@ColorInt int colorAccentDark) { 392 mColorAccentDark = colorAccentDark; 393 return this; 394 } 395 addIcon(Drawable icon)396 public Builder addIcon(Drawable icon) { 397 mIcons.add(icon); 398 return this; 399 } 400 addOverlayPackage(String category, String packageName)401 public Builder addOverlayPackage(String category, String packageName) { 402 mPackages.put(category, packageName); 403 return this; 404 } 405 setShapePath(String path)406 public Builder setShapePath(String path) { 407 mPathString = path; 408 return this; 409 } 410 setShapePath(Path path)411 public Builder setShapePath(Path path) { 412 mShapePath = path; 413 return this; 414 } 415 asDefault()416 public Builder asDefault() { 417 mIsDefault = true; 418 return this; 419 } 420 setShapePreviewIcons(List<ShapeAppIcon> appIcons)421 public Builder setShapePreviewIcons(List<ShapeAppIcon> appIcons) { 422 mAppIcons.clear(); 423 mAppIcons.addAll(appIcons); 424 return this; 425 } 426 setBottomSheetCornerRadius(@imension int radius)427 public Builder setBottomSheetCornerRadius(@Dimension int radius) { 428 mCornerRadius = radius; 429 return this; 430 } 431 } 432 } 433