// 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; } }