// 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.physicalweb;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.support.v4.app.NotificationCompat;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeApplication;
import org.chromium.chrome.browser.notifications.NotificationConstants;
import org.chromium.chrome.browser.notifications.NotificationManagerProxy;
import org.chromium.chrome.browser.notifications.NotificationManagerProxyImpl;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
/**
* This class stores URLs which are discovered by scanning for Physical Web beacons, and updates a
* Notification as the set changes.
*
* There are two sets of URLs maintained:
* - Those which are currently nearby, as tracked by calls to addUrl/removeUrl
* - Those which have ever resolved through the Physical Web Service (e.g. are known to produce
* good results).
*
* Whenever either list changes, we update the Physical Web Notification, based on the intersection
* of currently-nearby and known-resolved URLs.
*/
class UrlManager {
private static final String TAG = "PhysicalWeb";
private static final String PREFS_VERSION_KEY = "physicalweb_version";
private static final String PREFS_ALL_URLS_KEY = "physicalweb_all_urls";
private static final String PREFS_NEARBY_URLS_KEY = "physicalweb_nearby_urls";
private static final String PREFS_PWS_RESULTS_KEY = "physicalweb_pws_results";
private static final String PREFS_NOTIFICATION_UPDATE_TIMESTAMP =
"physicalweb_notification_update_timestamp";
private static final int PREFS_VERSION = 4;
private static final long STALE_NOTIFICATION_TIMEOUT_MILLIS = 30 * 60 * 1000; // 30 Minutes
private static final long MAX_CACHE_TIME = 24 * 60 * 60 * 1000; // 1 Day
private static final int MAX_CACHE_SIZE = 100;
private static UrlManager sInstance = null;
private final Context mContext;
private final ObserverList<Listener> mObservers;
private final Set<String> mNearbyUrls;
private final Map<String, UrlInfo> mUrlInfoMap;
private final Map<String, PwsResult> mPwsResultMap;
private final PriorityQueue<String> mUrlsSortedByTimestamp;
private NotificationManagerProxy mNotificationManager;
private PwsClient mPwsClient;
/**
* Interface for observers that should be notified when the nearby URL list changes.
*/
public interface Listener {
/**
* Callback called when one or more URLs are added to the URL list.
* @param urls A set of UrlInfos containing nearby URLs resolvable with our resolution
* service.
*/
void onDisplayableUrlsAdded(Collection<UrlInfo> urls);
}
/**
* Construct the UrlManager.
* @param context An instance of android.content.Context
*/
@VisibleForTesting
public UrlManager(Context context) {
mContext = context;
mNotificationManager = new NotificationManagerProxyImpl(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
mPwsClient = new PwsClientImpl(context);
mObservers = new ObserverList<Listener>();
mNearbyUrls = new HashSet<>();
mUrlInfoMap = new HashMap<>();
mPwsResultMap = new HashMap<>();
mUrlsSortedByTimestamp = new PriorityQueue<String>(1, new Comparator<String>() {
@Override
public int compare(String url1, String url2) {
Long scanTimestamp1 = Long.valueOf(mUrlInfoMap.get(url1).getScanTimestamp());
Long scanTimestamp2 = Long.valueOf(mUrlInfoMap.get(url2).getScanTimestamp());
return scanTimestamp1.compareTo(scanTimestamp2);
}
});
initSharedPreferences();
}
/**
* Get a singleton instance of this class.
* @return A singleton instance of this class.
*/
public static UrlManager getInstance() {
if (sInstance == null) {
sInstance = new UrlManager(ContextUtils.getApplicationContext());
}
return sInstance;
}
/**
* Get a singleton instance of this class.
* @param context unused
* @return A singleton instance of this class.
*/
public static UrlManager getInstance(Context context) {
return getInstance();
}
/**
* Add an observer to be notified on changes to the nearby URL list.
* @param observer The observer to add.
*/
public void addObserver(Listener observer) {
mObservers.addObserver(observer);
}
/**
* Remove an observer from the observer list.
* @param observer The observer to remove.
*/
public void removeObserver(Listener observer) {
mObservers.removeObserver(observer);
}
/**
* Add a URL to the store of URLs.
* This method additionally updates the Physical Web notification.
* @param urlInfo The URL to add.
*/
@VisibleForTesting
public void addUrl(UrlInfo urlInfo) {
Log.d(TAG, "URL found: %s", urlInfo);
urlInfo = updateCacheEntry(urlInfo);
garbageCollect();
putCachedUrlInfoMap();
recordUpdate();
if (mNearbyUrls.contains(urlInfo.getUrl())
// In the rare event that our entry is immediately garbage collected from the cache,
// we should stop here.
|| !mUrlInfoMap.containsKey(urlInfo.getUrl())) {
return;
}
mNearbyUrls.add(urlInfo.getUrl());
putCachedNearbyUrls();
if (!PhysicalWeb.isOnboarding() && !mPwsResultMap.keySet().contains(urlInfo.getUrl())) {
// We need to resolve the URL.
resolveUrl(urlInfo);
return;
}
registerNewDisplayableUrl(urlInfo);
}
/**
* Remove a URL to the store of URLs.
* This method additionally updates the Physical Web notification.
* @param urlInfo The URL to remove.
*/
@VisibleForTesting
public void removeUrl(UrlInfo urlInfo) {
Log.d(TAG, "URL lost: %s", urlInfo);
recordUpdate();
mNearbyUrls.remove(urlInfo.getUrl());
putCachedNearbyUrls();
// If there are no URLs nearby to display, clear the notification.
if (getUrls(PhysicalWeb.isOnboarding()).isEmpty()) {
clearNotification();
}
}
/**
* Get the list of URLs which are both nearby and resolved through PWS.
* @return A set of nearby and resolved URLs, sorted by distance.
*/
@VisibleForTesting
public List<UrlInfo> getUrls() {
return getUrls(false);
}
/**
* Get the list of URLs which are both nearby and resolved through PWS.
* @param allowUnresolved If true, include unresolved URLs only if the
* resolved URL list is empty.
* @return A set of nearby URLs, sorted by distance.
*/
@VisibleForTesting
public List<UrlInfo> getUrls(boolean allowUnresolved) {
Set<String> resolvedUrls = mPwsResultMap.keySet();
Set<String> intersection = new HashSet<>(mNearbyUrls);
intersection.retainAll(resolvedUrls);
Log.d(TAG, "Get URLs With: %d nearby, %d resolved, and %d in intersection.",
mNearbyUrls.size(), resolvedUrls.size(), intersection.size());
List<UrlInfo> urlInfos = null;
if (allowUnresolved && resolvedUrls.isEmpty()) {
urlInfos = getUrlInfoList(mNearbyUrls);
} else {
urlInfos = getUrlInfoList(intersection);
}
Collections.sort(urlInfos, new Comparator<UrlInfo>() {
@Override
public int compare(UrlInfo urlInfo1, UrlInfo urlInfo2) {
Double distance1 = Double.valueOf(urlInfo1.getDistance());
Double distance2 = Double.valueOf(urlInfo2.getDistance());
return distance1.compareTo(distance2);
}
});
return urlInfos;
}
public UrlInfo getUrlInfoByUrl(String url) {
return mUrlInfoMap.get(url);
}
public Set<String> getNearbyUrls() {
return mNearbyUrls;
}
public Set<String> getResolvedUrls() {
return mPwsResultMap.keySet();
}
/**
* Gets all UrlInfos and PwsResults for resolved URLs.
*/
public PwCollection getPwCollection() {
List<PwsResult> nearbyPwsResults = new ArrayList<>();
for (String url : mNearbyUrls) {
nearbyPwsResults.add(mPwsResultMap.get(url));
}
return new PwCollection(getUrlInfoList(mNearbyUrls), nearbyPwsResults);
}
/**
* Forget all stored URLs and clear the notification.
*/
public void clearAllUrls() {
clearNearbyUrls();
mUrlsSortedByTimestamp.clear();
mUrlInfoMap.clear();
mPwsResultMap.clear();
putCachedUrlInfoMap();
putCachedPwsResultMap();
}
/**
* Forget all nearby URLs and clear the notification.
*/
public void clearNearbyUrls() {
mNearbyUrls.clear();
putCachedNearbyUrls();
clearNotification();
cancelClearNotificationAlarm();
}
/**
* Clear the URLManager's notification.
* Typically, this should not be called except when we want to clear the notification without
* modifying the list of URLs, as is the case when we want to remove stale notifications.
*/
public void clearNotification() {
mNotificationManager.cancel(NotificationConstants.NOTIFICATION_ID_PHYSICAL_WEB);
cancelClearNotificationAlarm();
}
private List<UrlInfo> getUrlInfoList(Set<String> urls) {
List<UrlInfo> result = new ArrayList<>();
for (String url : urls) {
result.add(mUrlInfoMap.get(url));
}
return result;
}
/**
* Adds a URL that has been resolved by the PWS.
* @param pwsResult The meta data associated with the resolved URL.
*/
private void addResolvedUrl(PwsResult pwsResult) {
Log.d(TAG, "PWS resolved: %s", pwsResult.requestUrl);
if (mPwsResultMap.containsKey(pwsResult.requestUrl)) {
return;
}
mPwsResultMap.put(pwsResult.requestUrl, pwsResult);
putCachedPwsResultMap();
if (!mNearbyUrls.contains(pwsResult.requestUrl)
|| !mUrlInfoMap.containsKey(pwsResult.requestUrl)) {
return;
}
registerNewDisplayableUrl(mUrlInfoMap.get(pwsResult.requestUrl));
}
private void removeResolvedUrl(UrlInfo url) {
Log.d(TAG, "PWS unresolved: %s", url);
mPwsResultMap.remove(url.getUrl());
putCachedPwsResultMap();
// If there are no URLs nearby to display, clear the notification.
if (getUrls(PhysicalWeb.isOnboarding()).isEmpty()) {
clearNotification();
}
}
private void initSharedPreferences() {
// Check the version.
final SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
if (prefs.getInt(PREFS_VERSION_KEY, 0) != PREFS_VERSION) {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
prefs.edit()
.putInt(PREFS_VERSION_KEY, PREFS_VERSION)
// This clean up code can be deleted in m57.
.remove("physicalweb_resolved_urls")
.apply();
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return;
}
// Read the cache.
mNearbyUrls.addAll(prefs.getStringSet(PREFS_NEARBY_URLS_KEY, new HashSet<String>()));
for (String serializedUrl : prefs.getStringSet(PREFS_ALL_URLS_KEY, new HashSet<String>())) {
try {
JSONObject jsonObject = new JSONObject(serializedUrl);
UrlInfo urlInfo = UrlInfo.jsonDeserialize(jsonObject);
mUrlInfoMap.put(urlInfo.getUrl(), urlInfo);
mUrlsSortedByTimestamp.add(urlInfo.getUrl());
} catch (JSONException e) {
Log.e(TAG, "Could not deserialize UrlInfo", e);
}
}
for (String serializedPwsResult : prefs.getStringSet(PREFS_PWS_RESULTS_KEY,
new HashSet<String>())) {
try {
JSONObject jsonObject = new JSONObject(serializedPwsResult);
PwsResult pwsResult = PwsResult.jsonDeserialize(jsonObject);
mPwsResultMap.put(pwsResult.requestUrl, pwsResult);
} catch (JSONException e) {
Log.e(TAG, "Could not deserialize PwsResult", e);
}
}
garbageCollect();
}
private void setStringSetInSharedPreferences(String preferenceName, Set<String> urls) {
ContextUtils.getAppSharedPreferences().edit()
.putStringSet(preferenceName, urls)
.apply();
}
private void putCachedUrlInfoMap() {
Set<String> serializedUrls = new HashSet<>();
for (UrlInfo url : mUrlInfoMap.values()) {
try {
serializedUrls.add(url.jsonSerialize().toString());
} catch (JSONException e) {
Log.e(TAG, "Could not serialize UrlInfo", e);
}
}
setStringSetInSharedPreferences(PREFS_ALL_URLS_KEY, serializedUrls);
}
private void putCachedNearbyUrls() {
setStringSetInSharedPreferences(PREFS_NEARBY_URLS_KEY, mNearbyUrls);
}
private void putCachedPwsResultMap() {
Set<String> serializedPwsResults = new HashSet<>();
for (PwsResult pwsResult : mPwsResultMap.values()) {
try {
serializedPwsResults.add(pwsResult.jsonSerialize().toString());
} catch (JSONException e) {
Log.e(TAG, "Could not serialize PwsResult", e);
}
}
setStringSetInSharedPreferences(PREFS_PWS_RESULTS_KEY, serializedPwsResults);
}
private PendingIntent createListUrlsIntent() {
Intent intent = new Intent(mContext, ListUrlsActivity.class);
intent.putExtra(ListUrlsActivity.REFERER_KEY, ListUrlsActivity.NOTIFICATION_REFERER);
PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, intent, 0);
return pendingIntent;
}
private PendingIntent createOptInIntent() {
Intent intent = new Intent(mContext, PhysicalWebOptInActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, intent, 0);
return pendingIntent;
}
/**
* Updates a cache entry with new information.
* When we reencounter a URL, a subset of its metadata should update. Only distance and
* scanTimestamp fall into this category.
* @param urlInfo This should be a freshly discovered UrlInfo, though it does not have to be
* previously undiscovered.
* @return The updated cache entry
*/
private UrlInfo updateCacheEntry(UrlInfo urlInfo) {
UrlInfo currentUrlInfo = mUrlInfoMap.get(urlInfo.getUrl());
if (currentUrlInfo == null) {
mUrlInfoMap.put(urlInfo.getUrl(), urlInfo);
currentUrlInfo = urlInfo;
} else {
mUrlsSortedByTimestamp.remove(urlInfo.getUrl());
currentUrlInfo.setScanTimestamp(urlInfo.getScanTimestamp());
currentUrlInfo.setDistance(urlInfo.getDistance());
}
mUrlsSortedByTimestamp.add(urlInfo.getUrl());
return currentUrlInfo;
}
private void resolveUrl(final UrlInfo url) {
Set<UrlInfo> urls = new HashSet<UrlInfo>(Arrays.asList(url));
final long timestamp = SystemClock.elapsedRealtime();
mPwsClient.resolve(urls, new PwsClient.ResolveScanCallback() {
@Override
public void onPwsResults(final Collection<PwsResult> pwsResults) {
long duration = SystemClock.elapsedRealtime() - timestamp;
PhysicalWebUma.onBackgroundPwsResolution(mContext, duration);
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
for (PwsResult pwsResult : pwsResults) {
String requestUrl = pwsResult.requestUrl;
if (url.getUrl().equalsIgnoreCase(requestUrl)) {
addResolvedUrl(pwsResult);
return;
}
}
removeResolvedUrl(url);
}
});
}
});
}
/**
* Gets the time since the last notification update.
* @return the elapsed realtime since the most recent notification update.
*/
public long getTimeSinceNotificationUpdate() {
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
long timestamp = prefs.getLong(PREFS_NOTIFICATION_UPDATE_TIMESTAMP, 0);
return SystemClock.elapsedRealtime() - timestamp;
}
private void recordUpdate() {
// Record a timestamp.
// This is useful for tracking whether a notification is pressed soon after an update or
// much later.
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
SharedPreferences.Editor editor = prefs.edit();
editor.putLong(PREFS_NOTIFICATION_UPDATE_TIMESTAMP, SystemClock.elapsedRealtime());
editor.apply();
}
private void showNotification() {
// We should only show notifications if there's no other notification-based client.
if (!PhysicalWeb.shouldIgnoreOtherClients()
&& PhysicalWebEnvironment
.getInstance((ChromeApplication) mContext.getApplicationContext())
.hasNotificationBasedClient()) {
return;
}
if (PhysicalWeb.isOnboarding()) {
if (PhysicalWeb.getOptInNotifyCount() < PhysicalWeb.OPTIN_NOTIFY_MAX_TRIES) {
// high priority notification
createOptInNotification(true);
PhysicalWeb.recordOptInNotification();
PhysicalWebUma.onOptInHighPriorityNotificationShown(mContext);
} else {
// min priority notification
createOptInNotification(false);
PhysicalWebUma.onOptInMinPriorityNotificationShown(mContext);
}
} else if (PhysicalWeb.isPhysicalWebPreferenceEnabled()) {
createNotification();
}
}
private void createNotification() {
PendingIntent pendingIntent = createListUrlsIntent();
// Get values to display.
Resources resources = mContext.getResources();
String title = resources.getString(R.string.physical_web_notification_title);
Bitmap largeIcon = BitmapFactory.decodeResource(resources,
R.drawable.physical_web_notification_large);
// Create the notification.
Notification notification = new NotificationCompat.Builder(mContext)
.setLargeIcon(largeIcon)
.setSmallIcon(R.drawable.ic_chrome)
.setContentTitle(title)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setLocalOnly(true)
.build();
mNotificationManager.notify(NotificationConstants.NOTIFICATION_ID_PHYSICAL_WEB,
notification);
}
private void createOptInNotification(boolean highPriority) {
PendingIntent pendingIntent = createOptInIntent();
int priority = highPriority ? NotificationCompat.PRIORITY_HIGH
: NotificationCompat.PRIORITY_MIN;
// Get values to display.
Resources resources = mContext.getResources();
String title = resources.getString(R.string.physical_web_optin_notification_title);
String text = resources.getString(R.string.physical_web_optin_notification_text);
Bitmap largeIcon = BitmapFactory.decodeResource(resources, R.mipmap.app_icon);
// Create the notification.
Notification notification = new NotificationCompat.Builder(mContext)
.setLargeIcon(largeIcon)
.setSmallIcon(R.drawable.ic_physical_web_notification)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setPriority(priority)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setAutoCancel(true)
.setLocalOnly(true)
.build();
mNotificationManager.notify(NotificationConstants.NOTIFICATION_ID_PHYSICAL_WEB,
notification);
}
private PendingIntent createClearNotificationAlarmIntent() {
Intent intent = new Intent(mContext, ClearNotificationAlarmReceiver.class);
return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
private void scheduleClearNotificationAlarm() {
PendingIntent pendingIntent = createClearNotificationAlarmIntent();
AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
long time = SystemClock.elapsedRealtime() + STALE_NOTIFICATION_TIMEOUT_MILLIS;
alarmManager.set(AlarmManager.ELAPSED_REALTIME, time, pendingIntent);
}
private void cancelClearNotificationAlarm() {
PendingIntent pendingIntent = createClearNotificationAlarmIntent();
AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
alarmManager.cancel(pendingIntent);
}
private void registerNewDisplayableUrl(UrlInfo urlInfo) {
// Notify listeners about the new displayable URL.
Collection<UrlInfo> urlInfos = new ArrayList<>();
urlInfos.add(urlInfo);
Collection<UrlInfo> wrappedUrlInfos = Collections.unmodifiableCollection(urlInfos);
for (Listener observer : mObservers) {
observer.onDisplayableUrlsAdded(wrappedUrlInfos);
}
// Only trigger the notification if we know we didn't have a notification up already
// (i.e., we have exactly 1 displayble URL) or this URL doesn't exist in the cache
// (and hence the user hasn't swiped away a notification for this URL recently).
if (getUrls(PhysicalWeb.isOnboarding()).size() != 1
&& urlInfo.hasBeenDisplayed()) {
return;
}
// Show a notification and mark the URL as displayed.
showNotification();
urlInfo.setHasBeenDisplayed();
}
private void garbageCollect() {
for (String url = mUrlsSortedByTimestamp.peek(); url != null;
url = mUrlsSortedByTimestamp.peek()) {
UrlInfo urlInfo = mUrlInfoMap.get(url);
if ((System.currentTimeMillis() - urlInfo.getScanTimestamp() <= MAX_CACHE_TIME
&& mUrlsSortedByTimestamp.size() <= MAX_CACHE_SIZE)
|| mNearbyUrls.contains(url)) {
Log.d(TAG, "Not garbage collecting: ", urlInfo);
break;
}
Log.d(TAG, "Garbage collecting: ", urlInfo);
// The min value cannot have changed at this point, so it's OK to just remove via
// poll().
mUrlsSortedByTimestamp.poll();
mUrlInfoMap.remove(url);
mPwsResultMap.remove(url);
}
}
@VisibleForTesting
void overridePwsClientForTesting(PwsClient pwsClient) {
mPwsClient = pwsClient;
}
@VisibleForTesting
void overrideNotificationManagerForTesting(
NotificationManagerProxy notificationManager) {
mNotificationManager = notificationManager;
}
@VisibleForTesting
static void clearPrefsForTesting(Context context) {
ContextUtils.getAppSharedPreferences().edit()
.remove(PREFS_VERSION_KEY)
.remove(PREFS_NEARBY_URLS_KEY)
.remove(PREFS_NOTIFICATION_UPDATE_TIMESTAMP)
.remove(PREFS_PWS_RESULTS_KEY)
.apply();
}
@VisibleForTesting
static String getVersionKey() {
return PREFS_VERSION_KEY;
}
@VisibleForTesting
static int getVersion() {
return PREFS_VERSION;
}
@VisibleForTesting
boolean containsInAnyCache(String url) {
return mNearbyUrls.contains(url)
|| mPwsResultMap.containsKey(url)
|| mUrlInfoMap.containsKey(url)
|| mUrlsSortedByTimestamp.contains(url);
}
@VisibleForTesting
int getMaxCacheSize() {
return MAX_CACHE_SIZE;
}
}