package org.wordpress.android.ui.stats; import android.annotation.SuppressLint; import android.content.Context; import android.text.TextUtils; import com.android.volley.NetworkResponse; import com.android.volley.VolleyError; import org.json.JSONException; import org.json.JSONObject; import org.wordpress.android.R; import org.wordpress.android.WordPress; import org.wordpress.android.fluxc.model.SiteModel; import org.wordpress.android.ui.WPWebViewActivity; import org.wordpress.android.ui.reader.ReaderActivityLauncher; 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.InsightsTodayModel; 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.StatsPostModel; 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.VisitsModel; import org.wordpress.android.ui.stats.service.StatsService; import org.wordpress.android.util.AppLog; import org.wordpress.android.util.AppLog.T; import java.io.Serializable; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.TimeZone; import java.util.concurrent.TimeUnit; public class StatsUtils { @SuppressLint("SimpleDateFormat") private static long toMs(String date, String pattern) { if (date == null || date.equals("null")) { AppLog.w(T.UTILS, "Trying to parse a 'null' Stats Date."); return -1; } if (pattern == null) { AppLog.w(T.UTILS, "Trying to parse a Stats date with a null pattern"); return -1; } SimpleDateFormat sdf = new SimpleDateFormat(pattern); try { return sdf.parse(date).getTime(); } catch (ParseException e) { AppLog.e(T.UTILS, e); } return -1; } /** * Converts date in the form of 2013-07-18 to ms * */ public static long toMs(String date) { return toMs(date, StatsConstants.STATS_INPUT_DATE_FORMAT); } public static String msToString(long ms, String format) { SimpleDateFormat sdf = new SimpleDateFormat(format); return sdf.format(new Date(ms)); } /** * Get the current date of the blog in the form of yyyy-MM-dd (EX: 2013-07-18) * */ public static String getCurrentDateTZ(SiteModel site) { String timezone = site.getTimezone(); if (timezone == null) { AppLog.w(T.UTILS, "Timezone is null. Returning the device time!!"); return getCurrentDate(); } return getCurrentDateTimeTZ(timezone, StatsConstants.STATS_INPUT_DATE_FORMAT); } /** * Get the current datetime of the blog * */ public static String getCurrentDateTimeTZ(SiteModel site) { String timezone = site.getTimezone(); if (timezone == null) { AppLog.w(T.UTILS, "Timezone is null. Returning the device time!"); return getCurrentDatetime(); } String pattern = "yyyy-MM-dd HH:mm:ss"; // precision to seconds return getCurrentDateTimeTZ(timezone, pattern); } /** * Get the current datetime of the blog in Ms * */ public static long getCurrentDateTimeMsTZ(SiteModel site) { String timezone = site.getTimezone(); if (timezone == null) { AppLog.w(T.UTILS, "Timezone is null. Returning the device time!"); return new Date().getTime(); } String pattern = "yyyy-MM-dd HH:mm:ss"; // precision to seconds return toMs(getCurrentDateTimeTZ(timezone, pattern), pattern); } /** * Get the current date in the form of yyyy-MM-dd (EX: 2013-07-18) * */ public static String getCurrentDate() { SimpleDateFormat sdf = new SimpleDateFormat(StatsConstants.STATS_INPUT_DATE_FORMAT); return sdf.format(new Date()); } /** * Get the current date in the form of "yyyy-MM-dd HH:mm:ss" */ private static String getCurrentDatetime() { String pattern = "yyyy-MM-dd HH:mm:ss"; // precision to seconds SimpleDateFormat sdf = new SimpleDateFormat(pattern); return sdf.format(new Date()); } private static String getCurrentDateTimeTZ(String blogTimeZoneOption, String pattern) { Date date = new Date(); SimpleDateFormat gmtDf = new SimpleDateFormat(pattern); if (blogTimeZoneOption == null) { AppLog.w(T.UTILS, "blogTimeZoneOption is null. getCurrentDateTZ() will return the device time!"); return gmtDf.format(date); } /* Convert the timezone to a form that is compatible with Java TimeZone class WordPress returns something like the following: UTC+0:30 ----> 0.5 UTC+1 ----> 1.0 UTC-0:30 ----> -1.0 */ AppLog.v(T.STATS, "Parsing the following Timezone received from WP: " + blogTimeZoneOption); String timezoneNormalized; if (TextUtils.isEmpty(blogTimeZoneOption) || blogTimeZoneOption.equals("0") || blogTimeZoneOption.equals("0.0")) { timezoneNormalized = "GMT"; } else { String[] timezoneSplitted = org.apache.commons.lang3.StringUtils.split(blogTimeZoneOption, "."); timezoneNormalized = timezoneSplitted[0]; if(timezoneSplitted.length > 1 && timezoneSplitted[1].equals("5")){ timezoneNormalized += ":30"; } if (timezoneNormalized.startsWith("-")) { timezoneNormalized = "GMT" + timezoneNormalized; } else { if (timezoneNormalized.startsWith("+")) { timezoneNormalized = "GMT" + timezoneNormalized; } else { timezoneNormalized = "GMT+" + timezoneNormalized; } } } AppLog.v(T.STATS, "Setting the following Timezone: " + timezoneNormalized); gmtDf.setTimeZone(TimeZone.getTimeZone(timezoneNormalized)); return gmtDf.format(date); } public static String parseDate(String timestamp, String fromFormat, String toFormat) { SimpleDateFormat from = new SimpleDateFormat(fromFormat); SimpleDateFormat to = new SimpleDateFormat(toFormat); try { Date date = from.parse(timestamp); return to.format(date); } catch (ParseException e) { AppLog.e(T.STATS, e); } return ""; } /** * Get a diff between two dates * @param date1 the oldest date in Ms * @param date2 the newest date in Ms * @param timeUnit the unit in which you want the diff * @return the diff value, in the provided unit */ public static long getDateDiff(Date date1, Date date2, TimeUnit timeUnit) { long diffInMillies = date2.getTime() - date1.getTime(); return timeUnit.convert(diffInMillies, TimeUnit.MILLISECONDS); } //Calculate the correct start/end date for the selected period public static String getPublishedEndpointPeriodDateParameters(StatsTimeframe timeframe, String date) { if (date == null) { AppLog.w(AppLog.T.STATS, "Can't calculate start and end period without a reference date"); return null; } try { SimpleDateFormat sdf = new SimpleDateFormat(StatsConstants.STATS_INPUT_DATE_FORMAT); Calendar c = Calendar.getInstance(); c.setFirstDayOfWeek(Calendar.MONDAY); Date parsedDate = sdf.parse(date); c.setTime(parsedDate); final String after; final String before; switch (timeframe) { case DAY: after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); c.add(Calendar.DAY_OF_YEAR, +1); before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); break; case WEEK: c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY); after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); c.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY); c.add(Calendar.DAY_OF_YEAR, +1); before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); break; case MONTH: //first day of the next month c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH)); c.add(Calendar.DAY_OF_YEAR, +1); before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); //last day of the prev month c.setTime(parsedDate); c.set(Calendar.DAY_OF_MONTH, c.getActualMinimum(Calendar.DAY_OF_MONTH)); after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); break; case YEAR: //first day of the next year c.set(Calendar.MONTH, Calendar.DECEMBER); c.set(Calendar.DAY_OF_MONTH, 31); c.add(Calendar.DAY_OF_YEAR, +1); before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); c.setTime(parsedDate); c.set(Calendar.MONTH, Calendar.JANUARY); c.set(Calendar.DAY_OF_MONTH, 1); after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); break; default: AppLog.w(AppLog.T.STATS, "Can't calculate start and end period without a reference timeframe"); return null; } return "&after=" + after + "&before=" + before; } catch (ParseException e) { AppLog.e(AppLog.T.UTILS, e); return null; } } public static int getSmallestWidthDP() { return WordPress.getContext().getResources().getInteger(R.integer.smallest_width_dp); } public static synchronized void logVolleyErrorDetails(final VolleyError volleyError) { if (volleyError == null) { AppLog.e(T.STATS, "Tried to log a VolleyError, but the error obj was null!"); return; } if (volleyError.networkResponse != null) { NetworkResponse networkResponse = volleyError.networkResponse; AppLog.e(T.STATS, "Network status code: " + networkResponse.statusCode); if (networkResponse.data != null) { AppLog.e(T.STATS, "Network data: " + new String(networkResponse.data)); } } AppLog.e(T.STATS, "Volley Error Message: " + volleyError.getMessage(), volleyError); } public static synchronized boolean isRESTDisabledError(final Serializable error) { if (error == null || !(error instanceof com.android.volley.AuthFailureError)) { return false; } com.android.volley.AuthFailureError volleyError = (com.android.volley.AuthFailureError) error; if (volleyError.networkResponse != null && volleyError.networkResponse.data != null) { String errorMessage = new String(volleyError.networkResponse.data).toLowerCase(); return errorMessage.contains("api calls") && errorMessage.contains("disabled"); } else { AppLog.e(T.STATS, "Network response is null in Volley. Can't check if it is a Rest Disabled error."); return false; } } public static synchronized BaseStatsModel parseResponse(StatsService.StatsEndpointsEnum endpointName, long siteId, JSONObject response) throws JSONException { BaseStatsModel model = null; switch (endpointName) { case VISITS: model = new VisitsModel(siteId, response); break; case TOP_POSTS: model = new TopPostsAndPagesModel(siteId, response); break; case REFERRERS: model = new ReferrersModel(siteId, response); break; case CLICKS: model = new ClicksModel(siteId, response); break; case GEO_VIEWS: model = new GeoviewsModel(siteId, response); break; case AUTHORS: model = new AuthorsModel(siteId, response); break; case VIDEO_PLAYS: model = new VideoPlaysModel(siteId, response); break; case COMMENTS: model = new CommentsModel(siteId, response); break; case FOLLOWERS_WPCOM: model = new FollowersModel(siteId, response); break; case FOLLOWERS_EMAIL: model = new FollowersModel(siteId, response); break; case COMMENT_FOLLOWERS: model = new CommentFollowersModel(siteId, response); break; case TAGS_AND_CATEGORIES: model = new TagsContainerModel(siteId, response); break; case PUBLICIZE: model = new PublicizeModel(siteId, response); break; case SEARCH_TERMS: model = new SearchTermsModel(siteId, response); break; case INSIGHTS_ALL_TIME: model = new InsightsAllTimeModel(siteId, response); break; case INSIGHTS_POPULAR: model = new InsightsPopularModel(siteId, response); break; case INSIGHTS_TODAY: model = new InsightsTodayModel(siteId, response); break; case INSIGHTS_LATEST_POST_SUMMARY: model = new InsightsLatestPostModel(siteId, response); break; case INSIGHTS_LATEST_POST_VIEWS: model = new InsightsLatestPostDetailsModel(siteId, response); break; } return model; } public static void openPostInReaderOrInAppWebview(Context ctx, final long remoteBlogID, final String remoteItemID, final String itemType, final String itemURL) { final long blogID = remoteBlogID; final long itemID = Long.parseLong(remoteItemID); if (itemType == null) { // If we don't know the type of the item, open it with the browser. AppLog.d(AppLog.T.UTILS, "Type of the item is null. Opening it in the in-app browser: " + itemURL); WPWebViewActivity.openURL(ctx, itemURL); } else if (itemType.equals(StatsConstants.ITEM_TYPE_POST) || itemType.equals(StatsConstants.ITEM_TYPE_PAGE)) { // If the post/page has ID == 0 is the home page, and we need to load the blog preview, // otherwise 404 is returned if we try to show the post in the reader if (itemID == 0) { ReaderActivityLauncher.showReaderBlogPreview( ctx, blogID ); } else { ReaderActivityLauncher.showReaderPostDetail( ctx, blogID, itemID ); } } else if (itemType.equals(StatsConstants.ITEM_TYPE_HOME_PAGE)) { ReaderActivityLauncher.showReaderBlogPreview( ctx, blogID ); } else { AppLog.d(AppLog.T.UTILS, "Opening the in-app browser: " + itemURL); WPWebViewActivity.openURL(ctx, itemURL); } } public static void openPostInReaderOrInAppWebview(Context ctx, final StatsPostModel post) { final String postType = post.getPostType(); final String url = post.getUrl(); final long blogID = post.getBlogID(); final String itemID = post.getItemID(); openPostInReaderOrInAppWebview(ctx, blogID, itemID, postType, url); } /* * This function rewrites a VolleyError into a simple Stats Error by getting the error message. * This is a FIX for https://github.com/wordpress-mobile/WordPress-Android/issues/2228 where * VolleyErrors cannot be serializable. */ public static StatsError rewriteVolleyError(VolleyError volleyError, String defaultErrorString) { if (volleyError != null && volleyError.getMessage() != null) { return new StatsError(volleyError.getMessage()); } if (defaultErrorString != null) { return new StatsError(defaultErrorString); } // Error string should be localized here, but don't want to pass a context return new StatsError("Stats couldn't be refreshed at this time"); } private static int roundUp(double num, double divisor) { double unrounded = num / divisor; //return (int) Math.ceil(unrounded); return (int) (unrounded + 0.5); } public static String getSinceLabel(Context ctx, String dataSubscribed) { Date currentDateTime = new Date(); try { SimpleDateFormat from = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); Date date = from.parse(dataSubscribed); // See http://momentjs.com/docs/#/displaying/fromnow/ long currentDifference = Math.abs( StatsUtils.getDateDiff(date, currentDateTime, TimeUnit.SECONDS) ); if (currentDifference <= 45 ) { return ctx.getString(R.string.stats_followers_seconds_ago); } if (currentDifference < 90 ) { return ctx.getString(R.string.stats_followers_a_minute_ago); } // 90 seconds to 45 minutes if (currentDifference <= 2700 ) { long minutes = StatsUtils.roundUp(currentDifference, 60); String followersMinutes = ctx.getString(R.string.stats_followers_minutes); return String.format(followersMinutes, minutes); } // 45 to 90 minutes if (currentDifference <= 5400 ) { return ctx.getString(R.string.stats_followers_an_hour_ago); } // 90 minutes to 22 hours if (currentDifference <= 79200 ) { long hours = StatsUtils.roundUp(currentDifference, 60 * 60); String followersHours = ctx.getString(R.string.stats_followers_hours); return String.format(followersHours, hours); } // 22 to 36 hours if (currentDifference <= 129600 ) { return ctx.getString(R.string.stats_followers_a_day); } // 36 hours to 25 days // 86400 secs in a day - 2160000 secs in 25 days if (currentDifference <= 2160000 ) { long days = StatsUtils.roundUp(currentDifference, 86400); String followersDays = ctx.getString(R.string.stats_followers_days); return String.format(followersDays, days); } // 25 to 45 days // 3888000 secs in 45 days if (currentDifference <= 3888000 ) { return ctx.getString(R.string.stats_followers_a_month); } // 45 to 345 days // 2678400 secs in a month - 29808000 secs in 345 days if (currentDifference <= 29808000 ) { long months = StatsUtils.roundUp(currentDifference, 2678400); String followersMonths = ctx.getString(R.string.stats_followers_months); return String.format(followersMonths, months); } // 345 to 547 days (1.5 years) if (currentDifference <= 47260800 ) { return ctx.getString(R.string.stats_followers_a_year); } // 548 days+ // 31536000 secs in a year long years = StatsUtils.roundUp(currentDifference, 31536000); String followersYears = ctx.getString(R.string.stats_followers_years); return String.format(followersYears, years); } catch (ParseException e) { AppLog.e(AppLog.T.STATS, e); } return ""; } /** * Transform a 2 characters country code into a 2 characters emoji flag. * Emoji letter A starts at: 0x1F1E6 thus, * 0x1F1E6 + 5 = 0x1F1EB represents the letter F * 0x1F1E6 + 17 = 0x1F1F7 represents the letter R * * FR: 0x1F1EB 0x1F1F7 is the french flag: 🇫🇷 * More infos on https://apps.timwhitlock.info/emoji/tables/iso3166 * * @param countryCode - iso3166 country code (2chars) * @return emoji string representing the flag */ public static String countryCodeToEmoji(String countryCode) { if (TextUtils.isEmpty(countryCode) || countryCode.length() != 2) { return ""; } int char1 = Character.codePointAt(countryCode, 0) - 0x41 + 0x1F1E6; int char2 = Character.codePointAt(countryCode, 1) - 0x41 + 0x1F1E6; return new String(Character.toChars(char1)) + new String(Character.toChars(char2)); } }