/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.background.announcements;
import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.net.URI;
import java.util.List;
import java.util.Locale;
import org.mozilla.gecko.background.BackgroundConstants;
import org.mozilla.gecko.sync.Logger;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;
import android.os.IBinder;
/**
* A Service to periodically check for new published announcements,
* presenting them to the user if local conditions permit.
*
* We extend IntentService, rather than just Service, because this gives us
* a worker thread to avoid main-thread networking.
*
* Yes, even though we're in an alarm-triggered service, it still counts
* as main-thread.
*
* The operation of this service is as follows:
*
* 0. Decide if a request should be made.
* 1. Compute the arguments to the request. This includes enough
* pertinent details to allow the server to pre-filter a message
* set, recording enough tracking details to compute statistics.
* 2. Issue the request. If this succeeds with a 200 or 204, great;
* track that timestamp for the next run through Step 0.
* 3. Process any received messages.
*
* Message processing is as follows:
*
* 0. Decide if message display should occur. This might involve
* user preference or other kinds of environmental factors.
* 1. Use the AnnouncementPresenter to open the announcement.
*
* Future:
* * Persisting of multiple announcements.
* * Prioritization.
*/
public class AnnouncementsService extends IntentService implements AnnouncementsFetchDelegate {
private static final String WORKER_THREAD_NAME = "AnnouncementsServiceWorker";
private static final String LOG_TAG = "AnnounceService";
public AnnouncementsService() {
super(WORKER_THREAD_NAME);
Logger.setThreadLogTag(AnnouncementsConstants.GLOBAL_LOG_TAG);
Logger.debug(LOG_TAG, "Creating AnnouncementsService.");
}
public boolean shouldFetchAnnouncements() {
final long now = System.currentTimeMillis();
if (!backgroundDataIsEnabled()) {
Logger.debug(LOG_TAG, "Background data not possible. Skipping.");
return false;
}
// Don't fetch if we were told to back off.
if (getEarliestNextFetch() > now) {
return false;
}
// Don't do anything if we haven't waited long enough.
final long lastFetch = getLastFetch();
// Just in case the alarm manager schedules us more frequently, or something
// goes awry with relaunches.
if ((now - lastFetch) < AnnouncementsConstants.MINIMUM_FETCH_INTERVAL_MSEC) {
Logger.debug(LOG_TAG, "Returning: minimum fetch interval of " + AnnouncementsConstants.MINIMUM_FETCH_INTERVAL_MSEC + "ms not met.");
return false;
}
return true;
}
/**
* Display the first valid announcement in the list.
*/
protected void processAnnouncements(final List<Announcement> announcements) {
if (announcements == null) {
Logger.warn(LOG_TAG, "No announcements to present.");
return;
}
boolean presented = false;
for (Announcement an : announcements) {
// Do this so we at least log, rather than just returning.
if (presented) {
Logger.warn(LOG_TAG, "Skipping announcement \"" + an.getTitle() + "\": one already shown.");
continue;
}
if (Announcement.isValidAnnouncement(an)) {
presented = true;
AnnouncementPresenter.displayAnnouncement(this, an);
}
}
}
/**
* If it's time to do a fetch -- we've waited long enough,
* we're allowed to use background data, etc. -- then issue
* a fetch. The subsequent background check is handled implicitly
* by the AlarmManager.
*/
@Override
public void onHandleIntent(Intent intent) {
Logger.setThreadLogTag(AnnouncementsConstants.GLOBAL_LOG_TAG);
Logger.debug(LOG_TAG, "Running AnnouncementsService.");
if (!shouldFetchAnnouncements()) {
Logger.debug(LOG_TAG, "Not fetching.");
return;
}
// Otherwise, grab our announcements URL and process the contents.
AnnouncementsFetcher.fetchAndProcessAnnouncements(getLastLaunch(), this);
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
/**
* Returns true if the OS will allow us to perform background
* data operations. This logic varies by OS version.
*/
protected boolean backgroundDataIsEnabled() {
ConnectivityManager connectivity = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return connectivity.getBackgroundDataSetting();
}
NetworkInfo networkInfo = connectivity.getActiveNetworkInfo();
if (networkInfo == null) {
return false;
}
return networkInfo.isAvailable();
}
protected long getLastLaunch() {
return getSharedPreferences().getLong(AnnouncementsConstants.PREF_LAST_LAUNCH, 0);
}
private SharedPreferences getSharedPreferences() {
return this.getSharedPreferences(AnnouncementsConstants.PREFS_BRANCH, BackgroundConstants.SHARED_PREFERENCES_MODE);
}
@Override
protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
super.dump(fd, writer, args);
final long lastFetch = getLastFetch();
final long lastLaunch = getLastLaunch();
writer.write("AnnouncementsService: last fetch " + lastFetch +
", last Firefox activity: " + lastLaunch + "\n");
}
protected void setEarliestNextFetch(final long earliestInMsec) {
this.getSharedPreferences().edit().putLong(AnnouncementsConstants.PREF_EARLIEST_NEXT_ANNOUNCE_FETCH, earliestInMsec).commit();
}
protected long getEarliestNextFetch() {
return this.getSharedPreferences().getLong(AnnouncementsConstants.PREF_EARLIEST_NEXT_ANNOUNCE_FETCH, 0L);
}
protected void setLastFetch(final long fetch) {
this.getSharedPreferences().edit().putLong(AnnouncementsConstants.PREF_LAST_FETCH_LOCAL_TIME, fetch).commit();
}
public long getLastFetch() {
return this.getSharedPreferences().getLong(AnnouncementsConstants.PREF_LAST_FETCH_LOCAL_TIME, 0L);
}
protected String setLastDate(final String fetch) {
if (fetch == null) {
this.getSharedPreferences().edit().remove(AnnouncementsConstants.PREF_LAST_FETCH_SERVER_DATE).commit();
return null;
}
this.getSharedPreferences().edit().putString(AnnouncementsConstants.PREF_LAST_FETCH_SERVER_DATE, fetch).commit();
return fetch;
}
@Override
public String getLastDate() {
return this.getSharedPreferences().getString(AnnouncementsConstants.PREF_LAST_FETCH_SERVER_DATE, null);
}
/**
* Use this to write the persisted server URL, overriding
* the default value.
* @param url a URI identifying the full request path, e.g.,
* "http://foo.com:1234/announce/"
*/
public void setAnnouncementsServerBaseURL(final URI url) {
if (url == null) {
throw new IllegalArgumentException("url cannot be null.");
}
final String scheme = url.getScheme();
if (scheme == null) {
throw new IllegalArgumentException("url must have a scheme.");
}
if (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
throw new IllegalArgumentException("url must be http or https.");
}
SharedPreferences p = this.getSharedPreferences();
p.edit().putString(AnnouncementsConstants.PREF_ANNOUNCE_SERVER_BASE_URL, url.toASCIIString()).commit();
}
/**
* Return the service URL, including protocol version and application identifier. E.g.,
*
* "https://campaigns.services.mozilla.com/announce/1/android/"
*/
@Override
public String getServiceURL() {
SharedPreferences p = this.getSharedPreferences();
String base = p.getString(AnnouncementsConstants.PREF_ANNOUNCE_SERVER_BASE_URL, AnnouncementsConstants.DEFAULT_ANNOUNCE_SERVER_BASE_URL);
return base + AnnouncementsConstants.ANNOUNCE_PATH_SUFFIX;
}
@Override
public Locale getLocale() {
return Locale.getDefault();
}
@Override
public String getUserAgent() {
return AnnouncementsConstants.ANNOUNCE_USER_AGENT;
}
protected void persistTimes(long fetched, String date) {
setLastFetch(fetched);
if (date != null) {
setLastDate(date);
}
}
@Override
public void onNoNewAnnouncements(long fetched, String date) {
Logger.info(LOG_TAG, "No new announcements to display.");
persistTimes(fetched, date);
}
@Override
public void onNewAnnouncements(List<Announcement> announcements, long fetched, String date) {
Logger.info(LOG_TAG, "Processing announcements: " + announcements.size());
persistTimes(fetched, date);
processAnnouncements(announcements);
}
@Override
public void onRemoteFailure(int status) {
// Bump our fetch timestamp.
Logger.warn(LOG_TAG, "Got remote fetch status " + status + "; bumping fetch time.");
setLastFetch(System.currentTimeMillis());
}
@Override
public void onRemoteError(Exception e) {
// Bump our fetch timestamp.
Logger.warn(LOG_TAG, "Error processing response.", e);
setLastFetch(System.currentTimeMillis());
}
@Override
public void onLocalError(Exception e) {
Logger.error(LOG_TAG, "Got exception in fetch.", e);
// Do nothing yet, so we'll retry.
}
@Override
public void onBackoff(int retryAfterInSeconds) {
Logger.info(LOG_TAG, "Got retry after: " + retryAfterInSeconds);
final long delayInMsec = Math.max(retryAfterInSeconds * 1000, AnnouncementsConstants.DEFAULT_BACKOFF_MSEC);
final long fuzzedBackoffInMsec = delayInMsec + Math.round(((double) delayInMsec * 0.25d * Math.random()));
Logger.debug(LOG_TAG, "Fuzzed backoff: " + fuzzedBackoffInMsec + "ms.");
setEarliestNextFetch(fuzzedBackoffInMsec + System.currentTimeMillis());
}
}