// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.webapps; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.os.AsyncTask; import org.chromium.base.ContextUtils; import org.chromium.base.ThreadUtils; import org.chromium.base.VisibleForTesting; import org.chromium.blink_public.platform.WebDisplayMode; import org.chromium.chrome.browser.ShortcutHelper; import org.chromium.chrome.browser.ShortcutSource; import org.chromium.chrome.browser.util.IntentUtils; import org.chromium.content_public.common.ScreenOrientationValues; import java.util.Map; import java.util.concurrent.TimeUnit; /** * Stores data about an installed web app. Uses SharedPreferences to persist the data to disk. * This class must only be accessed via {@link WebappRegistry}, which is used to register and keep * track of web app data known to Chrome. */ public class WebappDataStorage { static final String SHARED_PREFS_FILE_PREFIX = "webapp_"; static final String KEY_SPLASH_ICON = "splash_icon"; static final String KEY_LAST_USED = "last_used"; static final String KEY_URL = "url"; static final String KEY_SCOPE = "scope"; static final String KEY_ICON = "icon"; static final String KEY_NAME = "name"; static final String KEY_SHORT_NAME = "short_name"; static final String KEY_DISPLAY_MODE = "display_mode"; static final String KEY_ORIENTATION = "orientation"; static final String KEY_THEME_COLOR = "theme_color"; static final String KEY_BACKGROUND_COLOR = "background_color"; static final String KEY_SOURCE = "source"; static final String KEY_ACTION = "action"; static final String KEY_IS_ICON_GENERATED = "is_icon_generated"; static final String KEY_VERSION = "version"; static final String KEY_WEBAPK_PACKAGE_NAME = "webapk_package_name"; // The last time that Chrome checked for Web Manifest updates for a WebAPK. static final String KEY_LAST_CHECK_WEB_MANIFEST_UPDATE_TIME = "last_check_web_manifest_update_time"; // The last time that the WebAPK update request completed (successfully or // unsuccessfully). static final String KEY_LAST_WEBAPK_UPDATE_REQUEST_COMPLETE_TIME = "last_webapk_update_request_complete_time"; // Whether the last WebAPK update request succeeded. static final String KEY_DID_LAST_WEBAPK_UPDATE_REQUEST_SUCCEED = "did_last_webapk_update_request_succeed"; // Unset/invalid constants for last used times and URLs. 0 is used as the null last // used time as WebappRegistry assumes that this is always a valid timestamp. static final long LAST_USED_UNSET = 0; static final long LAST_USED_INVALID = -1; static final String URL_INVALID = ""; static final int VERSION_INVALID = 0; // We use a heuristic to determine whether a web app is still installed on the home screen, as // there is no way to do so directly. Any web app which has been opened in the last ten days // is considered to be still on the home screen. static final long WEBAPP_LAST_OPEN_MAX_TIME = TimeUnit.DAYS.toMillis(10L); private static Clock sClock = new Clock(); private static Factory sFactory = new Factory(); private final String mId; private final SharedPreferences mPreferences; /** * Opens an instance of WebappDataStorage for the web app specified. Must not be run on the UI * thread. * @param webappId The ID of the web app which is being opened. */ static WebappDataStorage open(final String webappId) { final WebappDataStorage storage = sFactory.create(webappId); if (storage.getLastUsedTime() == LAST_USED_INVALID) { // If the last used time is invalid then ensure that there is no data in the // WebappDataStorage which needs to be cleaned up. assert storage.getAllData().isEmpty(); } return storage; } /** * Asynchronously retrieves the time which this WebappDataStorage was last opened. Used in * testing. * @param webappId The ID of the web app the used time is being read for. * @param callback Called when the last used time has been retrieved. */ @VisibleForTesting public static void getLastUsedTime(final String webappId, final FetchCallback<Long> callback) { new AsyncTask<Void, Void, Long>() { @Override protected final Long doInBackground(Void... nothing) { long lastUsed = new WebappDataStorage(webappId).getLastUsedTime(); assert lastUsed != LAST_USED_INVALID; return lastUsed; } @Override protected final void onPostExecute(Long lastUsed) { assert callback != null; callback.onDataRetrieved(lastUsed); } }.execute(); } /** * Asynchronously retrieves the scope stored in this WebappDataStorage. The scope is the URL * over which the web app data is applied to. Used in testing. * @param webappId The ID of the web app the used time is being read for. * @param callback Called when the scope has been retrieved. */ @VisibleForTesting public static void getScope(final String webappId, final FetchCallback<String> callback) { new AsyncTask<Void, Void, String>() { @Override protected final String doInBackground(Void... nothing) { return new WebappDataStorage(webappId).getScope(); } @Override protected final void onPostExecute(String scope) { assert callback != null; callback.onDataRetrieved(scope); } }.execute(); } /** * Asynchronously retrieves the URL stored in this WebappDataStorage. Used in testing. * @param webappId The ID of the web app the used time is being read for. * @param callback Called when the URL has been retrieved. */ @VisibleForTesting public static void getUrl(final String webappId, final FetchCallback<String> callback) { new AsyncTask<Void, Void, String>() { @Override protected final String doInBackground(Void... nothing) { return new WebappDataStorage(webappId).getUrl(); } @Override protected final void onPostExecute(String url) { assert callback != null; callback.onDataRetrieved(url); } }.execute(); } /** * Deletes the data for a web app by clearing all the information inside the SharedPreferences * file. This does NOT delete the file itself but the file is left empty. * @param webappId The ID of the web app being deleted. */ static void deleteDataForWebapp(final String webappId) { assert !ThreadUtils.runningOnUiThread(); openSharedPreferences(webappId).edit().clear().apply(); } /** * Deletes the URL and scope, and sets all timestamps to 0 in SharedPreferences. * This does not remove the stored splash screen image (if any) for the app. * @param webappId The ID of the web app for which history is being cleared. */ static void clearHistory(final String webappId) { assert !ThreadUtils.runningOnUiThread(); SharedPreferences.Editor editor = openSharedPreferences(webappId).edit(); // The last used time is set to 0 to ensure that a valid value is always present. // If the web app is not launched prior to the next cleanup, then its remaining data will be // removed. Otherwise, the next launch from home screen will update the last used time. editor.putLong(KEY_LAST_USED, LAST_USED_UNSET); editor.remove(KEY_URL); editor.remove(KEY_SCOPE); editor.remove(KEY_LAST_CHECK_WEB_MANIFEST_UPDATE_TIME); editor.remove(KEY_LAST_WEBAPK_UPDATE_REQUEST_COMPLETE_TIME); editor.remove(KEY_DID_LAST_WEBAPK_UPDATE_REQUEST_SUCCEED); editor.apply(); } /** * Sets the clock used to get the current time. */ @VisibleForTesting public static void setClockForTests(Clock clock) { sClock = clock; } /** * Sets the factory used to generate WebappDataStorage objects. */ @VisibleForTesting public static void setFactoryForTests(Factory factory) { sFactory = factory; } private static SharedPreferences openSharedPreferences(String webappId) { return ContextUtils.getApplicationContext().getSharedPreferences( SHARED_PREFS_FILE_PREFIX + webappId, Context.MODE_PRIVATE); } protected WebappDataStorage(String webappId) { mId = webappId; mPreferences = openSharedPreferences(webappId); } /** * Asynchronously retrieves the splash screen image associated with the current web app. * @param callback Called when the splash screen image has been retrieved. * The bitmap result may be null if no image was found. */ public void getSplashScreenImage(final FetchCallback<Bitmap> callback) { new AsyncTask<Void, Void, Bitmap>() { @Override protected final Bitmap doInBackground(Void... nothing) { return ShortcutHelper.decodeBitmapFromString( mPreferences.getString(KEY_SPLASH_ICON, null)); } @Override protected final void onPostExecute(Bitmap result) { assert callback != null; callback.onDataRetrieved(result); } }.execute(); } /** * Update the information associated with the web app with the specified data. * @param splashScreenImage The image which should be shown on the splash screen of the web app. */ public void updateSplashScreenImage(final Bitmap splashScreenImage) { // Use an AsyncTask as this method is invoked on the UI thread from the callbacks leading to // ShortcutHelper.storeWebappSplashImage. new AsyncTask<Void, Void, Void>() { @Override protected final Void doInBackground(Void... nothing) { String bitmap = ShortcutHelper.encodeBitmapAsString(splashScreenImage); mPreferences.edit().putString(KEY_SPLASH_ICON, bitmap).apply(); return null; } }.execute(); } /** * Update the information associated with the web app with the specified data. Used for testing. * @param splashScreenImage The image encoded as a string which should be shown on the splash * screen of the web app. */ @VisibleForTesting void updateSplashScreenImageForTests(String splashScreenImage) { mPreferences.edit().putString(KEY_SPLASH_ICON, splashScreenImage).apply(); } /** * Creates and returns a web app launch intent from the data stored in this object. Must not be * called on the UI thread as a Bitmap is decoded from a String (a potentially expensive * operation). * @return The web app launch intent. */ public Intent createWebappLaunchIntent() { assert !ThreadUtils.runningOnUiThread(); // Assume that all of the data is invalid if the version isn't set, so return a null intent. int version = mPreferences.getInt(KEY_VERSION, VERSION_INVALID); if (version == VERSION_INVALID) return null; // Use "standalone" as the default display mode as this was the original assumed default for // all web apps. return ShortcutHelper.createWebappShortcutIntent(mId, mPreferences.getString(KEY_ACTION, null), mPreferences.getString(KEY_URL, null), mPreferences.getString(KEY_SCOPE, null), mPreferences.getString(KEY_NAME, null), mPreferences.getString(KEY_SHORT_NAME, null), ShortcutHelper.decodeBitmapFromString( mPreferences.getString(KEY_ICON, null)), version, mPreferences.getInt(KEY_DISPLAY_MODE, WebDisplayMode.Standalone), mPreferences.getInt(KEY_ORIENTATION, ScreenOrientationValues.DEFAULT), mPreferences.getLong(KEY_THEME_COLOR, ShortcutHelper.MANIFEST_COLOR_INVALID_OR_MISSING), mPreferences.getLong(KEY_BACKGROUND_COLOR, ShortcutHelper.MANIFEST_COLOR_INVALID_OR_MISSING), mPreferences.getBoolean(KEY_IS_ICON_GENERATED, false)); } /** * Updates the data stored in this object to match that in the supplied intent. * @param shortcutIntent The intent to pull web app data from. */ public void updateFromShortcutIntent(Intent shortcutIntent) { if (shortcutIntent == null) return; SharedPreferences.Editor editor = mPreferences.edit(); boolean updated = false; // The URL and scope may have been deleted by the user clearing their history. Check whether // they are present, and update if necessary. String url = mPreferences.getString(KEY_URL, URL_INVALID); if (url.equals(URL_INVALID)) { url = IntentUtils.safeGetStringExtra(shortcutIntent, ShortcutHelper.EXTRA_URL); editor.putString(KEY_URL, url); updated = true; } if (mPreferences.getString(KEY_SCOPE, URL_INVALID).equals(URL_INVALID)) { String scope = IntentUtils.safeGetStringExtra( shortcutIntent, ShortcutHelper.EXTRA_SCOPE); if (scope == null) { scope = ShortcutHelper.getScopeFromUrl(url); } editor.putString(KEY_SCOPE, scope); updated = true; } // For all other fields, assume that if the version key is present and equal to // ShortcutHelper.WEBAPP_SHORTCUT_VERSION, then all fields are present and do not need to be // updated. All fields except for the last used time, scope, and URL are either set or // cleared together. if (mPreferences.getInt(KEY_VERSION, VERSION_INVALID) != ShortcutHelper.WEBAPP_SHORTCUT_VERSION) { editor.putString(KEY_NAME, IntentUtils.safeGetStringExtra( shortcutIntent, ShortcutHelper.EXTRA_NAME)); editor.putString(KEY_SHORT_NAME, IntentUtils.safeGetStringExtra( shortcutIntent, ShortcutHelper.EXTRA_SHORT_NAME)); editor.putString(KEY_ICON, IntentUtils.safeGetStringExtra( shortcutIntent, ShortcutHelper.EXTRA_ICON)); editor.putInt(KEY_VERSION, ShortcutHelper.WEBAPP_SHORTCUT_VERSION); // "Standalone" was the original assumed default for all web apps. editor.putInt(KEY_DISPLAY_MODE, IntentUtils.safeGetIntExtra( shortcutIntent, ShortcutHelper.EXTRA_DISPLAY_MODE, WebDisplayMode.Standalone)); editor.putInt(KEY_ORIENTATION, IntentUtils.safeGetIntExtra( shortcutIntent, ShortcutHelper.EXTRA_ORIENTATION, ScreenOrientationValues.DEFAULT)); editor.putLong(KEY_THEME_COLOR, IntentUtils.safeGetLongExtra( shortcutIntent, ShortcutHelper.EXTRA_THEME_COLOR, ShortcutHelper.MANIFEST_COLOR_INVALID_OR_MISSING)); editor.putLong(KEY_BACKGROUND_COLOR, IntentUtils.safeGetLongExtra( shortcutIntent, ShortcutHelper.EXTRA_BACKGROUND_COLOR, ShortcutHelper.MANIFEST_COLOR_INVALID_OR_MISSING)); editor.putBoolean(KEY_IS_ICON_GENERATED, IntentUtils.safeGetBooleanExtra( shortcutIntent, ShortcutHelper.EXTRA_IS_ICON_GENERATED, false)); editor.putString(KEY_ACTION, shortcutIntent.getAction()); editor.putInt(KEY_SOURCE, IntentUtils.safeGetIntExtra( shortcutIntent, ShortcutHelper.EXTRA_SOURCE, ShortcutSource.UNKNOWN)); editor.putString(KEY_WEBAPK_PACKAGE_NAME, IntentUtils.safeGetStringExtra( shortcutIntent, ShortcutHelper.EXTRA_WEBAPK_PACKAGE_NAME)); updated = true; } if (updated) editor.apply(); } /** * Returns the scope stored in this object, or URL_INVALID if it is not stored. */ String getScope() { return mPreferences.getString(KEY_SCOPE, URL_INVALID); } /** * Returns the URL stored in this object, or URL_INVALID if it is not stored. */ String getUrl() { return mPreferences.getString(KEY_URL, URL_INVALID); } /** * Updates the last used time of this object. */ void updateLastUsedTime() { mPreferences.edit().putLong(KEY_LAST_USED, sClock.currentTimeMillis()).apply(); } /** * Returns the last used time of this object, or -1 if it is not stored. */ long getLastUsedTime() { return mPreferences.getLong(KEY_LAST_USED, LAST_USED_INVALID); } /** * Returns the package name if the data is for a WebAPK, null otherwise. */ String getWebApkPackageName() { return mPreferences.getString(KEY_WEBAPK_PACKAGE_NAME, null); } /** * Updates the time of the last check for whether the WebAPK's Web Manifest was updated. */ void updateTimeOfLastCheckForUpdatedWebManifest() { mPreferences.edit() .putLong(KEY_LAST_CHECK_WEB_MANIFEST_UPDATE_TIME, sClock.currentTimeMillis()) .apply(); } /** * Returns the time of the last check for whether the WebAPK's Web Manifest was updated. * This time needs to be set when the WebAPK was registered. */ long getLastCheckForWebManifestUpdateTime() { return mPreferences.getLong(KEY_LAST_CHECK_WEB_MANIFEST_UPDATE_TIME, LAST_USED_INVALID); } /** * Updates the time that the last WebAPK update request completed (successfully or * unsuccessfully). */ void updateTimeOfLastWebApkUpdateRequestCompletion() { mPreferences.edit() .putLong(KEY_LAST_WEBAPK_UPDATE_REQUEST_COMPLETE_TIME, sClock.currentTimeMillis()) .apply(); } /** * Returns the time that the last WebAPK update request completed (successfully or * unsuccessfully). This time needs to be set when the WebAPK was registered. */ long getLastWebApkUpdateRequestCompletionTime() { return mPreferences.getLong( KEY_LAST_WEBAPK_UPDATE_REQUEST_COMPLETE_TIME, LAST_USED_INVALID); } /** * Updates the result of whether the last update request to WebAPK Server succeeded. */ void updateDidLastWebApkUpdateRequestSucceed(boolean sucess) { mPreferences.edit() .putBoolean(KEY_DID_LAST_WEBAPK_UPDATE_REQUEST_SUCCEED, sucess) .apply(); } /** * Returns whether the last update request to WebAPK Server succeeded. */ boolean getDidLastWebApkUpdateRequestSucceed() { return mPreferences.getBoolean(KEY_DID_LAST_WEBAPK_UPDATE_REQUEST_SUCCEED, false); } /** * Returns true if this web app has been launched from home screen recently (within * WEBAPP_LAST_OPEN_MAX_TIME milliseconds). */ public boolean wasLaunchedRecently() { // Registering the web app sets the last used time, so that counts as a 'launch'. return (sClock.currentTimeMillis() - getLastUsedTime() < WEBAPP_LAST_OPEN_MAX_TIME); } private Map<String, ?> getAllData() { return mPreferences.getAll(); } /** * Called after data has been retrieved from storage. */ public interface FetchCallback<T> { public void onDataRetrieved(T readObject); } /** * Factory used to generate WebappDataStorage objects. * * It is used in tests to override methods in WebappDataStorage and inject the mocked objects. */ public static class Factory { /** * Generates a WebappDataStorage class for a specified web app. */ public WebappDataStorage create(final String webappId) { return new WebappDataStorage(webappId); } } /** * Clock used to generate the current time in millseconds for updating and setting last used * time. */ public static class Clock { /** * Returns the current time in milliseconds. */ public long currentTimeMillis() { return System.currentTimeMillis(); } } }