chromium/chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappDataStorage.java

// Copyright 2015 The Chromium Authors
// 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.content.pm.PackageInfo;
import android.graphics.Bitmap;
import android.text.format.DateUtils;

import androidx.annotation.Nullable;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.PackageUtils;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.TimeUtils;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.blink.mojom.DisplayMode;
import org.chromium.chrome.browser.ShortcutHelper;
import org.chromium.chrome.browser.browserservices.intents.BitmapHelper;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.browserservices.intents.WebappConstants;
import org.chromium.chrome.browser.browserservices.intents.WebappInfo;
import org.chromium.components.webapk.lib.common.WebApkConstants;
import org.chromium.components.webapps.ShortcutSource;
import org.chromium.device.mojom.ScreenOrientationLockType;
import org.chromium.ui.util.ColorUtils;

import java.io.File;

/**
 * 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 {
    private static final String TAG = "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_IS_ICON_GENERATED = "is_icon_generated";
    static final String KEY_IS_ICON_ADAPTIVE = "is_icon_adaptive";
    static final String KEY_LAUNCH_COUNT = "launch_count";
    static final String KEY_VERSION = "version";
    static final String KEY_WEBAPK_PACKAGE_NAME = "webapk_package_name";
    static final String KEY_WEBAPK_INSTALL_TIMESTAMP = "webapk_install_timestamp";
    static final String KEY_WEBAPK_UNINSTALL_TIMESTAMP = "webapk_uninstall_timestamp";
    static final String KEY_WEBAPK_MANIFEST_URL = "webapk_manifest_url";
    static final String KEY_WEBAPK_MANIFEST_ID = "webapk_manifest_id";
    static final String KEY_WEBAPK_VERSION_CODE = "webapk_version_code";

    // The completion time of the last check for whether the WebAPK's Web Manifest was updated.
    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_UPDATE_REQUEST_COMPLETE_TIME = "last_update_request_complete_time";

    // Whether the last WebAPK update request succeeded.
    static final String KEY_DID_LAST_UPDATE_REQUEST_SUCCEED = "did_last_update_request_succeed";

    // The update pipeline might hold off on updating while the WebAPK is in use. If the usage drags
    // on, it could result in a new update check being issued (restarting the update pipeline)
    // before the update takes place, which can result in the App Identity Update dialog being shown
    // again to the user (showing the same update they already approved). This setting helps prevent
    // that, by storing a hash of what the last accepted update contained.
    static final String KEY_LAST_UPDATE_HASH_ACCEPTED = "last_update_hash_accepted";

    // Whether to check updates less frequently.
    static final String KEY_RELAX_UPDATES = "relax_updates";

    // The shell Apk version requested in the last update.
    static final String KEY_LAST_REQUESTED_SHELL_APK_VERSION = "last_requested_shell_apk_version";

    // Whether to show the user the Snackbar disclosure UI.
    static final String KEY_SHOW_DISCLOSURE = "show_disclosure";

    // The path where serialized update data is written before uploading to the WebAPK server.
    static final String KEY_PENDING_UPDATE_FILE_PATH = "pending_update_file_path";

    // Whether to force an update.
    static final String KEY_SHOULD_FORCE_UPDATE = "should_force_update";

    // Whether an update has been scheduled.
    static final String KEY_UPDATE_SCHEDULED = "update_scheduled";

    // Status indicating a WebAPK is not updatable through chrome://webapks.
    public static final String NOT_UPDATABLE = "Not updatable";

    // Number of milliseconds between checks for whether the WebAPK's Web Manifest has changed.
    public static final long UPDATE_INTERVAL = DateUtils.DAY_IN_MILLIS;

    // Number of milliseconds between checks of updates for a WebAPK that is expected to check
    // updates less frequently. crbug.com/680128.
    public static final long RELAXED_UPDATE_INTERVAL = DateUtils.DAY_IN_MILLIS * 30;

    // The default shell Apk version of WebAPKs.
    static final int DEFAULT_SHELL_APK_VERSION = 1;

    // Invalid constants for timestamps and URLs. '0' is used as the invalid timestamp as
    // WebappRegistry and WebApkUpdateManager assume that timestamps are always valid.
    static final long TIMESTAMP_INVALID = 0;
    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 = DateUtils.DAY_IN_MILLIS * 10;

    private static Factory sFactory = new Factory();

    private final String mId;
    private final SharedPreferences mPreferences;

    /**
     * Called after data has been retrieved from storage.
     * @param <T> The type of the data being retrieved.
     */
    public interface FetchCallback<T> {
        public void onDataRetrieved(T readObject);
    }

    /**
     * Factory used to generate WebappDataStorage objects.
     * Overridden in tests to inject mocked objects.
     */
    public static class Factory {
        /** Generates a WebappDataStorage instance for a specified web app. */
        public WebappDataStorage create(final String webappId) {
            return new WebappDataStorage(webappId);
        }
    }

    /**
     * Opens an instance of WebappDataStorage for the web app specified.
     *
     * @param webappId The ID of the web app.
     */
    static WebappDataStorage open(String webappId) {
        return sFactory.create(webappId);
    }

    /** Sets the factory used to generate WebappDataStorage objects. */
    public static void setFactoryForTests(Factory factory) {
        var oldValue = sFactory;
        sFactory = factory;
        ResettersForTesting.register(() -> sFactory = oldValue);
    }

    /**
     * Asynchronously retrieves the splash screen image associated with the web app. The work is
     * performed on a background thread as it requires a potentially expensive image decode.
     * @param callback Called when the splash screen image has been retrieved.
     *                 The bitmap result will be null if no image was found.
     */
    public void getSplashScreenImage(final FetchCallback<Bitmap> callback) {
        new AsyncTask<Bitmap>() {
            @Override
            protected final Bitmap doInBackground() {
                return BitmapHelper.decodeBitmapFromString(
                        mPreferences.getString(KEY_SPLASH_ICON, null));
            }

            @Override
            protected final void onPostExecute(Bitmap result) {
                assert callback != null;
                callback.onDataRetrieved(result);
            }
        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    /**
     * Update the splash screen image associated with the web app with the specified data. The image
     * must have been encoded using {@link BitmapHelper#encodeBitmapAsString}.
     * @param splashScreenImage The image which should be shown on the splash screen of the web app.
     */
    public void updateSplashScreenImage(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.
     * @return The web app launch intent.
     */
    public Intent createWebappLaunchIntent() {
        // 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_URL, null),
                mPreferences.getString(KEY_SCOPE, null),
                mPreferences.getString(KEY_NAME, null),
                mPreferences.getString(KEY_SHORT_NAME, null),
                mPreferences.getString(KEY_ICON, null),
                version,
                mPreferences.getInt(KEY_DISPLAY_MODE, DisplayMode.STANDALONE),
                mPreferences.getInt(KEY_ORIENTATION, ScreenOrientationLockType.DEFAULT),
                mPreferences.getLong(KEY_THEME_COLOR, ColorUtils.INVALID_COLOR),
                mPreferences.getLong(KEY_BACKGROUND_COLOR, ColorUtils.INVALID_COLOR),
                mPreferences.getBoolean(KEY_IS_ICON_GENERATED, false),
                mPreferences.getBoolean(KEY_IS_ICON_ADAPTIVE, false));
    }

    /**
     * Updates the data stored in this object to match that in the supplied
     * {@link BrowserServicesIntentDataProvider}.
     * @param info The WebappInfo to pull web app data from.
     */
    public void updateFromWebappIntentDataProvider(
            BrowserServicesIntentDataProvider intentDataProvider) {
        if (intentDataProvider == null) return;
        WebappInfo info = WebappInfo.create(intentDataProvider);

        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 = info.url();
            editor.putString(KEY_URL, url);
            updated = true;
        }

        if (mPreferences.getString(KEY_SCOPE, URL_INVALID).equals(URL_INVALID)) {
            editor.putString(KEY_SCOPE, info.scopeUrl());
            updated = true;
        }

        // For all other fields, assume that if the version key is present and equal to
        // WebappConstants.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)
                != WebappConstants.WEBAPP_SHORTCUT_VERSION) {
            editor.putInt(KEY_VERSION, WebappConstants.WEBAPP_SHORTCUT_VERSION);

            if (info.isForWebApk()) {
                editor.putString(KEY_WEBAPK_PACKAGE_NAME, info.webApkPackageName());
                editor.putString(KEY_WEBAPK_MANIFEST_URL, info.manifestUrl());
                editor.putString(KEY_WEBAPK_MANIFEST_ID, info.manifestIdWithFallback());
                editor.putInt(KEY_WEBAPK_VERSION_CODE, info.webApkVersionCode());
                editor.putLong(
                        KEY_WEBAPK_INSTALL_TIMESTAMP,
                        fetchWebApkInstallTimestamp(info.webApkPackageName()));
            } else {
                editor.putString(KEY_NAME, info.name());
                editor.putString(KEY_SHORT_NAME, info.shortName());
                editor.putString(KEY_ICON, info.icon().encoded());
                editor.putInt(KEY_DISPLAY_MODE, info.displayMode());
                editor.putInt(KEY_ORIENTATION, info.orientation());
                editor.putLong(KEY_THEME_COLOR, info.toolbarColor());
                editor.putLong(KEY_BACKGROUND_COLOR, info.backgroundColor());
                editor.putBoolean(KEY_IS_ICON_GENERATED, info.isIconGenerated());
                editor.putBoolean(KEY_IS_ICON_ADAPTIVE, info.isIconAdaptive());
                editor.putInt(KEY_SOURCE, info.source());
            }
            updated = true;
        }
        if (updated) editor.apply();
    }

    /**
     * Returns true if this web app recently (within WEBAPP_LAST_OPEN_MAX_TIME milliseconds) was
     * either:
     * - registered with WebappRegistry
     * - launched from the homescreen
     */
    public boolean wasUsedRecently() {
        // WebappRegistry.register sets the last used time, so that counts as a 'launch'.
        return (TimeUtils.currentTimeMillis() - getLastUsedTimeMs() < WEBAPP_LAST_OPEN_MAX_TIME);
    }

    /**
     * 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.
     */
    public void delete() {
        deletePendingUpdateRequestFile();
        mPreferences.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.
     */
    void clearHistory() {
        deletePendingUpdateRequestFile();

        SharedPreferences.Editor editor = mPreferences.edit();

        editor.remove(KEY_LAST_USED);
        editor.remove(KEY_URL);
        editor.remove(KEY_SCOPE);
        editor.remove(KEY_LAST_CHECK_WEB_MANIFEST_UPDATE_TIME);
        editor.remove(KEY_LAST_UPDATE_REQUEST_COMPLETE_TIME);
        editor.remove(KEY_DID_LAST_UPDATE_REQUEST_SUCCEED);
        editor.remove(KEY_LAST_UPDATE_HASH_ACCEPTED);
        editor.remove(KEY_RELAX_UPDATES);
        editor.remove(KEY_SHOW_DISCLOSURE);
        editor.remove(KEY_LAUNCH_COUNT);
        editor.remove(KEY_WEBAPK_UNINSTALL_TIMESTAMP);
        editor.apply();

        // Don't clear fields which can be fetched from WebAPK manifest.
    }

    /** Returns the scope stored in this object, or URL_INVALID if it is not stored. */
    public String getScope() {
        return mPreferences.getString(KEY_SCOPE, URL_INVALID);
    }

    /** Returns the URL stored in this object, or URL_INVALID if it is not stored. */
    public String getUrl() {
        return mPreferences.getString(KEY_URL, URL_INVALID);
    }

    /** Returns the source stored in this object, or ShortcutSource.UNKNOWN if it is not stored. */
    public int getSource() {
        return mPreferences.getInt(KEY_SOURCE, ShortcutSource.UNKNOWN);
    }

    /** Updates the source. */
    public void updateSource(int source) {
        mPreferences.edit().putInt(KEY_SOURCE, source).apply();
    }

    /** Returns the id stored in this object. */
    public String getId() {
        return mId;
    }

    /** Returns the last used time, in milliseconds, of this object, or -1 if it is not stored. */
    public long getLastUsedTimeMs() {
        return mPreferences.getLong(KEY_LAST_USED, TIMESTAMP_INVALID);
    }

    /**
     * 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.
     */
    void updateSplashScreenImageForTests(String splashScreenImage) {
        mPreferences.edit().putString(KEY_SPLASH_ICON, splashScreenImage).apply();
    }

    /**
     * Update the package name of the WebAPK. Used for testing.
     * @param webApkPackageName The package name of the WebAPK.
     */
    void updateWebApkPackageNameForTests(String webApkPackageName) {
        mPreferences.edit().putString(KEY_WEBAPK_PACKAGE_NAME, webApkPackageName).apply();
    }

    /** Updates the last used time of this object. */
    void updateLastUsedTime() {
        mPreferences.edit().putLong(KEY_LAST_USED, TimeUtils.currentTimeMillis()).apply();
    }

    /** Returns the package name if the data is for a WebAPK, null otherwise. */
    public String getWebApkPackageName() {
        return mPreferences.getString(KEY_WEBAPK_PACKAGE_NAME, null);
    }

    /**
     * Updates the time of the completion 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, TimeUtils.currentTimeMillis())
                .apply();
    }

    /**
     * Returns the completion time, in milliseconds, of the last check for whether the WebAPK's Web
     * Manifest was updated. This time needs to be set when the WebAPK is registered.
     */
    public long getLastCheckForWebManifestUpdateTimeMs() {
        return mPreferences.getLong(KEY_LAST_CHECK_WEB_MANIFEST_UPDATE_TIME, TIMESTAMP_INVALID);
    }

    /** Updates when the last WebAPK update request finished (successfully or unsuccessfully). */
    void updateTimeOfLastWebApkUpdateRequestCompletion() {
        mPreferences
                .edit()
                .putLong(KEY_LAST_UPDATE_REQUEST_COMPLETE_TIME, TimeUtils.currentTimeMillis())
                .apply();
    }

    /**
     * Returns the time, in milliseconds, that the last WebAPK update request completed
     * (successfully or unsuccessfully). This time needs to be set when the WebAPK is registered.
     */
    public long getLastWebApkUpdateRequestCompletionTimeMs() {
        return mPreferences.getLong(KEY_LAST_UPDATE_REQUEST_COMPLETE_TIME, TIMESTAMP_INVALID);
    }

    /** Updates whether the last update request to WebAPK Server succeeded. */
    void updateDidLastWebApkUpdateRequestSucceed(boolean success) {
        mPreferences.edit().putBoolean(KEY_DID_LAST_UPDATE_REQUEST_SUCCEED, success).apply();
    }

    /** Returns whether the last update request to WebAPK Server succeeded. */
    boolean getDidLastWebApkUpdateRequestSucceed() {
        return mPreferences.getBoolean(KEY_DID_LAST_UPDATE_REQUEST_SUCCEED, false);
    }

    /** Updates the `hash` of the last accepted identity update that was approved. */
    public void updateLastWebApkUpdateHashAccepted(String hash) {
        mPreferences.edit().putString(KEY_LAST_UPDATE_HASH_ACCEPTED, hash).apply();
    }

    /** Returns the `hash` of the last accepted identity update that was approved. */
    String getLastWebApkUpdateHashAccepted() {
        return mPreferences.getString(KEY_LAST_UPDATE_HASH_ACCEPTED, "");
    }

    /**
     * Returns whether to show the user a privacy disclosure (used for TWAs and unbound WebAPKs).
     * This is not cleared until the user explicitly acknowledges it.
     */
    public boolean shouldShowDisclosure() {
        return mPreferences.getBoolean(KEY_SHOW_DISCLOSURE, false);
    }

    /**
     * Clears the show disclosure bit, this stops TWAs and unbound WebAPKs from showing a privacy
     * disclosure on every resume of the Webapp. This should be called when the user has
     * acknowledged the disclosure.
     */
    public void clearShowDisclosure() {
        mPreferences.edit().putBoolean(KEY_SHOW_DISCLOSURE, false).apply();
    }

    /**
     * Sets the disclosure bit which causes TWAs and unbound WebAPKs to show a privacy disclosure.
     * This is set the first time an app is opened without storage (either right after install or
     * after Chrome's storage is cleared).
     */
    public void setShowDisclosure() {
        mPreferences.edit().putBoolean(KEY_SHOW_DISCLOSURE, true).apply();
    }

    /** Updates the shell Apk version requested in the last update. */
    void updateLastRequestedShellApkVersion(int shellApkVersion) {
        mPreferences.edit().putInt(KEY_LAST_REQUESTED_SHELL_APK_VERSION, shellApkVersion).apply();
    }

    /** Returns the shell Apk version requested in last update. */
    int getLastRequestedShellApkVersion() {
        return mPreferences.getInt(KEY_LAST_REQUESTED_SHELL_APK_VERSION, DEFAULT_SHELL_APK_VERSION);
    }

    /**
     * Returns whether the previous WebAPK update attempt succeeded. Returns true if there has not
     * been any update attempts.
     */
    boolean didPreviousUpdateSucceed() {
        long lastUpdateCompletionTime = getLastWebApkUpdateRequestCompletionTimeMs();
        if (lastUpdateCompletionTime == TIMESTAMP_INVALID) {
            return true;
        }
        return getDidLastWebApkUpdateRequestSucceed();
    }

    /** Sets whether we should check for updates less frequently. */
    void setRelaxedUpdates(boolean relaxUpdates) {
        mPreferences.edit().putBoolean(KEY_RELAX_UPDATES, relaxUpdates).apply();
    }

    /** Returns whether we should check for updates less frequently. */
    public boolean shouldRelaxUpdates() {
        return mPreferences.getBoolean(KEY_RELAX_UPDATES, false);
    }

    /** Sets whether an update has been scheduled. */
    public void setUpdateScheduled(boolean isUpdateScheduled) {
        mPreferences.edit().putBoolean(KEY_UPDATE_SCHEDULED, isUpdateScheduled).apply();
    }

    /** Gets whether an update has been scheduled. */
    public boolean isUpdateScheduled() {
        return mPreferences.getBoolean(KEY_UPDATE_SCHEDULED, false);
    }

    /** Whether a WebAPK is unbound. */
    private boolean isUnboundWebApk() {
        String webApkPackageName = getWebApkPackageName();
        return (webApkPackageName != null
                && !webApkPackageName.startsWith(WebApkConstants.WEBAPK_PACKAGE_PREFIX));
    }

    /** Sets whether an update should be forced. */
    public void setShouldForceUpdate(boolean forceUpdate) {
        if (!isUnboundWebApk()) {
            mPreferences.edit().putBoolean(KEY_SHOULD_FORCE_UPDATE, forceUpdate).apply();
        }
    }

    /** Whether to force an update. */
    public boolean shouldForceUpdate() {
        return mPreferences.getBoolean(KEY_SHOULD_FORCE_UPDATE, false);
    }

    /** Returns the update status. */
    public String getUpdateStatus() {
        if (isUnboundWebApk()) return NOT_UPDATABLE;
        if (isUpdateScheduled()) return "Scheduled";
        if (shouldForceUpdate()) return "Pending";
        return didPreviousUpdateSucceed() ? "Succeeded" : "Failed";
    }

    /**
     * Returns file where WebAPK update data should be stored and stores the file name in
     * SharedPreferences.
     */
    String createAndSetUpdateRequestFilePath(WebappInfo info) {
        String filePath = WebappDirectoryManager.getWebApkUpdateFilePathForStorage(this).getPath();
        mPreferences.edit().putString(KEY_PENDING_UPDATE_FILE_PATH, filePath).apply();
        return filePath;
    }

    /** Returns the path of the file which contains data to update the WebAPK. */
    @Nullable
    String getPendingUpdateRequestPath() {
        return mPreferences.getString(KEY_PENDING_UPDATE_FILE_PATH, null);
    }

    /**
     * Deletes the file which contains data to update the WebAPK. The file is large (> 1Kb) and
     * should be deleted when the update completes.
     */
    void deletePendingUpdateRequestFile() {
        final String pendingUpdateFilePath = getPendingUpdateRequestPath();
        if (pendingUpdateFilePath == null) return;

        mPreferences.edit().remove(KEY_PENDING_UPDATE_FILE_PATH).apply();
        PostTask.postTask(
                TaskTraits.BEST_EFFORT_MAY_BLOCK,
                () -> {
                    if (!new File(pendingUpdateFilePath).delete()) {
                        Log.d(TAG, "Failed to delete file " + pendingUpdateFilePath);
                    }
                });
    }

    /**
     * Returns whether a check for whether the Web Manifest needs to be updated has occurred in the
     * last {@link numMillis} milliseconds.
     */
    boolean wasCheckForUpdatesDoneInLastMs(long numMillis) {
        return (TimeUtils.currentTimeMillis() - getLastCheckForWebManifestUpdateTimeMs())
                < numMillis;
    }

    /** Returns whether we should check for update. */
    boolean shouldCheckForUpdate() {
        if (shouldForceUpdate()) return true;
        long checkUpdatesInterval =
                shouldRelaxUpdates() ? RELAXED_UPDATE_INTERVAL : UPDATE_INTERVAL;
        long now = TimeUtils.currentTimeMillis();
        long sinceLastCheckDurationMs = now - getLastCheckForWebManifestUpdateTimeMs();
        return sinceLastCheckDurationMs >= checkUpdatesInterval;
    }

    protected WebappDataStorage(String webappId) {
        mId = webappId;
        mPreferences =
                ContextUtils.getApplicationContext()
                        .getSharedPreferences(
                                SHARED_PREFS_FILE_PREFIX + webappId, Context.MODE_PRIVATE);
    }

    /** Fetches the timestamp that the WebAPK was installed from the PackageManager. */
    private long fetchWebApkInstallTimestamp(String webApkPackageName) {
        PackageInfo packageInfo = PackageUtils.getPackageInfo(webApkPackageName, 0);
        return packageInfo == null ? 0 : packageInfo.firstInstallTime;
    }

    /** Returns the timestamp when the WebAPK was installed. */
    public long getWebApkInstallTimestamp() {
        return mPreferences.getLong(KEY_WEBAPK_INSTALL_TIMESTAMP, 0);
    }

    /** Sets the timestamp when the WebAPK was uninstalled to the current time. */
    public void setWebApkUninstallTimestamp() {
        mPreferences
                .edit()
                .putLong(KEY_WEBAPK_UNINSTALL_TIMESTAMP, TimeUtils.currentTimeMillis())
                .apply();
    }

    /** Returns the timestamp when the WebAPK was uninstalled. */
    public long getWebApkUninstallTimestamp() {
        return mPreferences.getLong(KEY_WEBAPK_UNINSTALL_TIMESTAMP, 0);
    }

    /** Increments the number of times that the webapp was launched. */
    public void incrementLaunchCount() {
        int launchCount = getLaunchCount();
        mPreferences.edit().putInt(KEY_LAUNCH_COUNT, launchCount + 1).apply();
    }

    /** Returns the number of times that the webapp was launched. */
    public int getLaunchCount() {
        return mPreferences.getInt(KEY_LAUNCH_COUNT, 0);
    }

    /** Returns cached Web Manifest URL. */
    public String getWebApkManifestUrl() {
        return mPreferences.getString(KEY_WEBAPK_MANIFEST_URL, null);
    }

    /** Returns cached Web Manifest ID. */
    public String getWebApkManifestId() {
        return mPreferences.getString(KEY_WEBAPK_MANIFEST_ID, null);
    }

    /** Returns cached WebAPK version code. */
    public int getWebApkVersionCode() {
        return mPreferences.getInt(KEY_WEBAPK_VERSION_CODE, 0);
    }
}