package org.wordpress.android.ui.stats.service;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import com.android.volley.Request;
import com.android.volley.VolleyError;
import com.wordpress.rest.RestRequest;
import org.json.JSONException;
import org.json.JSONObject;
import org.wordpress.android.WordPress;
import org.wordpress.android.fluxc.model.SiteModel;
import org.wordpress.android.fluxc.store.SiteStore;
import org.wordpress.android.networking.RestClientUtils;
import org.wordpress.android.ui.stats.StatsEvents;
import org.wordpress.android.ui.stats.StatsTimeframe;
import org.wordpress.android.ui.stats.StatsUtils;
import org.wordpress.android.ui.stats.StatsWidgetProvider;
import org.wordpress.android.ui.stats.datasets.StatsTable;
import org.wordpress.android.ui.stats.exceptions.StatsError;
import org.wordpress.android.ui.stats.models.AuthorsModel;
import org.wordpress.android.ui.stats.models.BaseStatsModel;
import org.wordpress.android.ui.stats.models.ClicksModel;
import org.wordpress.android.ui.stats.models.CommentFollowersModel;
import org.wordpress.android.ui.stats.models.CommentsModel;
import org.wordpress.android.ui.stats.models.FollowersModel;
import org.wordpress.android.ui.stats.models.GeoviewsModel;
import org.wordpress.android.ui.stats.models.InsightsAllTimeModel;
import org.wordpress.android.ui.stats.models.InsightsLatestPostDetailsModel;
import org.wordpress.android.ui.stats.models.InsightsLatestPostModel;
import org.wordpress.android.ui.stats.models.InsightsPopularModel;
import org.wordpress.android.ui.stats.models.PublicizeModel;
import org.wordpress.android.ui.stats.models.ReferrersModel;
import org.wordpress.android.ui.stats.models.SearchTermsModel;
import org.wordpress.android.ui.stats.models.TagsContainerModel;
import org.wordpress.android.ui.stats.models.TopPostsAndPagesModel;
import org.wordpress.android.ui.stats.models.VideoPlaysModel;
import org.wordpress.android.ui.stats.models.VisitModel;
import org.wordpress.android.ui.stats.models.VisitsModel;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.T;
import java.io.Serializable;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import javax.inject.Inject;
import de.greenrobot.event.EventBus;
/**
* Background service to retrieve Stats.
* Parsing of response(s) and submission of new network calls are done by using a ThreadPoolExecutor with a single thread.
*/
public class StatsService extends Service {
public static final String ARG_BLOG_ID = "blog_id";
public static final String ARG_PERIOD = "stats_period";
public static final String ARG_DATE = "stats_date";
public static final String ARG_SECTION = "stats_section";
public static final String ARG_MAX_RESULTS = "stats_max_results";
public static final String ARG_PAGE_REQUESTED = "stats_page_requested";
private static final int DEFAULT_NUMBER_OF_RESULTS = 12;
// The number of results to return per page for Paged REST endpoints. Numbers larger than 20 will default to 20 on the server.
public static final int MAX_RESULTS_REQUESTED_PER_PAGE = 20;
public enum StatsEndpointsEnum {
VISITS,
TOP_POSTS,
REFERRERS,
CLICKS,
GEO_VIEWS,
AUTHORS,
VIDEO_PLAYS,
COMMENTS,
FOLLOWERS_WPCOM,
FOLLOWERS_EMAIL,
COMMENT_FOLLOWERS,
TAGS_AND_CATEGORIES,
PUBLICIZE,
SEARCH_TERMS,
INSIGHTS_POPULAR,
INSIGHTS_ALL_TIME,
INSIGHTS_TODAY,
INSIGHTS_LATEST_POST_SUMMARY,
INSIGHTS_LATEST_POST_VIEWS;
public String getRestEndpointPath() {
switch (this) {
case VISITS:
return "visits";
case TOP_POSTS:
return "top-posts";
case REFERRERS:
return "referrers";
case CLICKS:
return "clicks";
case GEO_VIEWS:
return "country-views";
case AUTHORS:
return "top-authors";
case VIDEO_PLAYS:
return "video-plays";
case COMMENTS:
return "comments";
case FOLLOWERS_WPCOM:
return "followers?type=wpcom";
case FOLLOWERS_EMAIL:
return "followers?type=email";
case COMMENT_FOLLOWERS:
return "comment-followers";
case TAGS_AND_CATEGORIES:
return "tags";
case PUBLICIZE:
return "publicize";
case SEARCH_TERMS:
return "search-terms";
case INSIGHTS_POPULAR:
return "insights";
case INSIGHTS_ALL_TIME:
return "";
case INSIGHTS_TODAY:
return "summary";
case INSIGHTS_LATEST_POST_SUMMARY:
return "posts";
case INSIGHTS_LATEST_POST_VIEWS:
return "post";
default:
AppLog.i(T.STATS, "Called an update of Stats of unknown section!?? " + this.name());
return "";
}
}
public StatsEvents.SectionUpdatedAbstract getEndpointUpdateEvent(final long siteId,
final StatsTimeframe timeframe,
final String date,
final int maxResultsRequested,
final int pageRequested,
final BaseStatsModel data) {
switch (this) {
case VISITS:
return new StatsEvents.VisitorsAndViewsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (VisitsModel)data);
case TOP_POSTS:
return new StatsEvents.TopPostsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (TopPostsAndPagesModel)data);
case REFERRERS:
return new StatsEvents.ReferrersUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (ReferrersModel)data);
case CLICKS:
return new StatsEvents.ClicksUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (ClicksModel)data);
case AUTHORS:
return new StatsEvents.AuthorsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (AuthorsModel)data);
case GEO_VIEWS:
return new StatsEvents.CountriesUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (GeoviewsModel)data);
case VIDEO_PLAYS:
return new StatsEvents.VideoPlaysUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (VideoPlaysModel)data);
case SEARCH_TERMS:
return new StatsEvents.SearchTermsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (SearchTermsModel)data);
case COMMENTS:
return new StatsEvents.CommentsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (CommentsModel)data);
case COMMENT_FOLLOWERS:
return new StatsEvents.CommentFollowersUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (CommentFollowersModel)data);
case TAGS_AND_CATEGORIES:
return new StatsEvents.TagsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (TagsContainerModel)data);
case PUBLICIZE:
return new StatsEvents.PublicizeUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (PublicizeModel)data);
case FOLLOWERS_WPCOM:
return new StatsEvents.FollowersWPCOMUdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (FollowersModel)data);
case FOLLOWERS_EMAIL:
return new StatsEvents.FollowersEmailUdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (FollowersModel)data);
case INSIGHTS_POPULAR:
return new StatsEvents.InsightsPopularUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (InsightsPopularModel)data);
case INSIGHTS_ALL_TIME:
return new StatsEvents.InsightsAllTimeUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (InsightsAllTimeModel)data);
case INSIGHTS_TODAY:
return new StatsEvents.VisitorsAndViewsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (VisitsModel)data);
case INSIGHTS_LATEST_POST_SUMMARY:
return new StatsEvents.InsightsLatestPostSummaryUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (InsightsLatestPostModel)data);
case INSIGHTS_LATEST_POST_VIEWS:
return new StatsEvents.InsightsLatestPostDetailsUpdated(siteId, timeframe, date,
maxResultsRequested, pageRequested, (InsightsLatestPostDetailsModel)data);
default:
AppLog.e(T.STATS, "Can't find an Update Event that match the current endpoint: " + this.name());
}
return null;
}
}
private int mServiceStartId;
private final LinkedList<Request<JSONObject>> mStatsNetworkRequests = new LinkedList<>();
private final ThreadPoolExecutor singleThreadNetworkHandler = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);
@Inject SiteStore mSiteStore;
@Override
public void onCreate() {
super.onCreate();
AppLog.i(T.STATS, "service created");
((WordPress) getApplication()).component().inject(this);
}
@Override
public void onDestroy() {
stopRefresh();
AppLog.i(T.STATS, "service destroyed");
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null) {
AppLog.e(T.STATS, "StatsService was killed and restarted with a null intent.");
// if this service's process is killed while it is started (after returning from onStartCommand(Intent, int, int)),
// then leave it in the started state but don't retain this delivered intent.
// Later the system will try to re-create the service.
// Because it is in the started state, it will guarantee to call onStartCommand(Intent, int, int) after creating the new service instance;
// if there are not any pending start commands to be delivered to the service, it will be called with a null intent object.
stopRefresh();
return START_NOT_STICKY;
}
final long siteId = intent.getLongExtra(ARG_BLOG_ID, 0);
if (siteId == 0) {
AppLog.e(T.STATS, "StatsService was started with siteid == 0");
return START_NOT_STICKY;
}
int[] sectionFromIntent = intent.getIntArrayExtra(ARG_SECTION);
if (sectionFromIntent == null || sectionFromIntent.length == 0) {
// No sections to update
AppLog.e(T.STATS, "StatsService was started without valid sections info");
return START_NOT_STICKY;
}
final StatsTimeframe period;
if (intent.hasExtra(ARG_PERIOD)) {
period = (StatsTimeframe) intent.getSerializableExtra(ARG_PERIOD);
} else {
period = StatsTimeframe.DAY;
}
final String requestedDate;
if (intent.getStringExtra(ARG_DATE) == null) {
AppLog.w(T.STATS, "StatsService is started with a NULL date on this blogID - "
+ siteId + ". Using current date.");
SiteModel site = mSiteStore.getSiteBySiteId(siteId);
requestedDate = StatsUtils.getCurrentDateTZ(site);
} else {
requestedDate = intent.getStringExtra(ARG_DATE);
}
final int maxResultsRequested = intent.getIntExtra(ARG_MAX_RESULTS, DEFAULT_NUMBER_OF_RESULTS);
final int pageRequested = intent.getIntExtra(ARG_PAGE_REQUESTED, -1);
this.mServiceStartId = startId;
for (int i=0; i < sectionFromIntent.length; i++){
final StatsEndpointsEnum currentSectionsToUpdate = StatsEndpointsEnum.values()[sectionFromIntent[i]];
singleThreadNetworkHandler.submit(new Thread() {
@Override
public void run() {
startTasks(siteId, period, requestedDate, currentSectionsToUpdate, maxResultsRequested, pageRequested);
}
});
}
return START_NOT_STICKY;
}
private void stopRefresh() {
synchronized (mStatsNetworkRequests) {
this.mServiceStartId = 0;
for (Request<JSONObject> req : mStatsNetworkRequests) {
if (req != null && !req.hasHadResponseDelivered() && !req.isCanceled()) {
req.cancel();
}
}
mStatsNetworkRequests.clear();
}
}
// A fast way to disable caching during develop or when we want to disable it
// under some circumstances. Always true for now.
private boolean isCacheEnabled() {
return true;
}
// Check if we already have Stats
private String getCachedStats(final long siteId, final StatsTimeframe timeframe, final String date,
final StatsEndpointsEnum sectionToUpdate, final int maxResultsRequested,
final int pageRequested) {
if (!isCacheEnabled()) {
return null;
}
return StatsTable.getStats(this, siteId, timeframe, date, sectionToUpdate, maxResultsRequested, pageRequested);
}
private void startTasks(final long blogId, final StatsTimeframe timeframe, final String date,
final StatsEndpointsEnum sectionToUpdate, final int maxResultsRequested,
final int pageRequested) {
EventBus.getDefault().post(new StatsEvents.UpdateStatusChanged(true));
String cachedStats = getCachedStats(blogId, timeframe, date, sectionToUpdate, maxResultsRequested, pageRequested);
if (cachedStats != null) {
BaseStatsModel mResponseObjectModel;
try {
JSONObject response = new JSONObject(cachedStats);
mResponseObjectModel = StatsUtils.parseResponse(sectionToUpdate, blogId, response);
EventBus.getDefault().post(
sectionToUpdate.getEndpointUpdateEvent(blogId, timeframe, date,
maxResultsRequested, pageRequested, mResponseObjectModel)
);
updateWidgetsUI(blogId, sectionToUpdate, timeframe, date, pageRequested, mResponseObjectModel);
checkAllRequestsFinished(null);
return;
} catch (JSONException e) {
AppLog.e(AppLog.T.STATS, e);
}
}
final RestClientUtils restClientUtils = WordPress.getRestClientUtilsV1_1();
String period = timeframe.getLabelForRestCall();
RestListener vListener = new RestListener(sectionToUpdate, blogId, timeframe, date, maxResultsRequested, pageRequested);
final String periodDateMaxPlaceholder = "?period=%s&date=%s&max=%s";
String path = String.format(Locale.US, "/sites/%s/stats/" + sectionToUpdate.getRestEndpointPath(), blogId);
synchronized (mStatsNetworkRequests) {
switch (sectionToUpdate) {
case VISITS:
path = String.format(Locale.US, path + "?unit=%s&quantity=15&date=%s", period, date);
break;
case TOP_POSTS:
case REFERRERS:
case CLICKS:
case GEO_VIEWS:
case AUTHORS:
case VIDEO_PLAYS:
case SEARCH_TERMS:
path = String.format(Locale.US, path + periodDateMaxPlaceholder, period, date, maxResultsRequested);
break;
case TAGS_AND_CATEGORIES:
case PUBLICIZE:
path = String.format(Locale.US, path + "?max=%s", maxResultsRequested);
break;
case COMMENTS:
// No parameters
break;
case FOLLOWERS_WPCOM:
if (pageRequested < 1) {
path = String.format(Locale.US, path + "&max=%s", maxResultsRequested);
} else {
path = String.format(Locale.US, path + "&period=%s&date=%s&max=%s&page=%s",
period, date, maxResultsRequested, pageRequested);
}
break;
case FOLLOWERS_EMAIL:
if (pageRequested < 1) {
path = String.format(Locale.US, path + "&max=%s", maxResultsRequested);
} else {
path = String.format(Locale.US, path + "&period=%s&date=%s&max=%s&page=%s",
period, date, maxResultsRequested, pageRequested);
}
break;
case COMMENT_FOLLOWERS:
if (pageRequested < 1) {
path = String.format(Locale.US, path + "?max=%s", maxResultsRequested);
} else {
path = String.format(Locale.US, path + "?period=%s&date=%s&max=%s&page=%s", period,
date, maxResultsRequested, pageRequested);
}
break;
case INSIGHTS_ALL_TIME:
case INSIGHTS_POPULAR:
break;
case INSIGHTS_TODAY:
path = String.format(Locale.US, path + "?period=day&date=%s", date);
break;
case INSIGHTS_LATEST_POST_SUMMARY:
// This is an edge cases since we're not loading stats but posts
path = String.format(Locale.US, "/sites/%s/%s", blogId, sectionToUpdate.getRestEndpointPath()
+ "?order_by=date&number=1&type=post&fields=ID,title,URL,discussion,like_count,date");
break;
case INSIGHTS_LATEST_POST_VIEWS:
// This is a kind of edge case, since we used the pageRequested parameter to request a single postID
path = String.format(Locale.US, path + "/%s?fields=views", pageRequested);
break;
default:
AppLog.i(T.STATS, "Called an update of Stats of unknown section!?? " + sectionToUpdate.name());
return;
}
// We need to check if we already have the same request in the queue
if (checkIfRequestShouldBeEnqueued(restClientUtils, path)) {
AppLog.d(AppLog.T.STATS, "Enqueuing the following Stats request " + path);
Request<JSONObject> currentRequest = restClientUtils.get(path, vListener, vListener);
vListener.currentRequest = currentRequest;
currentRequest.setTag("StatsCall");
mStatsNetworkRequests.add(currentRequest);
} else {
AppLog.d(AppLog.T.STATS, "Stats request is already in the queue:" + path);
}
}
}
/**
* This method checks if we already have the same request in the Queue. No need to re-enqueue a new request
* if one with the same parameters is there.
*
* This method is a kind of tricky, since it does the comparison by checking the origin URL of requests.
* To do that we had to get the fullURL of the new request by calling a method of the REST client `getAbsoluteURL`.
* That's good for now, but could lead to errors if the RestClient changes the way the URL is constructed internally,
* by calling `getAbsoluteURL`.
*
* - Another approach would involve the get of the requests ErrorListener and the check Listener's parameters.
* - Cleanest approach is for sure to create a new class that extends Request<JSONObject> and stores parameters for later comparison,
* unfortunately we have to change the REST Client and RestClientUtils a lot if we want follow this way...
*
*/
private boolean checkIfRequestShouldBeEnqueued(final RestClientUtils restClientUtils, String path) {
String absoluteRequestPath = restClientUtils.getRestClient().getAbsoluteURL(path);
Iterator<Request<JSONObject>> it = mStatsNetworkRequests.iterator();
while (it.hasNext()) {
Request<JSONObject> req = it.next();
if (!req.hasHadResponseDelivered() && !req.isCanceled() &&
absoluteRequestPath.equals(req.getUrl())) {
return false;
}
}
return true;
}
// Call an updates on the installed widgets if the blog is the primary, the endpoint is Visits
// the timeframe is DAY or INSIGHTS, and the date = TODAY
private void updateWidgetsUI(long siteId, final StatsEndpointsEnum endpointName,
StatsTimeframe timeframe, String date, int pageRequested,
Serializable responseObjectModel) {
if (pageRequested != -1) {
return;
}
if (endpointName != StatsEndpointsEnum.VISITS) {
return;
}
if (timeframe != StatsTimeframe.DAY && timeframe != StatsTimeframe.INSIGHTS) {
return;
}
SiteModel site = mSiteStore.getSiteBySiteId(siteId);
// make sure the data is for the current date
if (!date.equals(StatsUtils.getCurrentDateTZ(site))) {
return;
}
if (responseObjectModel == null) {
// TODO What we want to do here?
return;
}
if (!StatsWidgetProvider.isBlogDisplayedInWidget(siteId)) {
AppLog.d(AppLog.T.STATS, "The blog with remoteID " + siteId
+ " is NOT displayed in any widget. Stats Service doesn't call an update of the widget.");
return;
}
if (responseObjectModel instanceof VisitsModel) {
VisitsModel visitsModel = (VisitsModel) responseObjectModel;
if (visitsModel.getVisits() == null || visitsModel.getVisits().size() == 0) {
return;
}
List<VisitModel> visits = visitsModel.getVisits();
VisitModel data = visits.get(visits.size() - 1);
StatsWidgetProvider.updateWidgets(getApplicationContext(), site, data);
} else if (responseObjectModel instanceof VolleyError) {
VolleyError error = (VolleyError) responseObjectModel;
StatsWidgetProvider.updateWidgets(getApplicationContext(), site, mSiteStore, error);
} else if (responseObjectModel instanceof StatsError) {
StatsError statsError = (StatsError) responseObjectModel;
StatsWidgetProvider.updateWidgets(getApplicationContext(), site, mSiteStore, statsError);
}
}
private class RestListener implements RestRequest.Listener, RestRequest.ErrorListener {
final long mRequestBlogId;
private final StatsTimeframe mTimeframe;
final StatsEndpointsEnum mEndpointName;
private final String mDate;
private Request<JSONObject> currentRequest;
private final int mMaxResultsRequested, mPageRequested;
public RestListener(StatsEndpointsEnum endpointName, long blogId, StatsTimeframe timeframe, String date,
final int maxResultsRequested, final int pageRequested) {
mRequestBlogId = blogId;
mTimeframe = timeframe;
mEndpointName = endpointName;
mDate = date;
mMaxResultsRequested = maxResultsRequested;
mPageRequested = pageRequested;
}
@Override
public void onResponse(final JSONObject response) {
singleThreadNetworkHandler.submit(new Thread() {
@Override
public void run() {
// do other stuff here
BaseStatsModel mResponseObjectModel = null;
if (response != null) {
try {
mResponseObjectModel = StatsUtils.parseResponse(mEndpointName, mRequestBlogId, response);
if (isCacheEnabled()) {
StatsTable.insertStats(StatsService.this, mRequestBlogId, mTimeframe, mDate, mEndpointName,
mMaxResultsRequested, mPageRequested,
response.toString(), System.currentTimeMillis());
}
} catch (JSONException e) {
AppLog.e(AppLog.T.STATS, e);
}
}
EventBus.getDefault().post(
mEndpointName.getEndpointUpdateEvent(mRequestBlogId, mTimeframe, mDate,
mMaxResultsRequested, mPageRequested, mResponseObjectModel)
);
updateWidgetsUI(mRequestBlogId, mEndpointName, mTimeframe, mDate, mPageRequested, mResponseObjectModel);
checkAllRequestsFinished(currentRequest);
}
});
}
@Override
public void onErrorResponse(final VolleyError volleyError) {
singleThreadNetworkHandler.submit(new Thread() {
@Override
public void run() {
AppLog.e(T.STATS, "Error while loading Stats!");
StatsUtils.logVolleyErrorDetails(volleyError);
BaseStatsModel mResponseObjectModel = null;
EventBus.getDefault().post(new StatsEvents.SectionUpdateError(mEndpointName, mRequestBlogId, mTimeframe, mDate,
mMaxResultsRequested, mPageRequested, volleyError));
updateWidgetsUI(mRequestBlogId, mEndpointName, mTimeframe, mDate, mPageRequested, mResponseObjectModel);
checkAllRequestsFinished(currentRequest);
}
});
}
}
private void stopService() {
/* Stop the service if this is the current response, or mServiceBlogId is null
String currentServiceBlogId = getServiceBlogId();
if (currentServiceBlogId == null || currentServiceBlogId.equals(mRequestBlogId)) {
stopService();
}*/
EventBus.getDefault().post(new StatsEvents.UpdateStatusChanged(false));
stopSelf(mServiceStartId);
synchronized (mStatsNetworkRequests) {
mStatsNetworkRequests.clear();
}
}
private void checkAllRequestsFinished(Request<JSONObject> req) {
synchronized (mStatsNetworkRequests) {
if (req != null) {
mStatsNetworkRequests.remove(req);
}
boolean isStillWorking = mStatsNetworkRequests.size() > 0 || singleThreadNetworkHandler.getQueue().size() > 0;
EventBus.getDefault().post(new StatsEvents.UpdateStatusChanged(isStillWorking));
}
}
}