pt 1 in reviving the android build (copied from pm 28a1, wish me luck)

This commit is contained in:
wuggy 2026-04-08 15:43:25 -07:00
commit d7788a6d6d
4249 changed files with 468189 additions and 0 deletions

View file

@ -0,0 +1,89 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds;
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v4.app.NotificationManagerCompat;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.BrowserApp;
import org.mozilla.gecko.R;
import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.TelemetryContract;
import org.mozilla.gecko.delegates.BrowserAppDelegate;
import org.mozilla.gecko.mozglue.SafeIntent;
import java.util.List;
/**
* BrowserAppDelegate implementation that takes care of handling intents from content notifications.
*/
public class ContentNotificationsDelegate extends BrowserAppDelegate {
// The application is opened from a content notification
public static final String ACTION_CONTENT_NOTIFICATION = AppConstants.ANDROID_PACKAGE_NAME + ".action.CONTENT_NOTIFICATION";
public static final String EXTRA_READ_BUTTON = "read_button";
public static final String EXTRA_URLS = "urls";
private static final String TELEMETRY_EXTRA_CONTENT_UPDATE = "content_update";
private static final String TELEMETRY_EXTRA_READ_NOW_BUTTON = TELEMETRY_EXTRA_CONTENT_UPDATE + "_read_now";
@Override
public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
if (savedInstanceState != null) {
// This activity is getting restored: We do not want to handle the URLs in the Intent again. The browser
// will take care of restoring the tabs we already created.
return;
}
final Intent unsafeIntent = browserApp.getIntent();
// Nothing to do.
if (unsafeIntent == null) {
return;
}
final SafeIntent intent = new SafeIntent(unsafeIntent);
if (ACTION_CONTENT_NOTIFICATION.equals(intent.getAction())) {
openURLsFromIntent(browserApp, intent);
}
}
@Override
public void onNewIntent(BrowserApp browserApp, @NonNull final SafeIntent intent) {
if (ACTION_CONTENT_NOTIFICATION.equals(intent.getAction())) {
openURLsFromIntent(browserApp, intent);
}
}
private void openURLsFromIntent(BrowserApp browserApp, @NonNull final SafeIntent intent) {
final List<String> urls = intent.getStringArrayListExtra(EXTRA_URLS);
if (urls != null) {
browserApp.openUrls(urls);
}
Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(browserApp));
Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, TELEMETRY_EXTRA_CONTENT_UPDATE);
if (intent.getBooleanExtra(EXTRA_READ_BUTTON, false)) {
// "READ NOW" button in notification was clicked
Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, TELEMETRY_EXTRA_READ_NOW_BUTTON);
// Android's "auto cancel" won't remove the notification when an action button is pressed. So we do it ourselves here.
NotificationManagerCompat.from(browserApp).cancel(R.id.websiteContentNotification);
} else {
// Notification was clicked
Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, TELEMETRY_EXTRA_CONTENT_UPDATE);
}
Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(browserApp));
}
}

View file

@ -0,0 +1,31 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds;
import android.content.Context;
import android.content.Intent;
import android.support.v4.content.WakefulBroadcastReceiver;
import android.util.Log;
/**
* Broadcast receiver that will receive broadcasts from the AlarmManager and start the FeedService
* with the given action.
*/
public class FeedAlarmReceiver extends WakefulBroadcastReceiver {
private static final String LOGTAG = "FeedCheckAction";
@Override
public void onReceive(Context context, Intent intent) {
final String action = intent.getAction();
Log.d(LOGTAG, "Received alarm with action: " + action);
final Intent serviceIntent = new Intent(context, FeedService.class);
serviceIntent.setAction(action);
startWakefulService(context, serviceIntent);
}
}

View file

@ -0,0 +1,110 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.mozilla.gecko.feeds.parser.Feed;
import org.mozilla.gecko.feeds.parser.SimpleFeedParser;
import org.mozilla.gecko.util.IOUtils;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import ch.boye.httpclientandroidlib.util.TextUtils;
/**
* Helper class for fetching and parsing a feed.
*/
public class FeedFetcher {
private static final int CONNECT_TIMEOUT = 15000;
private static final int READ_TIMEOUT = 15000;
public static class FeedResponse {
public final Feed feed;
public final String etag;
public final String lastModified;
public FeedResponse(Feed feed, String etag, String lastModified) {
this.feed = feed;
this.etag = etag;
this.lastModified = lastModified;
}
}
/**
* Fetch and parse a feed from the given URL. Will return null if fetching or parsing failed.
*/
public static FeedResponse fetchAndParseFeed(String url) {
return fetchAndParseFeedIfModified(url, null, null);
}
/**
* Fetch and parse a feed from the given URL using the given ETag and "Last modified" value.
*
* Will return null if fetching or parsing failed. Will also return null if the feed has not
* changed (ETag / Last-Modified-Since).
*
* @param eTag The ETag from the last fetch or null if no ETag is available (will always fetch feed)
* @param lastModified The "Last modified" header from the last time the feed has been fetch or
* null if no value is available (will always fetch feed)
* @return A FeedResponse or null if no feed could be fetched (error or no new version available)
*/
@Nullable
public static FeedResponse fetchAndParseFeedIfModified(@NonNull String url, @Nullable String eTag, @Nullable String lastModified) {
HttpURLConnection connection = null;
InputStream stream = null;
try {
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setInstanceFollowRedirects(true);
connection.setConnectTimeout(CONNECT_TIMEOUT);
connection.setReadTimeout(READ_TIMEOUT);
if (!TextUtils.isEmpty(eTag)) {
connection.setRequestProperty("If-None-Match", eTag);
}
if (!TextUtils.isEmpty(lastModified)) {
connection.setRequestProperty("If-Modified-Since", lastModified);
}
final int statusCode = connection.getResponseCode();
if (statusCode != HttpURLConnection.HTTP_OK) {
return null;
}
String responseEtag = connection.getHeaderField("ETag");
if (!TextUtils.isEmpty(responseEtag) && responseEtag.startsWith("W/")) {
// Weak ETag, get actual ETag value
responseEtag = responseEtag.substring(2);
}
final String updatedLastModified = connection.getHeaderField("Last-Modified");
stream = new BufferedInputStream(connection.getInputStream());
final SimpleFeedParser parser = new SimpleFeedParser();
final Feed feed = parser.parse(stream);
return new FeedResponse(feed, responseEtag, updatedLastModified);
} catch (IOException e) {
return null;
} catch (SimpleFeedParser.ParserException e) {
return null;
} finally {
if (connection != null) {
connection.disconnect();
}
IOUtils.safeStreamClose(stream);
}
}
}

View file

@ -0,0 +1,168 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.support.annotation.Nullable;
import android.support.v4.net.ConnectivityManagerCompat;
import android.util.Log;
import com.keepsafe.switchboard.SwitchBoard;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.GeckoProfile;
import org.mozilla.gecko.GeckoSharedPrefs;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.feeds.action.FeedAction;
import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
import org.mozilla.gecko.feeds.action.EnrollSubscriptionsAction;
import org.mozilla.gecko.feeds.action.SetupAlarmsAction;
import org.mozilla.gecko.feeds.action.SubscribeToFeedAction;
import org.mozilla.gecko.feeds.action.WithdrawSubscriptionsAction;
import org.mozilla.gecko.preferences.GeckoPreferences;
import org.mozilla.gecko.Experiments;
/**
* Background service for subscribing to and checking website feeds to notify the user about updates.
*/
public class FeedService extends IntentService {
private static final String LOGTAG = "GeckoFeedService";
public static final String ACTION_SETUP = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SETUP";
public static final String ACTION_SUBSCRIBE = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SUBSCRIBE";
public static final String ACTION_CHECK = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.CHECK";
public static final String ACTION_ENROLL = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.ENROLL";
public static final String ACTION_WITHDRAW = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.WITHDRAW";
public static void setup(Context context) {
Intent intent = new Intent(context, FeedService.class);
intent.setAction(ACTION_SETUP);
context.startService(intent);
}
public static void subscribe(Context context, String feedUrl) {
Intent intent = new Intent(context, FeedService.class);
intent.setAction(ACTION_SUBSCRIBE);
intent.putExtra(SubscribeToFeedAction.EXTRA_FEED_URL, feedUrl);
context.startService(intent);
}
public FeedService() {
super(LOGTAG);
}
private BrowserDB browserDB;
@Override
public void onCreate() {
super.onCreate();
browserDB = BrowserDB.from(this);
}
@Override
protected void onHandleIntent(Intent intent) {
try {
if (intent == null) {
return;
}
Log.d(LOGTAG, "Service started with action: " + intent.getAction());
if (!isInExperiment(this)) {
Log.d(LOGTAG, "Not in content notifications experiment. Skipping.");
return;
}
FeedAction action = createActionForIntent(intent);
if (action == null) {
Log.d(LOGTAG, "No action to process");
return;
}
if (action.requiresPreferenceEnabled() && !isPreferenceEnabled()) {
Log.d(LOGTAG, "Preference is disabled. Skipping.");
return;
}
if (action.requiresNetwork() && !isConnectedToUnmeteredNetwork()) {
// For now just skip if we are not connected or the network is metered. We do not want
// to use precious mobile traffic.
Log.d(LOGTAG, "Not connected to a network or network is metered. Skipping.");
return;
}
action.perform(browserDB, intent);
} finally {
FeedAlarmReceiver.completeWakefulIntent(intent);
}
Log.d(LOGTAG, "Done.");
}
@Nullable
private FeedAction createActionForIntent(Intent intent) {
final Context context = getApplicationContext();
switch (intent.getAction()) {
case ACTION_SETUP:
return new SetupAlarmsAction(context);
case ACTION_SUBSCRIBE:
return new SubscribeToFeedAction(context);
case ACTION_CHECK:
return new CheckForUpdatesAction(context);
case ACTION_ENROLL:
return new EnrollSubscriptionsAction(context);
case ACTION_WITHDRAW:
return new WithdrawSubscriptionsAction(context);
default:
throw new AssertionError("Unknown action: " + intent.getAction());
}
}
private boolean isConnectedToUnmeteredNetwork() {
ConnectivityManager manager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo networkInfo = manager.getActiveNetworkInfo();
if (networkInfo == null || !networkInfo.isConnected()) {
return false;
}
return !ConnectivityManagerCompat.isActiveNetworkMetered(manager);
}
public static boolean isInExperiment(Context context) {
return SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS) ||
SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM) ||
SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM);
}
public static String getEnabledExperiment(Context context) {
String experiment = null;
if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS)) {
experiment = Experiments.CONTENT_NOTIFICATIONS_12HRS;
} else if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM)) {
experiment = Experiments.CONTENT_NOTIFICATIONS_8AM;
} else if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM)) {
experiment = Experiments.CONTENT_NOTIFICATIONS_5PM;
}
return experiment;
}
private boolean isPreferenceEnabled() {
return GeckoSharedPrefs.forApp(this).getBoolean(GeckoPreferences.PREFS_NOTIFICATIONS_CONTENT, true);
}
}

View file

@ -0,0 +1,281 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.action;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.content.ContextCompat;
import android.text.format.DateFormat;
import org.json.JSONException;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.BrowserApp;
import org.mozilla.gecko.GeckoApp;
import org.mozilla.gecko.R;
import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.TelemetryContract;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.db.UrlAnnotations;
import org.mozilla.gecko.feeds.ContentNotificationsDelegate;
import org.mozilla.gecko.feeds.FeedFetcher;
import org.mozilla.gecko.feeds.FeedService;
import org.mozilla.gecko.feeds.parser.Feed;
import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
import org.mozilla.gecko.preferences.GeckoPreferences;
import org.mozilla.gecko.util.StringUtils;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* CheckForUpdatesAction: Check if feeds we subscribed to have new content available.
*/
public class CheckForUpdatesAction extends FeedAction {
/**
* This extra will be added to Intents fired by the notification.
*/
public static final String EXTRA_CONTENT_NOTIFICATION = "content-notification";
private final Context context;
public CheckForUpdatesAction(Context context) {
this.context = context;
}
@Override
public void perform(BrowserDB browserDB, Intent intent) {
final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
final ContentResolver resolver = context.getContentResolver();
final List<Feed> updatedFeeds = new ArrayList<>();
log("Checking feeds for updates..");
Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver);
if (cursor == null) {
return;
}
try {
while (cursor.moveToNext()) {
FeedSubscription subscription = FeedSubscription.fromCursor(cursor);
FeedFetcher.FeedResponse response = checkFeedForUpdates(subscription);
if (response != null) {
final Feed feed = response.feed;
if (!hasBeenVisited(browserDB, feed.getLastItem().getURL())) {
// Only notify about this update if the last item hasn't been visited yet.
updatedFeeds.add(feed);
} else {
Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL,
TelemetryContract.Method.SERVICE,
"content_update");
Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
}
urlAnnotations.updateFeedSubscription(resolver, subscription);
}
}
} catch (JSONException e) {
log("Could not deserialize subscription", e);
} finally {
cursor.close();
}
showNotification(updatedFeeds);
}
private FeedFetcher.FeedResponse checkFeedForUpdates(FeedSubscription subscription) {
log("Checking feed: " + subscription.getFeedTitle());
FeedFetcher.FeedResponse response = fetchFeed(subscription);
if (response == null) {
return null;
}
if (subscription.hasBeenUpdated(response)) {
log("* Feed has changed. New item: " + response.feed.getLastItem().getTitle());
subscription.update(response);
return response;
}
return null;
}
/**
* Returns true if this URL has been visited before.
*
* We do an exact match. So this can fail if the feed uses a different URL and redirects to
* content. But it's better than no checks at all.
*/
private boolean hasBeenVisited(final BrowserDB browserDB, final String url) {
final Cursor cursor = browserDB.getHistoryForURL(context.getContentResolver(), url);
if (cursor == null) {
return false;
}
try {
if (cursor.moveToFirst()) {
return cursor.getInt(cursor.getColumnIndex(BrowserContract.History.VISITS)) > 0;
}
} finally {
cursor.close();
}
return false;
}
private void showNotification(List<Feed> updatedFeeds) {
final int feedCount = updatedFeeds.size();
if (feedCount == 0) {
return;
}
if (feedCount == 1) {
showNotificationForSingleUpdate(updatedFeeds.get(0));
} else {
showNotificationForMultipleUpdates(updatedFeeds);
}
Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.NOTIFICATION, "content_update");
Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
}
private void showNotificationForSingleUpdate(Feed feed) {
final String date = DateFormat.getMediumDateFormat(context).format(new Date(feed.getLastItem().getTimestamp()));
final NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle()
.bigText(feed.getLastItem().getTitle())
.setBigContentTitle(feed.getTitle())
.setSummaryText(context.getString(R.string.content_notification_updated_on, date));
final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, createOpenIntent(feed), PendingIntent.FLAG_UPDATE_CURRENT);
final Notification notification = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_status_logo)
.setContentTitle(feed.getTitle())
.setContentText(feed.getLastItem().getTitle())
.setStyle(style)
.setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.addAction(createOpenAction(feed))
.addAction(createNotificationSettingsAction())
.build();
NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification);
}
private void showNotificationForMultipleUpdates(List<Feed> feeds) {
final NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
for (Feed feed : feeds) {
inboxStyle.addLine(StringUtils.stripScheme(feed.getLastItem().getURL(), StringUtils.UrlFlags.STRIP_HTTPS));
}
inboxStyle.setSummaryText(context.getString(R.string.content_notification_summary));
final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, createOpenIntent(feeds), PendingIntent.FLAG_UPDATE_CURRENT);
Notification notification = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_status_logo)
.setContentTitle(context.getString(R.string.content_notification_title_plural, feeds.size()))
.setContentText(context.getString(R.string.content_notification_summary))
.setStyle(inboxStyle)
.setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.addAction(createOpenAction(feeds))
.setNumber(feeds.size())
.addAction(createNotificationSettingsAction())
.build();
NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification);
}
private Intent createOpenIntent(Feed feed) {
final List<Feed> feeds = new ArrayList<>();
feeds.add(feed);
return createOpenIntent(feeds);
}
private Intent createOpenIntent(List<Feed> feeds) {
final ArrayList<String> urls = new ArrayList<>();
for (Feed feed : feeds) {
urls.add(feed.getLastItem().getURL());
}
final Intent intent = new Intent(context, BrowserApp.class);
intent.setAction(ContentNotificationsDelegate.ACTION_CONTENT_NOTIFICATION);
intent.putStringArrayListExtra(ContentNotificationsDelegate.EXTRA_URLS, urls);
return intent;
}
private NotificationCompat.Action createOpenAction(Feed feed) {
final List<Feed> feeds = new ArrayList<>();
feeds.add(feed);
return createOpenAction(feeds);
}
private NotificationCompat.Action createOpenAction(List<Feed> feeds) {
Intent intent = createOpenIntent(feeds);
intent.putExtra(ContentNotificationsDelegate.EXTRA_READ_BUTTON, true);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT);
return new NotificationCompat.Action(
R.drawable.open_in_browser,
context.getString(R.string.content_notification_action_read_now),
pendingIntent);
}
private NotificationCompat.Action createNotificationSettingsAction() {
final Intent intent = new Intent(GeckoApp.ACTION_LAUNCH_SETTINGS);
intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
intent.putExtra(EXTRA_CONTENT_NOTIFICATION, true);
GeckoPreferences.setResourceToOpen(intent, "preferences_notifications");
PendingIntent settingsIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
return new NotificationCompat.Action(
R.drawable.settings_notifications,
context.getString(R.string.content_notification_action_settings),
settingsIntent);
}
private FeedFetcher.FeedResponse fetchFeed(FeedSubscription subscription) {
return FeedFetcher.fetchAndParseFeedIfModified(
subscription.getFeedUrl(),
subscription.getETag(),
subscription.getLastModified()
);
}
@Override
public boolean requiresNetwork() {
return true;
}
@Override
public boolean requiresPreferenceEnabled() {
return true;
}
}

View file

@ -0,0 +1,101 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.action;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.text.TextUtils;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.db.UrlAnnotations;
import org.mozilla.gecko.feeds.FeedService;
import org.mozilla.gecko.feeds.knownsites.KnownSiteBlogger;
import org.mozilla.gecko.feeds.knownsites.KnownSite;
import org.mozilla.gecko.feeds.knownsites.KnownSiteMedium;
import org.mozilla.gecko.feeds.knownsites.KnownSiteTumblr;
import org.mozilla.gecko.feeds.knownsites.KnownSiteWordpress;
/**
* EnrollSubscriptionsAction: Search for bookmarks of known sites we can subscribe to.
*/
public class EnrollSubscriptionsAction extends FeedAction {
private static final String LOGTAG = "FeedEnrollAction";
private static final KnownSite[] knownSites = {
new KnownSiteMedium(),
new KnownSiteBlogger(),
new KnownSiteWordpress(),
new KnownSiteTumblr(),
};
private Context context;
public EnrollSubscriptionsAction(Context context) {
this.context = context;
}
@Override
public void perform(BrowserDB db, Intent intent) {
log("Searching for bookmarks to enroll in updates");
final ContentResolver contentResolver = context.getContentResolver();
for (KnownSite knownSite : knownSites) {
searchFor(db, contentResolver, knownSite);
}
}
@Override
public boolean requiresNetwork() {
return false;
}
@Override
public boolean requiresPreferenceEnabled() {
return true;
}
private void searchFor(BrowserDB db, ContentResolver contentResolver, KnownSite knownSite) {
final UrlAnnotations urlAnnotations = db.getUrlAnnotations();
final Cursor cursor = db.getBookmarksForPartialUrl(contentResolver, knownSite.getURLSearchString());
if (cursor == null) {
log("Nothing found (" + knownSite.getClass().getSimpleName() + ")");
return;
}
try {
log("Found " + cursor.getCount() + " websites");
while (cursor.moveToNext()) {
final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.Bookmarks.URL));
log(" URL: " + url);
String feedUrl = knownSite.getFeedFromURL(url);
if (TextUtils.isEmpty(feedUrl)) {
log("Could not determine feed for URL: " + url);
return;
}
if (!urlAnnotations.hasFeedUrlForWebsite(contentResolver, url)) {
urlAnnotations.insertFeedUrl(contentResolver, url, feedUrl);
}
if (!urlAnnotations.hasFeedSubscription(contentResolver, feedUrl)) {
FeedService.subscribe(context, feedUrl);
}
}
} finally {
cursor.close();
}
}
}

View file

@ -0,0 +1,58 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.action;
import android.content.Intent;
import android.util.Log;
import org.mozilla.gecko.db.BrowserDB;
/**
* Interface for actions run by FeedService.
*/
public abstract class FeedAction {
public static final boolean DEBUG_LOG = false;
/**
* Perform this action.
*
* @param browserDB database instance to perform the action.
* @param intent used to start the service.
*/
public abstract void perform(BrowserDB browserDB, Intent intent);
/**
* Does this action require an active network connection?
*/
public abstract boolean requiresNetwork();
/**
* Should this action only run if the preference is enabled?
*/
public abstract boolean requiresPreferenceEnabled();
/**
* This method will swallow all log messages to avoid logging potential personal information.
*
* For debugging purposes set {@code DEBUG_LOG} to true.
*/
public void log(String message) {
if (DEBUG_LOG) {
Log.d("Gecko" + getClass().getSimpleName(), message);
}
}
/**
* This method will swallow all log messages to avoid logging potential personal information.
*
* For debugging purposes set {@code DEBUG_LOG} to true.
*/
public void log(String message, Throwable throwable) {
if (DEBUG_LOG) {
Log.d("Gecko" + getClass().getSimpleName(), message, throwable);
}
}
}

View file

@ -0,0 +1,146 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.action;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;
import com.keepsafe.switchboard.SwitchBoard;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.feeds.FeedAlarmReceiver;
import org.mozilla.gecko.feeds.FeedService;
import org.mozilla.gecko.Experiments;
import java.text.DateFormat;
import java.util.Calendar;
/**
* SetupAlarmsAction: Set up alarms to run various actions every now and then.
*/
public class SetupAlarmsAction extends FeedAction {
private static final String LOGTAG = "FeedSetupAction";
private Context context;
public SetupAlarmsAction(Context context) {
this.context = context;
}
@Override
public void perform(BrowserDB browserDB, Intent intent) {
final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
cancelPreviousAlarms(alarmManager);
scheduleAlarms(alarmManager);
}
@Override
public boolean requiresNetwork() {
return false;
}
@Override
public boolean requiresPreferenceEnabled() {
return false;
}
private void cancelPreviousAlarms(AlarmManager alarmManager) {
final PendingIntent withdrawIntent = getWithdrawPendingIntent();
alarmManager.cancel(withdrawIntent);
final PendingIntent enrollIntent = getEnrollPendingIntent();
alarmManager.cancel(enrollIntent);
final PendingIntent checkIntent = getCheckPendingIntent();
alarmManager.cancel(checkIntent);
log("Cancelled previous alarms");
}
private void scheduleAlarms(AlarmManager alarmManager) {
alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_FIFTEEN_MINUTES,
AlarmManager.INTERVAL_DAY,
getWithdrawPendingIntent());
alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HALF_HOUR,
AlarmManager.INTERVAL_DAY,
getEnrollPendingIntent()
);
if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS)) {
scheduleUpdateCheckEvery12Hours(alarmManager);
}
if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM)) {
scheduleUpdateAtFullHour(alarmManager, 8);
}
if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM)) {
scheduleUpdateAtFullHour(alarmManager, 17);
}
log("Scheduled alarms");
}
private void scheduleUpdateCheckEvery12Hours(AlarmManager alarmManager) {
alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR,
AlarmManager.INTERVAL_HALF_DAY,
getCheckPendingIntent()
);
}
private void scheduleUpdateAtFullHour(AlarmManager alarmManager, int hourOfDay) {
final Calendar calendar = Calendar.getInstance();
if (calendar.get(Calendar.HOUR_OF_DAY) >= hourOfDay) {
// This time has already passed today. Try again tomorrow.
calendar.add(Calendar.DAY_OF_MONTH, 1);
}
calendar.set(Calendar.HOUR_OF_DAY, hourOfDay);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
alarmManager.setInexactRepeating(
AlarmManager.RTC,
calendar.getTimeInMillis(),
AlarmManager.INTERVAL_DAY,
getCheckPendingIntent()
);
log("Scheduled update alarm at " + DateFormat.getDateTimeInstance().format(calendar.getTime()));
}
private PendingIntent getWithdrawPendingIntent() {
Intent intent = new Intent(context, FeedAlarmReceiver.class);
intent.setAction(FeedService.ACTION_WITHDRAW);
return PendingIntent.getBroadcast(context, 0, intent, 0);
}
private PendingIntent getEnrollPendingIntent() {
Intent intent = new Intent(context, FeedAlarmReceiver.class);
intent.setAction(FeedService.ACTION_ENROLL);
return PendingIntent.getBroadcast(context, 0, intent, 0);
}
private PendingIntent getCheckPendingIntent() {
Intent intent = new Intent(context, FeedAlarmReceiver.class);
intent.setAction(FeedService.ACTION_CHECK);
return PendingIntent.getBroadcast(context, 0, intent, 0);
}
}

View file

@ -0,0 +1,79 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.action;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.TelemetryContract;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.db.UrlAnnotations;
import org.mozilla.gecko.feeds.FeedFetcher;
import org.mozilla.gecko.feeds.FeedService;
import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
/**
* SubscribeToFeedAction: Try to fetch a feed and create a subscription if successful.
*/
public class SubscribeToFeedAction extends FeedAction {
private static final String LOGTAG = "FeedSubscribeAction";
public static final String EXTRA_FEED_URL = "feed_url";
private Context context;
public SubscribeToFeedAction(Context context) {
this.context = context;
}
@Override
public void perform(BrowserDB browserDB, Intent intent) {
final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
final Bundle extras = intent.getExtras();
final String feedUrl = extras.getString(EXTRA_FEED_URL);
if (urlAnnotations.hasFeedSubscription(context.getContentResolver(), feedUrl)) {
log("Already subscribed to " + feedUrl + ". Skipping.");
return;
}
log("Subscribing to feed: " + feedUrl);
subscribe(urlAnnotations, feedUrl);
}
@Override
public boolean requiresNetwork() {
return true;
}
@Override
public boolean requiresPreferenceEnabled() {
return true;
}
private void subscribe(UrlAnnotations urlAnnotations, String feedUrl) {
FeedFetcher.FeedResponse response = FeedFetcher.fetchAndParseFeed(feedUrl);
if (response == null) {
log(String.format("Could not fetch feed (%s). Not subscribing for now.", feedUrl));
return;
}
log("Subscribing to feed: " + response.feed.getTitle());
log(" Last item: " + response.feed.getLastItem().getTitle());
final FeedSubscription subscription = FeedSubscription.create(feedUrl, response);
urlAnnotations.insertFeedSubscription(context.getContentResolver(), subscription);
Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "content_update");
Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
}
}

View file

@ -0,0 +1,109 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.action;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import org.json.JSONException;
import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.TelemetryContract;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.db.UrlAnnotations;
import org.mozilla.gecko.feeds.FeedService;
import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
/**
* WithdrawSubscriptionsAction: Look for feeds to unsubscribe from.
*/
public class WithdrawSubscriptionsAction extends FeedAction {
private static final String LOGTAG = "FeedWithdrawAction";
private Context context;
public WithdrawSubscriptionsAction(Context context) {
this.context = context;
}
@Override
public void perform(BrowserDB browserDB, Intent intent) {
log("Searching for subscriptions to remove..");
final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
final ContentResolver resolver = context.getContentResolver();
removeFeedsOfUnknownUrls(browserDB, urlAnnotations, resolver);
removeSubscriptionsOfRemovedFeeds(urlAnnotations, resolver);
}
/**
* Search for website URLs with a feed assigned. Remove entry if website URL is not known anymore:
* For now this means the website is not bookmarked.
*/
private void removeFeedsOfUnknownUrls(BrowserDB browserDB, UrlAnnotations urlAnnotations, ContentResolver resolver) {
Cursor cursor = urlAnnotations.getWebsitesWithFeedUrl(resolver);
if (cursor == null) {
return;
}
try {
while (cursor.moveToNext()) {
final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL));
if (!browserDB.isBookmark(resolver, url)) {
log("Removing feed for unknown URL: " + url);
urlAnnotations.deleteFeedUrl(resolver, url);
}
}
} finally {
cursor.close();
}
}
/**
* Remove subscriptions of feed URLs that are not assigned to a website URL (anymore).
*/
private void removeSubscriptionsOfRemovedFeeds(UrlAnnotations urlAnnotations, ContentResolver resolver) {
Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver);
if (cursor == null) {
return;
}
try {
while (cursor.moveToNext()) {
final FeedSubscription subscription = FeedSubscription.fromCursor(cursor);
if (!urlAnnotations.hasWebsiteForFeedUrl(resolver, subscription.getFeedUrl())) {
log("Removing subscription for feed: " + subscription.getFeedUrl());
urlAnnotations.deleteFeedSubscription(resolver, subscription);
Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "content_update");
Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
}
}
} catch (JSONException e) {
log("Could not deserialize subscription", e);
} finally {
cursor.close();
}
}
@Override
public boolean requiresNetwork() {
return false;
}
@Override
public boolean requiresPreferenceEnabled() {
return true;
}
}

View file

@ -0,0 +1,38 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.knownsites;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
/**
* A site we know and for which we can guess the feed URL from an arbitrary URL.
*/
public interface KnownSite {
/**
* Get a search string to find URLs of this site in our database. This search string is usually
* a partial domain / URL.
*
* For example we could return "medium.com" to find all URLs that contain this string. This could
* obviously find URLs that are not actually medium.com sites. This is acceptable as long as
* getFeedFromURL() can handle these inputs and either returns a feed for valid URLs or null for
* other matches that are not related to this site.
*/
@NonNull String getURLSearchString();
/**
* Get the Feed URL for this URL. For a known site we can "guess" the feed URL from an URL
* pointing to any page. The input URL will be a result from the database found with the value
* returned by getURLSearchString().
*
* Example:
* - Input: https://medium.com/@antlam/ux-thoughts-for-2016-1fc1d6e515e8
* - Output: https://medium.com/feed/@antlam
*
* @return the url representing a feed, or null if a feed could not be determined.
*/
@Nullable String getFeedFromURL(String url);
}

View file

@ -0,0 +1,29 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.knownsites;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Blogger.com
*/
public class KnownSiteBlogger implements KnownSite {
@Override
public String getURLSearchString() {
return ".blogspot.com";
}
@Override
public String getFeedFromURL(String url) {
Pattern pattern = Pattern.compile("https?://(www\\.)?(.*?)\\.blogspot\\.com(/.*)?");
Matcher matcher = pattern.matcher(url);
if (matcher.matches()) {
return String.format("https://%s.blogspot.com/feeds/posts/default", matcher.group(2));
}
return null;
}
}

View file

@ -0,0 +1,29 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.knownsites;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Medium.com
*/
public class KnownSiteMedium implements KnownSite {
@Override
public String getURLSearchString() {
return "://medium.com/";
}
@Override
public String getFeedFromURL(String url) {
Pattern pattern = Pattern.compile("https?://medium.com/([^/]+)(/.*)?");
Matcher matcher = pattern.matcher(url);
if (matcher.matches()) {
return String.format("https://medium.com/feed/%s", matcher.group(1));
}
return null;
}
}

View file

@ -0,0 +1,33 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.knownsites;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Tumblr.com
*/
public class KnownSiteTumblr implements KnownSite {
@Override
public String getURLSearchString() {
return ".tumblr.com";
}
@Override
public String getFeedFromURL(String url) {
final Pattern pattern = Pattern.compile("https?://(.*?).tumblr.com(/.*)?");
final Matcher matcher = pattern.matcher(url);
if (matcher.matches()) {
final String username = matcher.group(1);
if (username.equals("www")) {
return null;
}
return "http://" + username + ".tumblr.com/rss";
}
return null;
}
}

View file

@ -0,0 +1,26 @@
package org.mozilla.gecko.feeds.knownsites;
import android.support.annotation.NonNull;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Wordpress.com
*/
public class KnownSiteWordpress implements KnownSite {
@Override
public String getURLSearchString() {
return ".wordpress.com";
}
@Override
public String getFeedFromURL(String url) {
Pattern pattern = Pattern.compile("https?://(.*?).wordpress.com(/.*)?");
Matcher matcher = pattern.matcher(url);
if (matcher.matches()) {
return "https://" + matcher.group(1) + ".wordpress.com/feed/";
}
return null;
}
}

View file

@ -0,0 +1,70 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.parser;
import ch.boye.httpclientandroidlib.util.TextUtils;
public class Feed {
private String title;
private String websiteURL;
private String feedURL;
private Item lastItem;
public static Feed create(String title, String websiteURL, String feedURL, Item lastItem) {
Feed feed = new Feed();
feed.setTitle(title);
feed.setWebsiteURL(websiteURL);
feed.setFeedURL(feedURL);
feed.setLastItem(lastItem);
return feed;
}
/* package-private */ Feed() {}
/* package-private */ void setTitle(String title) {
this.title = title;
}
/* package-private */ void setWebsiteURL(String websiteURL) {
this.websiteURL = websiteURL;
}
/* package-private */ void setFeedURL(String feedURL) {
this.feedURL = feedURL;
}
/* package-private */ void setLastItem(Item lastItem) {
this.lastItem = lastItem;
}
/**
* Is this feed object sufficiently complete so that we can use it?
*/
/* package-private */ boolean isSufficientlyComplete() {
return !TextUtils.isEmpty(title) &&
lastItem != null &&
!TextUtils.isEmpty(lastItem.getURL()) &&
!TextUtils.isEmpty(lastItem.getTitle());
}
public String getTitle() {
return title;
}
public String getWebsiteURL() {
return websiteURL;
}
public String getFeedURL() {
return feedURL;
}
public Item getLastItem() {
return lastItem;
}
}

View file

@ -0,0 +1,49 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.parser;
public class Item {
private String title;
private String url;
private long timestamp;
public static Item create(String title, String url, long timestamp) {
Item item = new Item();
item.setTitle(title);
item.setURL(url);
item.setTimestamp(timestamp);
return item;
}
/* package-private */ void setTitle(String title) {
this.title = title;
}
/* package-private */ void setURL(String url) {
this.url = url;
}
/* package-private */ void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
public String getTitle() {
return title;
}
public String getURL() {
return url;
}
/**
* @return the number of milliseconds since Jan. 1, 1970, midnight GMT.
*/
public long getTimestamp() {
return timestamp;
}
}

View file

@ -0,0 +1,367 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.parser;
import android.util.Log;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import ch.boye.httpclientandroidlib.util.TextUtils;
/**
* A super simple feed parser written for implementing "content notifications". This XML Pull Parser
* can read ATOM and RSS feeds and returns an object describing the feed and the latest entry.
*/
public class SimpleFeedParser {
/**
* Generic exception that's thrown by the parser whenever a stream cannot be parsed.
*/
public static class ParserException extends Exception {
private static final long serialVersionUID = -6119538440219805603L;
public ParserException(Throwable cause) {
super(cause);
}
public ParserException(String message) {
super(message);
}
}
private static final String LOGTAG = "Gecko/FeedParser";
private static final String TAG_RSS = "rss";
private static final String TAG_FEED = "feed";
private static final String TAG_RDF = "RDF";
private static final String TAG_TITLE = "title";
private static final String TAG_ITEM = "item";
private static final String TAG_LINK = "link";
private static final String TAG_ENTRY = "entry";
private static final String TAG_PUBDATE = "pubDate";
private static final String TAG_UPDATED = "updated";
private static final String TAG_DATE = "date";
private static final String TAG_SOURCE = "source";
private static final String TAG_IMAGE = "image";
private static final String TAG_CONTENT = "content";
private class ParserState {
public Feed feed;
public Item currentItem;
public boolean isRSS;
public boolean isATOM;
public boolean inSource;
public boolean inImage;
public boolean inContent;
}
public Feed parse(InputStream in) throws ParserException, IOException {
final ParserState state = new ParserState();
try {
final XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setNamespaceAware(true);
XmlPullParser parser = factory.newPullParser();
parser.setInput(in, null);
int eventType = parser.getEventType();
while (eventType != XmlPullParser.END_DOCUMENT) {
switch (eventType) {
case XmlPullParser.START_DOCUMENT:
handleStartDocument(state);
break;
case XmlPullParser.START_TAG:
handleStartTag(parser, state);
break;
case XmlPullParser.END_TAG:
handleEndTag(parser, state);
break;
}
eventType = parser.next();
}
} catch (XmlPullParserException e) {
throw new ParserException(e);
}
if (!state.feed.isSufficientlyComplete()) {
throw new ParserException("Feed is not sufficiently complete");
}
return state.feed;
}
private void handleStartDocument(ParserState state) {
state.feed = new Feed();
}
private void handleStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
switch (parser.getName()) {
case TAG_RSS:
state.isRSS = true;
break;
case TAG_FEED:
state.isATOM = true;
break;
case TAG_RDF:
// This is a RSS 1.0 feed
state.isRSS = true;
break;
case TAG_ITEM:
case TAG_ENTRY:
state.currentItem = new Item();
break;
case TAG_TITLE:
handleTitleStartTag(parser, state);
break;
case TAG_LINK:
handleLinkStartTag(parser, state);
break;
case TAG_PUBDATE:
handlePubDateStartTag(parser, state);
break;
case TAG_UPDATED:
handleUpdatedStartTag(parser, state);
break;
case TAG_DATE:
handleDateStartTag(parser, state);
break;
case TAG_SOURCE:
state.inSource = true;
break;
case TAG_IMAGE:
state.inImage = true;
break;
case TAG_CONTENT:
state.inContent = true;
break;
}
}
private void handleEndTag(XmlPullParser parser, ParserState state) {
switch (parser.getName()) {
case TAG_ITEM:
case TAG_ENTRY:
handleItemOrEntryREndTag(state);
break;
case TAG_SOURCE:
state.inSource = false;
break;
case TAG_IMAGE:
state.inImage = false;
break;
case TAG_CONTENT:
state.inContent = false;
break;
}
}
private void handleTitleStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
if (state.inSource || state.inImage || state.inContent) {
// We do not care about titles in <source>, <image> or <media> tags.
return;
}
String title = getTextUntilEndTag(parser, TAG_TITLE);
title = title.replaceAll("[\r\n]", " ");
title = title.replaceAll(" +", " ");
if (state.currentItem != null) {
state.currentItem.setTitle(title);
} else {
state.feed.setTitle(title);
}
}
private void handleLinkStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
if (state.inSource || state.inImage) {
// We do not care about links in <source> or <image> tags.
return;
}
Map<String, String> attributes = fetchAttributes(parser);
if (attributes.size() > 0) {
String rel = attributes.get("rel");
if (state.currentItem == null && "self".equals(rel)) {
state.feed.setFeedURL(attributes.get("href"));
return;
}
if (rel == null || "alternate".equals(rel)) {
String type = attributes.get("type");
if (type == null || type.equals("text/html")) {
String link = attributes.get("href");
if (TextUtils.isEmpty(link)) {
return;
}
if (state.currentItem != null) {
state.currentItem.setURL(link);
} else {
state.feed.setWebsiteURL(link);
}
return;
}
}
}
if (state.isRSS) {
String link = getTextUntilEndTag(parser, TAG_LINK);
if (TextUtils.isEmpty(link)) {
return;
}
if (state.currentItem != null) {
state.currentItem.setURL(link);
} else {
state.feed.setWebsiteURL(link);
}
}
}
private void handleItemOrEntryREndTag(ParserState state) {
if (state.feed.getLastItem() == null || state.feed.getLastItem().getTimestamp() < state.currentItem.getTimestamp()) {
// Only set this item as "last item" if we do not have an item yet or this item is newer.
state.feed.setLastItem(state.currentItem);
}
state.currentItem = null;
}
private void handlePubDateStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
if (state.currentItem == null) {
return;
}
String pubDate = getTextUntilEndTag(parser, TAG_PUBDATE);
if (TextUtils.isEmpty(pubDate)) {
return;
}
// RFC-822
SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
updateCurrentItemTimestamp(state, pubDate, format);
}
private void handleUpdatedStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
if (state.inSource) {
// We do not care about stuff in <source> tags.
return;
}
if (state.currentItem == null) {
// We are only interested in <updated> values of feed items.
return;
}
String updated = getTextUntilEndTag(parser, TAG_UPDATED);
if (TextUtils.isEmpty(updated)) {
return;
}
SimpleDateFormat[] formats = new SimpleDateFormat[] {
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US),
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US)
};
// Fix timezones SimpleDateFormat can't parse:
// 2016-01-26T18:56:54Z -> 2016-01-26T18:56:54+0000 (Timezone: Z -> +0000)
updated = updated.replaceFirst("Z$", "+0000");
// 2016-01-26T18:56:54+01:00 -> 2016-01-26T18:56:54+0100 (Timezone: +01:00 -> +0100)
updated = updated.replaceFirst("([0-9]{2})([\\+\\-])([0-9]{2}):([0-9]{2})$", "$1$2$3$4");
updateCurrentItemTimestamp(state, updated, formats);
}
private void handleDateStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
if (state.currentItem == null) {
// We are only interested in <updated> values of feed items.
return;
}
String text = getTextUntilEndTag(parser, TAG_DATE);
if (TextUtils.isEmpty(text)) {
return;
}
// Fix timezones SimpleDateFormat can't parse:
// 2016-01-26T18:56:54+00:00 -> 2016-01-26T18:56:54+0000
text = text.replaceFirst("([0-9]{2})([\\+\\-])([0-9]{2}):([0-9]{2})$", "$1$2$3$4");
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US);
updateCurrentItemTimestamp(state, text, format);
}
private void updateCurrentItemTimestamp(ParserState state, String text, SimpleDateFormat... formats) {
for (SimpleDateFormat format : formats) {
try {
Date date = format.parse(text);
state.currentItem.setTimestamp(date.getTime());
return;
} catch (ParseException e) {
Log.w(LOGTAG, "Could not parse 'updated': " + text);
}
}
}
private Map<String, String> fetchAttributes(XmlPullParser parser) {
Map<String, String> attributes = new HashMap<>();
for (int i = 0; i < parser.getAttributeCount(); i++) {
attributes.put(parser.getAttributeName(i), parser.getAttributeValue(i));
}
return attributes;
}
private String getTextUntilEndTag(XmlPullParser parser, String tag) throws IOException, XmlPullParserException {
StringBuilder builder = new StringBuilder();
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.getEventType() == XmlPullParser.TEXT) {
builder.append(parser.getText());
} else if (parser.getEventType() == XmlPullParser.END_TAG && tag.equals(parser.getName())) {
break;
}
}
return builder.toString().trim();
}
}

View file

@ -0,0 +1,130 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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.feeds.subscriptions;
import android.database.Cursor;
import android.text.TextUtils;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.feeds.FeedFetcher;
import org.mozilla.gecko.feeds.parser.Item;
/**
* An object describing a subscription and containing some meta data about the last time we fetched
* the feed.
*/
public class FeedSubscription {
private static final String JSON_KEY_FEED_TITLE = "feed_title";
private static final String JSON_KEY_LAST_ITEM_TITLE = "last_item_title";
private static final String JSON_KEY_LAST_ITEM_URL = "last_item_url";
private static final String JSON_KEY_LAST_ITEM_TIMESTAMP = "last_item_timestamp";
private static final String JSON_KEY_ETAG = "etag";
private static final String JSON_KEY_LAST_MODIFIED = "last_modified";
private String feedUrl;
private String feedTitle;
private String lastItemTitle;
private String lastItemUrl;
private long lastItemTimestamp;
private String etag;
private String lastModified;
public static FeedSubscription create(String feedUrl, FeedFetcher.FeedResponse response) {
FeedSubscription subscription = new FeedSubscription();
subscription.feedUrl = feedUrl;
subscription.update(response);
return subscription;
}
public static FeedSubscription fromCursor(Cursor cursor) throws JSONException {
final FeedSubscription subscription = new FeedSubscription();
subscription.feedUrl = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL));
final String value = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.VALUE));
subscription.fromJSON(new JSONObject(value));
return subscription;
}
private void fromJSON(JSONObject object) throws JSONException {
feedTitle = object.getString(JSON_KEY_FEED_TITLE);
lastItemTitle = object.getString(JSON_KEY_LAST_ITEM_TITLE);
lastItemUrl = object.getString(JSON_KEY_LAST_ITEM_URL);
lastItemTimestamp = object.getLong(JSON_KEY_LAST_ITEM_TIMESTAMP);
etag = object.optString(JSON_KEY_ETAG);
lastModified = object.optString(JSON_KEY_LAST_MODIFIED);
}
public void update(FeedFetcher.FeedResponse response) {
feedTitle = response.feed.getTitle();
lastItemTitle = response.feed.getLastItem().getTitle();
lastItemUrl = response.feed.getLastItem().getURL();
lastItemTimestamp = response.feed.getLastItem().getTimestamp();
etag = response.etag;
lastModified = response.lastModified;
}
/**
* Guesstimate if this response is a newer representation of the feed.
*/
public boolean hasBeenUpdated(FeedFetcher.FeedResponse response) {
final Item responseItem = response.feed.getLastItem();
if (responseItem.getTimestamp() > lastItemTimestamp) {
// The timestamp is from a newer date so we expect that this item is a new item. But this
// could also mean that the timestamp of an already existing item has been updated. We
// accept that and assume that the content will have changed too in this case.
return true;
}
if (responseItem.getTimestamp() == lastItemTimestamp && responseItem.getTimestamp() != 0) {
// We have a timestamp that is not zero and this item has still the timestamp: It's very
// likely that we are looking at the same item. We assume this is not new content.
return false;
}
if (!responseItem.getURL().equals(lastItemUrl)) {
// The URL changed: It is very likely that this is a new item. At least it has been updated
// in a way that we just treat it as new content here.
return true;
}
return false;
}
public String getFeedUrl() {
return feedUrl;
}
public String getFeedTitle() {
return feedTitle;
}
public String getETag() {
return etag;
}
public String getLastModified() {
return lastModified;
}
public JSONObject toJSON() throws JSONException {
JSONObject object = new JSONObject();
object.put(JSON_KEY_FEED_TITLE, feedTitle);
object.put(JSON_KEY_LAST_ITEM_TITLE, lastItemTitle);
object.put(JSON_KEY_LAST_ITEM_URL, lastItemUrl);
object.put(JSON_KEY_LAST_ITEM_TIMESTAMP, lastItemTimestamp);
object.put(JSON_KEY_ETAG, etag);
object.put(JSON_KEY_LAST_MODIFIED, lastModified);
return object;
}
}