/* * Android SDK for Piwik * * @link https://github.com/piwik/piwik-android-sdk * @license https://github.com/piwik/piwik-sdk-android/blob/master/LICENSE BSD-3 Clause */ package org.piwik.sdk; import android.content.SharedPreferences; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import org.piwik.sdk.dispatcher.DispatchMode; import org.piwik.sdk.dispatcher.Dispatcher; import org.piwik.sdk.dispatcher.Packet; import java.net.URL; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Random; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import timber.log.Timber; import static android.content.ContentValues.TAG; /** * Main tracking class * This class is threadsafe. */ public class Tracker { private static final String LOGGER_TAG = Piwik.LOGGER_PREFIX + "Tracker"; // Piwik default parameter values private static final String DEFAULT_UNKNOWN_VALUE = "unknown"; private static final String DEFAULT_TRUE_VALUE = "1"; private static final String DEFAULT_RECORD_VALUE = DEFAULT_TRUE_VALUE; private static final String DEFAULT_API_VERSION_VALUE = "1"; // Sharedpreference keys for persisted values protected static final String PREF_KEY_TRACKER_OPTOUT = "tracker.optout"; protected static final String PREF_KEY_TRACKER_USERID = "tracker.userid"; protected static final String PREF_KEY_TRACKER_FIRSTVISIT = "tracker.firstvisit"; protected static final String PREF_KEY_TRACKER_VISITCOUNT = "tracker.visitcount"; protected static final String PREF_KEY_TRACKER_PREVIOUSVISIT = "tracker.previousvisit"; protected static final String PREF_KEY_OFFLINE_CACHE_AGE = "tracker.cache.age"; protected static final String PREF_KEY_OFFLINE_CACHE_SIZE = "tracker.cache.size"; protected static final String PREF_KEY_DISPATCHER_MODE = "tracker.dispatcher.mode"; private final Piwik mPiwik; /** * Tracking HTTP API endpoint, for example, http://your-piwik-domain.tld/piwik.php */ private final URL mApiUrl; /** * The ID of the website we're tracking a visit/action for. */ private final int mSiteId; private final Object mSessionLock = new Object(); private final Dispatcher mDispatcher; private final String mName; private final Random mRandomAntiCachingValue = new Random(new Date().getTime()); private final TrackMe mDefaultTrackMe = new TrackMe(); private TrackMe mLastEvent; private String mApplicationDomain; private long mSessionTimeout = 30 * 60 * 1000; private long mSessionStartTime; private boolean mOptOut = false; private SharedPreferences mPreferences; /** * Use Piwik.newTracker() method to create new trackers * * @param piwik piwik object used to gain access to application params such as name, resolution or lang * @param trackerConfig configuration for this Tracker. * @throws RuntimeException if the supplied Piwik-Tracker URL is incompatible */ public Tracker(@NonNull Piwik piwik, @NonNull TrackerConfig trackerConfig) { mPiwik = piwik; mApiUrl = trackerConfig.getApiUrl(); mSiteId = trackerConfig.getSiteId(); mName = trackerConfig.getTrackerName(); new LegacySettingsPorter(piwik).port(this); mOptOut = getPreferences().getBoolean(PREF_KEY_TRACKER_OPTOUT, false); mDispatcher = piwik.getDispatcherFactory().build(this); String userId = getPreferences().getString(PREF_KEY_TRACKER_USERID, null); if (userId == null) { userId = UUID.randomUUID().toString(); getPreferences().edit().putString(PREF_KEY_TRACKER_USERID, userId).apply(); } mDefaultTrackMe.set(QueryParams.USER_ID, userId); mDefaultTrackMe.set(QueryParams.SESSION_START, DEFAULT_TRUE_VALUE); String resolution = DEFAULT_UNKNOWN_VALUE; int[] res = mPiwik.getDeviceHelper().getResolution(); if (res != null) resolution = String.format("%sx%s", res[0], res[1]); mDefaultTrackMe.set(QueryParams.SCREEN_RESOLUTION, resolution); mDefaultTrackMe.set(QueryParams.USER_AGENT, mPiwik.getDeviceHelper().getUserAgent()); mDefaultTrackMe.set(QueryParams.LANGUAGE, mPiwik.getDeviceHelper().getUserLanguage()); mDefaultTrackMe.set(QueryParams.VISITOR_ID, makeRandomVisitorId()); mDefaultTrackMe.set(QueryParams.URL_PATH, fixUrl(null, getApplicationBaseURL())); } /** * Use this to disable this Tracker, e.g. if the user opted out of tracking. * The Tracker will persist the choice and remain disable on next instance creation.<p> * * @param optOut true to disable reporting */ public void setOptOut(boolean optOut) { mOptOut = optOut; getPreferences().edit().putBoolean(PREF_KEY_TRACKER_OPTOUT, optOut).apply(); mDispatcher.clear(); } /** * @return true if Piwik is currently disabled */ public boolean isOptOut() { return mOptOut; } public String getName() { return mName; } public Piwik getPiwik() { return mPiwik; } public URL getAPIUrl() { return mApiUrl; } protected int getSiteId() { return mSiteId; } /** * Piwik will use the content of this object to fill in missing values before any transmission. * While you can modify it's values, you can also just set them in your {@link TrackMe} object as already set values will not be overwritten. * * @return the default TrackMe object */ public TrackMe getDefaultTrackMe() { return mDefaultTrackMe; } public void startNewSession() { synchronized (mSessionLock) { mSessionStartTime = 0; } } public void setSessionTimeout(int milliseconds) { synchronized (mSessionLock) { mSessionTimeout = milliseconds; } } protected boolean tryNewSession() { synchronized (mSessionLock) { boolean expired = System.currentTimeMillis() - mSessionStartTime > mSessionTimeout; // Update the session timer mSessionStartTime = System.currentTimeMillis(); return expired; } } /** * Default is 30min (30*60*1000). * * @return session timeout value in miliseconds */ public long getSessionTimeout() { return mSessionTimeout; } /** * {@link Dispatcher#getConnectionTimeOut()} */ public int getDispatchTimeout() { return mDispatcher.getConnectionTimeOut(); } /** * {@link Dispatcher#setConnectionTimeOut(int)} */ public void setDispatchTimeout(int timeout) { mDispatcher.setConnectionTimeOut(timeout); } /** * Processes all queued events in background thread */ public void dispatch() { if (mOptOut) return; mDispatcher.forceDispatch(); } /** * Set the interval to 0 to dispatch events as soon as they are queued. * If a negative value is used the dispatch timer will never run, a manual dispatch must be used. * * @param dispatchInterval in milliseconds */ public Tracker setDispatchInterval(long dispatchInterval) { mDispatcher.setDispatchInterval(dispatchInterval); return this; } /** * Defines if when dispatched, posted JSON must be Gzipped. * Need to be handle from web server side with mod_deflate/APACHE lua_zlib/NGINX. * * @param dispatchGzipped boolean */ public Tracker setDispatchGzipped(boolean dispatchGzipped) { mDispatcher.setDispatchGzipped(dispatchGzipped); return this; } /** * @return in milliseconds */ public long getDispatchInterval() { return mDispatcher.getDispatchInterval(); } /** * For how long events should be stored if they could not be send. * Events older than the set limit will be discarded on the next dispatch attempt.<br> * The Piwik backend accepts backdated events for up to 24 hours by default. * <p> * >0 = limit in ms<br> * 0 = unlimited<br> * -1 = disabled offline cache<br> * * @param age in milliseconds */ public void setOfflineCacheAge(long age) { getPreferences().edit().putLong(PREF_KEY_OFFLINE_CACHE_AGE, age).apply(); } /** * See {@link #setOfflineCacheAge(long)} * * @return maximum cache age in milliseconds */ public long getOfflineCacheAge() { return getPreferences().getLong(PREF_KEY_OFFLINE_CACHE_AGE, 24 * 60 * 60 * 1000); } /** * How large the offline cache may be. * If the limit is reached the oldest files will be deleted first. * Events older than the set limit will be discarded on the next dispatch attempt.<br> * The Piwik backend accepts backdated events for up to 24 hours by default. * <p> * >0 = limit in byte<br> * 0 = unlimited<br> * * @param size in byte */ public void setOfflineCacheSize(long size) { getPreferences().edit().putLong(PREF_KEY_OFFLINE_CACHE_SIZE, size).apply(); } /** * Maximum size the offline cache is allowed to grow to. * * @return size in byte */ public long getOfflineCacheSize() { return getPreferences().getLong(PREF_KEY_OFFLINE_CACHE_SIZE, 4 * 1024 * 1024); } /** * The current dispatch behavior. * * @see DispatchMode */ public DispatchMode getDispatchMode() { String raw = getPreferences().getString(PREF_KEY_DISPATCHER_MODE, null); DispatchMode mode = DispatchMode.fromString(raw); if (mode == null) { mode = DispatchMode.ALWAYS; setDispatchMode(mode); } return mode; } /** * Sets the dispatch mode. * * @see DispatchMode */ public void setDispatchMode(DispatchMode mode) { getPreferences().edit().putString(PREF_KEY_DISPATCHER_MODE, mode.toString()).apply(); mDispatcher.setDispatchMode(mode); } /** * Defines the User ID for this request. * User ID is any non empty unique string identifying the user (such as an email address or a username). * To access this value, users must be logged-in in your system so you can * fetch this user ID from your system, and pass it to Piwik. * <p> * When specified, the User ID will be "enforced". * This means that if there is no recent visit with this User ID, a new one will be created. * If a visit is found in the last 30 minutes with your specified User ID, * then the new action will be recorded to this existing visit. * * @param userId passing null will delete the current user-id. */ public Tracker setUserId(String userId) { mDefaultTrackMe.set(QueryParams.USER_ID, userId); getPreferences().edit().putString(PREF_KEY_TRACKER_USERID, userId).apply(); return this; } /** * @return a user-id string, either the one you set or the one Piwik generated for you. */ public String getUserId() { return mDefaultTrackMe.get(QueryParams.USER_ID); } /** * The unique visitor ID, must be a 16 characters hexadecimal string. * Every unique visitor must be assigned a different ID and this ID must not change after it is assigned. * If this value is not set Piwik will still track visits, but the unique visitors metric might be less accurate. */ public Tracker setVisitorId(String visitorId) throws IllegalArgumentException { if (confirmVisitorIdFormat(visitorId)) mDefaultTrackMe.set(QueryParams.VISITOR_ID, visitorId); return this; } public String getVisitorId() { return mDefaultTrackMe.get(QueryParams.VISITOR_ID); } private static final Pattern PATTERN_VISITOR_ID = Pattern.compile("^[0-9a-f]{16}$"); private boolean confirmVisitorIdFormat(String visitorId) throws IllegalArgumentException { if (PATTERN_VISITOR_ID.matcher(visitorId).matches()) return true; throw new IllegalArgumentException("VisitorId: " + visitorId + " is not of valid format, " + " the format must match the regular expression: " + PATTERN_VISITOR_ID.pattern()); } /** * Domain used to build required parameter url (http://developer.piwik.org/api-reference/tracking-api) * If domain wasn't set `Application.getPackageName()` method will be used * * @param domain your-domain.com */ public Tracker setApplicationDomain(String domain) { mApplicationDomain = domain; mDefaultTrackMe.set(QueryParams.URL_PATH, fixUrl(null, getApplicationBaseURL())); return this; } protected String getApplicationDomain() { return mApplicationDomain != null ? mApplicationDomain : mPiwik.getApplicationDomain(); } /** * There parameters are only interesting for the very first query. */ private void injectInitialParams(TrackMe trackMe) { long firstVisitTime; long visitCount; long previousVisit; // Protected against Trackers on other threads trying to do the same thing. // This works because they would use the same preference object. synchronized (getPreferences()) { visitCount = 1 + getPreferences().getLong(PREF_KEY_TRACKER_VISITCOUNT, 0); getPreferences().edit().putLong(PREF_KEY_TRACKER_VISITCOUNT, visitCount).apply(); } synchronized (getPreferences()) { firstVisitTime = getPreferences().getLong(PREF_KEY_TRACKER_FIRSTVISIT, -1); if (firstVisitTime == -1) { firstVisitTime = System.currentTimeMillis() / 1000; getPreferences().edit().putLong(PREF_KEY_TRACKER_FIRSTVISIT, firstVisitTime).apply(); } } synchronized (getPreferences()) { previousVisit = getPreferences().getLong(PREF_KEY_TRACKER_PREVIOUSVISIT, -1); getPreferences().edit().putLong(PREF_KEY_TRACKER_PREVIOUSVISIT, System.currentTimeMillis() / 1000).apply(); } // trySet because the developer could have modded these after creating the Tracker mDefaultTrackMe.trySet(QueryParams.FIRST_VISIT_TIMESTAMP, firstVisitTime); mDefaultTrackMe.trySet(QueryParams.TOTAL_NUMBER_OF_VISITS, visitCount); if (previousVisit != -1) mDefaultTrackMe.trySet(QueryParams.PREVIOUS_VISIT_TIMESTAMP, previousVisit); trackMe.trySet(QueryParams.SESSION_START, mDefaultTrackMe.get(QueryParams.SESSION_START)); trackMe.trySet(QueryParams.SCREEN_RESOLUTION, mDefaultTrackMe.get(QueryParams.SCREEN_RESOLUTION)); trackMe.trySet(QueryParams.USER_AGENT, mDefaultTrackMe.get(QueryParams.USER_AGENT)); trackMe.trySet(QueryParams.LANGUAGE, mDefaultTrackMe.get(QueryParams.LANGUAGE)); trackMe.trySet(QueryParams.FIRST_VISIT_TIMESTAMP, mDefaultTrackMe.get(QueryParams.FIRST_VISIT_TIMESTAMP)); trackMe.trySet(QueryParams.TOTAL_NUMBER_OF_VISITS, mDefaultTrackMe.get(QueryParams.TOTAL_NUMBER_OF_VISITS)); trackMe.trySet(QueryParams.PREVIOUS_VISIT_TIMESTAMP, mDefaultTrackMe.get(QueryParams.PREVIOUS_VISIT_TIMESTAMP)); } /** * These parameters are required for all queries. */ private void injectBaseParams(TrackMe trackMe) { trackMe.trySet(QueryParams.SITE_ID, mSiteId); trackMe.trySet(QueryParams.RECORD, DEFAULT_RECORD_VALUE); trackMe.trySet(QueryParams.API_VERSION, DEFAULT_API_VERSION_VALUE); trackMe.trySet(QueryParams.RANDOM_NUMBER, mRandomAntiCachingValue.nextInt(100000)); trackMe.trySet(QueryParams.DATETIME_OF_REQUEST, new SimpleDateFormat("yyyy-MM-dd HH:mm:ssZ", Locale.US).format(new Date())); trackMe.trySet(QueryParams.SEND_IMAGE, "0"); trackMe.trySet(QueryParams.VISITOR_ID, mDefaultTrackMe.get(QueryParams.VISITOR_ID)); trackMe.trySet(QueryParams.USER_ID, mDefaultTrackMe.get(QueryParams.USER_ID)); String urlPath = trackMe.get(QueryParams.URL_PATH); if (urlPath == null) { urlPath = mDefaultTrackMe.get(QueryParams.URL_PATH); } else { urlPath = fixUrl(urlPath, getApplicationBaseURL()); mDefaultTrackMe.set(QueryParams.URL_PATH, urlPath); } trackMe.set(QueryParams.URL_PATH, urlPath); } private static String fixUrl(String url, String baseUrl) { if (url == null) url = baseUrl + "/"; if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("ftp://")) { url = baseUrl + (url.startsWith("/") ? "" : "/") + url; } return url; } private CountDownLatch mSessionStartLatch = new CountDownLatch(0); public Tracker track(TrackMe trackMe) { boolean newSession; synchronized (mSessionLock) { newSession = tryNewSession(); if (newSession) mSessionStartLatch = new CountDownLatch(1); } if (newSession) { injectInitialParams(trackMe); } else { try { // Another thread might be creating a sessions first transmission. mSessionStartLatch.await(getDispatchTimeout(), TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Timber.tag(TAG).e(e, null); } } injectBaseParams(trackMe); mLastEvent = trackMe; if (!mOptOut) { mDispatcher.submit(trackMe); Timber.tag(LOGGER_TAG).d("Event added to the queue: %s", trackMe); } else Timber.tag(LOGGER_TAG).d("Event omitted due to opt out: %s", trackMe); // we did a first transmission, let the other through. if (newSession) mSessionStartLatch.countDown(); return this; } public static String makeRandomVisitorId() { return UUID.randomUUID().toString().replaceAll("-", "").substring(0, 16); } public SharedPreferences getPreferences() { if (mPreferences == null) mPreferences = mPiwik.getTrackerPreferences(this); return mPreferences; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Tracker tracker = (Tracker) o; if (mSiteId != tracker.mSiteId) return false; if (!mApiUrl.equals(tracker.mApiUrl)) return false; return mName.equals(tracker.mName); } @Override public int hashCode() { int result = mApiUrl.hashCode(); result = 31 * result + mSiteId; result = 31 * result + mName.hashCode(); return result; } protected String getApplicationBaseURL() { return String.format("http://%s", getApplicationDomain()); } /** * For testing purposes * * @return query of the event */ @VisibleForTesting public TrackMe getLastEventX() { return mLastEvent; } /** * Set a data structure here to put the Dispatcher into dry-run-mode. * Data will be processed but at the last step just stored instead of transmitted. * Set it to null to disable it. * * @param dryRunTarget a data structure the data should be passed into */ public void setDryRunTarget(List<Packet> dryRunTarget) { mDispatcher.setDryRunTarget(dryRunTarget); } /** * If we are in dry-run mode then this will return a datastructure. * * @return a datastructure or null */ public List<Packet> getDryRunTarget() { return mDispatcher.getDryRunTarget(); } }