// // ActivityHandler.java // Adjust // // Created by Christian Wellenbrock on 2013-06-25. // Copyright (c) 2013 adjust GmbH. All rights reserved. // See the file MIT-LICENSE for copying permission. // package com.adjust.sdk; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import org.json.JSONObject; import java.lang.ref.WeakReference; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import static com.adjust.sdk.Constants.ACTIVITY_STATE_FILENAME; import static com.adjust.sdk.Constants.ATTRIBUTION_FILENAME; import static com.adjust.sdk.Constants.LOGTAG; public class ActivityHandler extends HandlerThread implements IActivityHandler { private static long TIMER_INTERVAL; private static long TIMER_START; private static long SESSION_INTERVAL; private static long SUBSESSION_INTERVAL; private static final String TIME_TRAVEL = "Time travel!"; private static final String ADJUST_PREFIX = "adjust_"; private static final String ACTIVITY_STATE_NAME = "Activity state"; private static final String ATTRIBUTION_NAME = "Attribution"; private SessionHandler sessionHandler; private IPackageHandler packageHandler; private ActivityState activityState; private ILogger logger; private static ScheduledExecutorService timer; private boolean enabled; private boolean offline; private DeviceInfo deviceInfo; private AdjustConfig adjustConfig; // always valid after construction private AdjustAttribution attribution; private IAttributionHandler attributionHandler; private ActivityHandler(AdjustConfig adjustConfig) { super(LOGTAG, MIN_PRIORITY); setDaemon(true); start(); logger = AdjustFactory.getLogger(); sessionHandler = new SessionHandler(getLooper(), this); enabled = true; init(adjustConfig); Message message = Message.obtain(); message.arg1 = SessionHandler.INIT; sessionHandler.sendMessage(message); } @Override public void init(AdjustConfig adjustConfig) { this.adjustConfig = adjustConfig; } public static ActivityHandler getInstance(AdjustConfig adjustConfig) { if (adjustConfig == null) { AdjustFactory.getLogger().error("AdjustConfig missing"); return null; } if (!adjustConfig.isValid()) { AdjustFactory.getLogger().error("AdjustConfig not initialized correctly"); return null; } ActivityHandler activityHandler = new ActivityHandler(adjustConfig); return activityHandler; } @Override public void trackSubsessionStart() { Message message = Message.obtain(); message.arg1 = SessionHandler.START; sessionHandler.sendMessage(message); } @Override public void trackSubsessionEnd() { Message message = Message.obtain(); message.arg1 = SessionHandler.END; sessionHandler.sendMessage(message); } @Override public void trackEvent(AdjustEvent event) { Message message = Message.obtain(); message.arg1 = SessionHandler.EVENT; message.obj = event; sessionHandler.sendMessage(message); } @Override public void finishedTrackingActivity(JSONObject jsonResponse) { if (jsonResponse == null) { return; } Message message = Message.obtain(); message.arg1 = SessionHandler.FINISH_TRACKING; message.obj = jsonResponse; sessionHandler.sendMessage(message); } @Override public void setEnabled(boolean enabled) { if (enabled == this.enabled) { if (enabled) { logger.debug("Adjust already enabled"); } else { logger.debug("Adjust already disabled"); } return; } this.enabled = enabled; if (activityState != null) { activityState.enabled = enabled; } if (enabled) { if (toPause()) { logger.info("Package and attribution handler remain paused due to the SDK is offline"); } else { logger.info("Resuming package handler and attribution handler to enabled the SDK"); } trackSubsessionStart(); } else { logger.info("Pausing package handler and attribution handler to disable the SDK"); trackSubsessionEnd(); } } @Override public void setOfflineMode(boolean offline) { if (offline == this.offline) { if (offline) { logger.debug("Adjust already in offline mode"); } else { logger.debug("Adjust already in online mode"); } return; } this.offline = offline; if (offline) { logger.info("Pausing package and attribution handler to put in offline mode"); } else { if (toPause()) { logger.info("Package and attribution handler remain paused because the SDK is disabled"); } else { logger.info("Resuming package handler and attribution handler to put in online mode"); } } updateStatus(); } @Override public boolean isEnabled() { if (activityState != null) { return activityState.enabled; } else { return enabled; } } @Override public void readOpenUrl(Uri url, long clickTime) { Message message = Message.obtain(); message.arg1 = SessionHandler.DEEP_LINK; UrlClickTime urlClickTime = new UrlClickTime(url, clickTime); message.obj = urlClickTime; sessionHandler.sendMessage(message); } @Override public boolean tryUpdateAttribution(AdjustAttribution attribution) { if (attribution == null) return false; if (attribution.equals(this.attribution)) { return false; } saveAttribution(attribution); launchAttributionListener(); return true; } private void saveAttribution(AdjustAttribution attribution) { this.attribution = attribution; writeAttribution(); } private void launchAttributionListener() { if (adjustConfig.onAttributionChangedListener == null) { return; } Handler handler = new Handler(adjustConfig.context.getMainLooper()); Runnable runnable = new Runnable() { @Override public void run() { adjustConfig.onAttributionChangedListener.onAttributionChanged(attribution); } }; handler.post(runnable); } @Override public void setAskingAttribution(boolean askingAttribution) { activityState.askingAttribution = askingAttribution; writeActivityState(); } @Override public ActivityPackage getAttributionPackage() { long now = System.currentTimeMillis(); PackageBuilder attributionBuilder = new PackageBuilder(adjustConfig, deviceInfo, activityState, now); return attributionBuilder.buildAttributionPackage(); } @Override public void sendReferrer(String referrer, long clickTime) { Message message = Message.obtain(); message.arg1 = SessionHandler.SEND_REFERRER; ReferrerClickTime referrerClickTime = new ReferrerClickTime(referrer, clickTime); message.obj = referrerClickTime; sessionHandler.sendMessage(message); } private class UrlClickTime { Uri url; long clickTime; UrlClickTime(Uri url, long clickTime) { this.url = url; this.clickTime = clickTime; } } private class ReferrerClickTime { String referrer; long clickTime; ReferrerClickTime(String referrer, long clickTime) { this.referrer = referrer; this.clickTime = clickTime; } } private void updateStatus() { Message message = Message.obtain(); message.arg1 = SessionHandler.UPDATE_STATUS; sessionHandler.sendMessage(message); } private static final class SessionHandler extends Handler { private static final int BASE_ADDRESS = 72630; private static final int INIT = BASE_ADDRESS + 1; private static final int START = BASE_ADDRESS + 2; private static final int END = BASE_ADDRESS + 3; private static final int EVENT = BASE_ADDRESS + 4; private static final int FINISH_TRACKING = BASE_ADDRESS + 5; private static final int DEEP_LINK = BASE_ADDRESS + 6; private static final int SEND_REFERRER = BASE_ADDRESS + 7; private static final int UPDATE_STATUS = BASE_ADDRESS + 8; private final WeakReference sessionHandlerReference; protected SessionHandler(Looper looper, ActivityHandler sessionHandler) { super(looper); this.sessionHandlerReference = new WeakReference(sessionHandler); } @Override public void handleMessage(Message message) { super.handleMessage(message); ActivityHandler sessionHandler = sessionHandlerReference.get(); if (sessionHandler == null) { return; } switch (message.arg1) { case INIT: sessionHandler.initInternal(); break; case START: sessionHandler.startInternal(); break; case END: sessionHandler.endInternal(); break; case EVENT: AdjustEvent event = (AdjustEvent) message.obj; sessionHandler.trackEventInternal(event); break; case FINISH_TRACKING: JSONObject jsonResponse = (JSONObject) message.obj; sessionHandler.finishedTrackingActivityInternal(jsonResponse); break; case DEEP_LINK: UrlClickTime urlClickTime = (UrlClickTime) message.obj; sessionHandler.readOpenUrlInternal(urlClickTime.url, urlClickTime.clickTime); break; case SEND_REFERRER: ReferrerClickTime referrerClickTime = (ReferrerClickTime) message.obj; sessionHandler.sendReferrerInternal(referrerClickTime.referrer, referrerClickTime.clickTime); break; case UPDATE_STATUS: sessionHandler.updateStatusInternal(); break; } } } private void initInternal() { TIMER_INTERVAL = AdjustFactory.getTimerInterval(); TIMER_START = AdjustFactory.getTimerStart(); SESSION_INTERVAL = AdjustFactory.getSessionInterval(); SUBSESSION_INTERVAL = AdjustFactory.getSubsessionInterval(); deviceInfo = new DeviceInfo(adjustConfig.context, adjustConfig.sdkPrefix); if (adjustConfig.environment == AdjustConfig.ENVIRONMENT_PRODUCTION) { logger.setLogLevel(LogLevel.ASSERT); } else { logger.setLogLevel(adjustConfig.logLevel); } if (adjustConfig.eventBufferingEnabled) { logger.info("Event buffering is enabled"); } String playAdId = Util.getPlayAdId(adjustConfig.context); if (playAdId == null) { logger.info("Unable to get Google Play Services Advertising ID at start time"); } if (adjustConfig.defaultTracker != null) { logger.info("Default tracker: '%s'", adjustConfig.defaultTracker); } if (adjustConfig.referrer != null) { sendReferrer(adjustConfig.referrer, adjustConfig.referrerClickTime); // send to background queue to make sure that activityState is valid } readAttribution(); readActivityState(); packageHandler = AdjustFactory.getPackageHandler(this, adjustConfig.context, toPause()); startInternal(); } private void startInternal() { // it shouldn't start if it was disabled after a first session if (activityState != null && !activityState.enabled) { return; } updateStatusInternal(); processSession(); checkAttributionState(); startTimer(); } private void processSession() { long now = System.currentTimeMillis(); // very first session if (activityState == null) { activityState = new ActivityState(); activityState.sessionCount = 1; // this is the first session transferSessionPackage(now); activityState.resetSessionAttributes(now); activityState.enabled = this.enabled; writeActivityState(); return; } long lastInterval = now - activityState.lastActivity; if (lastInterval < 0) { logger.error(TIME_TRAVEL); activityState.lastActivity = now; writeActivityState(); return; } // new session if (lastInterval > SESSION_INTERVAL) { activityState.sessionCount++; activityState.lastInterval = lastInterval; transferSessionPackage(now); activityState.resetSessionAttributes(now); writeActivityState(); return; } // new subsession if (lastInterval > SUBSESSION_INTERVAL) { activityState.subsessionCount++; activityState.sessionLength += lastInterval; activityState.lastActivity = now; writeActivityState(); logger.info("Started subsession %d of session %d", activityState.subsessionCount, activityState.sessionCount); } } private void checkAttributionState() { // if there is no attribution saved, or there is one being asked if (attribution == null || activityState.askingAttribution) { getAttributionHandler().getAttribution(); } } private void endInternal() { packageHandler.pauseSending(); getAttributionHandler().pauseSending(); stopTimer(); if (updateActivityState(System.currentTimeMillis())) { writeActivityState(); } } private void trackEventInternal(AdjustEvent event) { if (!checkEvent(event)) return; if (!activityState.enabled) return; long now = System.currentTimeMillis(); activityState.eventCount++; updateActivityState(now); PackageBuilder eventBuilder = new PackageBuilder(adjustConfig, deviceInfo, activityState, now); ActivityPackage eventPackage = eventBuilder.buildEventPackage(event); packageHandler.addPackage(eventPackage); if (adjustConfig.eventBufferingEnabled) { logger.info("Buffered event %s", eventPackage.getSuffix()); } else { packageHandler.sendFirstPackage(); } writeActivityState(); } private void finishedTrackingActivityInternal(JSONObject jsonResponse) { if (jsonResponse == null) { return; } String deeplink = jsonResponse.optString("deeplink", null); launchDeeplinkMain(deeplink); getAttributionHandler().checkAttribution(jsonResponse); } private void sendReferrerInternal(String referrer, long clickTime) { ActivityPackage clickPackage = buildQueryStringClickPackage(referrer, "reftag", clickTime); if (clickPackage == null) { return; } getAttributionHandler().getAttribution(); packageHandler.sendClickPackage(clickPackage); } private void readOpenUrlInternal(Uri url, long clickTime) { if (url == null) { return; } String queryString = url.getQuery(); ActivityPackage clickPackage = buildQueryStringClickPackage(queryString, "deeplink", clickTime); if (clickPackage == null) { return; } getAttributionHandler().getAttribution(); packageHandler.sendClickPackage(clickPackage); } private ActivityPackage buildQueryStringClickPackage(String queryString, String source, long clickTime) { if (queryString == null) { return null; } long now = System.currentTimeMillis(); Map queryStringParameters = new HashMap(); AdjustAttribution queryStringAttribution = new AdjustAttribution(); boolean hasAdjustTags = false; String[] queryPairs = queryString.split("&"); for (String pair : queryPairs) { if (readQueryString(pair, queryStringParameters, queryStringAttribution)) { hasAdjustTags = true; } } if (!hasAdjustTags) { return null; } String reftag = queryStringParameters.remove("reftag"); PackageBuilder builder = new PackageBuilder(adjustConfig, deviceInfo, activityState, now); builder.extraParameters = queryStringParameters; builder.attribution = queryStringAttribution; builder.reftag = reftag; ActivityPackage clickPackage = builder.buildClickPackage(source, clickTime); return clickPackage; } private boolean readQueryString(String queryString, Map extraParameters, AdjustAttribution queryStringAttribution) { String[] pairComponents = queryString.split("="); if (pairComponents.length != 2) return false; String key = pairComponents[0]; if (!key.startsWith(ADJUST_PREFIX)) return false; String value = pairComponents[1]; if (value.length() == 0) return false; String keyWOutPrefix = key.substring(ADJUST_PREFIX.length()); if (keyWOutPrefix.length() == 0) return false; if (!trySetAttribution(queryStringAttribution, keyWOutPrefix, value)) { extraParameters.put(keyWOutPrefix, value); } return true; } private boolean trySetAttribution(AdjustAttribution queryStringAttribution, String key, String value) { if (key.equals("tracker")) { queryStringAttribution.trackerName = value; return true; } if (key.equals("campaign")) { queryStringAttribution.campaign = value; return true; } if (key.equals("adgroup")) { queryStringAttribution.adgroup = value; return true; } if (key.equals("creative")) { queryStringAttribution.creative = value; return true; } return false; } private void updateStatusInternal() { updateAttributionHandlerStatus(); updatePackageHandlerStatus(); } private void updateAttributionHandlerStatus() { if (attributionHandler == null) { return; } if (toPause()) { attributionHandler.pauseSending(); } else { attributionHandler.resumeSending(); } } private void updatePackageHandlerStatus() { if (packageHandler == null) { return; } if (toPause()) { packageHandler.pauseSending(); } else { packageHandler.resumeSending(); } } private void launchDeeplinkMain(String deeplink) { if (deeplink == null) return; Uri location = Uri.parse(deeplink); Intent mapIntent = new Intent(Intent.ACTION_VIEW, location); mapIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Verify it resolves PackageManager packageManager = adjustConfig.context.getPackageManager(); List activities = packageManager.queryIntentActivities(mapIntent, 0); boolean isIntentSafe = activities.size() > 0; // Start an activity if it's safe if (!isIntentSafe) { logger.error("Unable to open deep link (%s)", deeplink); return; } logger.info("Open deep link (%s)", deeplink); adjustConfig.context.startActivity(mapIntent); } private boolean updateActivityState(long now) { long lastInterval = now - activityState.lastActivity; // ignore late updates if (lastInterval > SESSION_INTERVAL) { return false; } activityState.lastActivity = now; if (lastInterval < 0) { logger.error(TIME_TRAVEL); } else { activityState.sessionLength += lastInterval; activityState.timeSpent += lastInterval; } return true; } public static boolean deleteActivityState(Context context) { return context.deleteFile(ACTIVITY_STATE_FILENAME); } public static boolean deleteAttribution(Context context) { return context.deleteFile(ATTRIBUTION_FILENAME); } private void transferSessionPackage(long now) { PackageBuilder builder = new PackageBuilder(adjustConfig, deviceInfo, activityState, now); ActivityPackage sessionPackage = builder.buildSessionPackage(); packageHandler.addPackage(sessionPackage); packageHandler.sendFirstPackage(); } private void startTimer() { stopTimer(); if (!activityState.enabled) { return; } timer = Executors.newSingleThreadScheduledExecutor(); timer.scheduleWithFixedDelay(new Runnable() { @Override public void run() { timerFired(); } }, TIMER_START, TIMER_INTERVAL, TimeUnit.MILLISECONDS); } private void stopTimer() { if (timer != null) { timer.shutdown(); timer = null; } } private void timerFired() { if (!activityState.enabled) { stopTimer(); return; } packageHandler.sendFirstPackage(); if (updateActivityState(System.currentTimeMillis())) { writeActivityState(); } } private void readActivityState() { try { /** * Mozilla: * readObject is a generic object, and can therefore return arbitrary generic objects * that might not match the expected type. Therefore there will be an implicit cast * here, which can fail. Therefore we have to add the catch (ClassCastException) * Note: this has been fixed in upstream, we only need this for the version we are still shipping. */ activityState = Util.readObject(adjustConfig.context, ACTIVITY_STATE_FILENAME, ACTIVITY_STATE_NAME); } catch (ClassCastException e) { activityState = null; } } private void readAttribution() { try { /** * Mozilla: (same as in readActivityState() ) * readObject is a generic object, and can therefore return arbitrary generic objects * that might not match the expected type. Therefore there will be an implicit cast * here, which can fail. Therefore we have to add the catch (ClassCastException) * Note: this has been fixed in upstream, we only need this for the version we are still shipping. */ attribution = Util.readObject(adjustConfig.context, ATTRIBUTION_FILENAME, ATTRIBUTION_NAME); } catch (ClassCastException e) { activityState = null; } } private void writeActivityState() { Util.writeObject(activityState, adjustConfig.context, ACTIVITY_STATE_FILENAME, ACTIVITY_STATE_NAME); } private void writeAttribution() { Util.writeObject(attribution, adjustConfig.context, ATTRIBUTION_FILENAME, ATTRIBUTION_NAME); } private boolean checkEvent(AdjustEvent event) { if (event == null) { logger.error("Event missing"); return false; } if (!event.isValid()) { logger.error("Event not initialized correctly"); return false; } return true; } // lazy initialization to prevent null activity state before first session private IAttributionHandler getAttributionHandler() { if (attributionHandler == null) { ActivityPackage attributionPackage = getAttributionPackage(); attributionHandler = AdjustFactory.getAttributionHandler(this, attributionPackage, toPause()); } return attributionHandler; } private boolean toPause() { return offline || !isEnabled(); } }