// 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.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.AsyncTask;
import org.chromium.base.ContextUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.chrome.browser.browsing_data.UrlFilter;
import org.chromium.chrome.browser.browsing_data.UrlFilterBridge;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Keeps track of web apps which have created a SharedPreference file (through the used of the
* WebappDataStorage class) which may need to be cleaned up in the future.
*
* It is NOT intended to be 100% accurate nor a comprehensive list of all installed web apps
* because it is impossible to track when the user removes a web app from the Home screen and it
* is similarily impossible to track pre-registry era web apps (this case is not a problem anyway
* as these web apps have no external data to cleanup).
*/
public class WebappRegistry {
static final String REGISTRY_FILE_NAME = "webapp_registry";
static final String KEY_WEBAPP_SET = "webapp_set";
static final String KEY_LAST_CLEANUP = "last_cleanup";
/** Represents a period of 4 weeks in milliseconds */
static final long FULL_CLEANUP_DURATION = TimeUnit.DAYS.toMillis(4L * 7L);
/** Represents a period of 13 weeks in milliseconds */
static final long WEBAPP_UNOPENED_CLEANUP_DURATION = TimeUnit.DAYS.toMillis(13L * 7L);
/**
* Called when a retrieval of the set of stored web app IDs occurs.
*/
public interface FetchCallback {
void onWebappIdsRetrieved(Set<String> readObject);
}
/**
* Called when a retrieval of the stored WebappDataStorage occurs. The storage parameter will
* be null if the web app queried for was not in the registry.
*/
public interface FetchWebappDataStorageCallback {
void onWebappDataStorageRetrieved(WebappDataStorage storage);
}
/**
* Registers the existence of a web app, creates a SharedPreference entry for it, and runs the
* supplied callback (if not null) on the UI thread with the resulting WebappDataStorage object.
* @param webappId The id of the web app to register.
* @param callback The callback to run with the WebappDataStorage argument.
* @return The storage object for the web app.
*/
public static void registerWebapp(final String webappId,
final FetchWebappDataStorageCallback callback) {
new AsyncTask<Void, Void, WebappDataStorage>() {
@Override
protected final WebappDataStorage doInBackground(Void... nothing) {
SharedPreferences preferences = openSharedPreferences();
// The set returned by getRegisteredWebappIds must be treated as immutable, so we
// make a copy to edit and save.
Set<String> webapps = new HashSet<>(getRegisteredWebappIds(preferences));
boolean added = webapps.add(webappId);
assert added;
preferences.edit().putStringSet(KEY_WEBAPP_SET, webapps).apply();
// Create the WebappDataStorage and update the last used time, so we can guarantee
// that a web app which appears in the registry will have a
// last used time != WebappDataStorage.LAST_USED_INVALID.
WebappDataStorage storage = new WebappDataStorage(webappId);
storage.updateLastUsedTime();
return storage;
}
@Override
protected final void onPostExecute(WebappDataStorage storage) {
if (callback != null) callback.onWebappDataStorageRetrieved(storage);
}
}.execute();
}
/**
* Runs the callback, supplying the WebappDataStorage object for webappId, or null if the web
* app has not been registered.
* @param webappId The id of the web app to register.
* @return The storage object for the web app, or null if webappId is not registered.
*/
public static void getWebappDataStorage(final String webappId,
final FetchWebappDataStorageCallback callback) {
new AsyncTask<Void, Void, WebappDataStorage>() {
@Override
protected final WebappDataStorage doInBackground(Void... nothing) {
SharedPreferences preferences = openSharedPreferences();
if (getRegisteredWebappIds(preferences).contains(webappId)) {
WebappDataStorage storage = WebappDataStorage.open(webappId);
return storage;
}
return null;
}
@Override
protected final void onPostExecute(WebappDataStorage storage) {
assert callback != null;
callback.onWebappDataStorageRetrieved(storage);
}
}.execute();
}
/**
* Runs the callback, supplying the WebappDataStorage object whose scope most closely matches
* the provided URL, or null if a matching web app cannot be found. The most closely matching
* scope is the longest scope which has the same prefix as the URL to open.
* @param url The URL to search for.
* @return The storage object for the web app, or null if webappId is not registered.
*/
public static void getWebappDataStorageForUrl(final String url,
final FetchWebappDataStorageCallback callback) {
new AsyncTask<Void, Void, WebappDataStorage>() {
@Override
protected final WebappDataStorage doInBackground(Void... nothing) {
SharedPreferences preferences = openSharedPreferences();
WebappDataStorage bestMatch = null;
int largestOverlap = 0;
for (String id : getRegisteredWebappIds(preferences)) {
WebappDataStorage storage = WebappDataStorage.open(id);
String scope = storage.getScope();
if (url.startsWith(scope) && scope.length() > largestOverlap) {
bestMatch = storage;
largestOverlap = scope.length();
}
}
return bestMatch;
}
protected final void onPostExecute(WebappDataStorage storage) {
assert callback != null;
callback.onWebappDataStorageRetrieved(storage);
}
}.execute();
}
/**
* Asynchronously retrieves the list of web app IDs which this registry is aware of.
* @param callback Called when the set has been retrieved. The set may be empty.
*/
@VisibleForTesting
public static void getRegisteredWebappIds(final FetchCallback callback) {
new AsyncTask<Void, Void, Set<String>>() {
@Override
protected final Set<String> doInBackground(Void... nothing) {
return getRegisteredWebappIds(openSharedPreferences());
}
@Override
protected final void onPostExecute(Set<String> result) {
assert callback != null;
callback.onWebappIdsRetrieved(result);
}
}.execute();
}
/**
* 1. Deletes the data for all "old" web apps.
* "Old" web apps have not been opened by the user in the last 3 months, or have had their last
* used time set to 0 by the user clearing their history. Cleanup is run, at most, once a month.
* 2. Deletes the data for all WebAPKs that have been uninstalled in the last month.
*
* @param currentTime The current time which will be checked to decide if the task should be run
* and if a web app should be cleaned up.
*/
static void unregisterOldWebapps(final long currentTime) {
new AsyncTask<Void, Void, Void>() {
@Override
protected final Void doInBackground(Void... nothing) {
SharedPreferences preferences = openSharedPreferences();
long lastCleanup = preferences.getLong(KEY_LAST_CLEANUP, 0);
if ((currentTime - lastCleanup) < FULL_CLEANUP_DURATION) return null;
Set<String> currentWebapps = getRegisteredWebappIds(preferences);
Set<String> retainedWebapps = new HashSet<>(currentWebapps);
PackageManager pm = ContextUtils.getApplicationContext().getPackageManager();
for (String id : currentWebapps) {
WebappDataStorage storage = new WebappDataStorage(id);
String webApkPackage = storage.getWebApkPackageName();
if (webApkPackage != null) {
if (isWebApkInstalled(pm, webApkPackage)) continue;
} else {
long lastUsed = storage.getLastUsedTime();
if ((currentTime - lastUsed) < WEBAPP_UNOPENED_CLEANUP_DURATION) continue;
}
WebappDataStorage.deleteDataForWebapp(id);
retainedWebapps.remove(id);
}
preferences.edit()
.putLong(KEY_LAST_CLEANUP, currentTime)
.putStringSet(KEY_WEBAPP_SET, retainedWebapps)
.apply();
return null;
}
}.execute();
}
/**
* Returns whether the given WebAPK is still installed.
*/
private static boolean isWebApkInstalled(PackageManager pm, String webApkPackage) {
assert !ThreadUtils.runningOnUiThread();
try {
pm.getPackageInfo(webApkPackage, PackageManager.GET_ACTIVITIES);
} catch (NameNotFoundException e) {
return false;
}
return true;
}
/**
* Deletes the data of all web apps whose url matches |urlFilter|, as well as the registry
* tracking those web apps.
*/
@VisibleForTesting
static void unregisterWebappsForUrls(final UrlFilter urlFilter, final Runnable callback) {
new AsyncTask<Void, Void, Void>() {
@Override
protected final Void doInBackground(Void... nothing) {
SharedPreferences preferences = openSharedPreferences();
Set<String> registeredWebapps =
new HashSet<>(getRegisteredWebappIds(preferences));
Set<String> webappsToUnregister = new HashSet<>();
for (String id : registeredWebapps) {
if (urlFilter.matchesUrl(WebappDataStorage.open(id).getUrl())) {
WebappDataStorage.deleteDataForWebapp(id);
webappsToUnregister.add(id);
}
}
// TODO(dominickn): SharedPreferences should be accessed on the main thread, not
// from an AsyncTask. Simultaneous access from two threads creates a race condition.
// Update all callsites in this class.
registeredWebapps.removeAll(webappsToUnregister);
if (registeredWebapps.isEmpty()) {
preferences.edit().clear().apply();
} else {
preferences.edit().putStringSet(KEY_WEBAPP_SET, registeredWebapps).apply();
}
return null;
}
@Override
protected final void onPostExecute(Void nothing) {
assert callback != null;
callback.run();
}
}.execute();
}
@CalledByNative
static void unregisterWebappsForUrls(
final UrlFilterBridge urlFilter, final long callbackPointer) {
unregisterWebappsForUrls(urlFilter, new Runnable() {
@Override
public void run() {
urlFilter.destroy();
nativeOnWebappsUnregistered(callbackPointer);
}
});
}
/**
* Deletes the URL and scope, and sets the last used time to 0 for all web apps whose url
* matches |urlFilter|.
*/
@VisibleForTesting
static void clearWebappHistoryForUrls(final UrlFilter urlFilter, final Runnable callback) {
new AsyncTask<Void, Void, Void>() {
@Override
protected final Void doInBackground(Void... nothing) {
SharedPreferences preferences = openSharedPreferences();
for (String id : getRegisteredWebappIds(preferences)) {
if (urlFilter.matchesUrl(WebappDataStorage.open(id).getUrl())) {
WebappDataStorage.clearHistory(id);
}
}
return null;
}
@Override
protected final void onPostExecute(Void nothing) {
assert callback != null;
callback.run();
}
}.execute();
}
@CalledByNative
static void clearWebappHistoryForUrls(
final UrlFilterBridge urlFilter, final long callbackPointer) {
clearWebappHistoryForUrls(urlFilter, new Runnable() {
@Override
public void run() {
urlFilter.destroy();
nativeOnClearedWebappHistory(callbackPointer);
}
});
}
private static SharedPreferences openSharedPreferences() {
return ContextUtils.getApplicationContext().getSharedPreferences(
REGISTRY_FILE_NAME, Context.MODE_PRIVATE);
}
private static Set<String> getRegisteredWebappIds(SharedPreferences preferences) {
// Wrap with unmodifiableSet to ensure it's never modified. See crbug.com/568369.
return Collections.unmodifiableSet(
preferences.getStringSet(KEY_WEBAPP_SET, Collections.<String>emptySet()));
}
private WebappRegistry() {
}
private static native void nativeOnWebappsUnregistered(long callbackPointer);
private static native void nativeOnClearedWebappHistory(long callbackPointer);
}