diff options
Diffstat (limited to 'mobile/android/thirdparty/com')
73 files changed, 10057 insertions, 0 deletions
diff --git a/mobile/android/thirdparty/com/adjust/sdk/ActivityHandler.java b/mobile/android/thirdparty/com/adjust/sdk/ActivityHandler.java new file mode 100644 index 000000000..8c7858b97 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/ActivityHandler.java @@ -0,0 +1,781 @@ +// +// 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<ActivityHandler> sessionHandlerReference; + + protected SessionHandler(Looper looper, ActivityHandler sessionHandler) { + super(looper); + this.sessionHandlerReference = new WeakReference<ActivityHandler>(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<String, String> queryStringParameters = new HashMap<String, String>(); + 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<String, String> 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<ResolveInfo> 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(); + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/ActivityKind.java b/mobile/android/thirdparty/com/adjust/sdk/ActivityKind.java new file mode 100644 index 000000000..a255b83a9 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/ActivityKind.java @@ -0,0 +1,35 @@ +package com.adjust.sdk; + +public enum ActivityKind { + UNKNOWN, SESSION, EVENT, CLICK, ATTRIBUTION; + + public static ActivityKind fromString(String string) { + if ("session".equals(string)) { + return SESSION; + } else if ("event".equals(string)) { + return EVENT; + } else if ("click".equals(string)) { + return CLICK; + } else if ("attribution".equals(string)) { + return ATTRIBUTION; + } else { + return UNKNOWN; + } + } + + @Override + public String toString() { + switch (this) { + case SESSION: + return "session"; + case EVENT: + return "event"; + case CLICK: + return "click"; + case ATTRIBUTION: + return "attribution"; + default: + return "unknown"; + } + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/ActivityPackage.java b/mobile/android/thirdparty/com/adjust/sdk/ActivityPackage.java new file mode 100644 index 000000000..27ab969fd --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/ActivityPackage.java @@ -0,0 +1,100 @@ +// +// ActivityPackage.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 java.io.Serializable; +import java.util.Map; + +public class ActivityPackage implements Serializable { + private static final long serialVersionUID = -35935556512024097L; + + // data + private String path; + private String clientSdk; + private Map<String, String> parameters; + + // logs + private ActivityKind activityKind; + private String suffix; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getClientSdk() { + return clientSdk; + } + + public void setClientSdk(String clientSdk) { + this.clientSdk = clientSdk; + } + + public Map<String, String> getParameters() { + return parameters; + } + + public void setParameters(Map<String, String> parameters) { + this.parameters = parameters; + } + + public ActivityKind getActivityKind() { + return activityKind; + } + + public void setActivityKind(ActivityKind activityKind) { + this.activityKind = activityKind; + } + + public String getSuffix() { + return suffix; + } + + public void setSuffix(String suffix) { + this.suffix = suffix; + } + + public String toString() { + return String.format("%s%s", activityKind.toString(), suffix); + } + + public String getExtendedString() { + StringBuilder builder = new StringBuilder(); + builder.append(String.format("Path: %s\n", path)); + builder.append(String.format("ClientSdk: %s\n", clientSdk)); + + if (parameters != null) { + builder.append("Parameters:"); + for (Map.Entry<String, String> entry : parameters.entrySet()) { + builder.append(String.format("\n\t%-16s %s", entry.getKey(), entry.getValue())); + } + } + return builder.toString(); + } + + protected String getSuccessMessage() { + try { + return String.format("Tracked %s%s", activityKind.toString(), suffix); + } catch (NullPointerException e) { + return "Tracked ???"; + } + } + + protected String getFailureMessage() { + try { + return String.format("Failed to track %s%s", activityKind.toString(), suffix); + } catch (NullPointerException e) { + return "Failed to track ???"; + } + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/ActivityState.java b/mobile/android/thirdparty/com/adjust/sdk/ActivityState.java new file mode 100644 index 000000000..41ad2ca3b --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/ActivityState.java @@ -0,0 +1,151 @@ +// +// ActivityState.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 java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectInputStream.GetField; +import java.io.Serializable; +import java.util.Calendar; +import java.util.Locale; + +public class ActivityState implements Serializable, Cloneable { + private static final long serialVersionUID = 9039439291143138148L; + private transient String readErrorMessage = "Unable to read '%s' field in migration device with message (%s)"; + private transient ILogger logger; + + // persistent data + protected String uuid; + protected boolean enabled; + protected boolean askingAttribution; + + // global counters + protected int eventCount; + protected int sessionCount; + + // session attributes + protected int subsessionCount; + protected long sessionLength; // all durations in milliseconds + protected long timeSpent; + protected long lastActivity; // all times in milliseconds since 1970 + + protected long lastInterval; + + protected ActivityState() { + logger = AdjustFactory.getLogger(); + // create UUID for new devices + uuid = Util.createUuid(); + enabled = true; + askingAttribution = false; + + eventCount = 0; // no events yet + sessionCount = 0; // the first session just started + subsessionCount = -1; // we don't know how many subsessions this first session will have + sessionLength = -1; // same for session length and time spent + timeSpent = -1; // this information will be collected and attached to the next session + lastActivity = -1; + lastInterval = -1; + } + + protected void resetSessionAttributes(long now) { + subsessionCount = 1; // first subsession + sessionLength = 0; // no session length yet + timeSpent = 0; // no time spent yet + lastActivity = now; + lastInterval = -1; + } + + @Override + public String toString() { + return String.format(Locale.US, + "ec:%d sc:%d ssc:%d sl:%.1f ts:%.1f la:%s uuid:%s", + eventCount, sessionCount, subsessionCount, + sessionLength / 1000.0, timeSpent / 1000.0, + stamp(lastActivity), uuid); + } + + @Override + public ActivityState clone() { + try { + return (ActivityState) super.clone(); + } catch (CloneNotSupportedException e) { + return null; + } + } + + + private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException { + GetField fields = stream.readFields(); + + eventCount = readIntField(fields, "eventCount", 0); + sessionCount = readIntField(fields, "sessionCount", 0); + subsessionCount = readIntField(fields, "subsessionCount", -1); + sessionLength = readLongField(fields, "sessionLength", -1l); + timeSpent = readLongField(fields, "timeSpent", -1l); + lastActivity = readLongField(fields, "lastActivity", -1l); + lastInterval = readLongField(fields, "lastInterval", -1l); + + // new fields + uuid = readStringField(fields, "uuid", null); + enabled = readBooleanField(fields, "enabled", true); + askingAttribution = readBooleanField(fields, "askingAttribution", false); + + // create UUID for migrating devices + if (uuid == null) { + uuid = Util.createUuid(); + } + } + + private String readStringField(GetField fields, String name, String defaultValue) { + try { + return (String) fields.get(name, defaultValue); + } catch (Exception e) { + logger.debug(readErrorMessage, name, e.getMessage()); + return defaultValue; + } + } + + private boolean readBooleanField(GetField fields, String name, boolean defaultValue) { + try { + return fields.get(name, defaultValue); + } catch (Exception e) { + logger.debug(readErrorMessage, name, e.getMessage()); + return defaultValue; + } + } + + private int readIntField(GetField fields, String name, int defaultValue) { + try { + return fields.get(name, defaultValue); + } catch (Exception e) { + logger.debug(readErrorMessage, name, e.getMessage()); + return defaultValue; + } + } + + private long readLongField(GetField fields, String name, long defaultValue) { + try { + return fields.get(name, defaultValue); + } catch (Exception e) { + logger.debug(readErrorMessage, name, e.getMessage()); + return defaultValue; + } + } + + private static String stamp(long dateMillis) { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(dateMillis); + return String.format(Locale.US, + "%02d:%02d:%02d", + calendar.HOUR_OF_DAY, + calendar.MINUTE, + calendar.SECOND); + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/Adjust.java b/mobile/android/thirdparty/com/adjust/sdk/Adjust.java new file mode 100644 index 000000000..3b81a077b --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/Adjust.java @@ -0,0 +1,79 @@ +// +// Adjust.java +// Adjust +// +// Created by Christian Wellenbrock on 2012-10-11. +// Copyright (c) 2012-2014 adjust GmbH. All rights reserved. +// See the file MIT-LICENSE for copying permission. +// + +package com.adjust.sdk; + +import android.net.Uri; + +/** + * The main interface to Adjust. + * Use the methods of this class to tell Adjust about the usage of your app. + * See the README for details. + */ +public class Adjust { + + private static AdjustInstance defaultInstance; + + private Adjust() { + } + + public static synchronized AdjustInstance getDefaultInstance() { + if (defaultInstance == null) { + defaultInstance = new AdjustInstance(); + } + return defaultInstance; + } + + public static void onCreate(AdjustConfig adjustConfig) { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.onCreate(adjustConfig); + } + + public static void trackEvent(AdjustEvent event) { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.trackEvent(event); + } + + public static void onResume() { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.onResume(); + } + + public static void onPause() { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.onPause(); + } + + public static void setEnabled(boolean enabled) { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.setEnabled(enabled); + } + + public static boolean isEnabled() { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + return adjustInstance.isEnabled(); + } + + public static void appWillOpenUrl(Uri url) { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.appWillOpenUrl(url); + } + + public static void setReferrer(String referrer) { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.sendReferrer(referrer); + } + + public static void setOfflineMode(boolean enabled) { + AdjustInstance adjustInstance = Adjust.getDefaultInstance(); + adjustInstance.setOfflineMode(enabled); + } +} + + diff --git a/mobile/android/thirdparty/com/adjust/sdk/AdjustAttribution.java b/mobile/android/thirdparty/com/adjust/sdk/AdjustAttribution.java new file mode 100644 index 000000000..4e3abb017 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/AdjustAttribution.java @@ -0,0 +1,62 @@ +package com.adjust.sdk; + +import org.json.JSONObject; + +import java.io.Serializable; + +/** + * Created by pfms on 07/11/14. + */ +public class AdjustAttribution implements Serializable { + private static final long serialVersionUID = 1L; + + public String trackerToken; + public String trackerName; + public String network; + public String campaign; + public String adgroup; + public String creative; + + public static AdjustAttribution fromJson(JSONObject jsonObject) { + if (jsonObject == null) return null; + + AdjustAttribution attribution = new AdjustAttribution(); + + attribution.trackerToken = jsonObject.optString("tracker_token", null); + attribution.trackerName = jsonObject.optString("tracker_name", null); + attribution.network = jsonObject.optString("network", null); + attribution.campaign = jsonObject.optString("campaign", null); + attribution.adgroup = jsonObject.optString("adgroup", null); + attribution.creative = jsonObject.optString("creative", null); + + return attribution; + } + + public boolean equals(Object other) { + if (other == this) return true; + if (other == null) return false; + if (getClass() != other.getClass()) return false; + AdjustAttribution otherAttribution = (AdjustAttribution) other; + + if (!equalString(trackerToken, otherAttribution.trackerToken)) return false; + if (!equalString(trackerName, otherAttribution.trackerName)) return false; + if (!equalString(network, otherAttribution.network)) return false; + if (!equalString(campaign, otherAttribution.campaign)) return false; + if (!equalString(adgroup, otherAttribution.adgroup)) return false; + if (!equalString(creative, otherAttribution.creative)) return false; + return true; + } + + private boolean equalString(String first, String second) { + if (first == null || second == null) { + return first == null && second == null; + } + return first.equals(second); + } + + @Override + public String toString() { + return String.format("tt:%s tn:%s net:%s cam:%s adg:%s cre:%s", + trackerToken, trackerName, network, campaign, adgroup, creative); + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/AdjustConfig.java b/mobile/android/thirdparty/com/adjust/sdk/AdjustConfig.java new file mode 100644 index 000000000..148a5f670 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/AdjustConfig.java @@ -0,0 +1,128 @@ +package com.adjust.sdk; + +import android.content.Context; + +/** + * Created by pfms on 06/11/14. + */ +public class AdjustConfig { + Context context; + String appToken; + String environment; + LogLevel logLevel; + String sdkPrefix; + Boolean eventBufferingEnabled; + String defaultTracker; + OnAttributionChangedListener onAttributionChangedListener; + String referrer; + long referrerClickTime; + Boolean knownDevice; + + public static final String ENVIRONMENT_SANDBOX = "sandbox"; + public static final String ENVIRONMENT_PRODUCTION = "production"; + + public AdjustConfig(Context context, String appToken, String environment) { + if (!isValid(context, appToken, environment)) { + return; + } + + this.context = context.getApplicationContext(); + this.appToken = appToken; + this.environment = environment; + + // default values + this.logLevel = LogLevel.INFO; + this.eventBufferingEnabled = false; + } + + public void setEventBufferingEnabled(Boolean eventBufferingEnabled) { + this.eventBufferingEnabled = eventBufferingEnabled; + } + + public void setLogLevel(LogLevel logLevel) { + this.logLevel = logLevel; + } + + public void setSdkPrefix(String sdkPrefix) { + this.sdkPrefix = sdkPrefix; + } + + public void setDefaultTracker(String defaultTracker) { + this.defaultTracker = defaultTracker; + } + + public void setOnAttributionChangedListener(OnAttributionChangedListener onAttributionChangedListener) { + this.onAttributionChangedListener = onAttributionChangedListener; + } + + public boolean hasListener() { + return onAttributionChangedListener != null; + } + + public boolean isValid() { + return appToken != null; + } + + private boolean isValid(Context context, String appToken, String environment) { + if (!checkAppToken(appToken)) return false; + if (!checkEnvironment(environment)) return false; + if (!checkContext(context)) return false; + + return true; + } + + private static boolean checkContext(Context context) { + ILogger logger = AdjustFactory.getLogger(); + if (context == null) { + logger.error("Missing context"); + return false; + } + + if (!Util.checkPermission(context, android.Manifest.permission.INTERNET)) { + logger.error("Missing permission: INTERNET"); + return false; + } + + return true; + } + + private static boolean checkAppToken(String appToken) { + ILogger logger = AdjustFactory.getLogger(); + if (appToken == null) { + logger.error("Missing App Token."); + return false; + } + + if (appToken.length() != 12) { + logger.error("Malformed App Token '%s'", appToken); + return false; + } + + return true; + } + + private static boolean checkEnvironment(String environment) { + ILogger logger = AdjustFactory.getLogger(); + if (environment == null) { + logger.error("Missing environment"); + return false; + } + + if (environment == AdjustConfig.ENVIRONMENT_SANDBOX) { + logger.Assert("SANDBOX: Adjust is running in Sandbox mode. " + + "Use this setting for testing. " + + "Don't forget to set the environment to `production` before publishing!"); + return true; + } + if (environment == AdjustConfig.ENVIRONMENT_PRODUCTION) { + logger.Assert( + "PRODUCTION: Adjust is running in Production mode. " + + "Use this setting only for the build that you want to publish. " + + "Set the environment to `sandbox` if you want to test your app!"); + return true; + } + + logger.error("Unknown environment '%s'", environment); + return false; + } +}
\ No newline at end of file diff --git a/mobile/android/thirdparty/com/adjust/sdk/AdjustEvent.java b/mobile/android/thirdparty/com/adjust/sdk/AdjustEvent.java new file mode 100644 index 000000000..f03718183 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/AdjustEvent.java @@ -0,0 +1,112 @@ +package com.adjust.sdk; + +import java.util.HashMap; +import java.util.Map; + +/** + * Created by pfms on 05/11/14. + */ +public class AdjustEvent { + String eventToken; + Double revenue; + String currency; + Map<String, String> callbackParameters; + Map<String, String> partnerParameters; + + private static ILogger logger = AdjustFactory.getLogger(); + + public AdjustEvent(String eventToken) { + if (!checkEventToken(eventToken, logger)) return; + + this.eventToken = eventToken; + } + + public void setRevenue(double revenue, String currency) { + if (!checkRevenue(revenue, currency)) return; + + this.revenue = revenue; + this.currency = currency; + } + + public void addCallbackParameter(String key, String value) { + if (!isValidParameter(key, "key", "Callback")) return; + if (!isValidParameter(value, "value", "Callback")) return; + + if (callbackParameters == null) { + callbackParameters = new HashMap<String, String>(); + } + + String previousValue = callbackParameters.put(key, value); + + if (previousValue != null) { + logger.warn("key %s was overwritten", key); + } + } + + public void addPartnerParameter(String key, String value) { + if (!isValidParameter(key, "key", "Partner")) return; + if (!isValidParameter(value, "value", "Partner")) return; + + if (partnerParameters == null) { + partnerParameters = new HashMap<String, String>(); + } + + String previousValue = partnerParameters.put(key, value); + + if (previousValue != null) { + logger.warn("key %s was overwritten", key); + } + } + + public boolean isValid() { + return eventToken != null; + } + + private static boolean checkEventToken(String eventToken, ILogger logger) { + if (eventToken == null) { + logger.error("Missing Event Token"); + return false; + } + if (eventToken.length() != 6) { + logger.error("Malformed Event Token '%s'", eventToken); + return false; + } + return true; + } + + private boolean checkRevenue(Double revenue, String currency) { + if (revenue != null) { + if (revenue < 0.0) { + logger.error("Invalid amount %.4f", revenue); + return false; + } + + if (currency == null) { + logger.error("Currency must be set with revenue"); + return false; + } + if (currency == "") { + logger.error("Currency is empty"); + return false; + } + + } else if (currency != null) { + logger.error("Revenue must be set with currency"); + return false; + } + return true; + } + + private boolean isValidParameter(String attribute, String attributeType, String parameterName) { + if (attribute == null) { + logger.error("%s parameter %s is missing", parameterName, attributeType); + return false; + } + if (attribute == "") { + logger.error("%s parameter %s is empty", parameterName, attributeType); + return false; + } + + return true; + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/AdjustFactory.java b/mobile/android/thirdparty/com/adjust/sdk/AdjustFactory.java new file mode 100644 index 000000000..802af6416 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/AdjustFactory.java @@ -0,0 +1,141 @@ +package com.adjust.sdk; + +import android.content.Context; + +import ch.boye.httpclientandroidlib.client.HttpClient; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.params.HttpParams; + +public class AdjustFactory { + private static IPackageHandler packageHandler = null; + private static IRequestHandler requestHandler = null; + private static IAttributionHandler attributionHandler = null; + private static IActivityHandler activityHandler = null; + private static ILogger logger = null; + private static HttpClient httpClient = null; + + private static long timerInterval = -1; + private static long timerStart = -1; + private static long sessionInterval = -1; + private static long subsessionInterval = -1; + + public static IPackageHandler getPackageHandler(ActivityHandler activityHandler, + Context context, + boolean startPaused) { + if (packageHandler == null) { + return new PackageHandler(activityHandler, context, startPaused); + } + packageHandler.init(activityHandler, context, startPaused); + return packageHandler; + } + + public static IRequestHandler getRequestHandler(IPackageHandler packageHandler) { + if (requestHandler == null) { + return new RequestHandler(packageHandler); + } + requestHandler.init(packageHandler); + return requestHandler; + } + + public static ILogger getLogger() { + if (logger == null) { + // Logger needs to be "static" to retain the configuration throughout the app + logger = new Logger(); + } + return logger; + } + + public static HttpClient getHttpClient(HttpParams params) { + if (httpClient == null) { + return new DefaultHttpClient(params); + } + return httpClient; + } + + public static long getTimerInterval() { + if (timerInterval == -1) { + return Constants.ONE_MINUTE; + } + return timerInterval; + } + + public static long getTimerStart() { + if (timerStart == -1) { + return 0; + } + return timerStart; + } + + public static long getSessionInterval() { + if (sessionInterval == -1) { + return Constants.THIRTY_MINUTES; + } + return sessionInterval; + } + + public static long getSubsessionInterval() { + if (subsessionInterval == -1) { + return Constants.ONE_SECOND; + } + return subsessionInterval; + } + + public static IActivityHandler getActivityHandler(AdjustConfig config) { + if (activityHandler == null) { + return ActivityHandler.getInstance(config); + } + activityHandler.init(config); + return activityHandler; + } + + public static IAttributionHandler getAttributionHandler(IActivityHandler activityHandler, + ActivityPackage attributionPackage, + boolean startPaused) { + if (attributionHandler == null) { + return new AttributionHandler(activityHandler, attributionPackage, startPaused); + } + attributionHandler.init(activityHandler, attributionPackage, startPaused); + return attributionHandler; + } + + public static void setPackageHandler(IPackageHandler packageHandler) { + AdjustFactory.packageHandler = packageHandler; + } + + public static void setRequestHandler(IRequestHandler requestHandler) { + AdjustFactory.requestHandler = requestHandler; + } + + public static void setLogger(ILogger logger) { + AdjustFactory.logger = logger; + } + + public static void setHttpClient(HttpClient httpClient) { + AdjustFactory.httpClient = httpClient; + } + + public static void setTimerInterval(long timerInterval) { + AdjustFactory.timerInterval = timerInterval; + } + + public static void setTimerStart(long timerStart) { + AdjustFactory.timerStart = timerStart; + } + + public static void setSessionInterval(long sessionInterval) { + AdjustFactory.sessionInterval = sessionInterval; + } + + public static void setSubsessionInterval(long subsessionInterval) { + AdjustFactory.subsessionInterval = subsessionInterval; + } + + public static void setActivityHandler(IActivityHandler activityHandler) { + AdjustFactory.activityHandler = activityHandler; + } + + public static void setAttributionHandler(IAttributionHandler attributionHandler) { + AdjustFactory.attributionHandler = attributionHandler; + } + +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/AdjustInstance.java b/mobile/android/thirdparty/com/adjust/sdk/AdjustInstance.java new file mode 100644 index 000000000..158fb7ca1 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/AdjustInstance.java @@ -0,0 +1,86 @@ +package com.adjust.sdk; + +import android.net.Uri; + +/** + * Created by pfms on 04/12/14. + */ +public class AdjustInstance { + + private String referrer; + private long referrerClickTime; + private ActivityHandler activityHandler; + + private static ILogger getLogger() { + return AdjustFactory.getLogger(); + } + + public void onCreate(AdjustConfig adjustConfig) { + if (activityHandler != null) { + getLogger().error("Adjust already initialized"); + return; + } + + adjustConfig.referrer = this.referrer; + adjustConfig.referrerClickTime = this.referrerClickTime; + + activityHandler = ActivityHandler.getInstance(adjustConfig); + } + + public void trackEvent(AdjustEvent event) { + if (!checkActivityHandler()) return; + activityHandler.trackEvent(event); + } + + public void onResume() { + if (!checkActivityHandler()) return; + activityHandler.trackSubsessionStart(); + } + + public void onPause() { + if (!checkActivityHandler()) return; + activityHandler.trackSubsessionEnd(); + } + + public void setEnabled(boolean enabled) { + if (!checkActivityHandler()) return; + activityHandler.setEnabled(enabled); + } + + public boolean isEnabled() { + if (!checkActivityHandler()) return false; + return activityHandler.isEnabled(); + } + + public void appWillOpenUrl(Uri url) { + if (!checkActivityHandler()) return; + long clickTime = System.currentTimeMillis(); + activityHandler.readOpenUrl(url, clickTime); + } + + public void sendReferrer(String referrer) { + long clickTime = System.currentTimeMillis(); + // sendReferrer might be triggered before Adjust + if (activityHandler == null) { + // save it to inject in the config before launch + this.referrer = referrer; + this.referrerClickTime = clickTime; + } else { + activityHandler.sendReferrer(referrer, clickTime); + } + } + + public void setOfflineMode(boolean enabled) { + if (!checkActivityHandler()) return; + activityHandler.setOfflineMode(enabled); + } + + private boolean checkActivityHandler() { + if (activityHandler == null) { + getLogger().error("Please initialize Adjust by calling 'onCreate' before"); + return false; + } else { + return true; + } + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/AdjustReferrerReceiver.java b/mobile/android/thirdparty/com/adjust/sdk/AdjustReferrerReceiver.java new file mode 100644 index 000000000..cfeecd8d0 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/AdjustReferrerReceiver.java @@ -0,0 +1,35 @@ +package com.adjust.sdk; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; + +import static com.adjust.sdk.Constants.ENCODING; +import static com.adjust.sdk.Constants.MALFORMED; +import static com.adjust.sdk.Constants.REFERRER; + +// support multiple BroadcastReceivers for the INSTALL_REFERRER: +// http://blog.appington.com/2012/08/01/giving-credit-for-android-app-installs + +public class AdjustReferrerReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String rawReferrer = intent.getStringExtra(REFERRER); + if (null == rawReferrer) { + return; + } + + String referrer; + try { + referrer = URLDecoder.decode(rawReferrer, ENCODING); + } catch (UnsupportedEncodingException e) { + referrer = MALFORMED; + } + + AdjustInstance adjust = Adjust.getDefaultInstance(); + adjust.sendReferrer(referrer); + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/AttributionHandler.java b/mobile/android/thirdparty/com/adjust/sdk/AttributionHandler.java new file mode 100644 index 000000000..0d550a83a --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/AttributionHandler.java @@ -0,0 +1,155 @@ +package com.adjust.sdk; + +import android.net.Uri; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.HttpClient; +import ch.boye.httpclientandroidlib.client.methods.HttpGet; +import org.json.JSONObject; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Created by pfms on 07/11/14. + */ +public class AttributionHandler implements IAttributionHandler { + private ScheduledExecutorService scheduler; + private IActivityHandler activityHandler; + private ILogger logger; + private ActivityPackage attributionPackage; + private ScheduledFuture waitingTask; + private HttpClient httpClient; + private boolean paused; + + public AttributionHandler(IActivityHandler activityHandler, + ActivityPackage attributionPackage, + boolean startPaused) { + scheduler = Executors.newSingleThreadScheduledExecutor(); + logger = AdjustFactory.getLogger(); + httpClient = Util.getHttpClient(); + init(activityHandler, attributionPackage, startPaused); + } + + @Override + public void init(IActivityHandler activityHandler, + ActivityPackage attributionPackage, + boolean startPaused) { + this.activityHandler = activityHandler; + this.attributionPackage = attributionPackage; + this.paused = startPaused; + } + + @Override + public void getAttribution() { + getAttribution(0); + } + + @Override + public void checkAttribution(final JSONObject jsonResponse) { + scheduler.submit(new Runnable() { + @Override + public void run() { + checkAttributionInternal(jsonResponse); + } + }); + } + + @Override + public void pauseSending() { + paused = true; + } + + @Override + public void resumeSending() { + paused = false; + } + + private void getAttribution(int delayInMilliseconds) { + if (waitingTask != null) { + waitingTask.cancel(false); + } + + if (delayInMilliseconds != 0) { + logger.debug("Waiting to query attribution in %d milliseconds", delayInMilliseconds); + } + + waitingTask = scheduler.schedule(new Runnable() { + @Override + public void run() { + getAttributionInternal(); + } + }, delayInMilliseconds, TimeUnit.MILLISECONDS); + } + + private void checkAttributionInternal(JSONObject jsonResponse) { + if (jsonResponse == null) return; + + JSONObject attributionJson = jsonResponse.optJSONObject("attribution"); + AdjustAttribution attribution = AdjustAttribution.fromJson(attributionJson); + + int timerMilliseconds = jsonResponse.optInt("ask_in", -1); + + // without ask_in attribute + if (timerMilliseconds < 0) { + activityHandler.tryUpdateAttribution(attribution); + + activityHandler.setAskingAttribution(false); + + return; + } + + activityHandler.setAskingAttribution(true); + + getAttribution(timerMilliseconds); + } + + private void getAttributionInternal() { + if (paused) { + logger.debug("Attribution Handler is paused"); + return; + } + logger.verbose("%s", attributionPackage.getExtendedString()); + HttpResponse httpResponse = null; + try { + HttpGet request = getRequest(attributionPackage); + httpResponse = httpClient.execute(request); + } catch (Exception e) { + logger.error("Failed to get attribution (%s)", e.getMessage()); + return; + } + + JSONObject jsonResponse = Util.parseJsonResponse(httpResponse, logger); + + checkAttributionInternal(jsonResponse); + } + + private Uri buildUri(ActivityPackage attributionPackage) { + Uri.Builder uriBuilder = new Uri.Builder(); + + uriBuilder.scheme(Constants.SCHEME); + uriBuilder.authority(Constants.AUTHORITY); + uriBuilder.appendPath(attributionPackage.getPath()); + + for (Map.Entry<String, String> entry : attributionPackage.getParameters().entrySet()) { + uriBuilder.appendQueryParameter(entry.getKey(), entry.getValue()); + } + + return uriBuilder.build(); + } + + private HttpGet getRequest(ActivityPackage attributionPackage) throws URISyntaxException { + HttpGet request = new HttpGet(); + Uri uri = buildUri(attributionPackage); + request.setURI(new URI(uri.toString())); + + request.addHeader("Client-SDK", attributionPackage.getClientSdk()); + + return request; + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/Constants.java b/mobile/android/thirdparty/com/adjust/sdk/Constants.java new file mode 100644 index 000000000..7a97cb2f4 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/Constants.java @@ -0,0 +1,53 @@ +// +// Constants.java +// Adjust +// +// Created by keyboardsurfer on 2013-11-08. +// Copyright (c) 2012-2014 adjust GmbH. All rights reserved. +// See the file MIT-LICENSE for copying permission. +// + +package com.adjust.sdk; + +import java.util.Arrays; +import java.util.List; + +/** + * @author keyboardsurfer + * @since 8.11.13 + */ +public interface Constants { + int ONE_SECOND = 1000; + int ONE_MINUTE = 60 * ONE_SECOND; + int THIRTY_MINUTES = 30 * ONE_MINUTE; + + int CONNECTION_TIMEOUT = Constants.ONE_MINUTE; + int SOCKET_TIMEOUT = Constants.ONE_MINUTE; + + String BASE_URL = "https://app.adjust.com"; + String SCHEME = "https"; + String AUTHORITY = "app.adjust.com"; + String CLIENT_SDK = "android4.0.0"; + String LOGTAG = "Adjust"; + + String ACTIVITY_STATE_FILENAME = "AdjustIoActivityState"; + String ATTRIBUTION_FILENAME = "AdjustAttribution"; + + String MALFORMED = "malformed"; + String SMALL = "small"; + String NORMAL = "normal"; + String LONG = "long"; + String LARGE = "large"; + String XLARGE = "xlarge"; + String LOW = "low"; + String MEDIUM = "medium"; + String HIGH = "high"; + String REFERRER = "referrer"; + + String ENCODING = "UTF-8"; + String MD5 = "MD5"; + String SHA1 = "SHA-1"; + + // List of known plugins, possibly not active + List<String> PLUGINS = Arrays.asList(); +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/DeviceInfo.java b/mobile/android/thirdparty/com/adjust/sdk/DeviceInfo.java new file mode 100644 index 000000000..5cccb77f4 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/DeviceInfo.java @@ -0,0 +1,290 @@ +package com.adjust.sdk; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.util.DisplayMetrics; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.util.Locale; +import java.util.Map; + +import static com.adjust.sdk.Constants.ENCODING; +import static com.adjust.sdk.Constants.HIGH; +import static com.adjust.sdk.Constants.LARGE; +import static com.adjust.sdk.Constants.LONG; +import static com.adjust.sdk.Constants.LOW; +import static com.adjust.sdk.Constants.MD5; +import static com.adjust.sdk.Constants.MEDIUM; +import static com.adjust.sdk.Constants.NORMAL; +import static com.adjust.sdk.Constants.SHA1; +import static com.adjust.sdk.Constants.SMALL; +import static com.adjust.sdk.Constants.XLARGE; + +/** + * Created by pfms on 06/11/14. + */ +class DeviceInfo { + String macSha1; + String macShortMd5; + String androidId; + String fbAttributionId; + String clientSdk; + String packageName; + String appVersion; + String deviceType; + String deviceName; + String deviceManufacturer; + String osName; + String osVersion; + String language; + String country; + String screenSize; + String screenFormat; + String screenDensity; + String displayWidth; + String displayHeight; + Map<String, String> pluginKeys; + + DeviceInfo(Context context, String sdkPrefix) { + Resources resources = context.getResources(); + DisplayMetrics displayMetrics = resources.getDisplayMetrics(); + Configuration configuration = resources.getConfiguration(); + Locale locale = configuration.locale; + int screenLayout = configuration.screenLayout; + boolean isGooglePlayServicesAvailable = Reflection.isGooglePlayServicesAvailable(context); + String macAddress = getMacAddress(context, isGooglePlayServicesAvailable); + + packageName = getPackageName(context); + appVersion = getAppVersion(context); + deviceType = getDeviceType(screenLayout); + deviceName = getDeviceName(); + deviceManufacturer = getDeviceManufacturer(); + osName = getOsName(); + osVersion = getOsVersion(); + language = getLanguage(locale); + country = getCountry(locale); + screenSize = getScreenSize(screenLayout); + screenFormat = getScreenFormat(screenLayout); + screenDensity = getScreenDensity(displayMetrics); + displayWidth = getDisplayWidth(displayMetrics); + displayHeight = getDisplayHeight(displayMetrics); + clientSdk = getClientSdk(sdkPrefix); + androidId = getAndroidId(context, isGooglePlayServicesAvailable); + fbAttributionId = getFacebookAttributionId(context); + pluginKeys = Reflection.getPluginKeys(context); + macSha1 = getMacSha1(macAddress); + macShortMd5 = getMacShortMd5(macAddress); + } + + private String getMacAddress(Context context, boolean isGooglePlayServicesAvailable) { + if (!isGooglePlayServicesAvailable) { + if (!!Util.checkPermission(context, android.Manifest.permission.ACCESS_WIFI_STATE)) { + AdjustFactory.getLogger().warn("Missing permission: ACCESS_WIFI_STATE"); + } + return Reflection.getMacAddress(context); + } else { + return null; + } + } + + private String getPackageName(Context context) { + return context.getPackageName(); + } + + private String getAppVersion(Context context) { + try { + PackageManager packageManager = context.getPackageManager(); + String name = context.getPackageName(); + PackageInfo info = packageManager.getPackageInfo(name, 0); + return info.versionName; + } catch (PackageManager.NameNotFoundException e) { + return null; + } + } + + private String getDeviceType(int screenLayout) { + int screenSize = screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK; + + switch (screenSize) { + case Configuration.SCREENLAYOUT_SIZE_SMALL: + case Configuration.SCREENLAYOUT_SIZE_NORMAL: + return "phone"; + case Configuration.SCREENLAYOUT_SIZE_LARGE: + case 4: + return "tablet"; + default: + return null; + } + } + + private String getDeviceName() { + return Build.MODEL; + } + + private String getDeviceManufacturer() { + return Build.MANUFACTURER; + } + + private String getOsName() { + return "android"; + } + + private String getOsVersion() { + return osVersion = "" + Build.VERSION.SDK_INT; + } + + private String getLanguage(Locale locale) { + return locale.getLanguage(); + } + + private String getCountry(Locale locale) { + return locale.getCountry(); + } + + private String getScreenSize(int screenLayout) { + int screenSize = screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK; + + switch (screenSize) { + case Configuration.SCREENLAYOUT_SIZE_SMALL: + return SMALL; + case Configuration.SCREENLAYOUT_SIZE_NORMAL: + return NORMAL; + case Configuration.SCREENLAYOUT_SIZE_LARGE: + return LARGE; + case 4: + return XLARGE; + default: + return null; + } + } + + private String getScreenFormat(int screenLayout) { + int screenFormat = screenLayout & Configuration.SCREENLAYOUT_LONG_MASK; + + switch (screenFormat) { + case Configuration.SCREENLAYOUT_LONG_YES: + return LONG; + case Configuration.SCREENLAYOUT_LONG_NO: + return NORMAL; + default: + return null; + } + } + + private String getScreenDensity(DisplayMetrics displayMetrics) { + int density = displayMetrics.densityDpi; + int low = (DisplayMetrics.DENSITY_MEDIUM + DisplayMetrics.DENSITY_LOW) / 2; + int high = (DisplayMetrics.DENSITY_MEDIUM + DisplayMetrics.DENSITY_HIGH) / 2; + + if (0 == density) { + return null; + } else if (density < low) { + return LOW; + } else if (density > high) { + return HIGH; + } + return MEDIUM; + } + + private String getDisplayWidth(DisplayMetrics displayMetrics) { + return String.valueOf(displayMetrics.widthPixels); + } + + private String getDisplayHeight(DisplayMetrics displayMetrics) { + return String.valueOf(displayMetrics.heightPixels); + } + + private String getClientSdk(String sdkPrefix) { + if (sdkPrefix == null) { + return Constants.CLIENT_SDK; + } else { + return String.format("%s@%s", sdkPrefix, Constants.CLIENT_SDK); + } + } + + private String getMacSha1(String macAddress) { + if (macAddress == null) { + return null; + } + String macSha1 = sha1(macAddress); + + return macSha1; + } + + private String getMacShortMd5(String macAddress) { + if (macAddress == null) { + return null; + } + String macShort = macAddress.replaceAll(":", ""); + String macShortMd5 = md5(macShort); + + return macShortMd5; + } + + private String getAndroidId(Context context, boolean isGooglePlayServicesAvailable) { + if (!isGooglePlayServicesAvailable) { + return Reflection.getAndroidId(context); + } else { + return null; + } + } + + private String sha1(final String text) { + return hash(text, SHA1); + } + + private String md5(final String text) { + return hash(text, MD5); + } + + private String hash(final String text, final String method) { + String hashString = null; + try { + final byte[] bytes = text.getBytes(ENCODING); + final MessageDigest mesd = MessageDigest.getInstance(method); + mesd.update(bytes, 0, bytes.length); + final byte[] hash = mesd.digest(); + hashString = convertToHex(hash); + } catch (Exception e) { + } + return hashString; + } + + private static String convertToHex(final byte[] bytes) { + final BigInteger bigInt = new BigInteger(1, bytes); + final String formatString = "%0" + (bytes.length << 1) + "x"; + return String.format(formatString, bigInt); + } + + private String getFacebookAttributionId(final Context context) { + try { + final ContentResolver contentResolver = context.getContentResolver(); + final Uri uri = Uri.parse("content://com.facebook.katana.provider.AttributionIdProvider"); + final String columnName = "aid"; + final String[] projection = {columnName}; + final Cursor cursor = contentResolver.query(uri, projection, null, null, null); + + if (null == cursor) { + return null; + } + if (!cursor.moveToFirst()) { + cursor.close(); + return null; + } + + final String attributionId = cursor.getString(cursor.getColumnIndex(columnName)); + cursor.close(); + return attributionId; + } catch (Exception e) { + return null; + } + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/IActivityHandler.java b/mobile/android/thirdparty/com/adjust/sdk/IActivityHandler.java new file mode 100644 index 000000000..10b92205d --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/IActivityHandler.java @@ -0,0 +1,36 @@ +package com.adjust.sdk; + +import android.net.Uri; + +import org.json.JSONObject; + +/** + * Created by pfms on 15/12/14. + */ +public interface IActivityHandler { + public void init(AdjustConfig config); + + public void trackSubsessionStart(); + + public void trackSubsessionEnd(); + + public void trackEvent(AdjustEvent event); + + public void finishedTrackingActivity(JSONObject jsonResponse); + + public void setEnabled(boolean enabled); + + public boolean isEnabled(); + + public void readOpenUrl(Uri url, long clickTime); + + public boolean tryUpdateAttribution(AdjustAttribution attribution); + + public void sendReferrer(String referrer, long clickTime); + + public void setOfflineMode(boolean enabled); + + public void setAskingAttribution(boolean askingAttribution); + + public ActivityPackage getAttributionPackage(); +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/IAttributionHandler.java b/mobile/android/thirdparty/com/adjust/sdk/IAttributionHandler.java new file mode 100644 index 000000000..d4e701f75 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/IAttributionHandler.java @@ -0,0 +1,20 @@ +package com.adjust.sdk; + +import org.json.JSONObject; + +/** + * Created by pfms on 15/12/14. + */ +public interface IAttributionHandler { + public void init(IActivityHandler activityHandler, + ActivityPackage attributionPackage, + boolean startPaused); + + public void getAttribution(); + + public void checkAttribution(JSONObject jsonResponse); + + public void pauseSending(); + + public void resumeSending(); +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/ILogger.java b/mobile/android/thirdparty/com/adjust/sdk/ILogger.java new file mode 100644 index 000000000..28f92af4b --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/ILogger.java @@ -0,0 +1,20 @@ +package com.adjust.sdk; + +public interface ILogger { + public void setLogLevel(LogLevel logLevel); + + public void setLogLevelString(String logLevelString); + + public void verbose(String message, Object... parameters); + + public void debug(String message, Object... parameters); + + public void info(String message, Object... parameters); + + public void warn(String message, Object... parameters); + + public void error(String message, Object... parameters); + + public void Assert(String message, Object... parameters); + +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/IPackageHandler.java b/mobile/android/thirdparty/com/adjust/sdk/IPackageHandler.java new file mode 100644 index 000000000..99c300364 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/IPackageHandler.java @@ -0,0 +1,27 @@ +package com.adjust.sdk; + +import android.content.Context; + +import org.json.JSONObject; + +public interface IPackageHandler { + public void init(IActivityHandler activityHandler, Context context, boolean startPaused); + + public void addPackage(ActivityPackage pack); + + public void sendFirstPackage(); + + public void sendNextPackage(); + + public void closeFirstPackage(); + + public void pauseSending(); + + public void resumeSending(); + + public String getFailureMessage(); + + public void finishedTrackingActivity(JSONObject jsonResponse); + + public void sendClickPackage(ActivityPackage clickPackage); +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/IRequestHandler.java b/mobile/android/thirdparty/com/adjust/sdk/IRequestHandler.java new file mode 100644 index 000000000..5b18e2ee9 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/IRequestHandler.java @@ -0,0 +1,9 @@ +package com.adjust.sdk; + +public interface IRequestHandler { + public void init(IPackageHandler packageHandler); + + public void sendPackage(ActivityPackage pack); + + public void sendClickPackage(ActivityPackage clickPackage); +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/LICENSE b/mobile/android/thirdparty/com/adjust/sdk/LICENSE new file mode 100644 index 000000000..25e1d5eb5 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2012-2014 adjust GmbH, +http://www.adjust.com + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/mobile/android/thirdparty/com/adjust/sdk/LogLevel.java b/mobile/android/thirdparty/com/adjust/sdk/LogLevel.java new file mode 100644 index 000000000..5c0b410c2 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/LogLevel.java @@ -0,0 +1,19 @@ +package com.adjust.sdk; + +import android.util.Log; + +/** + * Created by pfms on 11/03/15. + */ +public enum LogLevel { + VERBOSE(Log.VERBOSE), DEBUG(Log.DEBUG), INFO(Log.INFO), WARN(Log.WARN), ERROR(Log.ERROR), ASSERT(Log.ASSERT); + final int androidLogLevel; + + LogLevel(final int androidLogLevel) { + this.androidLogLevel = androidLogLevel; + } + + public int getAndroidLogLevel() { + return androidLogLevel; + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/Logger.java b/mobile/android/thirdparty/com/adjust/sdk/Logger.java new file mode 100644 index 000000000..86a644d4a --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/Logger.java @@ -0,0 +1,107 @@ +// +// Logger.java +// Adjust +// +// Created by Christian Wellenbrock on 2013-04-18. +// Copyright (c) 2013 adjust GmbH. All rights reserved. +// See the file MIT-LICENSE for copying permission. +// + +package com.adjust.sdk; + +import android.util.Log; + +import java.util.Arrays; +import java.util.Locale; + +import static com.adjust.sdk.Constants.LOGTAG; + +public class Logger implements ILogger { + + private LogLevel logLevel; + private static String formatErrorMessage = "Error formating log message: %s, with params: %s"; + + public Logger() { + setLogLevel(LogLevel.INFO); + } + + @Override + public void setLogLevel(LogLevel logLevel) { + this.logLevel = logLevel; + } + + @Override + public void setLogLevelString(String logLevelString) { + if (null != logLevelString) { + try { + setLogLevel(LogLevel.valueOf(logLevelString.toUpperCase(Locale.US))); + } catch (IllegalArgumentException iae) { + error("Malformed logLevel '%s', falling back to 'info'", logLevelString); + } + } + } + + @Override + public void verbose(String message, Object... parameters) { + if (logLevel.androidLogLevel <= Log.VERBOSE) { + try { + Log.v(LOGTAG, String.format(message, parameters)); + } catch (Exception e) { + Log.e(LOGTAG, String.format(formatErrorMessage, message, Arrays.toString(parameters))); + } + } + } + + @Override + public void debug(String message, Object... parameters) { + if (logLevel.androidLogLevel <= Log.DEBUG) { + try { + Log.d(LOGTAG, String.format(message, parameters)); + } catch (Exception e) { + Log.e(LOGTAG, String.format(formatErrorMessage, message, Arrays.toString(parameters))); + } + } + } + + @Override + public void info(String message, Object... parameters) { + if (logLevel.androidLogLevel <= Log.INFO) { + try { + Log.i(LOGTAG, String.format(message, parameters)); + } catch (Exception e) { + Log.e(LOGTAG, String.format(formatErrorMessage, message, Arrays.toString(parameters))); + } + } + } + + @Override + public void warn(String message, Object... parameters) { + if (logLevel.androidLogLevel <= Log.WARN) { + try { + Log.w(LOGTAG, String.format(message, parameters)); + } catch (Exception e) { + Log.e(LOGTAG, String.format(formatErrorMessage, message, Arrays.toString(parameters))); + } + } + } + + @Override + public void error(String message, Object... parameters) { + if (logLevel.androidLogLevel <= Log.ERROR) { + try { + Log.e(LOGTAG, String.format(message, parameters)); + } catch (Exception e) { + Log.e(LOGTAG, String.format(formatErrorMessage, message, Arrays.toString(parameters))); + } + } + } + + @Override + public void Assert(String message, Object... parameters) { + try { + Log.println(Log.ASSERT, LOGTAG, String.format(message, parameters)); + } catch (Exception e) { + Log.e(LOGTAG, String.format(formatErrorMessage, message, Arrays.toString(parameters))); + } + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/OnAttributionChangedListener.java b/mobile/android/thirdparty/com/adjust/sdk/OnAttributionChangedListener.java new file mode 100644 index 000000000..137d50d4d --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/OnAttributionChangedListener.java @@ -0,0 +1,5 @@ +package com.adjust.sdk; + +public interface OnAttributionChangedListener { + public void onAttributionChanged(AdjustAttribution attribution); +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/PackageBuilder.java b/mobile/android/thirdparty/com/adjust/sdk/PackageBuilder.java new file mode 100644 index 000000000..3a43045fd --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/PackageBuilder.java @@ -0,0 +1,291 @@ +// +// PackageBuilder.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.text.TextUtils; + +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +class PackageBuilder { + private AdjustConfig adjustConfig; + private DeviceInfo deviceInfo; + private ActivityState activityState; + private long createdAt; + + // reattributions + Map<String, String> extraParameters; + AdjustAttribution attribution; + String reftag; + + private static ILogger logger = AdjustFactory.getLogger(); + + public PackageBuilder(AdjustConfig adjustConfig, + DeviceInfo deviceInfo, + ActivityState activityState, + long createdAt) { + this.adjustConfig = adjustConfig; + this.deviceInfo = deviceInfo; + this.activityState = activityState.clone(); + this.createdAt = createdAt; + } + + public ActivityPackage buildSessionPackage() { + Map<String, String> parameters = getDefaultParameters(); + addDuration(parameters, "last_interval", activityState.lastInterval); + addString(parameters, "default_tracker", adjustConfig.defaultTracker); + + ActivityPackage sessionPackage = getDefaultActivityPackage(); + sessionPackage.setPath("/session"); + sessionPackage.setActivityKind(ActivityKind.SESSION); + sessionPackage.setSuffix(""); + sessionPackage.setParameters(parameters); + + return sessionPackage; + } + + public ActivityPackage buildEventPackage(AdjustEvent event) { + Map<String, String> parameters = getDefaultParameters(); + addInt(parameters, "event_count", activityState.eventCount); + addString(parameters, "event_token", event.eventToken); + addDouble(parameters, "revenue", event.revenue); + addString(parameters, "currency", event.currency); + addMapJson(parameters, "callback_params", event.callbackParameters); + addMapJson(parameters, "partner_params", event.partnerParameters); + + ActivityPackage eventPackage = getDefaultActivityPackage(); + eventPackage.setPath("/event"); + eventPackage.setActivityKind(ActivityKind.EVENT); + eventPackage.setSuffix(getEventSuffix(event)); + eventPackage.setParameters(parameters); + + return eventPackage; + } + + public ActivityPackage buildClickPackage(String source, long clickTime) { + Map<String, String> parameters = getDefaultParameters(); + + addString(parameters, "source", source); + addDate(parameters, "click_time", clickTime); + addString(parameters, "reftag", reftag); + addMapJson(parameters, "params", extraParameters); + injectAttribution(parameters); + + ActivityPackage clickPackage = getDefaultActivityPackage(); + clickPackage.setPath("/sdk_click"); + clickPackage.setActivityKind(ActivityKind.CLICK); + clickPackage.setSuffix(""); + clickPackage.setParameters(parameters); + + return clickPackage; + } + + public ActivityPackage buildAttributionPackage() { + Map<String, String> parameters = getIdsParameters(); + + ActivityPackage attributionPackage = getDefaultActivityPackage(); + attributionPackage.setPath("attribution"); // does not contain '/' because of Uri.Builder.appendPath + attributionPackage.setActivityKind(ActivityKind.ATTRIBUTION); + attributionPackage.setSuffix(""); + attributionPackage.setParameters(parameters); + + return attributionPackage; + } + + private ActivityPackage getDefaultActivityPackage() { + ActivityPackage activityPackage = new ActivityPackage(); + activityPackage.setClientSdk(deviceInfo.clientSdk); + return activityPackage; + } + + private Map<String, String> getDefaultParameters() { + Map<String, String> parameters = new HashMap<String, String>(); + + injectDeviceInfo(parameters); + injectConfig(parameters); + injectActivityState(parameters); + addDate(parameters, "created_at", createdAt); + + // general + checkDeviceIds(parameters); + + return parameters; + } + + private Map<String, String> getIdsParameters() { + Map<String, String> parameters = new HashMap<String, String>(); + + injectDeviceInfoIds(parameters); + injectConfig(parameters); + injectActivityStateIds(parameters); + + checkDeviceIds(parameters); + + return parameters; + } + + private void injectDeviceInfo(Map<String, String> parameters) { + injectDeviceInfoIds(parameters); + addString(parameters, "fb_id", deviceInfo.fbAttributionId); + addString(parameters, "package_name", deviceInfo.packageName); + addString(parameters, "app_version", deviceInfo.appVersion); + addString(parameters, "device_type", deviceInfo.deviceType); + addString(parameters, "device_name", deviceInfo.deviceName); + addString(parameters, "device_manufacturer", deviceInfo.deviceManufacturer); + addString(parameters, "os_name", deviceInfo.osName); + addString(parameters, "os_version", deviceInfo.osVersion); + addString(parameters, "language", deviceInfo.language); + addString(parameters, "country", deviceInfo.country); + addString(parameters, "screen_size", deviceInfo.screenSize); + addString(parameters, "screen_format", deviceInfo.screenFormat); + addString(parameters, "screen_density", deviceInfo.screenDensity); + addString(parameters, "display_width", deviceInfo.displayWidth); + addString(parameters, "display_height", deviceInfo.displayHeight); + fillPluginKeys(parameters); + } + + private void injectDeviceInfoIds(Map<String, String> parameters) { + addString(parameters, "mac_sha1", deviceInfo.macSha1); + addString(parameters, "mac_md5", deviceInfo.macShortMd5); + addString(parameters, "android_id", deviceInfo.androidId); + } + + private void injectConfig(Map<String, String> parameters) { + addString(parameters, "app_token", adjustConfig.appToken); + addString(parameters, "environment", adjustConfig.environment); + addBoolean(parameters, "device_known", adjustConfig.knownDevice); + addBoolean(parameters, "needs_attribution_data", adjustConfig.hasListener()); + + String playAdId = Util.getPlayAdId(adjustConfig.context); + addString(parameters, "gps_adid", playAdId); + Boolean isTrackingEnabled = Util.isPlayTrackingEnabled(adjustConfig.context); + addBoolean(parameters, "tracking_enabled", isTrackingEnabled); + } + + private void injectActivityState(Map<String, String> parameters) { + injectActivityStateIds(parameters); + addInt(parameters, "session_count", activityState.sessionCount); + addInt(parameters, "subsession_count", activityState.subsessionCount); + addDuration(parameters, "session_length", activityState.sessionLength); + addDuration(parameters, "time_spent", activityState.timeSpent); + } + + private void injectActivityStateIds(Map<String, String> parameters) { + addString(parameters, "android_uuid", activityState.uuid); + } + + private void injectAttribution(Map<String, String> parameters) { + if (attribution == null) { + return; + } + addString(parameters, "tracker", attribution.trackerName); + addString(parameters, "campaign", attribution.campaign); + addString(parameters, "adgroup", attribution.adgroup); + addString(parameters, "creative", attribution.creative); + } + + private void checkDeviceIds(Map<String, String> parameters) { + if (!parameters.containsKey("mac_sha1") + && !parameters.containsKey("mac_md5") + && !parameters.containsKey("android_id") + && !parameters.containsKey("gps_adid")) { + logger.error("Missing device id's. Please check if Proguard is correctly set with Adjust SDK"); + } + } + + private void fillPluginKeys(Map<String, String> parameters) { + if (deviceInfo.pluginKeys == null) { + return; + } + + for (Map.Entry<String, String> entry : deviceInfo.pluginKeys.entrySet()) { + addString(parameters, entry.getKey(), entry.getValue()); + } + } + + private String getEventSuffix(AdjustEvent event) { + if (event.revenue == null) { + return String.format(" '%s'", event.eventToken); + } else { + return String.format(Locale.US, " (%.4f %s, '%s')", event.revenue, event.currency, event.eventToken); + } + } + + private void addString(Map<String, String> parameters, String key, String value) { + if (TextUtils.isEmpty(value)) { + return; + } + + parameters.put(key, value); + } + + private void addInt(Map<String, String> parameters, String key, long value) { + if (value < 0) { + return; + } + + String valueString = Long.toString(value); + addString(parameters, key, valueString); + } + + private void addDate(Map<String, String> parameters, String key, long value) { + if (value < 0) { + return; + } + + String dateString = Util.dateFormat(value); + addString(parameters, key, dateString); + } + + private void addDuration(Map<String, String> parameters, String key, long durationInMilliSeconds) { + if (durationInMilliSeconds < 0) { + return; + } + + long durationInSeconds = (durationInMilliSeconds + 500) / 1000; + addInt(parameters, key, durationInSeconds); + } + + private void addMapJson(Map<String, String> parameters, String key, Map<String, String> map) { + if (map == null) { + return; + } + + if (map.size() == 0) { + return; + } + + JSONObject jsonObject = new JSONObject(map); + String jsonString = jsonObject.toString(); + + addString(parameters, key, jsonString); + } + + private void addBoolean(Map<String, String> parameters, String key, Boolean value) { + if (value == null) { + return; + } + + int intValue = value ? 1 : 0; + + addInt(parameters, key, intValue); + } + + private void addDouble(Map<String, String> parameters, String key, Double value) { + if (value == null) return; + + String doubleString = String.format("%.5f", value); + + addString(parameters, key, doubleString); + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/PackageHandler.java b/mobile/android/thirdparty/com/adjust/sdk/PackageHandler.java new file mode 100644 index 000000000..d0a84ccd1 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/PackageHandler.java @@ -0,0 +1,274 @@ +// +// PackageHandler.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.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; + +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.NotSerializableException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.OptionalDataException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +// persistent +public class PackageHandler extends HandlerThread implements IPackageHandler { + private static final String PACKAGE_QUEUE_FILENAME = "AdjustIoPackageQueue"; + + private final InternalHandler internalHandler; + private IRequestHandler requestHandler; + private IActivityHandler activityHandler; + private List<ActivityPackage> packageQueue; + private AtomicBoolean isSending; + private boolean paused; + private Context context; + private ILogger logger; + + public PackageHandler(IActivityHandler activityHandler, + Context context, + boolean startPaused) { + super(Constants.LOGTAG, MIN_PRIORITY); + setDaemon(true); + start(); + this.internalHandler = new InternalHandler(getLooper(), this); + this.logger = AdjustFactory.getLogger(); + + init(activityHandler, context, startPaused); + + Message message = Message.obtain(); + message.arg1 = InternalHandler.INIT; + internalHandler.sendMessage(message); + } + + @Override + public void init(IActivityHandler activityHandler, Context context, boolean startPaused) { + this.activityHandler = activityHandler; + this.context = context; + this.paused = startPaused; + } + + // add a package to the queue + @Override + public void addPackage(ActivityPackage pack) { + Message message = Message.obtain(); + message.arg1 = InternalHandler.ADD; + message.obj = pack; + internalHandler.sendMessage(message); + } + + // try to send the oldest package + @Override + public void sendFirstPackage() { + Message message = Message.obtain(); + message.arg1 = InternalHandler.SEND_FIRST; + internalHandler.sendMessage(message); + } + + // remove oldest package and try to send the next one + // (after success or possibly permanent failure) + @Override + public void sendNextPackage() { + Message message = Message.obtain(); + message.arg1 = InternalHandler.SEND_NEXT; + internalHandler.sendMessage(message); + } + + // close the package to retry in the future (after temporary failure) + @Override + public void closeFirstPackage() { + isSending.set(false); + } + + // interrupt the sending loop after the current request has finished + @Override + public void pauseSending() { + paused = true; + } + + // allow sending requests again + @Override + public void resumeSending() { + paused = false; + } + + // short info about how failing packages are handled + @Override + public String getFailureMessage() { + return "Will retry later."; + } + + @Override + public void finishedTrackingActivity(JSONObject jsonResponse) { + activityHandler.finishedTrackingActivity(jsonResponse); + } + + @Override + public void sendClickPackage(ActivityPackage clickPackage) { + logger.debug("Sending click package (%s)", clickPackage); + logger.verbose("%s", clickPackage.getExtendedString()); + requestHandler.sendClickPackage(clickPackage); + } + + private static final class InternalHandler extends Handler { + private static final int INIT = 1; + private static final int ADD = 2; + private static final int SEND_NEXT = 3; + private static final int SEND_FIRST = 4; + + private final WeakReference<PackageHandler> packageHandlerReference; + + protected InternalHandler(Looper looper, PackageHandler packageHandler) { + super(looper); + this.packageHandlerReference = new WeakReference<PackageHandler>(packageHandler); + } + + @Override + public void handleMessage(Message message) { + super.handleMessage(message); + + PackageHandler packageHandler = packageHandlerReference.get(); + if (null == packageHandler) { + return; + } + + switch (message.arg1) { + case INIT: + packageHandler.initInternal(); + break; + case ADD: + ActivityPackage activityPackage = (ActivityPackage) message.obj; + packageHandler.addInternal(activityPackage); + break; + case SEND_FIRST: + packageHandler.sendFirstInternal(); + break; + case SEND_NEXT: + packageHandler.sendNextInternal(); + break; + } + } + } + + // internal methods run in dedicated queue thread + + private void initInternal() { + requestHandler = AdjustFactory.getRequestHandler(this); + + isSending = new AtomicBoolean(); + + readPackageQueue(); + } + + private void addInternal(ActivityPackage newPackage) { + packageQueue.add(newPackage); + logger.debug("Added package %d (%s)", packageQueue.size(), newPackage); + logger.verbose("%s", newPackage.getExtendedString()); + + writePackageQueue(); + } + + private void sendFirstInternal() { + if (packageQueue.isEmpty()) { + return; + } + + if (paused) { + logger.debug("Package handler is paused"); + return; + } + if (isSending.getAndSet(true)) { + logger.verbose("Package handler is already sending"); + return; + } + + ActivityPackage firstPackage = packageQueue.get(0); + requestHandler.sendPackage(firstPackage); + } + + private void sendNextInternal() { + packageQueue.remove(0); + writePackageQueue(); + isSending.set(false); + sendFirstInternal(); + } + + private void readPackageQueue() { + try { + FileInputStream inputStream = context.openFileInput(PACKAGE_QUEUE_FILENAME); + BufferedInputStream bufferedStream = new BufferedInputStream(inputStream); + ObjectInputStream objectStream = new ObjectInputStream(bufferedStream); + + try { + Object object = objectStream.readObject(); + @SuppressWarnings("unchecked") + List<ActivityPackage> packageQueue = (List<ActivityPackage>) object; + logger.debug("Package handler read %d packages", packageQueue.size()); + this.packageQueue = packageQueue; + return; + } catch (ClassNotFoundException e) { + logger.error("Failed to find package queue class"); + } catch (OptionalDataException e) { + /* no-op */ + } catch (IOException e) { + logger.error("Failed to read package queue object"); + } catch (ClassCastException e) { + logger.error("Failed to cast package queue object"); + } finally { + objectStream.close(); + } + } catch (FileNotFoundException e) { + logger.verbose("Package queue file not found"); + } catch (Exception e) { + logger.error("Failed to read package queue file"); + } + + // start with a fresh package queue in case of any exception + packageQueue = new ArrayList<ActivityPackage>(); + } + + public static Boolean deletePackageQueue(Context context) { + return context.deleteFile(PACKAGE_QUEUE_FILENAME); + } + + + private void writePackageQueue() { + try { + FileOutputStream outputStream = context.openFileOutput(PACKAGE_QUEUE_FILENAME, Context.MODE_PRIVATE); + BufferedOutputStream bufferedStream = new BufferedOutputStream(outputStream); + ObjectOutputStream objectStream = new ObjectOutputStream(bufferedStream); + + try { + objectStream.writeObject(packageQueue); + logger.debug("Package handler wrote %d packages", packageQueue.size()); + } catch (NotSerializableException e) { + logger.error("Failed to serialize packages"); + } finally { + objectStream.close(); + } + } catch (Exception e) { + logger.error("Failed to write packages (%s)", e.getLocalizedMessage()); + e.printStackTrace(); + } + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/Reflection.java b/mobile/android/thirdparty/com/adjust/sdk/Reflection.java new file mode 100644 index 000000000..d9d9a9dbc --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/Reflection.java @@ -0,0 +1,210 @@ +package com.adjust.sdk; + +import android.content.Context; + +import com.adjust.sdk.plugin.Plugin; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.adjust.sdk.Constants.PLUGINS; + +public class Reflection { + + public static String getPlayAdId(Context context) { + try { + Object AdvertisingInfoObject = getAdvertisingInfoObject(context); + + String playAdid = (String) invokeInstanceMethod(AdvertisingInfoObject, "getId", null); + + return playAdid; + } catch (Throwable t) { + return null; + } + } + + public static Boolean isPlayTrackingEnabled(Context context) { + try { + Object AdvertisingInfoObject = getAdvertisingInfoObject(context); + + Boolean isLimitedTrackingEnabled = (Boolean) invokeInstanceMethod(AdvertisingInfoObject, "isLimitAdTrackingEnabled", null); + + return !isLimitedTrackingEnabled; + } catch (Throwable t) { + return null; + } + } + + public static boolean isGooglePlayServicesAvailable(Context context) { + try { + Integer isGooglePlayServicesAvailableStatusCode = (Integer) invokeStaticMethod( + "com.google.android.gms.common.GooglePlayServicesUtil", + "isGooglePlayServicesAvailable", + new Class[]{Context.class}, context + ); + + boolean isGooglePlayServicesAvailable = (Boolean) isConnectionResultSuccess(isGooglePlayServicesAvailableStatusCode); + + return isGooglePlayServicesAvailable; + } catch (Throwable t) { + return false; + } + } + + public static String getMacAddress(Context context) { + try { + String macSha1 = (String) invokeStaticMethod( + "com.adjust.sdk.plugin.MacAddressUtil", + "getMacAddress", + new Class[]{Context.class}, context + ); + + return macSha1; + } catch (Throwable t) { + return null; + } + } + + public static String getAndroidId(Context context) { + try { + String androidId = (String) invokeStaticMethod("com.adjust.sdk.plugin.AndroidIdUtil", "getAndroidId" + , new Class[]{Context.class}, context); + + return androidId; + } catch (Throwable t) { + return null; + } + } + + public static String getSha1EmailAddress(Context context, String key) { + try { + String sha1EmailAddress = (String) invokeStaticMethod("com.adjust.sdk.plugin.EmailUtil", "getSha1EmailAddress" + , new Class[]{Context.class, String.class}, context, key); + + return sha1EmailAddress; + } catch (Throwable t) { + return null; + } + } + + private static Object getAdvertisingInfoObject(Context context) + throws Exception { + return invokeStaticMethod("com.google.android.gms.ads.identifier.AdvertisingIdClient", + "getAdvertisingIdInfo", + new Class[]{Context.class}, context + ); + } + + private static boolean isConnectionResultSuccess(Integer statusCode) { + if (statusCode == null) { + return false; + } + + try { + Class ConnectionResultClass = Class.forName("com.google.android.gms.common.ConnectionResult"); + + Field SuccessField = ConnectionResultClass.getField("SUCCESS"); + + int successStatusCode = SuccessField.getInt(null); + + return successStatusCode == statusCode; + } catch (Throwable t) { + return false; + } + } + + public static Class forName(String className) { + try { + Class classObject = Class.forName(className); + return classObject; + } catch (Throwable t) { + return null; + } + } + + public static Object createDefaultInstance(String className) { + Class classObject = forName(className); + Object instance = createDefaultInstance(classObject); + return instance; + } + + public static Object createDefaultInstance(Class classObject) { + try { + Object instance = classObject.newInstance(); + return instance; + } catch (Throwable t) { + return null; + } + } + + public static Object createInstance(String className, Class[] cArgs, Object... args) { + try { + Class classObject = Class.forName(className); + @SuppressWarnings("unchecked") + Constructor constructor = classObject.getConstructor(cArgs); + Object instance = constructor.newInstance(args); + return instance; + } catch (Throwable t) { + return null; + } + } + + public static Object invokeStaticMethod(String className, String methodName, Class[] cArgs, Object... args) + throws Exception { + Class classObject = Class.forName(className); + + return invokeMethod(classObject, methodName, null, cArgs, args); + } + + public static Object invokeInstanceMethod(Object instance, String methodName, Class[] cArgs, Object... args) + throws Exception { + Class classObject = instance.getClass(); + + return invokeMethod(classObject, methodName, instance, cArgs, args); + } + + public static Object invokeMethod(Class classObject, String methodName, Object instance, Class[] cArgs, Object... args) + throws Exception { + @SuppressWarnings("unchecked") + Method methodObject = classObject.getMethod(methodName, cArgs); + + Object resultObject = methodObject.invoke(instance, args); + + return resultObject; + } + + public static Map<String, String> getPluginKeys(Context context) { + Map<String, String> pluginKeys = new HashMap<String, String>(); + + for (Plugin plugin : getPlugins()) { + Map.Entry<String, String> pluginEntry = plugin.getParameter(context); + if (pluginEntry != null) { + pluginKeys.put(pluginEntry.getKey(), pluginEntry.getValue()); + } + } + + if (pluginKeys.size() == 0) { + return null; + } else { + return pluginKeys; + } + } + + private static List<Plugin> getPlugins() { + List<Plugin> plugins = new ArrayList<Plugin>(PLUGINS.size()); + + for (String pluginName : PLUGINS) { + Object pluginObject = Reflection.createDefaultInstance(pluginName); + if (pluginObject != null && pluginObject instanceof Plugin) { + plugins.add((Plugin) pluginObject); + } + } + + return plugins; + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/RequestHandler.java b/mobile/android/thirdparty/com/adjust/sdk/RequestHandler.java new file mode 100644 index 000000000..84d45d0ce --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/RequestHandler.java @@ -0,0 +1,210 @@ +// +// RequestHandler.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.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.NameValuePair; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.HttpClient; +import ch.boye.httpclientandroidlib.client.entity.UrlEncodedFormEntity; +import ch.boye.httpclientandroidlib.client.methods.HttpPost; +import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; +import ch.boye.httpclientandroidlib.client.utils.URLEncodedUtils; +import ch.boye.httpclientandroidlib.message.BasicNameValuePair; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class RequestHandler extends HandlerThread implements IRequestHandler { + private InternalHandler internalHandler; + private IPackageHandler packageHandler; + private HttpClient httpClient; + private ILogger logger; + + public RequestHandler(IPackageHandler packageHandler) { + super(Constants.LOGTAG, MIN_PRIORITY); + setDaemon(true); + start(); + + this.logger = AdjustFactory.getLogger(); + this.internalHandler = new InternalHandler(getLooper(), this); + init(packageHandler); + + Message message = Message.obtain(); + message.arg1 = InternalHandler.INIT; + internalHandler.sendMessage(message); + } + + @Override + public void init(IPackageHandler packageHandler) { + this.packageHandler = packageHandler; + } + + @Override + public void sendPackage(ActivityPackage pack) { + Message message = Message.obtain(); + message.arg1 = InternalHandler.SEND; + message.obj = pack; + internalHandler.sendMessage(message); + } + + @Override + public void sendClickPackage(ActivityPackage clickPackage) { + Message message = Message.obtain(); + message.arg1 = InternalHandler.SEND_CLICK; + message.obj = clickPackage; + internalHandler.sendMessage(message); + + } + + private static final class InternalHandler extends Handler { + private static final int INIT = 72401; + private static final int SEND = 72400; + private static final int SEND_CLICK = 72402; + + private final WeakReference<RequestHandler> requestHandlerReference; + + protected InternalHandler(Looper looper, RequestHandler requestHandler) { + super(looper); + this.requestHandlerReference = new WeakReference<RequestHandler>(requestHandler); + } + + @Override + public void handleMessage(Message message) { + super.handleMessage(message); + + RequestHandler requestHandler = requestHandlerReference.get(); + if (null == requestHandler) { + return; + } + + switch (message.arg1) { + case INIT: + requestHandler.initInternal(); + break; + case SEND: + ActivityPackage activityPackage = (ActivityPackage) message.obj; + requestHandler.sendInternal(activityPackage, true); + break; + case SEND_CLICK: + ActivityPackage clickPackage = (ActivityPackage) message.obj; + requestHandler.sendInternal(clickPackage, false); + break; + } + } + } + + private void initInternal() { + httpClient = Util.getHttpClient(); + } + + private void sendInternal(ActivityPackage activityPackage, boolean sendToPackageHandler) { + try { + HttpUriRequest request = getRequest(activityPackage); + HttpResponse response = httpClient.execute(request); + requestFinished(response, sendToPackageHandler); + } catch (UnsupportedEncodingException e) { + sendNextPackage(activityPackage, "Failed to encode parameters", e, sendToPackageHandler); + } catch (ClientProtocolException e) { + closePackage(activityPackage, "Client protocol error", e, sendToPackageHandler); + } catch (SocketTimeoutException e) { + closePackage(activityPackage, "Request timed out", e, sendToPackageHandler); + } catch (IOException e) { + closePackage(activityPackage, "Request failed", e, sendToPackageHandler); + } catch (Throwable e) { + sendNextPackage(activityPackage, "Runtime exception", e, sendToPackageHandler); + } + } + + private void requestFinished(HttpResponse response, boolean sendToPackageHandler) { + JSONObject jsonResponse = Util.parseJsonResponse(response, logger); + + if (jsonResponse == null) { + if (sendToPackageHandler) { + packageHandler.closeFirstPackage(); + } + return; + } + + packageHandler.finishedTrackingActivity(jsonResponse); + if (sendToPackageHandler) { + packageHandler.sendNextPackage(); + } + } + + // close current package because it failed + private void closePackage(ActivityPackage activityPackage, String message, Throwable throwable, boolean sendToPackageHandler) { + final String packageMessage = activityPackage.getFailureMessage(); + final String handlerMessage = packageHandler.getFailureMessage(); + final String reasonString = getReasonString(message, throwable); + logger.error("%s. (%s) %s", packageMessage, reasonString, handlerMessage); + + if (sendToPackageHandler) { + packageHandler.closeFirstPackage(); + } + } + + // send next package because the current package failed + private void sendNextPackage(ActivityPackage activityPackage, String message, Throwable throwable, boolean sendToPackageHandler) { + final String failureMessage = activityPackage.getFailureMessage(); + final String reasonString = getReasonString(message, throwable); + logger.error("%s. (%s)", failureMessage, reasonString); + + if (sendToPackageHandler) { + packageHandler.sendNextPackage(); + } + } + + private String getReasonString(String message, Throwable throwable) { + if (throwable != null) { + return String.format("%s: %s", message, throwable); + } else { + return String.format("%s", message); + } + } + + private HttpUriRequest getRequest(ActivityPackage activityPackage) throws UnsupportedEncodingException { + String url = Constants.BASE_URL + activityPackage.getPath(); + HttpPost request = new HttpPost(url); + + String language = Locale.getDefault().getLanguage(); + request.addHeader("Client-SDK", activityPackage.getClientSdk()); + request.addHeader("Accept-Language", language); + + List<NameValuePair> pairs = new ArrayList<NameValuePair>(); + for (Map.Entry<String, String> entry : activityPackage.getParameters().entrySet()) { + NameValuePair pair = new BasicNameValuePair(entry.getKey(), entry.getValue()); + pairs.add(pair); + } + + long now = System.currentTimeMillis(); + String dateString = Util.dateFormat(now); + NameValuePair sentAtPair = new BasicNameValuePair("sent_at", dateString); + pairs.add(sentAtPair); + + UrlEncodedFormEntity entity = new UrlEncodedFormEntity(pairs); + entity.setContentType(URLEncodedUtils.CONTENT_TYPE); + request.setEntity(entity); + + return request; + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/UnitTestActivity.java b/mobile/android/thirdparty/com/adjust/sdk/UnitTestActivity.java new file mode 100644 index 000000000..799fb8982 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/UnitTestActivity.java @@ -0,0 +1,38 @@ +package com.adjust.sdk; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +public class UnitTestActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + //setContentView(com.adjust.sdk.test.R.layout.activity_unit_test); + } + + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + //getMenuInflater().inflate(com.adjust.sdk.test.R.menu.menu_unit_test, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. +/* int id = item.getItemId(); + + //noinspection SimplifiableIfStatement + if (id == com.adjust.sdk.test.R.id.action_settings) { + return true; + } +*/ + return super.onOptionsItemSelected(item); + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/Util.java b/mobile/android/thirdparty/com/adjust/sdk/Util.java new file mode 100644 index 000000000..84c47f87e --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/Util.java @@ -0,0 +1,202 @@ +// +// Util.java +// Adjust +// +// Created by Christian Wellenbrock on 2012-10-11. +// Copyright (c) 2012-2014 adjust GmbH. All rights reserved. +// See the file MIT-LICENSE for copying permission. +// + +package com.adjust.sdk; + +import android.content.Context; +import android.content.pm.PackageManager; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.HttpStatus; +import ch.boye.httpclientandroidlib.client.HttpClient; +import ch.boye.httpclientandroidlib.params.BasicHttpParams; +import ch.boye.httpclientandroidlib.params.HttpConnectionParams; +import ch.boye.httpclientandroidlib.params.HttpParams; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.NotSerializableException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.OptionalDataException; +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Collects utility functions used by Adjust. + */ +public class Util { + + private static SimpleDateFormat dateFormat; + private static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'Z"; + + protected static String createUuid() { + return UUID.randomUUID().toString(); + } + + public static String quote(String string) { + if (string == null) { + return null; + } + + Pattern pattern = Pattern.compile("\\s"); + Matcher matcher = pattern.matcher(string); + if (!matcher.find()) { + return string; + } + + return String.format("'%s'", string); + } + + public static String dateFormat(long date) { + if (null == dateFormat) { + dateFormat = new SimpleDateFormat(DATE_FORMAT, Locale.US); + } + return dateFormat.format(date); + } + + public static String getPlayAdId(Context context) { + return Reflection.getPlayAdId(context); + } + + public static Boolean isPlayTrackingEnabled(Context context) { + return Reflection.isPlayTrackingEnabled(context); + } + + public static <T> T readObject(Context context, String filename, String objectName) { + ILogger logger = AdjustFactory.getLogger(); + try { + FileInputStream inputStream = context.openFileInput(filename); + BufferedInputStream bufferedStream = new BufferedInputStream(inputStream); + ObjectInputStream objectStream = new ObjectInputStream(bufferedStream); + + try { + @SuppressWarnings("unchecked") + T t = (T) objectStream.readObject(); + logger.debug("Read %s: %s", objectName, t); + return t; + } catch (ClassNotFoundException e) { + logger.error("Failed to find %s class", objectName); + } catch (OptionalDataException e) { + /* no-op */ + } catch (IOException e) { + logger.error("Failed to read %s object", objectName); + } catch (ClassCastException e) { + logger.error("Failed to cast %s object", objectName); + } finally { + objectStream.close(); + } + + } catch (FileNotFoundException e) { + logger.verbose("%s file not found", objectName); + } catch (Exception e) { + logger.error("Failed to open %s file for reading (%s)", objectName, e); + } + + return null; + } + + public static <T> void writeObject(T object, Context context, String filename, String objectName) { + ILogger logger = AdjustFactory.getLogger(); + try { + FileOutputStream outputStream = context.openFileOutput(filename, Context.MODE_PRIVATE); + BufferedOutputStream bufferedStream = new BufferedOutputStream(outputStream); + ObjectOutputStream objectStream = new ObjectOutputStream(bufferedStream); + + try { + objectStream.writeObject(object); + logger.debug("Wrote %s: %s", objectName, object); + } catch (NotSerializableException e) { + logger.error("Failed to serialize %s", objectName); + } finally { + objectStream.close(); + } + + } catch (Exception e) { + logger.error("Failed to open %s for writing (%s)", objectName, e); + } + } + + public static String parseResponse(HttpResponse httpResponse, ILogger logger) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + httpResponse.getEntity().writeTo(out); + out.close(); + String response = out.toString().trim(); + logger.verbose("Response: %s", response); + return response; + } catch (Exception e) { + logger.error("Failed to parse response (%s)", e); + return null; + } + } + + public static JSONObject parseJsonResponse(HttpResponse httpResponse, ILogger logger) { + if (httpResponse == null) { + return null; + } + String stringResponse = null; + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + httpResponse.getEntity().writeTo(out); + out.close(); + stringResponse = out.toString().trim(); + } catch (Exception e) { + logger.error("Failed to parse response (%s)", e.getMessage()); + } + + logger.verbose("Response: %s", stringResponse); + if (stringResponse == null) return null; + + JSONObject jsonResponse = null; + try { + jsonResponse = new JSONObject(stringResponse); + } catch (JSONException e) { + logger.error("Failed to parse json response: %s (%s)", stringResponse, e.getMessage()); + } + + if (jsonResponse == null) return null; + + String message = jsonResponse.optString("message", null); + + if (message == null) { + message = "No message found"; + } + + if (httpResponse.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { + logger.info("%s", message); + } else { + logger.error("%s", message); + } + + return jsonResponse; + } + + public static HttpClient getHttpClient() { + HttpParams httpParams = new BasicHttpParams(); + HttpConnectionParams.setConnectionTimeout(httpParams, Constants.CONNECTION_TIMEOUT); + HttpConnectionParams.setSoTimeout(httpParams, Constants.SOCKET_TIMEOUT); + return AdjustFactory.getHttpClient(httpParams); + } + + public static boolean checkPermission(Context context, String permission) { + int result = context.checkCallingOrSelfPermission(permission); + return result == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/plugin/AndroidIdUtil.java b/mobile/android/thirdparty/com/adjust/sdk/plugin/AndroidIdUtil.java new file mode 100644 index 000000000..96a072287 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/plugin/AndroidIdUtil.java @@ -0,0 +1,10 @@ +package com.adjust.sdk.plugin; + +import android.content.Context; +import android.provider.Settings.Secure; + +public class AndroidIdUtil { + public static String getAndroidId(final Context context) { + return Secure.getString(context.getContentResolver(), Secure.ANDROID_ID); + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/plugin/MacAddressUtil.java b/mobile/android/thirdparty/com/adjust/sdk/plugin/MacAddressUtil.java new file mode 100644 index 000000000..c8bdbadd7 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/plugin/MacAddressUtil.java @@ -0,0 +1,82 @@ +package com.adjust.sdk.plugin; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.text.TextUtils; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.util.Locale; + +public class MacAddressUtil { + public static String getMacAddress(Context context) { + final String rawAddress = getRawMacAddress(context); + if (rawAddress == null) { + return null; + } + final String upperAddress = rawAddress.toUpperCase(Locale.US); + return removeSpaceString(upperAddress); + } + + private static String getRawMacAddress(Context context) { + // android devices should have a wlan address + final String wlanAddress = loadAddress("wlan0"); + if (wlanAddress != null) { + return wlanAddress; + } + + // emulators should have an ethernet address + final String ethAddress = loadAddress("eth0"); + if (ethAddress != null) { + return ethAddress; + } + + // query the wifi manager (requires the ACCESS_WIFI_STATE permission) + try { + final WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + final String wifiAddress = wifiManager.getConnectionInfo().getMacAddress(); + if (wifiAddress != null) { + return wifiAddress; + } + } catch (Exception e) { + /* no-op */ + } + + return null; + } + + private static String loadAddress(final String interfaceName) { + try { + final String filePath = "/sys/class/net/" + interfaceName + "/address"; + final StringBuilder fileData = new StringBuilder(1000); + final BufferedReader reader = new BufferedReader(new FileReader(filePath), 1024); + final char[] buf = new char[1024]; + int numRead; + + String readData; + while ((numRead = reader.read(buf)) != -1) { + readData = String.valueOf(buf, 0, numRead); + fileData.append(readData); + } + + reader.close(); + return fileData.toString(); + } catch (IOException e) { + return null; + } + } + + private static String removeSpaceString(final String inputString) { + if (inputString == null) { + return null; + } + + String outputString = inputString.replaceAll("\\s", ""); + if (TextUtils.isEmpty(outputString)) { + return null; + } + + return outputString; + } +} diff --git a/mobile/android/thirdparty/com/adjust/sdk/plugin/Plugin.java b/mobile/android/thirdparty/com/adjust/sdk/plugin/Plugin.java new file mode 100644 index 000000000..ab704e6d3 --- /dev/null +++ b/mobile/android/thirdparty/com/adjust/sdk/plugin/Plugin.java @@ -0,0 +1,12 @@ +package com.adjust.sdk.plugin; + +import android.content.Context; + +import java.util.Map; + +/** + * Created by pfms on 18/09/14. + */ +public interface Plugin { + Map.Entry<String, String> getParameter(Context context); +} diff --git a/mobile/android/thirdparty/com/jakewharton/disklrucache/DiskLruCache.java b/mobile/android/thirdparty/com/jakewharton/disklrucache/DiskLruCache.java new file mode 100644 index 000000000..e81e7fbcc --- /dev/null +++ b/mobile/android/thirdparty/com/jakewharton/disklrucache/DiskLruCache.java @@ -0,0 +1,943 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jakewharton.disklrucache; + +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A cache that uses a bounded amount of space on a filesystem. Each cache + * entry has a string key and a fixed number of values. Each key must match + * the regex <strong>[a-z0-9_-]{1,120}</strong>. Values are byte sequences, + * accessible as streams or files. Each value must be between {@code 0} and + * {@code Integer.MAX_VALUE} bytes in length. + * + * <p>The cache stores its data in a directory on the filesystem. This + * directory must be exclusive to the cache; the cache may delete or overwrite + * files from its directory. It is an error for multiple processes to use the + * same cache directory at the same time. + * + * <p>This cache limits the number of bytes that it will store on the + * filesystem. When the number of stored bytes exceeds the limit, the cache will + * remove entries in the background until the limit is satisfied. The limit is + * not strict: the cache may temporarily exceed it while waiting for files to be + * deleted. The limit does not include filesystem overhead or the cache + * journal so space-sensitive applications should set a conservative limit. + * + * <p>Clients call {@link #edit} to create or update the values of an entry. An + * entry may have only one editor at one time; if a value is not available to be + * edited then {@link #edit} will return null. + * <ul> + * <li>When an entry is being <strong>created</strong> it is necessary to + * supply a full set of values; the empty value should be used as a + * placeholder if necessary. + * <li>When an entry is being <strong>edited</strong>, it is not necessary + * to supply data for every value; values default to their previous + * value. + * </ul> + * Every {@link #edit} call must be matched by a call to {@link Editor#commit} + * or {@link Editor#abort}. Committing is atomic: a read observes the full set + * of values as they were before or after the commit, but never a mix of values. + * + * <p>Clients call {@link #get} to read a snapshot of an entry. The read will + * observe the value at the time that {@link #get} was called. Updates and + * removals after the call do not impact ongoing reads. + * + * <p>This class is tolerant of some I/O errors. If files are missing from the + * filesystem, the corresponding entries will be dropped from the cache. If + * an error occurs while writing a cache value, the edit will fail silently. + * Callers should handle other problems by catching {@code IOException} and + * responding appropriately. + */ +public final class DiskLruCache implements Closeable { + static final String JOURNAL_FILE = "journal"; + static final String JOURNAL_FILE_TEMP = "journal.tmp"; + static final String JOURNAL_FILE_BACKUP = "journal.bkp"; + static final String MAGIC = "libcore.io.DiskLruCache"; + static final String VERSION_1 = "1"; + static final long ANY_SEQUENCE_NUMBER = -1; + static final String STRING_KEY_PATTERN = "[a-z0-9_-]{1,120}"; + static final Pattern LEGAL_KEY_PATTERN = Pattern.compile(STRING_KEY_PATTERN); + private static final String CLEAN = "CLEAN"; + private static final String DIRTY = "DIRTY"; + private static final String REMOVE = "REMOVE"; + private static final String READ = "READ"; + + /* + * This cache uses a journal file named "journal". A typical journal file + * looks like this: + * libcore.io.DiskLruCache + * 1 + * 100 + * 2 + * + * CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 + * DIRTY 335c4c6028171cfddfbaae1a9c313c52 + * CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342 + * REMOVE 335c4c6028171cfddfbaae1a9c313c52 + * DIRTY 1ab96a171faeeee38496d8b330771a7a + * CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234 + * READ 335c4c6028171cfddfbaae1a9c313c52 + * READ 3400330d1dfc7f3f7f4b8d4d803dfcf6 + * + * The first five lines of the journal form its header. They are the + * constant string "libcore.io.DiskLruCache", the disk cache's version, + * the application's version, the value count, and a blank line. + * + * Each of the subsequent lines in the file is a record of the state of a + * cache entry. Each line contains space-separated values: a state, a key, + * and optional state-specific values. + * o DIRTY lines track that an entry is actively being created or updated. + * Every successful DIRTY action should be followed by a CLEAN or REMOVE + * action. DIRTY lines without a matching CLEAN or REMOVE indicate that + * temporary files may need to be deleted. + * o CLEAN lines track a cache entry that has been successfully published + * and may be read. A publish line is followed by the lengths of each of + * its values. + * o READ lines track accesses for LRU. + * o REMOVE lines track entries that have been deleted. + * + * The journal file is appended to as cache operations occur. The journal may + * occasionally be compacted by dropping redundant lines. A temporary file named + * "journal.tmp" will be used during compaction; that file should be deleted if + * it exists when the cache is opened. + */ + + private final File directory; + private final File journalFile; + private final File journalFileTmp; + private final File journalFileBackup; + private final int appVersion; + private long maxSize; + private final int valueCount; + private long size = 0; + private Writer journalWriter; + private final LinkedHashMap<String, Entry> lruEntries = + new LinkedHashMap<String, Entry>(0, 0.75f, true); + private int redundantOpCount; + + /** + * To differentiate between old and current snapshots, each entry is given + * a sequence number each time an edit is committed. A snapshot is stale if + * its sequence number is not equal to its entry's sequence number. + */ + private long nextSequenceNumber = 0; + + /** This cache uses a single background thread to evict entries. */ + final ThreadPoolExecutor executorService = + new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()); + private final Callable<Void> cleanupCallable = new Callable<Void>() { + public Void call() throws Exception { + synchronized (DiskLruCache.this) { + if (journalWriter == null) { + return null; // Closed. + } + trimToSize(); + if (journalRebuildRequired()) { + rebuildJournal(); + redundantOpCount = 0; + } + } + return null; + } + }; + + private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) { + this.directory = directory; + this.appVersion = appVersion; + this.journalFile = new File(directory, JOURNAL_FILE); + this.journalFileTmp = new File(directory, JOURNAL_FILE_TEMP); + this.journalFileBackup = new File(directory, JOURNAL_FILE_BACKUP); + this.valueCount = valueCount; + this.maxSize = maxSize; + } + + /** + * Opens the cache in {@code directory}, creating a cache if none exists + * there. + * + * @param directory a writable directory + * @param valueCount the number of values per cache entry. Must be positive. + * @param maxSize the maximum number of bytes this cache should use to store + * @throws IOException if reading or writing the cache directory fails + */ + public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) + throws IOException { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + if (valueCount <= 0) { + throw new IllegalArgumentException("valueCount <= 0"); + } + + // If a bkp file exists, use it instead. + File backupFile = new File(directory, JOURNAL_FILE_BACKUP); + if (backupFile.exists()) { + File journalFile = new File(directory, JOURNAL_FILE); + // If journal file also exists just delete backup file. + if (journalFile.exists()) { + backupFile.delete(); + } else { + renameTo(backupFile, journalFile, false); + } + } + + // Prefer to pick up where we left off. + DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + if (cache.journalFile.exists()) { + try { + cache.readJournal(); + cache.processJournal(); + return cache; + } catch (IOException journalIsCorrupt) { + System.out + .println("DiskLruCache " + + directory + + " is corrupt: " + + journalIsCorrupt.getMessage() + + ", removing"); + cache.delete(); + } + } + + // Create a new empty cache. + directory.mkdirs(); + cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + cache.rebuildJournal(); + return cache; + } + + private void readJournal() throws IOException { + StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII); + try { + String magic = reader.readLine(); + String version = reader.readLine(); + String appVersionString = reader.readLine(); + String valueCountString = reader.readLine(); + String blank = reader.readLine(); + if (!MAGIC.equals(magic) + || !VERSION_1.equals(version) + || !Integer.toString(appVersion).equals(appVersionString) + || !Integer.toString(valueCount).equals(valueCountString) + || !"".equals(blank)) { + throw new IOException("unexpected journal header: [" + magic + ", " + version + ", " + + valueCountString + ", " + blank + "]"); + } + + int lineCount = 0; + while (true) { + try { + readJournalLine(reader.readLine()); + lineCount++; + } catch (EOFException endOfJournal) { + break; + } + } + redundantOpCount = lineCount - lruEntries.size(); + + // If we ended on a truncated line, rebuild the journal before appending to it. + if (reader.hasUnterminatedLine()) { + rebuildJournal(); + } else { + journalWriter = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(journalFile, true), Util.US_ASCII)); + } + } finally { + Util.closeQuietly(reader); + } + } + + private void readJournalLine(String line) throws IOException { + int firstSpace = line.indexOf(' '); + if (firstSpace == -1) { + throw new IOException("unexpected journal line: " + line); + } + + int keyBegin = firstSpace + 1; + int secondSpace = line.indexOf(' ', keyBegin); + final String key; + if (secondSpace == -1) { + key = line.substring(keyBegin); + if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) { + lruEntries.remove(key); + return; + } + } else { + key = line.substring(keyBegin, secondSpace); + } + + Entry entry = lruEntries.get(key); + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } + + if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) { + String[] parts = line.substring(secondSpace + 1).split(" "); + entry.readable = true; + entry.currentEditor = null; + entry.setLengths(parts); + } else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) { + entry.currentEditor = new Editor(entry); + } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) { + // This work was already done by calling lruEntries.get(). + } else { + throw new IOException("unexpected journal line: " + line); + } + } + + /** + * Computes the initial size and collects garbage as a part of opening the + * cache. Dirty entries are assumed to be inconsistent and will be deleted. + */ + private void processJournal() throws IOException { + deleteIfExists(journalFileTmp); + for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) { + Entry entry = i.next(); + if (entry.currentEditor == null) { + for (int t = 0; t < valueCount; t++) { + size += entry.lengths[t]; + } + } else { + entry.currentEditor = null; + for (int t = 0; t < valueCount; t++) { + deleteIfExists(entry.getCleanFile(t)); + deleteIfExists(entry.getDirtyFile(t)); + } + i.remove(); + } + } + } + + /** + * Creates a new journal that omits redundant information. This replaces the + * current journal if it exists. + */ + private synchronized void rebuildJournal() throws IOException { + if (journalWriter != null) { + journalWriter.close(); + } + + Writer writer = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII)); + try { + writer.write(MAGIC); + writer.write("\n"); + writer.write(VERSION_1); + writer.write("\n"); + writer.write(Integer.toString(appVersion)); + writer.write("\n"); + writer.write(Integer.toString(valueCount)); + writer.write("\n"); + writer.write("\n"); + + for (Entry entry : lruEntries.values()) { + if (entry.currentEditor != null) { + writer.write(DIRTY + ' ' + entry.key + '\n'); + } else { + writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); + } + } + } finally { + writer.close(); + } + + if (journalFile.exists()) { + renameTo(journalFile, journalFileBackup, true); + } + renameTo(journalFileTmp, journalFile, false); + journalFileBackup.delete(); + + journalWriter = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII)); + } + + private static void deleteIfExists(File file) throws IOException { + if (file.exists() && !file.delete()) { + throw new IOException(); + } + } + + private static void renameTo(File from, File to, boolean deleteDestination) throws IOException { + if (deleteDestination) { + deleteIfExists(to); + } + if (!from.renameTo(to)) { + throw new IOException(); + } + } + + /** + * Returns a snapshot of the entry named {@code key}, or null if it doesn't + * exist is not currently readable. If a value is returned, it is moved to + * the head of the LRU queue. + */ + public synchronized Snapshot get(String key) throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (entry == null) { + return null; + } + + if (!entry.readable) { + return null; + } + + // Open all streams eagerly to guarantee that we see a single published + // snapshot. If we opened streams lazily then the streams could come + // from different edits. + InputStream[] ins = new InputStream[valueCount]; + try { + for (int i = 0; i < valueCount; i++) { + ins[i] = new FileInputStream(entry.getCleanFile(i)); + } + } catch (FileNotFoundException e) { + // A file must have been deleted manually! + for (int i = 0; i < valueCount; i++) { + if (ins[i] != null) { + Util.closeQuietly(ins[i]); + } else { + break; + } + } + return null; + } + + redundantOpCount++; + journalWriter.append(READ + ' ' + key + '\n'); + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths); + } + + /** + * Returns an editor for the entry named {@code key}, or null if another + * edit is in progress. + */ + public Editor edit(String key) throws IOException { + return edit(key, ANY_SEQUENCE_NUMBER); + } + + private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null + || entry.sequenceNumber != expectedSequenceNumber)) { + return null; // Snapshot is stale. + } + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } else if (entry.currentEditor != null) { + return null; // Another edit is in progress. + } + + Editor editor = new Editor(entry); + entry.currentEditor = editor; + + // Flush the journal before creating files to prevent file leaks. + journalWriter.write(DIRTY + ' ' + key + '\n'); + journalWriter.flush(); + return editor; + } + + /** Returns the directory where this cache stores its data. */ + public File getDirectory() { + return directory; + } + + /** + * Returns the maximum number of bytes that this cache should use to store + * its data. + */ + public synchronized long getMaxSize() { + return maxSize; + } + + /** + * Changes the maximum number of bytes the cache can store and queues a job + * to trim the existing store, if necessary. + */ + public synchronized void setMaxSize(long maxSize) { + this.maxSize = maxSize; + executorService.submit(cleanupCallable); + } + + /** + * Returns the number of bytes currently being used to store the values in + * this cache. This may be greater than the max size if a background + * deletion is pending. + */ + public synchronized long size() { + return size; + } + + private synchronized void completeEdit(Editor editor, boolean success) throws IOException { + Entry entry = editor.entry; + if (entry.currentEditor != editor) { + throw new IllegalStateException(); + } + + // If this edit is creating the entry for the first time, every index must have a value. + if (success && !entry.readable) { + for (int i = 0; i < valueCount; i++) { + if (!editor.written[i]) { + editor.abort(); + throw new IllegalStateException("Newly created entry didn't create value for index " + i); + } + if (!entry.getDirtyFile(i).exists()) { + editor.abort(); + return; + } + } + } + + for (int i = 0; i < valueCount; i++) { + File dirty = entry.getDirtyFile(i); + if (success) { + if (dirty.exists()) { + File clean = entry.getCleanFile(i); + dirty.renameTo(clean); + long oldLength = entry.lengths[i]; + long newLength = clean.length(); + entry.lengths[i] = newLength; + size = size - oldLength + newLength; + } + } else { + deleteIfExists(dirty); + } + } + + redundantOpCount++; + entry.currentEditor = null; + if (entry.readable | success) { + entry.readable = true; + journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); + if (success) { + entry.sequenceNumber = nextSequenceNumber++; + } + } else { + lruEntries.remove(entry.key); + journalWriter.write(REMOVE + ' ' + entry.key + '\n'); + } + journalWriter.flush(); + + if (size > maxSize || journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + } + + /** + * We only rebuild the journal when it will halve the size of the journal + * and eliminate at least 2000 ops. + */ + private boolean journalRebuildRequired() { + final int redundantOpCompactThreshold = 2000; + return redundantOpCount >= redundantOpCompactThreshold // + && redundantOpCount >= lruEntries.size(); + } + + /** + * Drops the entry for {@code key} if it exists and can be removed. Entries + * actively being edited cannot be removed. + * + * @return true if an entry was removed. + */ + public synchronized boolean remove(String key) throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (entry == null || entry.currentEditor != null) { + return false; + } + + for (int i = 0; i < valueCount; i++) { + File file = entry.getCleanFile(i); + if (file.exists() && !file.delete()) { + throw new IOException("failed to delete " + file); + } + size -= entry.lengths[i]; + entry.lengths[i] = 0; + } + + redundantOpCount++; + journalWriter.append(REMOVE + ' ' + key + '\n'); + lruEntries.remove(key); + + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return true; + } + + /** Returns true if this cache has been closed. */ + public synchronized boolean isClosed() { + return journalWriter == null; + } + + private void checkNotClosed() { + if (journalWriter == null) { + throw new IllegalStateException("cache is closed"); + } + } + + /** Force buffered operations to the filesystem. */ + public synchronized void flush() throws IOException { + checkNotClosed(); + trimToSize(); + journalWriter.flush(); + } + + /** Closes this cache. Stored values will remain on the filesystem. */ + public synchronized void close() throws IOException { + if (journalWriter == null) { + return; // Already closed. + } + for (Entry entry : new ArrayList<Entry>(lruEntries.values())) { + if (entry.currentEditor != null) { + entry.currentEditor.abort(); + } + } + trimToSize(); + journalWriter.close(); + journalWriter = null; + } + + private void trimToSize() throws IOException { + while (size > maxSize) { + Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next(); + remove(toEvict.getKey()); + } + } + + /** + * Closes the cache and deletes all of its stored values. This will delete + * all files in the cache directory including files that weren't created by + * the cache. + */ + public void delete() throws IOException { + close(); + Util.deleteContents(directory); + } + + private void validateKey(String key) { + Matcher matcher = LEGAL_KEY_PATTERN.matcher(key); + if (!matcher.matches()) { + throw new IllegalArgumentException("keys must match regex " + + STRING_KEY_PATTERN + ": \"" + key + "\""); + } + } + + private static String inputStreamToString(InputStream in) throws IOException { + return Util.readFully(new InputStreamReader(in, Util.UTF_8)); + } + + /** A snapshot of the values for an entry. */ + public final class Snapshot implements Closeable { + private final String key; + private final long sequenceNumber; + private final InputStream[] ins; + private final long[] lengths; + + private Snapshot(String key, long sequenceNumber, InputStream[] ins, long[] lengths) { + this.key = key; + this.sequenceNumber = sequenceNumber; + this.ins = ins; + this.lengths = lengths; + } + + /** + * Returns an editor for this snapshot's entry, or null if either the + * entry has changed since this snapshot was created or if another edit + * is in progress. + */ + public Editor edit() throws IOException { + return DiskLruCache.this.edit(key, sequenceNumber); + } + + /** Returns the unbuffered stream with the value for {@code index}. */ + public InputStream getInputStream(int index) { + return ins[index]; + } + + /** Returns the string value for {@code index}. */ + public String getString(int index) throws IOException { + return inputStreamToString(getInputStream(index)); + } + + /** Returns the byte length of the value for {@code index}. */ + public long getLength(int index) { + return lengths[index]; + } + + public void close() { + for (InputStream in : ins) { + Util.closeQuietly(in); + } + } + } + + private static final OutputStream NULL_OUTPUT_STREAM = new OutputStream() { + @Override + public void write(int b) throws IOException { + // Eat all writes silently. Nom nom. + } + }; + + /** Edits the values for an entry. */ + public final class Editor { + private final Entry entry; + private final boolean[] written; + private boolean hasErrors; + private boolean committed; + + private Editor(Entry entry) { + this.entry = entry; + this.written = (entry.readable) ? null : new boolean[valueCount]; + } + + /** + * Returns an unbuffered input stream to read the last committed value, + * or null if no value has been committed. + */ + public InputStream newInputStream(int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + return null; + } + try { + return new FileInputStream(entry.getCleanFile(index)); + } catch (FileNotFoundException e) { + return null; + } + } + } + + /** + * Returns the last committed value as a string, or null if no value + * has been committed. + */ + public String getString(int index) throws IOException { + InputStream in = newInputStream(index); + return in != null ? inputStreamToString(in) : null; + } + + /** + * Returns a new unbuffered output stream to write the value at + * {@code index}. If the underlying output stream encounters errors + * when writing to the filesystem, this edit will be aborted when + * {@link #commit} is called. The returned output stream does not throw + * IOExceptions. + */ + public OutputStream newOutputStream(int index) throws IOException { + if (index < 0 || index >= valueCount) { + throw new IllegalArgumentException("Expected index " + index + " to " + + "be greater than 0 and less than the maximum value count " + + "of " + valueCount); + } + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + written[index] = true; + } + File dirtyFile = entry.getDirtyFile(index); + FileOutputStream outputStream; + try { + outputStream = new FileOutputStream(dirtyFile); + } catch (FileNotFoundException e) { + // Attempt to recreate the cache directory. + directory.mkdirs(); + try { + outputStream = new FileOutputStream(dirtyFile); + } catch (FileNotFoundException e2) { + // We are unable to recover. Silently eat the writes. + return NULL_OUTPUT_STREAM; + } + } + return new FaultHidingOutputStream(outputStream); + } + } + + /** Sets the value at {@code index} to {@code value}. */ + public void set(int index, String value) throws IOException { + Writer writer = null; + try { + writer = new OutputStreamWriter(newOutputStream(index), Util.UTF_8); + writer.write(value); + } finally { + Util.closeQuietly(writer); + } + } + + /** + * Commits this edit so it is visible to readers. This releases the + * edit lock so another edit may be started on the same key. + */ + public void commit() throws IOException { + if (hasErrors) { + completeEdit(this, false); + remove(entry.key); // The previous entry is stale. + } else { + completeEdit(this, true); + } + committed = true; + } + + /** + * Aborts this edit. This releases the edit lock so another edit may be + * started on the same key. + */ + public void abort() throws IOException { + completeEdit(this, false); + } + + public void abortUnlessCommitted() { + if (!committed) { + try { + abort(); + } catch (IOException ignored) { + } + } + } + + private class FaultHidingOutputStream extends FilterOutputStream { + private FaultHidingOutputStream(OutputStream out) { + super(out); + } + + @Override public void write(int oneByte) { + try { + out.write(oneByte); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void write(byte[] buffer, int offset, int length) { + try { + out.write(buffer, offset, length); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void close() { + try { + out.close(); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void flush() { + try { + out.flush(); + } catch (IOException e) { + hasErrors = true; + } + } + } + } + + private final class Entry { + private final String key; + + /** Lengths of this entry's files. */ + private final long[] lengths; + + /** True if this entry has ever been published. */ + private boolean readable; + + /** The ongoing edit or null if this entry is not being edited. */ + private Editor currentEditor; + + /** The sequence number of the most recently committed edit to this entry. */ + private long sequenceNumber; + + private Entry(String key) { + this.key = key; + this.lengths = new long[valueCount]; + } + + public String getLengths() throws IOException { + StringBuilder result = new StringBuilder(); + for (long size : lengths) { + result.append(' ').append(size); + } + return result.toString(); + } + + /** Set lengths using decimal numbers like "10123". */ + private void setLengths(String[] strings) throws IOException { + if (strings.length != valueCount) { + throw invalidLengths(strings); + } + + try { + for (int i = 0; i < strings.length; i++) { + lengths[i] = Long.parseLong(strings[i]); + } + } catch (NumberFormatException e) { + throw invalidLengths(strings); + } + } + + private IOException invalidLengths(String[] strings) throws IOException { + throw new IOException("unexpected journal line: " + java.util.Arrays.toString(strings)); + } + + public File getCleanFile(int i) { + return new File(directory, key + "." + i); + } + + public File getDirtyFile(int i) { + return new File(directory, key + "." + i + ".tmp"); + } + } +} diff --git a/mobile/android/thirdparty/com/jakewharton/disklrucache/StrictLineReader.java b/mobile/android/thirdparty/com/jakewharton/disklrucache/StrictLineReader.java new file mode 100644 index 000000000..c90691ce4 --- /dev/null +++ b/mobile/android/thirdparty/com/jakewharton/disklrucache/StrictLineReader.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jakewharton.disklrucache; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; + +/** + * Buffers input from an {@link InputStream} for reading lines. + * + * <p>This class is used for buffered reading of lines. For purposes of this class, a line ends + * with "\n" or "\r\n". End of input is reported by throwing {@code EOFException}. Unterminated + * line at end of input is invalid and will be ignored, the caller may use {@code + * hasUnterminatedLine()} to detect it after catching the {@code EOFException}. + * + * <p>This class is intended for reading input that strictly consists of lines, such as line-based + * cache entries or cache journal. Unlike the {@link java.io.BufferedReader} which in conjunction + * with {@link java.io.InputStreamReader} provides similar functionality, this class uses different + * end-of-input reporting and a more restrictive definition of a line. + * + * <p>This class supports only charsets that encode '\r' and '\n' as a single byte with value 13 + * and 10, respectively, and the representation of no other character contains these values. + * We currently check in constructor that the charset is one of US-ASCII, UTF-8 and ISO-8859-1. + * The default charset is US_ASCII. + */ +class StrictLineReader implements Closeable { + private static final byte CR = (byte) '\r'; + private static final byte LF = (byte) '\n'; + + private final InputStream in; + private final Charset charset; + + /* + * Buffered data is stored in {@code buf}. As long as no exception occurs, 0 <= pos <= end + * and the data in the range [pos, end) is buffered for reading. At end of input, if there is + * an unterminated line, we set end == -1, otherwise end == pos. If the underlying + * {@code InputStream} throws an {@code IOException}, end may remain as either pos or -1. + */ + private byte[] buf; + private int pos; + private int end; + + /** + * Constructs a new {@code LineReader} with the specified charset and the default capacity. + * + * @param in the {@code InputStream} to read data from. + * @param charset the charset used to decode data. Only US-ASCII, UTF-8 and ISO-8859-1 are + * supported. + * @throws NullPointerException if {@code in} or {@code charset} is null. + * @throws IllegalArgumentException if the specified charset is not supported. + */ + public StrictLineReader(InputStream in, Charset charset) { + this(in, 8192, charset); + } + + /** + * Constructs a new {@code LineReader} with the specified capacity and charset. + * + * @param in the {@code InputStream} to read data from. + * @param capacity the capacity of the buffer. + * @param charset the charset used to decode data. Only US-ASCII, UTF-8 and ISO-8859-1 are + * supported. + * @throws NullPointerException if {@code in} or {@code charset} is null. + * @throws IllegalArgumentException if {@code capacity} is negative or zero + * or the specified charset is not supported. + */ + public StrictLineReader(InputStream in, int capacity, Charset charset) { + if (in == null || charset == null) { + throw new NullPointerException(); + } + if (capacity < 0) { + throw new IllegalArgumentException("capacity <= 0"); + } + if (!(charset.equals(Util.US_ASCII))) { + throw new IllegalArgumentException("Unsupported encoding"); + } + + this.in = in; + this.charset = charset; + buf = new byte[capacity]; + } + + /** + * Closes the reader by closing the underlying {@code InputStream} and + * marking this reader as closed. + * + * @throws IOException for errors when closing the underlying {@code InputStream}. + */ + public void close() throws IOException { + synchronized (in) { + if (buf != null) { + buf = null; + in.close(); + } + } + } + + /** + * Reads the next line. A line ends with {@code "\n"} or {@code "\r\n"}, + * this end of line marker is not included in the result. + * + * @return the next line from the input. + * @throws IOException for underlying {@code InputStream} errors. + * @throws EOFException for the end of source stream. + */ + public String readLine() throws IOException { + synchronized (in) { + if (buf == null) { + throw new IOException("LineReader is closed"); + } + + // Read more data if we are at the end of the buffered data. + // Though it's an error to read after an exception, we will let {@code fillBuf()} + // throw again if that happens; thus we need to handle end == -1 as well as end == pos. + if (pos >= end) { + fillBuf(); + } + // Try to find LF in the buffered data and return the line if successful. + for (int i = pos; i != end; ++i) { + if (buf[i] == LF) { + int lineEnd = (i != pos && buf[i - 1] == CR) ? i - 1 : i; + String res = new String(buf, pos, lineEnd - pos, charset.name()); + pos = i + 1; + return res; + } + } + + // Let's anticipate up to 80 characters on top of those already read. + ByteArrayOutputStream out = new ByteArrayOutputStream(end - pos + 80) { + @Override + public String toString() { + int length = (count > 0 && buf[count - 1] == CR) ? count - 1 : count; + try { + return new String(buf, 0, length, charset.name()); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); // Since we control the charset this will never happen. + } + } + }; + + while (true) { + out.write(buf, pos, end - pos); + // Mark unterminated line in case fillBuf throws EOFException or IOException. + end = -1; + fillBuf(); + // Try to find LF in the buffered data and return the line if successful. + for (int i = pos; i != end; ++i) { + if (buf[i] == LF) { + if (i != pos) { + out.write(buf, pos, i - pos); + } + pos = i + 1; + return out.toString(); + } + } + } + } + } + + public boolean hasUnterminatedLine() { + return end == -1; + } + + /** + * Reads new input data into the buffer. Call only with pos == end or end == -1, + * depending on the desired outcome if the function throws. + */ + private void fillBuf() throws IOException { + int result = in.read(buf, 0, buf.length); + if (result == -1) { + throw new EOFException(); + } + pos = 0; + end = result; + } +} + diff --git a/mobile/android/thirdparty/com/jakewharton/disklrucache/Util.java b/mobile/android/thirdparty/com/jakewharton/disklrucache/Util.java new file mode 100644 index 000000000..0a7dba9bc --- /dev/null +++ b/mobile/android/thirdparty/com/jakewharton/disklrucache/Util.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jakewharton.disklrucache; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.StringWriter; +import java.nio.charset.Charset; + +/** Junk drawer of utility methods. */ +final class Util { + static final Charset US_ASCII = Charset.forName("US-ASCII"); + static final Charset UTF_8 = Charset.forName("UTF-8"); + + private Util() { + } + + static String readFully(Reader reader) throws IOException { + try { + StringWriter writer = new StringWriter(); + char[] buffer = new char[1024]; + int count; + while ((count = reader.read(buffer)) != -1) { + writer.write(buffer, 0, count); + } + return writer.toString(); + } finally { + reader.close(); + } + } + + /** + * Deletes the contents of {@code dir}. Throws an IOException if any file + * could not be deleted, or if {@code dir} is not a readable directory. + */ + static void deleteContents(File dir) throws IOException { + File[] files = dir.listFiles(); + if (files == null) { + throw new IOException("not a readable directory: " + dir); + } + for (File file : files) { + if (file.isDirectory()) { + deleteContents(file); + } + if (!file.delete()) { + throw new IOException("failed to delete file: " + file); + } + } + } + + static void closeQuietly(/*Auto*/Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } +} diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/AsyncConfigLoader.java b/mobile/android/thirdparty/com/keepsafe/switchboard/AsyncConfigLoader.java new file mode 100644 index 000000000..2cff4b4c3 --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/AsyncConfigLoader.java @@ -0,0 +1,54 @@ +/* + Copyright 2012 KeepSafe Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +package com.keepsafe.switchboard; + + +import android.content.Context; +import android.os.AsyncTask; + +/** + * An async loader to load user config in background thread based on internal generated UUID. + * + * Call <code>AsyncConfigLoader.execute()</code> to load SwitchBoard.loadConfig() with own ID. + * To use your custom UUID call <code>AsyncConfigLoader.execute(uuid)</code> with uuid being your unique user id + * as a String + * + * @author Philipp Berner + * + */ +public class AsyncConfigLoader extends AsyncTask<Void, Void, Void> { + + private Context context; + private String defaultServerUrl; + + /** + * Sets the params for async loading either SwitchBoard.updateConfigServerUrl() + * or SwitchBoard.loadConfig. + * Loads config with a custom UUID + * @param c Application context + * @param defaultServerUrl Default URL endpoint for Switchboard config. + */ + public AsyncConfigLoader(Context c, String defaultServerUrl) { + this.context = c; + this.defaultServerUrl = defaultServerUrl; + } + + @Override + protected Void doInBackground(Void... params) { + SwitchBoard.loadConfig(context, defaultServerUrl); + return null; + } +} diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/DeviceUuidFactory.java b/mobile/android/thirdparty/com/keepsafe/switchboard/DeviceUuidFactory.java new file mode 100644 index 000000000..c4476d2cd --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/DeviceUuidFactory.java @@ -0,0 +1,70 @@ +/* + Copyright 2012 KeepSafe Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +package com.keepsafe.switchboard; + +import java.util.UUID; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * Generates a UUID and stores is persistent as in the apps shared preferences. + * + * @author Philipp Berner + */ +public class DeviceUuidFactory { + protected static final String PREFS_FILE = "com.keepsafe.switchboard.uuid"; + protected static final String PREFS_DEVICE_ID = "device_id"; + + private static UUID uuid = null; + + public DeviceUuidFactory(Context context) { + if (uuid == null) { + synchronized (DeviceUuidFactory.class) { + if (uuid == null) { + final SharedPreferences prefs = context + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE); + final String id = prefs.getString(PREFS_DEVICE_ID, null); + + if (id != null) { + // Use the ids previously computed and stored in the prefs file + uuid = UUID.fromString(id); + } else { + uuid = UUID.randomUUID(); + + // Write the value out to the prefs file + prefs.edit().putString(PREFS_DEVICE_ID, uuid.toString()).apply(); + } + } + } + } + } + + /** + * Returns a unique UUID for the current android device. As with all UUIDs, + * this unique ID is "very highly likely" to be unique across all Android + * devices. Much more so than ANDROID_ID is. + * + * The UUID is generated with <code>UUID.randomUUID()</code>. + * + * @return a UUID that may be used to uniquely identify your device for most + * purposes. + */ + public UUID getDeviceUuid() { + return uuid; + } + +}
\ No newline at end of file diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/Preferences.java b/mobile/android/thirdparty/com/keepsafe/switchboard/Preferences.java new file mode 100644 index 000000000..f7f6f7cb7 --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/Preferences.java @@ -0,0 +1,105 @@ +/* + Copyright 2012 KeepSafe Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +package com.keepsafe.switchboard; + + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.Nullable; + +/** + * Application preferences for SwitchBoard. + * @author Philipp Berner + * + */ +public class Preferences { + + private static final String switchBoardSettings = "com.keepsafe.switchboard.settings"; + + private static final String CONFIG_JSON = "config-json"; + private static final String OVERRIDE_PREFIX = "experiment.override."; + + + /** + * Gets the user config as a JSON string. + * @param c Context + * @return Config JSON + */ + @Nullable public static String getDynamicConfigJson(Context c) { + final SharedPreferences prefs = c.getApplicationContext().getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE); + return prefs.getString(CONFIG_JSON, null); + } + + /** + * Saves the user config as a JSON sting. + * @param c Context + * @param configJson Config JSON + */ + public static void setDynamicConfigJson(Context c, String configJson) { + final SharedPreferences.Editor editor = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE).edit(); + editor.putString(CONFIG_JSON, configJson); + editor.apply(); + } + + /** + * Gets the override value for an experiment. + * + * @param c Context + * @param experimentName Experiment name + * @return Whether or not the experiment should be enabled, or null if there is no override. + */ + @Nullable public static Boolean getOverrideValue(Context c, String experimentName) { + final SharedPreferences prefs = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE); + + final String key = OVERRIDE_PREFIX + experimentName; + if (prefs.contains(key)) { + // This will never fall back to the default value. + return prefs.getBoolean(key, false); + } + + // Default to returning null if no override was found. + return null; + } + + /** + * Saves an override value for an experiment. + * + * @param c Context + * @param experimentName Experiment name + * @param isEnabled Whether or not to enable the experiment + */ + public static void setOverrideValue(Context c, String experimentName, boolean isEnabled) { + final SharedPreferences.Editor editor = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE).edit(); + editor.putBoolean(OVERRIDE_PREFIX + experimentName, isEnabled); + editor.apply(); + } + + /** + * Clears the override value for an experiment. + * + * @param c Context + * @param experimentName Experiment name + */ + public static void clearOverrideValue(Context c, String experimentName) { + final SharedPreferences.Editor editor = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE).edit(); + editor.remove(OVERRIDE_PREFIX + experimentName); + editor.apply(); + } +} diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/Switch.java b/mobile/android/thirdparty/com/keepsafe/switchboard/Switch.java new file mode 100644 index 000000000..5307750bb --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/Switch.java @@ -0,0 +1,72 @@ +/* + Copyright 2012 KeepSafe Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +package com.keepsafe.switchboard; + +import org.json.JSONObject; + +import android.content.Context; + +/** + * Single instance of an existing experiment for easier and cleaner code. + * + * @author Philipp Berner + * + */ +public class Switch { + + private Context context; + private String experimentName; + + /** + * Creates an instance of a single experiment to give more convenient access to its values. + * When the given experiment does not exist, it will give back default valued that can be found + * in <code>Switchboard</code>. Developer has to know that experiment exists when using it. + * @param c Application context + * @param experimentName Name of the experiment as defined on the server + */ + public Switch(Context c, String experimentName) { + this.context = c; + this.experimentName = experimentName; + } + + /** + * Returns true if the experiment is active for this particular user. + * @return Status of the experiment and false when experiment does not exist. + */ + public boolean isActive() { + return SwitchBoard.isInExperiment(context, experimentName); + } + + /** + * Returns true if the experiment has additional values. + * @return true when values exist + */ + public boolean hasValues() { + return SwitchBoard.hasExperimentValues(context, experimentName); + } + + /** + * Gives back all the experiment values in a JSONObject. This function checks if + * values exists. If no values exist, it returns null. + * @return Values in JSONObject or null if non + */ + public JSONObject getValues() { + if(hasValues()) + return SwitchBoard.getExperimentValuesFromJson(context, experimentName); + else + return null; + } +} diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java b/mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java new file mode 100644 index 000000000..e99144045 --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java @@ -0,0 +1,390 @@ +/* + Copyright 2012 KeepSafe Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +package com.keepsafe.switchboard; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.zip.CRC32; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONArray; + +import android.content.Context; +import android.content.pm.PackageManager.NameNotFoundException; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; + + +/** + * SwitchBoard is the core class of the KeepSafe Switchboard mobile A/B testing framework. + * This class provides a bunch of static methods that can be used in your app to run A/B tests. + * + * The SwitchBoard supports production and staging environment. + * + * For usage <code>initDefaultServerUrls</code> for first time usage. Server URLs can be updates from + * a remote location with <code>initConfigServerUrl</code>. + * + * To run a experiment use <code>isInExperiment()</code>. The experiment name has to match the one you + * setup on the server. + * All functions are design to be safe for programming mistakes and network connection issues. If the + * experiment does not exists it will return false and pretend the user is not part of it. + * + * @author Philipp Berner + * + */ +public class SwitchBoard { + + private static final String TAG = "SwitchBoard"; + + /** Set if the application is run in debug mode. */ + public static boolean DEBUG = true; + + // Top-level experiment keys. + private static final String KEY_DATA = "data"; + private static final String KEY_NAME = "name"; + private static final String KEY_MATCH = "match"; + private static final String KEY_BUCKETS = "buckets"; + private static final String KEY_VALUES = "values"; + + // Match keys. + private static final String KEY_APP_ID = "appId"; + private static final String KEY_COUNTRY = "country"; + private static final String KEY_DEVICE = "device"; + private static final String KEY_LANG = "lang"; + private static final String KEY_MANUFACTURER = "manufacturer"; + private static final String KEY_VERSION = "version"; + + // Bucket keys. + private static final String KEY_MIN = "min"; + private static final String KEY_MAX = "max"; + + /** + * Loads a new config for a user. This method does network I/O, so it + * should not be called on the main thread. + * + * @param c ApplicationContext + * @param serverUrl Server URL endpoint. + */ + static void loadConfig(Context c, @NonNull String serverUrl) { + final URL url; + try { + url = new URL(serverUrl); + } catch (MalformedURLException e) { + Log.e(TAG, "Exception creating server URL", e); + return; + } + + final String result = readFromUrlGET(url); + if (DEBUG) Log.d(TAG, "Result: " + result); + if (result == null) { + return; + } + + // Cache result locally in shared preferences. + Preferences.setDynamicConfigJson(c, result); + } + + public static boolean isInBucket(Context c, int low, int high) { + final int userBucket = getUserBucket(c); + return (userBucket >= low) && (userBucket < high); + } + + /** + * Looks up in config if user is in certain experiment. Returns false as a default value when experiment + * does not exist. + * Experiment names are defined server side as Key in array for return values. + * @param experimentName Name of the experiment to lookup + * @return returns value for experiment or false if experiment does not exist. + */ + public static boolean isInExperiment(Context c, String experimentName) { + final Boolean override = Preferences.getOverrideValue(c, experimentName); + if (override != null) { + return override; + } + + final String config = Preferences.getDynamicConfigJson(c); + if (config == null) { + return false; + } + + try { + // TODO: cache the array into a mapping so we don't do a loop everytime we are looking for a experiment key + final JSONArray experiments = new JSONObject(config).getJSONArray(KEY_DATA); + JSONObject experiment = null; + + for (int i = 0; i < experiments.length(); i++) { + JSONObject entry = experiments.getJSONObject(i); + final String name = entry.getString(KEY_NAME); + if (name.equals(experimentName)) { + experiment = entry; + break; + } + } + + if (experiment == null) { + return false; + } + + if (!isMatch(c, experiment.optJSONObject(KEY_MATCH))) { + return false; + } + + final JSONObject buckets = experiment.getJSONObject(KEY_BUCKETS); + final boolean inExperiment = isInBucket(c, buckets.getInt(KEY_MIN), buckets.getInt(KEY_MAX)); + + if (DEBUG) { + Log.d(TAG, experimentName + " = " + inExperiment); + } + return inExperiment; + } catch (JSONException e) { + // If the experiment name is not found in the JSON, just return false. + // There is no need to log an error, since we don't really care if an + // inactive experiment is missing from the config. + return false; + } + } + + private static List<String> getExperimentNames(Context c) throws JSONException { + // TODO: cache the array into a mapping so we don't do a loop everytime we are looking for a experiment key + final List<String> returnList = new ArrayList<>(); + final String config = Preferences.getDynamicConfigJson(c); + final JSONArray experiments = new JSONObject(config).getJSONArray(KEY_DATA); + + for (int i = 0; i < experiments.length(); i++) { + JSONObject entry = experiments.getJSONObject(i); + returnList.add(entry.getString(KEY_NAME)); + } + return returnList; + } + + @Nullable + private static JSONObject getExperiment(Context c, String experimentName) throws JSONException { + // TODO: cache the array into a mapping so we don't do a loop everytime we are looking for a experiment key + final String config = Preferences.getDynamicConfigJson(c); + final JSONArray experiments = new JSONObject(config).getJSONArray(KEY_DATA); + JSONObject experiment = null; + + for (int i = 0; i < experiments.length(); i++) { + JSONObject entry = experiments.getJSONObject(i); + if (entry.getString(KEY_NAME).equals(experimentName)) { + experiment = entry; + break; + } + } + return experiment; + } + + private static boolean isMatch(Context c, @Nullable JSONObject matchKeys) { + // If no match keys are specified, default to enabling the experiment. + if (matchKeys == null) { + return true; + } + + if (matchKeys.has(KEY_APP_ID)) { + final String packageName = c.getPackageName(); + try { + if (!packageName.matches(matchKeys.getString(KEY_APP_ID))) { + return false; + } + } catch (JSONException e) { + Log.e(TAG, "Exception matching appId", e); + } + } + + if (matchKeys.has(KEY_COUNTRY)) { + try { + final String country = Locale.getDefault().getISO3Country(); + if (!country.matches(matchKeys.getString(KEY_COUNTRY))) { + return false; + } + } catch (MissingResourceException|JSONException e) { + Log.e(TAG, "Exception matching country", e); + } + } + + if (matchKeys.has(KEY_DEVICE)) { + final String device = Build.DEVICE; + try { + if (!device.matches(matchKeys.getString(KEY_DEVICE))) { + return false; + } + } catch (JSONException e) { + Log.e(TAG, "Exception matching device", e); + } + + } + if (matchKeys.has(KEY_LANG)) { + try { + final String lang = Locale.getDefault().getISO3Language(); + if (!lang.matches(matchKeys.getString(KEY_LANG))) { + return false; + } + } catch (MissingResourceException|JSONException e) { + Log.e(TAG, "Exception matching lang", e); + } + } + if (matchKeys.has(KEY_MANUFACTURER)) { + final String manufacturer = Build.MANUFACTURER; + try { + if (!manufacturer.matches(matchKeys.getString(KEY_MANUFACTURER))) { + return false; + } + } catch (JSONException e) { + Log.e(TAG, "Exception matching manufacturer", e); + } + } + + if (matchKeys.has(KEY_VERSION)) { + try { + final String version = c.getPackageManager().getPackageInfo(c.getPackageName(), 0).versionName; + if (!version.matches(matchKeys.getString(KEY_VERSION))) { + return false; + } + } catch (NameNotFoundException|JSONException e) { + Log.e(TAG, "Exception matching version", e); + } + } + + // Default to return true if no matches failed. + return true; + } + + /** + * @return a list of all active experiments. + */ + public static List<String> getActiveExperiments(Context c) { + final List<String> returnList = new ArrayList<>(); + + final String config = Preferences.getDynamicConfigJson(c); + if (config == null) { + return returnList; + } + + try { + final JSONObject data = new JSONObject(config); + final List<String> experiments = getExperimentNames(c); + + for (int i = 0; i < experiments.size(); i++) { + final String name = experiments.get(i); + + // Check override value before reading saved JSON. + Boolean isActive = Preferences.getOverrideValue(c, name); + if (isActive == null) { + // TODO: This is inefficient because it will check all the match cases on all experiments. + isActive = isInExperiment(c, name); + } + if (isActive) { + returnList.add(name); + } + } + } catch (JSONException e) { + // Something went wrong! + } + + return returnList; + } + + /** + * Checks if a certain experiment has additional values. + * @param c ApplicationContext + * @param experimentName Name of the experiment + * @return true when experiment exists + */ + public static boolean hasExperimentValues(Context c, String experimentName) { + return getExperimentValuesFromJson(c, experimentName) != null; + } + + /** + * Returns the experiment value as a JSONObject. + * @param experimentName Name of the experiment + * @return Experiment value as String, null if experiment does not exist. + */ + @Nullable + public static JSONObject getExperimentValuesFromJson(Context c, String experimentName) { + final String config = Preferences.getDynamicConfigJson(c); + + if (config == null) { + return null; + } + + try { + final JSONObject experiment = getExperiment(c, experimentName); + if (experiment == null) { + return null; + } + return experiment.getJSONObject(KEY_VALUES); + } catch (JSONException e) { + Log.e(TAG, "Could not create JSON object from config string", e); + } + + return null; + } + + /** + * Returns a String containing the server response from a GET request + * @param url URL for GET request. + * @return Returns String from server or null when failed. + */ + @Nullable private static String readFromUrlGET(URL url) { + try { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setUseCaches(false); + + InputStream is = connection.getInputStream(); + InputStreamReader inputStreamReader = new InputStreamReader(is); + BufferedReader bufferReader = new BufferedReader(inputStreamReader, 8192); + String line; + StringBuilder resultContent = new StringBuilder(); + while ((line = bufferReader.readLine()) != null) { + resultContent.append(line); + } + bufferReader.close(); + + return resultContent.toString(); + } catch (IOException e) { + e.printStackTrace(); + } + + return null; + } + + /** + * Return the bucket number of the user. There are 100 possible buckets. + */ + private static int getUserBucket(Context c) { + final DeviceUuidFactory df = new DeviceUuidFactory(c); + final String uuid = df.getDeviceUuid().toString(); + + CRC32 crc = new CRC32(); + crc.update(uuid.getBytes()); + long checksum = crc.getValue(); + return (int)(checksum % 100L); + } +} diff --git a/mobile/android/thirdparty/com/squareup/leakcanary/LeakCanary.java b/mobile/android/thirdparty/com/squareup/leakcanary/LeakCanary.java new file mode 100644 index 000000000..a9e44437d --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/leakcanary/LeakCanary.java @@ -0,0 +1,21 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 com.squareup.leakcanary; + +import android.app.Application; + +/** + * A no-op version of {@link LeakCanary} that can be used in release builds. + */ +public final class LeakCanary { + public static RefWatcher install(Application application) { + return RefWatcher.DISABLED; + } + + private LeakCanary() { + throw new AssertionError(); + } +} diff --git a/mobile/android/thirdparty/com/squareup/leakcanary/RefWatcher.java b/mobile/android/thirdparty/com/squareup/leakcanary/RefWatcher.java new file mode 100644 index 000000000..3c75dcdee --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/leakcanary/RefWatcher.java @@ -0,0 +1,20 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 com.squareup.leakcanary; + +/** + * No-op implementation of {@link RefWatcher} for release builds. Please use {@link + * RefWatcher#DISABLED}. + */ +public final class RefWatcher { + public static final RefWatcher DISABLED = new RefWatcher(); + + private RefWatcher() {} + + public void watch(Object watchedReference) {} + + public void watch(Object watchedReference, String referenceName) {} +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Action.java b/mobile/android/thirdparty/com/squareup/picasso/Action.java new file mode 100644 index 000000000..5f176d770 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Action.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; + +abstract class Action<T> { + static class RequestWeakReference<T> extends WeakReference<T> { + final Action action; + + public RequestWeakReference(Action action, T referent, ReferenceQueue<? super T> q) { + super(referent, q); + this.action = action; + } + } + + final Picasso picasso; + final Request data; + final WeakReference<T> target; + final boolean skipCache; + final boolean noFade; + final int errorResId; + final Drawable errorDrawable; + final String key; + + boolean cancelled; + + Action(Picasso picasso, T target, Request data, boolean skipCache, boolean noFade, + int errorResId, Drawable errorDrawable, String key) { + this.picasso = picasso; + this.data = data; + this.target = new RequestWeakReference<T>(this, target, picasso.referenceQueue); + this.skipCache = skipCache; + this.noFade = noFade; + this.errorResId = errorResId; + this.errorDrawable = errorDrawable; + this.key = key; + } + + abstract void complete(Bitmap result, Picasso.LoadedFrom from); + + abstract void error(); + + void cancel() { + cancelled = true; + } + + Request getData() { + return data; + } + + T getTarget() { + return target.get(); + } + + String getKey() { + return key; + } + + boolean isCancelled() { + return cancelled; + } + + Picasso getPicasso() { + return picasso; + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/AssetBitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/AssetBitmapHunter.java new file mode 100644 index 000000000..c0245ed3f --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/AssetBitmapHunter.java @@ -0,0 +1,51 @@ +package com.squareup.picasso; + +import android.content.Context; +import android.content.res.AssetManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import java.io.IOException; +import java.io.InputStream; + +import static com.squareup.picasso.Picasso.LoadedFrom.DISK; + +class AssetBitmapHunter extends BitmapHunter { + private AssetManager assetManager; + + public AssetBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache, + Stats stats, Action action) { + super(picasso, dispatcher, cache, stats, action); + assetManager = context.getAssets(); + } + + @Override Bitmap decode(Request data) throws IOException { + String filePath = data.uri.toString().substring(ASSET_PREFIX_LENGTH); + return decodeAsset(filePath); + } + + @Override Picasso.LoadedFrom getLoadedFrom() { + return DISK; + } + + Bitmap decodeAsset(String filePath) throws IOException { + BitmapFactory.Options options = null; + if (data.hasSize()) { + options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + InputStream is = null; + try { + is = assetManager.open(filePath); + BitmapFactory.decodeStream(is, null, options); + } finally { + Utils.closeQuietly(is); + } + calculateInSampleSize(data.targetWidth, data.targetHeight, options); + } + InputStream is = assetManager.open(filePath); + try { + return BitmapFactory.decodeStream(is, null, options); + } finally { + Utils.closeQuietly(is); + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/BitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/BitmapHunter.java new file mode 100644 index 000000000..b8aa87c48 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/BitmapHunter.java @@ -0,0 +1,357 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.net.NetworkInfo; +import android.net.Uri; +import android.provider.MediaStore; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; + +import static android.content.ContentResolver.SCHEME_ANDROID_RESOURCE; +import static android.content.ContentResolver.SCHEME_CONTENT; +import static android.content.ContentResolver.SCHEME_FILE; +import static android.provider.ContactsContract.Contacts; +import static com.squareup.picasso.Picasso.LoadedFrom.MEMORY; + +abstract class BitmapHunter implements Runnable { + + /** + * Global lock for bitmap decoding to ensure that we are only are decoding one at a time. Since + * this will only ever happen in background threads we help avoid excessive memory thrashing as + * well as potential OOMs. Shamelessly stolen from Volley. + */ + private static final Object DECODE_LOCK = new Object(); + private static final String ANDROID_ASSET = "android_asset"; + protected static final int ASSET_PREFIX_LENGTH = + (SCHEME_FILE + ":///" + ANDROID_ASSET + "/").length(); + + final Picasso picasso; + final Dispatcher dispatcher; + final Cache cache; + final Stats stats; + final String key; + final Request data; + final List<Action> actions; + final boolean skipMemoryCache; + + Bitmap result; + Future<?> future; + Picasso.LoadedFrom loadedFrom; + Exception exception; + int exifRotation; // Determined during decoding of original resource. + + BitmapHunter(Picasso picasso, Dispatcher dispatcher, Cache cache, Stats stats, Action action) { + this.picasso = picasso; + this.dispatcher = dispatcher; + this.cache = cache; + this.stats = stats; + this.key = action.getKey(); + this.data = action.getData(); + this.skipMemoryCache = action.skipCache; + this.actions = new ArrayList<Action>(4); + attach(action); + } + + protected void setExifRotation(int exifRotation) { + this.exifRotation = exifRotation; + } + + @Override public void run() { + try { + Thread.currentThread().setName(Utils.THREAD_PREFIX + data.getName()); + + result = hunt(); + + if (result == null) { + dispatcher.dispatchFailed(this); + } else { + dispatcher.dispatchComplete(this); + } + } catch (Downloader.ResponseException e) { + exception = e; + dispatcher.dispatchFailed(this); + } catch (IOException e) { + exception = e; + dispatcher.dispatchRetry(this); + } catch (OutOfMemoryError e) { + StringWriter writer = new StringWriter(); + stats.createSnapshot().dump(new PrintWriter(writer)); + exception = new RuntimeException(writer.toString(), e); + dispatcher.dispatchFailed(this); + } catch (Exception e) { + exception = e; + dispatcher.dispatchFailed(this); + } finally { + Thread.currentThread().setName(Utils.THREAD_IDLE_NAME); + } + } + + abstract Bitmap decode(Request data) throws IOException; + + Bitmap hunt() throws IOException { + Bitmap bitmap; + + if (!skipMemoryCache) { + bitmap = cache.get(key); + if (bitmap != null) { + stats.dispatchCacheHit(); + loadedFrom = MEMORY; + return bitmap; + } + } + + bitmap = decode(data); + + if (bitmap != null) { + stats.dispatchBitmapDecoded(bitmap); + if (data.needsTransformation() || exifRotation != 0) { + synchronized (DECODE_LOCK) { + if (data.needsMatrixTransform() || exifRotation != 0) { + bitmap = transformResult(data, bitmap, exifRotation); + } + if (data.hasCustomTransformations()) { + bitmap = applyCustomTransformations(data.transformations, bitmap); + } + } + stats.dispatchBitmapTransformed(bitmap); + } + } + + return bitmap; + } + + void attach(Action action) { + actions.add(action); + } + + void detach(Action action) { + actions.remove(action); + } + + boolean cancel() { + return actions.isEmpty() && future != null && future.cancel(false); + } + + boolean isCancelled() { + return future != null && future.isCancelled(); + } + + boolean shouldSkipMemoryCache() { + return skipMemoryCache; + } + + boolean shouldRetry(boolean airplaneMode, NetworkInfo info) { + return false; + } + + Bitmap getResult() { + return result; + } + + String getKey() { + return key; + } + + Request getData() { + return data; + } + + List<Action> getActions() { + return actions; + } + + Exception getException() { + return exception; + } + + Picasso.LoadedFrom getLoadedFrom() { + return loadedFrom; + } + + static BitmapHunter forRequest(Context context, Picasso picasso, Dispatcher dispatcher, + Cache cache, Stats stats, Action action, Downloader downloader) { + if (action.getData().resourceId != 0) { + return new ResourceBitmapHunter(context, picasso, dispatcher, cache, stats, action); + } + Uri uri = action.getData().uri; + String scheme = uri.getScheme(); + if (SCHEME_CONTENT.equals(scheme)) { + if (Contacts.CONTENT_URI.getHost().equals(uri.getHost()) // + && !uri.getPathSegments().contains(Contacts.Photo.CONTENT_DIRECTORY)) { + return new ContactsPhotoBitmapHunter(context, picasso, dispatcher, cache, stats, action); + } else if (MediaStore.AUTHORITY.equals(uri.getAuthority())) { + return new MediaStoreBitmapHunter(context, picasso, dispatcher, cache, stats, action); + } else { + return new ContentStreamBitmapHunter(context, picasso, dispatcher, cache, stats, action); + } + } else if (SCHEME_FILE.equals(scheme)) { + if (!uri.getPathSegments().isEmpty() && ANDROID_ASSET.equals(uri.getPathSegments().get(0))) { + return new AssetBitmapHunter(context, picasso, dispatcher, cache, stats, action); + } + return new FileBitmapHunter(context, picasso, dispatcher, cache, stats, action); + } else if (SCHEME_ANDROID_RESOURCE.equals(scheme)) { + return new ResourceBitmapHunter(context, picasso, dispatcher, cache, stats, action); + } else { + return new NetworkBitmapHunter(picasso, dispatcher, cache, stats, action, downloader); + } + } + + static void calculateInSampleSize(int reqWidth, int reqHeight, BitmapFactory.Options options) { + calculateInSampleSize(reqWidth, reqHeight, options.outWidth, options.outHeight, options); + } + + static void calculateInSampleSize(int reqWidth, int reqHeight, int width, int height, + BitmapFactory.Options options) { + int sampleSize = 1; + if (height > reqHeight || width > reqWidth) { + final int heightRatio = Math.round((float) height / (float) reqHeight); + final int widthRatio = Math.round((float) width / (float) reqWidth); + sampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + options.inSampleSize = sampleSize; + options.inJustDecodeBounds = false; + } + + static Bitmap applyCustomTransformations(List<Transformation> transformations, Bitmap result) { + for (int i = 0, count = transformations.size(); i < count; i++) { + final Transformation transformation = transformations.get(i); + Bitmap newResult = transformation.transform(result); + + if (newResult == null) { + final StringBuilder builder = new StringBuilder() // + .append("Transformation ") + .append(transformation.key()) + .append(" returned null after ") + .append(i) + .append(" previous transformation(s).\n\nTransformation list:\n"); + for (Transformation t : transformations) { + builder.append(t.key()).append('\n'); + } + Picasso.HANDLER.post(new Runnable() { + @Override public void run() { + throw new NullPointerException(builder.toString()); + } + }); + return null; + } + + if (newResult == result && result.isRecycled()) { + Picasso.HANDLER.post(new Runnable() { + @Override public void run() { + throw new IllegalStateException("Transformation " + + transformation.key() + + " returned input Bitmap but recycled it."); + } + }); + return null; + } + + // If the transformation returned a new bitmap ensure they recycled the original. + if (newResult != result && !result.isRecycled()) { + Picasso.HANDLER.post(new Runnable() { + @Override public void run() { + throw new IllegalStateException("Transformation " + + transformation.key() + + " mutated input Bitmap but failed to recycle the original."); + } + }); + return null; + } + + result = newResult; + } + return result; + } + + static Bitmap transformResult(Request data, Bitmap result, int exifRotation) { + int inWidth = result.getWidth(); + int inHeight = result.getHeight(); + + int drawX = 0; + int drawY = 0; + int drawWidth = inWidth; + int drawHeight = inHeight; + + Matrix matrix = new Matrix(); + + if (data.needsMatrixTransform()) { + int targetWidth = data.targetWidth; + int targetHeight = data.targetHeight; + + float targetRotation = data.rotationDegrees; + if (targetRotation != 0) { + if (data.hasRotationPivot) { + matrix.setRotate(targetRotation, data.rotationPivotX, data.rotationPivotY); + } else { + matrix.setRotate(targetRotation); + } + } + + if (data.centerCrop) { + float widthRatio = targetWidth / (float) inWidth; + float heightRatio = targetHeight / (float) inHeight; + float scale; + if (widthRatio > heightRatio) { + scale = widthRatio; + int newSize = (int) Math.ceil(inHeight * (heightRatio / widthRatio)); + drawY = (inHeight - newSize) / 2; + drawHeight = newSize; + } else { + scale = heightRatio; + int newSize = (int) Math.ceil(inWidth * (widthRatio / heightRatio)); + drawX = (inWidth - newSize) / 2; + drawWidth = newSize; + } + matrix.preScale(scale, scale); + } else if (data.centerInside) { + float widthRatio = targetWidth / (float) inWidth; + float heightRatio = targetHeight / (float) inHeight; + float scale = widthRatio < heightRatio ? widthRatio : heightRatio; + matrix.preScale(scale, scale); + } else if (targetWidth != 0 && targetHeight != 0 // + && (targetWidth != inWidth || targetHeight != inHeight)) { + // If an explicit target size has been specified and they do not match the results bounds, + // pre-scale the existing matrix appropriately. + float sx = targetWidth / (float) inWidth; + float sy = targetHeight / (float) inHeight; + matrix.preScale(sx, sy); + } + } + + if (exifRotation != 0) { + matrix.preRotate(exifRotation); + } + + Bitmap newResult = + Bitmap.createBitmap(result, drawX, drawY, drawWidth, drawHeight, matrix, true); + if (newResult != result) { + result.recycle(); + result = newResult; + } + + return result; + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Cache.java b/mobile/android/thirdparty/com/squareup/picasso/Cache.java new file mode 100644 index 000000000..bce297f9e --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Cache.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; + +/** + * A memory cache for storing the most recently used images. + * <p/> + * <em>Note:</em> The {@link Cache} is accessed by multiple threads. You must ensure + * your {@link Cache} implementation is thread safe when {@link Cache#get(String)} or {@link + * Cache#set(String, android.graphics.Bitmap)} is called. + */ +public interface Cache { + /** Retrieve an image for the specified {@code key} or {@code null}. */ + Bitmap get(String key); + + /** Store an image in the cache for the specified {@code key}. */ + void set(String key, Bitmap bitmap); + + /** Returns the current size of the cache in bytes. */ + int size(); + + /** Returns the maximum size in bytes that the cache can hold. */ + int maxSize(); + + /** Clears the cache. */ + void clear(); + + /** A cache which does not store any values. */ + Cache NONE = new Cache() { + @Override public Bitmap get(String key) { + return null; + } + + @Override public void set(String key, Bitmap bitmap) { + // Ignore. + } + + @Override public int size() { + return 0; + } + + @Override public int maxSize() { + return 0; + } + + @Override public void clear() { + } + }; +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Callback.java b/mobile/android/thirdparty/com/squareup/picasso/Callback.java new file mode 100644 index 000000000..d93620889 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Callback.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +public interface Callback { + void onSuccess(); + + void onError(); + + public static class EmptyCallback implements Callback { + + @Override public void onSuccess() { + } + + @Override public void onError() { + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/ContactsPhotoBitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/ContactsPhotoBitmapHunter.java new file mode 100644 index 000000000..9444167b4 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/ContactsPhotoBitmapHunter.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.UriMatcher; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.provider.ContactsContract; + +import java.io.IOException; +import java.io.InputStream; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH; +import static android.provider.ContactsContract.Contacts.openContactPhotoInputStream; +import static com.squareup.picasso.Picasso.LoadedFrom.DISK; + +class ContactsPhotoBitmapHunter extends BitmapHunter { + /** A lookup uri (e.g. content://com.android.contacts/contacts/lookup/3570i61d948d30808e537) */ + private static final int ID_LOOKUP = 1; + /** A contact thumbnail uri (e.g. content://com.android.contacts/contacts/38/photo) */ + private static final int ID_THUMBNAIL = 2; + /** A contact uri (e.g. content://com.android.contacts/contacts/38) */ + private static final int ID_CONTACT = 3; + /** + * A contact display photo (high resolution) uri + * (e.g. content://com.android.contacts/display_photo/5) + */ + private static final int ID_DISPLAY_PHOTO = 4; + + private static final UriMatcher matcher; + + static { + matcher = new UriMatcher(UriMatcher.NO_MATCH); + matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/#", ID_LOOKUP); + matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*", ID_LOOKUP); + matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/photo", ID_THUMBNAIL); + matcher.addURI(ContactsContract.AUTHORITY, "contacts/#", ID_CONTACT); + matcher.addURI(ContactsContract.AUTHORITY, "display_photo/#", ID_DISPLAY_PHOTO); + } + + final Context context; + + ContactsPhotoBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache, + Stats stats, Action action) { + super(picasso, dispatcher, cache, stats, action); + this.context = context; + } + + @Override Bitmap decode(Request data) throws IOException { + InputStream is = null; + try { + is = getInputStream(); + return decodeStream(is, data); + } finally { + Utils.closeQuietly(is); + } + } + + @Override Picasso.LoadedFrom getLoadedFrom() { + return DISK; + } + + private InputStream getInputStream() throws IOException { + ContentResolver contentResolver = context.getContentResolver(); + Uri uri = getData().uri; + switch (matcher.match(uri)) { + case ID_LOOKUP: + uri = ContactsContract.Contacts.lookupContact(contentResolver, uri); + if (uri == null) { + return null; + } + // Resolved the uri to a contact uri, intentionally fall through to process the resolved uri + case ID_CONTACT: + if (SDK_INT < ICE_CREAM_SANDWICH) { + return openContactPhotoInputStream(contentResolver, uri); + } else { + return ContactPhotoStreamIcs.get(contentResolver, uri); + } + case ID_THUMBNAIL: + case ID_DISPLAY_PHOTO: + return contentResolver.openInputStream(uri); + default: + throw new IllegalStateException("Invalid uri: " + uri); + } + } + + private Bitmap decodeStream(InputStream stream, Request data) throws IOException { + if (stream == null) { + return null; + } + BitmapFactory.Options options = null; + if (data.hasSize()) { + options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + InputStream is = getInputStream(); + try { + BitmapFactory.decodeStream(is, null, options); + } finally { + Utils.closeQuietly(is); + } + calculateInSampleSize(data.targetWidth, data.targetHeight, options); + } + return BitmapFactory.decodeStream(stream, null, options); + } + + @TargetApi(ICE_CREAM_SANDWICH) + private static class ContactPhotoStreamIcs { + static InputStream get(ContentResolver contentResolver, Uri uri) { + return openContactPhotoInputStream(contentResolver, uri, true); + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/ContentStreamBitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/ContentStreamBitmapHunter.java new file mode 100644 index 000000000..624ffe078 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/ContentStreamBitmapHunter.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import java.io.IOException; +import java.io.InputStream; + +import static com.squareup.picasso.Picasso.LoadedFrom.DISK; + +class ContentStreamBitmapHunter extends BitmapHunter { + final Context context; + + ContentStreamBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache, + Stats stats, Action action) { + super(picasso, dispatcher, cache, stats, action); + this.context = context; + } + + @Override Bitmap decode(Request data) + throws IOException { + return decodeContentStream(data); + } + + @Override Picasso.LoadedFrom getLoadedFrom() { + return DISK; + } + + protected Bitmap decodeContentStream(Request data) throws IOException { + ContentResolver contentResolver = context.getContentResolver(); + BitmapFactory.Options options = null; + if (data.hasSize()) { + options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + InputStream is = null; + try { + is = contentResolver.openInputStream(data.uri); + BitmapFactory.decodeStream(is, null, options); + } finally { + Utils.closeQuietly(is); + } + calculateInSampleSize(data.targetWidth, data.targetHeight, options); + } + InputStream is = contentResolver.openInputStream(data.uri); + try { + return BitmapFactory.decodeStream(is, null, options); + } finally { + Utils.closeQuietly(is); + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/DeferredRequestCreator.java b/mobile/android/thirdparty/com/squareup/picasso/DeferredRequestCreator.java new file mode 100644 index 000000000..fbdaab1c3 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/DeferredRequestCreator.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.view.ViewTreeObserver; +import android.widget.ImageView; +import java.lang.ref.WeakReference; + +class DeferredRequestCreator implements ViewTreeObserver.OnPreDrawListener { + + final RequestCreator creator; + final WeakReference<ImageView> target; + Callback callback; + + DeferredRequestCreator(RequestCreator creator, ImageView target, Callback callback) { + this.creator = creator; + this.target = new WeakReference<ImageView>(target); + this.callback = callback; + target.getViewTreeObserver().addOnPreDrawListener(this); + } + + @Override public boolean onPreDraw() { + ImageView target = this.target.get(); + if (target == null) { + return true; + } + ViewTreeObserver vto = target.getViewTreeObserver(); + if (!vto.isAlive()) { + return true; + } + + int width = target.getMeasuredWidth(); + int height = target.getMeasuredHeight(); + + if (width <= 0 || height <= 0) { + return true; + } + + vto.removeOnPreDrawListener(this); + + this.creator.unfit().resize(width, height).into(target, callback); + return true; + } + + void cancel() { + callback = null; + ImageView target = this.target.get(); + if (target == null) { + return; + } + ViewTreeObserver vto = target.getViewTreeObserver(); + if (!vto.isAlive()) { + return; + } + vto.removeOnPreDrawListener(this); + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Dispatcher.java b/mobile/android/thirdparty/com/squareup/picasso/Dispatcher.java new file mode 100644 index 000000000..6401431fb --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Dispatcher.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +import static android.content.Context.CONNECTIVITY_SERVICE; +import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED; +import static android.net.ConnectivityManager.CONNECTIVITY_ACTION; +import static android.os.Process.THREAD_PRIORITY_BACKGROUND; +import static com.squareup.picasso.BitmapHunter.forRequest; + +class Dispatcher { + private static final int RETRY_DELAY = 500; + private static final int AIRPLANE_MODE_ON = 1; + private static final int AIRPLANE_MODE_OFF = 0; + + static final int REQUEST_SUBMIT = 1; + static final int REQUEST_CANCEL = 2; + static final int REQUEST_GCED = 3; + static final int HUNTER_COMPLETE = 4; + static final int HUNTER_RETRY = 5; + static final int HUNTER_DECODE_FAILED = 6; + static final int HUNTER_DELAY_NEXT_BATCH = 7; + static final int HUNTER_BATCH_COMPLETE = 8; + static final int NETWORK_STATE_CHANGE = 9; + static final int AIRPLANE_MODE_CHANGE = 10; + + private static final String DISPATCHER_THREAD_NAME = "Dispatcher"; + private static final int BATCH_DELAY = 200; // ms + + final DispatcherThread dispatcherThread; + final Context context; + final ExecutorService service; + final Downloader downloader; + final Map<String, BitmapHunter> hunterMap; + final Handler handler; + final Handler mainThreadHandler; + final Cache cache; + final Stats stats; + final List<BitmapHunter> batch; + final NetworkBroadcastReceiver receiver; + + NetworkInfo networkInfo; + boolean airplaneMode; + + Dispatcher(Context context, ExecutorService service, Handler mainThreadHandler, + Downloader downloader, Cache cache, Stats stats) { + this.dispatcherThread = new DispatcherThread(); + this.dispatcherThread.start(); + this.context = context; + this.service = service; + this.hunterMap = new LinkedHashMap<String, BitmapHunter>(); + this.handler = new DispatcherHandler(dispatcherThread.getLooper(), this); + this.downloader = downloader; + this.mainThreadHandler = mainThreadHandler; + this.cache = cache; + this.stats = stats; + this.batch = new ArrayList<BitmapHunter>(4); + this.airplaneMode = Utils.isAirplaneModeOn(this.context); + this.receiver = new NetworkBroadcastReceiver(this.context); + receiver.register(); + } + + void shutdown() { + service.shutdown(); + dispatcherThread.quit(); + receiver.unregister(); + } + + void dispatchSubmit(Action action) { + handler.sendMessage(handler.obtainMessage(REQUEST_SUBMIT, action)); + } + + void dispatchCancel(Action action) { + handler.sendMessage(handler.obtainMessage(REQUEST_CANCEL, action)); + } + + void dispatchComplete(BitmapHunter hunter) { + handler.sendMessage(handler.obtainMessage(HUNTER_COMPLETE, hunter)); + } + + void dispatchRetry(BitmapHunter hunter) { + handler.sendMessageDelayed(handler.obtainMessage(HUNTER_RETRY, hunter), RETRY_DELAY); + } + + void dispatchFailed(BitmapHunter hunter) { + handler.sendMessage(handler.obtainMessage(HUNTER_DECODE_FAILED, hunter)); + } + + void dispatchNetworkStateChange(NetworkInfo info) { + handler.sendMessage(handler.obtainMessage(NETWORK_STATE_CHANGE, info)); + } + + void dispatchAirplaneModeChange(boolean airplaneMode) { + handler.sendMessage(handler.obtainMessage(AIRPLANE_MODE_CHANGE, + airplaneMode ? AIRPLANE_MODE_ON : AIRPLANE_MODE_OFF, 0)); + } + + void performSubmit(Action action) { + BitmapHunter hunter = hunterMap.get(action.getKey()); + if (hunter != null) { + hunter.attach(action); + return; + } + + if (service.isShutdown()) { + return; + } + + hunter = forRequest(context, action.getPicasso(), this, cache, stats, action, downloader); + hunter.future = service.submit(hunter); + hunterMap.put(action.getKey(), hunter); + } + + void performCancel(Action action) { + String key = action.getKey(); + BitmapHunter hunter = hunterMap.get(key); + if (hunter != null) { + hunter.detach(action); + if (hunter.cancel()) { + hunterMap.remove(key); + } + } + } + + void performRetry(BitmapHunter hunter) { + if (hunter.isCancelled()) return; + + if (service.isShutdown()) { + performError(hunter); + return; + } + + if (hunter.shouldRetry(airplaneMode, networkInfo)) { + hunter.future = service.submit(hunter); + } else { + performError(hunter); + } + } + + void performComplete(BitmapHunter hunter) { + if (!hunter.shouldSkipMemoryCache()) { + cache.set(hunter.getKey(), hunter.getResult()); + } + hunterMap.remove(hunter.getKey()); + batch(hunter); + } + + void performBatchComplete() { + List<BitmapHunter> copy = new ArrayList<BitmapHunter>(batch); + batch.clear(); + mainThreadHandler.sendMessage(mainThreadHandler.obtainMessage(HUNTER_BATCH_COMPLETE, copy)); + } + + void performError(BitmapHunter hunter) { + hunterMap.remove(hunter.getKey()); + batch(hunter); + } + + void performAirplaneModeChange(boolean airplaneMode) { + this.airplaneMode = airplaneMode; + } + + void performNetworkStateChange(NetworkInfo info) { + networkInfo = info; + if (service instanceof PicassoExecutorService) { + ((PicassoExecutorService) service).adjustThreadCount(info); + } + } + + private void batch(BitmapHunter hunter) { + if (hunter.isCancelled()) { + return; + } + batch.add(hunter); + if (!handler.hasMessages(HUNTER_DELAY_NEXT_BATCH)) { + handler.sendEmptyMessageDelayed(HUNTER_DELAY_NEXT_BATCH, BATCH_DELAY); + } + } + + private static class DispatcherHandler extends Handler { + private final Dispatcher dispatcher; + + public DispatcherHandler(Looper looper, Dispatcher dispatcher) { + super(looper); + this.dispatcher = dispatcher; + } + + @Override public void handleMessage(final Message msg) { + switch (msg.what) { + case REQUEST_SUBMIT: { + Action action = (Action) msg.obj; + dispatcher.performSubmit(action); + break; + } + case REQUEST_CANCEL: { + Action action = (Action) msg.obj; + dispatcher.performCancel(action); + break; + } + case HUNTER_COMPLETE: { + BitmapHunter hunter = (BitmapHunter) msg.obj; + dispatcher.performComplete(hunter); + break; + } + case HUNTER_RETRY: { + BitmapHunter hunter = (BitmapHunter) msg.obj; + dispatcher.performRetry(hunter); + break; + } + case HUNTER_DECODE_FAILED: { + BitmapHunter hunter = (BitmapHunter) msg.obj; + dispatcher.performError(hunter); + break; + } + case HUNTER_DELAY_NEXT_BATCH: { + dispatcher.performBatchComplete(); + break; + } + case NETWORK_STATE_CHANGE: { + NetworkInfo info = (NetworkInfo) msg.obj; + dispatcher.performNetworkStateChange(info); + break; + } + case AIRPLANE_MODE_CHANGE: { + dispatcher.performAirplaneModeChange(msg.arg1 == AIRPLANE_MODE_ON); + break; + } + default: + Picasso.HANDLER.post(new Runnable() { + @Override public void run() { + throw new AssertionError("Unknown handler message received: " + msg.what); + } + }); + } + } + } + + static class DispatcherThread extends HandlerThread { + DispatcherThread() { + super(Utils.THREAD_PREFIX + DISPATCHER_THREAD_NAME, THREAD_PRIORITY_BACKGROUND); + } + } + + private class NetworkBroadcastReceiver extends BroadcastReceiver { + private static final String EXTRA_AIRPLANE_STATE = "state"; + + private final ConnectivityManager connectivityManager; + + NetworkBroadcastReceiver(Context context) { + connectivityManager = (ConnectivityManager) context.getSystemService(CONNECTIVITY_SERVICE); + } + + void register() { + boolean shouldScanState = service instanceof PicassoExecutorService && // + Utils.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE); + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_AIRPLANE_MODE_CHANGED); + if (shouldScanState) { + filter.addAction(CONNECTIVITY_ACTION); + } + context.registerReceiver(this, filter); + } + + void unregister() { + context.unregisterReceiver(this); + } + + @Override public void onReceive(Context context, Intent intent) { + // On some versions of Android this may be called with a null Intent + if (null == intent) { + return; + } + + String action = intent.getAction(); + Bundle extras = intent.getExtras(); + + if (ACTION_AIRPLANE_MODE_CHANGED.equals(action)) { + dispatchAirplaneModeChange(extras.getBoolean(EXTRA_AIRPLANE_STATE, false)); + } else if (CONNECTIVITY_ACTION.equals(action)) { + dispatchNetworkStateChange(connectivityManager.getActiveNetworkInfo()); + } + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Downloader.java b/mobile/android/thirdparty/com/squareup/picasso/Downloader.java new file mode 100644 index 000000000..33a909371 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Downloader.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; +import android.net.Uri; +import java.io.IOException; +import java.io.InputStream; + +/** A mechanism to load images from external resources such as a disk cache and/or the internet. */ +public interface Downloader { + /** + * Download the specified image {@code url} from the internet. + * + * @param uri Remote image URL. + * @param localCacheOnly If {@code true} the URL should only be loaded if available in a local + * disk cache. + * @return {@link Response} containing either a {@link Bitmap} representation of the request or an + * {@link InputStream} for the image data. {@code null} can be returned to indicate a problem + * loading the bitmap. + * @throws IOException if the requested URL cannot successfully be loaded. + */ + Response load(Uri uri, boolean localCacheOnly) throws IOException; + + /** Thrown for non-2XX responses. */ + class ResponseException extends IOException { + public ResponseException(String message) { + super(message); + } + } + + /** Response stream or bitmap and info. */ + class Response { + final InputStream stream; + final Bitmap bitmap; + final boolean cached; + + /** + * Response image and info. + * + * @param bitmap Image. + * @param loadedFromCache {@code true} if the source of the image is from a local disk cache. + */ + public Response(Bitmap bitmap, boolean loadedFromCache) { + if (bitmap == null) { + throw new IllegalArgumentException("Bitmap may not be null."); + } + this.stream = null; + this.bitmap = bitmap; + this.cached = loadedFromCache; + } + + /** + * Response stream and info. + * + * @param stream Image data stream. + * @param loadedFromCache {@code true} if the source of the stream is from a local disk cache. + */ + public Response(InputStream stream, boolean loadedFromCache) { + if (stream == null) { + throw new IllegalArgumentException("Stream may not be null."); + } + this.stream = stream; + this.bitmap = null; + this.cached = loadedFromCache; + } + + /** + * Input stream containing image data. + * <p> + * If this returns {@code null}, image data will be available via {@link #getBitmap()}. + */ + public InputStream getInputStream() { + return stream; + } + + /** + * Bitmap representing the image. + * <p> + * If this returns {@code null}, image data will be available via {@link #getInputStream()}. + */ + public Bitmap getBitmap() { + return bitmap; + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/FetchAction.java b/mobile/android/thirdparty/com/squareup/picasso/FetchAction.java new file mode 100644 index 000000000..d8f1c3fb4 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/FetchAction.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; + +class FetchAction extends Action<Void> { + FetchAction(Picasso picasso, Request data, boolean skipCache, String key) { + super(picasso, null, data, skipCache, false, 0, null, key); + } + + @Override void complete(Bitmap result, Picasso.LoadedFrom from) { + } + + @Override public void error() { + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/FileBitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/FileBitmapHunter.java new file mode 100644 index 000000000..dac38fb80 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/FileBitmapHunter.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.graphics.Bitmap; +import android.media.ExifInterface; +import android.net.Uri; +import java.io.IOException; + +import static android.media.ExifInterface.ORIENTATION_NORMAL; +import static android.media.ExifInterface.ORIENTATION_ROTATE_180; +import static android.media.ExifInterface.ORIENTATION_ROTATE_270; +import static android.media.ExifInterface.ORIENTATION_ROTATE_90; +import static android.media.ExifInterface.TAG_ORIENTATION; + +class FileBitmapHunter extends ContentStreamBitmapHunter { + + FileBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache, + Stats stats, Action action) { + super(context, picasso, dispatcher, cache, stats, action); + } + + @Override Bitmap decode(Request data) + throws IOException { + setExifRotation(getFileExifRotation(data.uri)); + return super.decode(data); + } + + static int getFileExifRotation(Uri uri) throws IOException { + ExifInterface exifInterface = new ExifInterface(uri.getPath()); + int orientation = exifInterface.getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL); + switch (orientation) { + case ORIENTATION_ROTATE_90: + return 90; + case ORIENTATION_ROTATE_180: + return 180; + case ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/GetAction.java b/mobile/android/thirdparty/com/squareup/picasso/GetAction.java new file mode 100644 index 000000000..e30024e89 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/GetAction.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; + +class GetAction extends Action<Void> { + GetAction(Picasso picasso, Request data, boolean skipCache, String key) { + super(picasso, null, data, skipCache, false, 0, null, key); + } + + @Override void complete(Bitmap result, Picasso.LoadedFrom from) { + } + + @Override public void error() { + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/ImageViewAction.java b/mobile/android/thirdparty/com/squareup/picasso/ImageViewAction.java new file mode 100644 index 000000000..16f550907 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/ImageViewAction.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.widget.ImageView; + +class ImageViewAction extends Action<ImageView> { + + Callback callback; + + ImageViewAction(Picasso picasso, ImageView imageView, Request data, boolean skipCache, + boolean noFade, int errorResId, Drawable errorDrawable, String key, Callback callback) { + super(picasso, imageView, data, skipCache, noFade, errorResId, errorDrawable, key); + this.callback = callback; + } + + @Override public void complete(Bitmap result, Picasso.LoadedFrom from) { + if (result == null) { + throw new AssertionError( + String.format("Attempted to complete action with no result!\n%s", this)); + } + + ImageView target = this.target.get(); + if (target == null) { + return; + } + + Context context = picasso.context; + boolean debugging = picasso.debugging; + PicassoDrawable.setBitmap(target, context, result, from, noFade, debugging); + + if (callback != null) { + callback.onSuccess(); + } + } + + @Override public void error() { + ImageView target = this.target.get(); + if (target == null) { + return; + } + if (errorResId != 0) { + target.setImageResource(errorResId); + } else if (errorDrawable != null) { + target.setImageDrawable(errorDrawable); + } + + if (callback != null) { + callback.onError(); + } + } + + @Override void cancel() { + super.cancel(); + if (callback != null) { + callback = null; + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/LruCache.java b/mobile/android/thirdparty/com/squareup/picasso/LruCache.java new file mode 100644 index 000000000..5d5f07fcb --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/LruCache.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.graphics.Bitmap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** A memory cache which uses a least-recently used eviction policy. */ +public class LruCache implements Cache { + final LinkedHashMap<String, Bitmap> map; + private final int maxSize; + + private int size; + private int putCount; + private int evictionCount; + private int hitCount; + private int missCount; + + /** Create a cache using an appropriate portion of the available RAM as the maximum size. */ + public LruCache(Context context) { + this(Utils.calculateMemoryCacheSize(context)); + } + + /** Create a cache with a given maximum size in bytes. */ + public LruCache(int maxSize) { + if (maxSize <= 0) { + throw new IllegalArgumentException("Max size must be positive."); + } + this.maxSize = maxSize; + this.map = new LinkedHashMap<String, Bitmap>(0, 0.75f, true); + } + + @Override public Bitmap get(String key) { + if (key == null) { + throw new NullPointerException("key == null"); + } + + Bitmap mapValue; + synchronized (this) { + mapValue = map.get(key); + if (mapValue != null) { + hitCount++; + return mapValue; + } + missCount++; + } + + return null; + } + + @Override public void set(String key, Bitmap bitmap) { + if (key == null || bitmap == null) { + throw new NullPointerException("key == null || bitmap == null"); + } + + Bitmap previous; + synchronized (this) { + putCount++; + size += Utils.getBitmapBytes(bitmap); + previous = map.put(key, bitmap); + if (previous != null) { + size -= Utils.getBitmapBytes(previous); + } + } + + trimToSize(maxSize); + } + + private void trimToSize(int maxSize) { + while (true) { + String key; + Bitmap value; + synchronized (this) { + if (size < 0 || (map.isEmpty() && size != 0)) { + throw new IllegalStateException( + getClass().getName() + ".sizeOf() is reporting inconsistent results!"); + } + + if (size <= maxSize || map.isEmpty()) { + break; + } + + Map.Entry<String, Bitmap> toEvict = map.entrySet().iterator().next(); + key = toEvict.getKey(); + value = toEvict.getValue(); + map.remove(key); + size -= Utils.getBitmapBytes(value); + evictionCount++; + } + } + } + + /** Clear the cache. */ + public final void evictAll() { + trimToSize(-1); // -1 will evict 0-sized elements + } + + /** Returns the sum of the sizes of the entries in this cache. */ + public final synchronized int size() { + return size; + } + + /** Returns the maximum sum of the sizes of the entries in this cache. */ + public final synchronized int maxSize() { + return maxSize; + } + + public final synchronized void clear() { + evictAll(); + } + + /** Returns the number of times {@link #get} returned a value. */ + public final synchronized int hitCount() { + return hitCount; + } + + /** Returns the number of times {@link #get} returned {@code null}. */ + public final synchronized int missCount() { + return missCount; + } + + /** Returns the number of times {@link #set(String, Bitmap)} was called. */ + public final synchronized int putCount() { + return putCount; + } + + /** Returns the number of values that have been evicted. */ + public final synchronized int evictionCount() { + return evictionCount; + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/MarkableInputStream.java b/mobile/android/thirdparty/com/squareup/picasso/MarkableInputStream.java new file mode 100644 index 000000000..17043a1b0 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/MarkableInputStream.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * An input stream wrapper that supports unlimited independent cursors for + * marking and resetting. Each cursor is a token, and it's the caller's + * responsibility to keep track of these. + */ +final class MarkableInputStream extends InputStream { + private final InputStream in; + + private long offset; + private long reset; + private long limit; + + private long defaultMark = -1; + + public MarkableInputStream(InputStream in) { + if (!in.markSupported()) { + in = new BufferedInputStream(in); + } + this.in = in; + } + + /** Marks this place in the stream so we can reset back to it later. */ + @Override public void mark(int readLimit) { + defaultMark = savePosition(readLimit); + } + + /** + * Returns an opaque token representing the current position in the stream. + * Call {@link #reset(long)} to return to this position in the stream later. + * It is an error to call {@link #reset(long)} after consuming more than + * {@code readLimit} bytes from this stream. + */ + public long savePosition(int readLimit) { + long offsetLimit = offset + readLimit; + if (limit < offsetLimit) { + setLimit(offsetLimit); + } + return offset; + } + + /** + * Makes sure that the underlying stream can backtrack the full range from + * {@code reset} thru {@code limit}. Since we can't call {@code mark()} + * without also adjusting the reset-to-position on the underlying stream this + * method resets first and then marks the union of the two byte ranges. On + * buffered streams this additional cursor motion shouldn't result in any + * additional I/O. + */ + private void setLimit(long limit) { + try { + if (reset < offset && offset <= this.limit) { + in.reset(); + in.mark((int) (limit - reset)); + skip(reset, offset); + } else { + reset = offset; + in.mark((int) (limit - offset)); + } + this.limit = limit; + } catch (IOException e) { + throw new IllegalStateException("Unable to mark: " + e); + } + } + + /** Resets the stream to the most recent {@link #mark mark}. */ + @Override public void reset() throws IOException { + reset(defaultMark); + } + + /** Resets the stream to the position recorded by {@code token}. */ + public void reset(long token) throws IOException { + if (offset > limit || token < reset) { + throw new IOException("Cannot reset"); + } + in.reset(); + skip(reset, token); + offset = token; + } + + /** Skips {@code target - current} bytes and returns. */ + private void skip(long current, long target) throws IOException { + while (current < target) { + long skipped = in.skip(target - current); + if (skipped == 0) { + if (read() == -1) { + break; // EOF + } else { + skipped = 1; + } + } + current += skipped; + } + } + + @Override public int read() throws IOException { + int result = in.read(); + if (result != -1) { + offset++; + } + return result; + } + + @Override public int read(byte[] buffer) throws IOException { + int count = in.read(buffer); + if (count != -1) { + offset += count; + } + return count; + } + + @Override public int read(byte[] buffer, int offset, int length) throws IOException { + int count = in.read(buffer, offset, length); + if (count != -1) { + this.offset += count; + } + return count; + } + + @Override public long skip(long byteCount) throws IOException { + long skipped = in.skip(byteCount); + offset += skipped; + return skipped; + } + + @Override public int available() throws IOException { + return in.available(); + } + + @Override public void close() throws IOException { + in.close(); + } + + @Override public boolean markSupported() { + return in.markSupported(); + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/MediaStoreBitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/MediaStoreBitmapHunter.java new file mode 100644 index 000000000..4f8ae1c24 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/MediaStoreBitmapHunter.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.provider.MediaStore; +import java.io.IOException; + +import static android.content.ContentUris.parseId; +import static android.provider.MediaStore.Images.Thumbnails.FULL_SCREEN_KIND; +import static android.provider.MediaStore.Images.Thumbnails.MICRO_KIND; +import static android.provider.MediaStore.Images.Thumbnails.MINI_KIND; +import static android.provider.MediaStore.Images.Thumbnails.getThumbnail; +import static com.squareup.picasso.MediaStoreBitmapHunter.PicassoKind.FULL; +import static com.squareup.picasso.MediaStoreBitmapHunter.PicassoKind.MICRO; +import static com.squareup.picasso.MediaStoreBitmapHunter.PicassoKind.MINI; + +class MediaStoreBitmapHunter extends ContentStreamBitmapHunter { + private static final String[] CONTENT_ORIENTATION = new String[] { + MediaStore.Images.ImageColumns.ORIENTATION + }; + + MediaStoreBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache, + Stats stats, Action action) { + super(context, picasso, dispatcher, cache, stats, action); + } + + @Override Bitmap decode(Request data) throws IOException { + ContentResolver contentResolver = context.getContentResolver(); + setExifRotation(getExitOrientation(contentResolver, data.uri)); + + if (data.hasSize()) { + PicassoKind picassoKind = getPicassoKind(data.targetWidth, data.targetHeight); + if (picassoKind == FULL) { + return super.decode(data); + } + + long id = parseId(data.uri); + + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + calculateInSampleSize(data.targetWidth, data.targetHeight, picassoKind.width, + picassoKind.height, options); + + Bitmap result = getThumbnail(contentResolver, id, picassoKind.androidKind, options); + + if (result != null) { + return result; + } + } + + return super.decode(data); + } + + static PicassoKind getPicassoKind(int targetWidth, int targetHeight) { + if (targetWidth <= MICRO.width && targetHeight <= MICRO.height) { + return MICRO; + } else if (targetWidth <= MINI.width && targetHeight <= MINI.height) { + return MINI; + } + return FULL; + } + + static int getExitOrientation(ContentResolver contentResolver, Uri uri) { + Cursor cursor = null; + try { + cursor = contentResolver.query(uri, CONTENT_ORIENTATION, null, null, null); + if (cursor == null || !cursor.moveToFirst()) { + return 0; + } + return cursor.getInt(0); + } catch (RuntimeException ignored) { + // If the orientation column doesn't exist, assume no rotation. + return 0; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + enum PicassoKind { + MICRO(MICRO_KIND, 96, 96), + MINI(MINI_KIND, 512, 384), + FULL(FULL_SCREEN_KIND, -1, -1); + + final int androidKind; + final int width; + final int height; + + PicassoKind(int androidKind, int width, int height) { + this.androidKind = androidKind; + this.width = width; + this.height = height; + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/NetworkBitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/NetworkBitmapHunter.java new file mode 100644 index 000000000..6d148211d --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/NetworkBitmapHunter.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.NetworkInfo; +import java.io.IOException; +import java.io.InputStream; + +import static com.squareup.picasso.Downloader.Response; +import static com.squareup.picasso.Picasso.LoadedFrom.DISK; +import static com.squareup.picasso.Picasso.LoadedFrom.NETWORK; + +class NetworkBitmapHunter extends BitmapHunter { + static final int DEFAULT_RETRY_COUNT = 2; + private static final int MARKER = 65536; + + private final Downloader downloader; + + int retryCount; + + public NetworkBitmapHunter(Picasso picasso, Dispatcher dispatcher, Cache cache, Stats stats, + Action action, Downloader downloader) { + super(picasso, dispatcher, cache, stats, action); + this.downloader = downloader; + this.retryCount = DEFAULT_RETRY_COUNT; + } + + @Override Bitmap decode(Request data) throws IOException { + boolean loadFromLocalCacheOnly = retryCount == 0; + + Response response = downloader.load(data.uri, loadFromLocalCacheOnly); + if (response == null) { + return null; + } + + loadedFrom = response.cached ? DISK : NETWORK; + + Bitmap result = response.getBitmap(); + if (result != null) { + return result; + } + + InputStream is = response.getInputStream(); + try { + return decodeStream(is, data); + } finally { + Utils.closeQuietly(is); + } + } + + @Override boolean shouldRetry(boolean airplaneMode, NetworkInfo info) { + boolean hasRetries = retryCount > 0; + if (!hasRetries) { + return false; + } + retryCount--; + return info == null || info.isConnectedOrConnecting(); + } + + private Bitmap decodeStream(InputStream stream, Request data) throws IOException { + if (stream == null) { + return null; + } + MarkableInputStream markStream = new MarkableInputStream(stream); + stream = markStream; + + long mark = markStream.savePosition(MARKER); + + boolean isWebPFile = Utils.isWebPFile(stream); + markStream.reset(mark); + // When decode WebP network stream, BitmapFactory throw JNI Exception and make app crash. + // Decode byte array instead + if (isWebPFile) { + byte[] bytes = Utils.toByteArray(stream); + BitmapFactory.Options options = null; + if (data.hasSize()) { + options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + calculateInSampleSize(data.targetWidth, data.targetHeight, options); + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + } else { + BitmapFactory.Options options = null; + if (data.hasSize()) { + options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + BitmapFactory.decodeStream(stream, null, options); + calculateInSampleSize(data.targetWidth, data.targetHeight, options); + + markStream.reset(mark); + } + return BitmapFactory.decodeStream(stream, null, options); + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Picasso.java b/mobile/android/thirdparty/com/squareup/picasso/Picasso.java new file mode 100644 index 000000000..9b510f977 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Picasso.java @@ -0,0 +1,522 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.Process; +import android.widget.ImageView; +import java.io.File; +import java.lang.ref.ReferenceQueue; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.ExecutorService; + +import static android.os.Process.THREAD_PRIORITY_BACKGROUND; +import static com.squareup.picasso.Action.RequestWeakReference; +import static com.squareup.picasso.Dispatcher.HUNTER_BATCH_COMPLETE; +import static com.squareup.picasso.Dispatcher.REQUEST_GCED; +import static com.squareup.picasso.Utils.THREAD_PREFIX; + +/** + * Image downloading, transformation, and caching manager. + * <p/> + * Use {@link #with(android.content.Context)} for the global singleton instance or construct your + * own instance with {@link Builder}. + */ +public class Picasso { + + /** Callbacks for Picasso events. */ + public interface Listener { + /** + * Invoked when an image has failed to load. This is useful for reporting image failures to a + * remote analytics service, for example. + */ + void onImageLoadFailed(Picasso picasso, Uri uri, Exception exception); + } + + /** + * A transformer that is called immediately before every request is submitted. This can be used to + * modify any information about a request. + * <p> + * For example, if you use a CDN you can change the hostname for the image based on the current + * location of the user in order to get faster download speeds. + * <p> + * <b>NOTE:</b> This is a beta feature. The API is subject to change in a backwards incompatible + * way at any time. + */ + public interface RequestTransformer { + /** + * Transform a request before it is submitted to be processed. + * + * @return The original request or a new request to replace it. Must not be null. + */ + Request transformRequest(Request request); + + /** A {@link RequestTransformer} which returns the original request. */ + RequestTransformer IDENTITY = new RequestTransformer() { + @Override public Request transformRequest(Request request) { + return request; + } + }; + } + + static final Handler HANDLER = new Handler(Looper.getMainLooper()) { + @Override public void handleMessage(Message msg) { + switch (msg.what) { + case HUNTER_BATCH_COMPLETE: { + @SuppressWarnings("unchecked") List<BitmapHunter> batch = (List<BitmapHunter>) msg.obj; + for (BitmapHunter hunter : batch) { + hunter.picasso.complete(hunter); + } + break; + } + case REQUEST_GCED: { + Action action = (Action) msg.obj; + action.picasso.cancelExistingRequest(action.getTarget()); + break; + } + default: + throw new AssertionError("Unknown handler message received: " + msg.what); + } + } + }; + + static Picasso singleton = null; + + private final Listener listener; + private final RequestTransformer requestTransformer; + private final CleanupThread cleanupThread; + + final Context context; + final Dispatcher dispatcher; + final Cache cache; + final Stats stats; + final Map<Object, Action> targetToAction; + final Map<ImageView, DeferredRequestCreator> targetToDeferredRequestCreator; + final ReferenceQueue<Object> referenceQueue; + + boolean debugging; + boolean shutdown; + + Picasso(Context context, Dispatcher dispatcher, Cache cache, Listener listener, + RequestTransformer requestTransformer, Stats stats, boolean debugging) { + this.context = context; + this.dispatcher = dispatcher; + this.cache = cache; + this.listener = listener; + this.requestTransformer = requestTransformer; + this.stats = stats; + this.targetToAction = new WeakHashMap<Object, Action>(); + this.targetToDeferredRequestCreator = new WeakHashMap<ImageView, DeferredRequestCreator>(); + this.debugging = debugging; + this.referenceQueue = new ReferenceQueue<Object>(); + this.cleanupThread = new CleanupThread(referenceQueue, HANDLER); + this.cleanupThread.start(); + } + + /** Cancel any existing requests for the specified target {@link ImageView}. */ + public void cancelRequest(ImageView view) { + cancelExistingRequest(view); + } + + /** Cancel any existing requests for the specified {@link Target} instance. */ + public void cancelRequest(Target target) { + cancelExistingRequest(target); + } + + /** + * Start an image request using the specified URI. + * <p> + * Passing {@code null} as a {@code uri} will not trigger any request but will set a placeholder, + * if one is specified. + * + * @see #load(File) + * @see #load(String) + * @see #load(int) + */ + public RequestCreator load(Uri uri) { + return new RequestCreator(this, uri, 0); + } + + /** + * Start an image request using the specified path. This is a convenience method for calling + * {@link #load(Uri)}. + * <p> + * This path may be a remote URL, file resource (prefixed with {@code file:}), content resource + * (prefixed with {@code content:}), or android resource (prefixed with {@code + * android.resource:}. + * <p> + * Passing {@code null} as a {@code path} will not trigger any request but will set a + * placeholder, if one is specified. + * + * @see #load(Uri) + * @see #load(File) + * @see #load(int) + */ + public RequestCreator load(String path) { + if (path == null) { + return new RequestCreator(this, null, 0); + } + if (path.trim().length() == 0) { + throw new IllegalArgumentException("Path must not be empty."); + } + return load(Uri.parse(path)); + } + + /** + * Start an image request using the specified image file. This is a convenience method for + * calling {@link #load(Uri)}. + * <p> + * Passing {@code null} as a {@code file} will not trigger any request but will set a + * placeholder, if one is specified. + * + * @see #load(Uri) + * @see #load(String) + * @see #load(int) + */ + public RequestCreator load(File file) { + if (file == null) { + return new RequestCreator(this, null, 0); + } + return load(Uri.fromFile(file)); + } + + /** + * Start an image request using the specified drawable resource ID. + * + * @see #load(Uri) + * @see #load(String) + * @see #load(File) + */ + public RequestCreator load(int resourceId) { + if (resourceId == 0) { + throw new IllegalArgumentException("Resource ID must not be zero."); + } + return new RequestCreator(this, null, resourceId); + } + + /** {@code true} if debug display, logging, and statistics are enabled. */ + @SuppressWarnings("UnusedDeclaration") public boolean isDebugging() { + return debugging; + } + + /** Toggle whether debug display, logging, and statistics are enabled. */ + @SuppressWarnings("UnusedDeclaration") public void setDebugging(boolean debugging) { + this.debugging = debugging; + } + + /** Creates a {@link StatsSnapshot} of the current stats for this instance. */ + @SuppressWarnings("UnusedDeclaration") public StatsSnapshot getSnapshot() { + return stats.createSnapshot(); + } + + /** Stops this instance from accepting further requests. */ + public void shutdown() { + if (this == singleton) { + throw new UnsupportedOperationException("Default singleton instance cannot be shutdown."); + } + if (shutdown) { + return; + } + cache.clear(); + cleanupThread.shutdown(); + stats.shutdown(); + dispatcher.shutdown(); + for (DeferredRequestCreator deferredRequestCreator : targetToDeferredRequestCreator.values()) { + deferredRequestCreator.cancel(); + } + targetToDeferredRequestCreator.clear(); + shutdown = true; + } + + Request transformRequest(Request request) { + Request transformed = requestTransformer.transformRequest(request); + if (transformed == null) { + throw new IllegalStateException("Request transformer " + + requestTransformer.getClass().getCanonicalName() + + " returned null for " + + request); + } + return transformed; + } + + void defer(ImageView view, DeferredRequestCreator request) { + targetToDeferredRequestCreator.put(view, request); + } + + void enqueueAndSubmit(Action action) { + Object target = action.getTarget(); + if (target != null) { + cancelExistingRequest(target); + targetToAction.put(target, action); + } + submit(action); + } + + void submit(Action action) { + dispatcher.dispatchSubmit(action); + } + + Bitmap quickMemoryCacheCheck(String key) { + Bitmap cached = cache.get(key); + if (cached != null) { + stats.dispatchCacheHit(); + } else { + stats.dispatchCacheMiss(); + } + return cached; + } + + void complete(BitmapHunter hunter) { + List<Action> joined = hunter.getActions(); + if (joined.isEmpty()) { + return; + } + + Uri uri = hunter.getData().uri; + Exception exception = hunter.getException(); + Bitmap result = hunter.getResult(); + LoadedFrom from = hunter.getLoadedFrom(); + + for (Action join : joined) { + if (join.isCancelled()) { + continue; + } + targetToAction.remove(join.getTarget()); + if (result != null) { + if (from == null) { + throw new AssertionError("LoadedFrom cannot be null."); + } + join.complete(result, from); + } else { + join.error(); + } + } + + if (listener != null && exception != null) { + listener.onImageLoadFailed(this, uri, exception); + } + } + + private void cancelExistingRequest(Object target) { + Action action = targetToAction.remove(target); + if (action != null) { + action.cancel(); + dispatcher.dispatchCancel(action); + } + if (target instanceof ImageView) { + ImageView targetImageView = (ImageView) target; + DeferredRequestCreator deferredRequestCreator = + targetToDeferredRequestCreator.remove(targetImageView); + if (deferredRequestCreator != null) { + deferredRequestCreator.cancel(); + } + } + } + + private static class CleanupThread extends Thread { + private final ReferenceQueue<?> referenceQueue; + private final Handler handler; + + CleanupThread(ReferenceQueue<?> referenceQueue, Handler handler) { + this.referenceQueue = referenceQueue; + this.handler = handler; + setDaemon(true); + setName(THREAD_PREFIX + "refQueue"); + } + + @Override public void run() { + Process.setThreadPriority(THREAD_PRIORITY_BACKGROUND); + while (true) { + try { + RequestWeakReference<?> remove = (RequestWeakReference<?>) referenceQueue.remove(); + handler.sendMessage(handler.obtainMessage(REQUEST_GCED, remove.action)); + } catch (InterruptedException e) { + break; + } catch (final Exception e) { + handler.post(new Runnable() { + @Override public void run() { + throw new RuntimeException(e); + } + }); + break; + } + } + } + + void shutdown() { + interrupt(); + } + } + + /** + * The global default {@link Picasso} instance. + * <p> + * This instance is automatically initialized with defaults that are suitable to most + * implementations. + * <ul> + * <li>LRU memory cache of 15% the available application RAM</li> + * <li>Disk cache of 2% storage space up to 50MB but no less than 5MB. (Note: this is only + * available on API 14+ <em>or</em> if you are using a standalone library that provides a disk + * cache on all API levels like OkHttp)</li> + * <li>Three download threads for disk and network access.</li> + * </ul> + * <p> + * If these settings do not meet the requirements of your application you can construct your own + * instance with full control over the configuration by using {@link Picasso.Builder}. + */ + public static Picasso with(Context context) { + if (singleton == null) { + singleton = new Builder(context).build(); + } + return singleton; + } + + /** Fluent API for creating {@link Picasso} instances. */ + @SuppressWarnings("UnusedDeclaration") // Public API. + public static class Builder { + private final Context context; + private Downloader downloader; + private ExecutorService service; + private Cache cache; + private Listener listener; + private RequestTransformer transformer; + private boolean debugging; + + /** Start building a new {@link Picasso} instance. */ + public Builder(Context context) { + if (context == null) { + throw new IllegalArgumentException("Context must not be null."); + } + this.context = context.getApplicationContext(); + } + + /** Specify the {@link Downloader} that will be used for downloading images. */ + public Builder downloader(Downloader downloader) { + if (downloader == null) { + throw new IllegalArgumentException("Downloader must not be null."); + } + if (this.downloader != null) { + throw new IllegalStateException("Downloader already set."); + } + this.downloader = downloader; + return this; + } + + /** Specify the executor service for loading images in the background. */ + public Builder executor(ExecutorService executorService) { + if (executorService == null) { + throw new IllegalArgumentException("Executor service must not be null."); + } + if (this.service != null) { + throw new IllegalStateException("Executor service already set."); + } + this.service = executorService; + return this; + } + + /** Specify the memory cache used for the most recent images. */ + public Builder memoryCache(Cache memoryCache) { + if (memoryCache == null) { + throw new IllegalArgumentException("Memory cache must not be null."); + } + if (this.cache != null) { + throw new IllegalStateException("Memory cache already set."); + } + this.cache = memoryCache; + return this; + } + + /** Specify a listener for interesting events. */ + public Builder listener(Listener listener) { + if (listener == null) { + throw new IllegalArgumentException("Listener must not be null."); + } + if (this.listener != null) { + throw new IllegalStateException("Listener already set."); + } + this.listener = listener; + return this; + } + + /** + * Specify a transformer for all incoming requests. + * <p> + * <b>NOTE:</b> This is a beta feature. The API is subject to change in a backwards incompatible + * way at any time. + */ + public Builder requestTransformer(RequestTransformer transformer) { + if (transformer == null) { + throw new IllegalArgumentException("Transformer must not be null."); + } + if (this.transformer != null) { + throw new IllegalStateException("Transformer already set."); + } + this.transformer = transformer; + return this; + } + + /** Whether debugging is enabled or not. */ + public Builder debugging(boolean debugging) { + this.debugging = debugging; + return this; + } + + /** Create the {@link Picasso} instance. */ + public Picasso build() { + Context context = this.context; + + if (downloader == null) { + downloader = Utils.createDefaultDownloader(context); + } + if (cache == null) { + cache = new LruCache(context); + } + if (service == null) { + service = new PicassoExecutorService(); + } + if (transformer == null) { + transformer = RequestTransformer.IDENTITY; + } + + Stats stats = new Stats(cache); + + Dispatcher dispatcher = new Dispatcher(context, service, HANDLER, downloader, cache, stats); + + return new Picasso(context, dispatcher, cache, listener, transformer, stats, debugging); + } + } + + /** Describes where the image was loaded from. */ + public enum LoadedFrom { + MEMORY(Color.GREEN), + DISK(Color.YELLOW), + NETWORK(Color.RED); + + final int debugColor; + + private LoadedFrom(int debugColor) { + this.debugColor = debugColor; + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/PicassoDrawable.java b/mobile/android/thirdparty/com/squareup/picasso/PicassoDrawable.java new file mode 100644 index 000000000..07f762c31 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/PicassoDrawable.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.AnimationDrawable; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.SystemClock; +import android.widget.ImageView; + +import static android.graphics.Color.WHITE; +import static com.squareup.picasso.Picasso.LoadedFrom.MEMORY; + +final class PicassoDrawable extends Drawable { + // Only accessed from main thread. + private static final Paint DEBUG_PAINT = new Paint(); + + private static final float FADE_DURATION = 200f; //ms + + /** + * Create or update the drawable on the target {@link ImageView} to display the supplied bitmap + * image. + */ + static void setBitmap(ImageView target, Context context, Bitmap bitmap, + Picasso.LoadedFrom loadedFrom, boolean noFade, boolean debugging) { + Drawable placeholder = target.getDrawable(); + if (placeholder instanceof AnimationDrawable) { + ((AnimationDrawable) placeholder).stop(); + } + PicassoDrawable drawable = + new PicassoDrawable(context, placeholder, bitmap, loadedFrom, noFade, debugging); + target.setImageDrawable(drawable); + } + + /** + * Create or update the drawable on the target {@link ImageView} to display the supplied + * placeholder image. + */ + static void setPlaceholder(ImageView target, int placeholderResId, Drawable placeholderDrawable) { + if (placeholderResId != 0) { + target.setImageResource(placeholderResId); + } else { + target.setImageDrawable(placeholderDrawable); + } + if (target.getDrawable() instanceof AnimationDrawable) { + ((AnimationDrawable) target.getDrawable()).start(); + } + } + + private final boolean debugging; + private final float density; + private final Picasso.LoadedFrom loadedFrom; + final BitmapDrawable image; + + Drawable placeholder; + + long startTimeMillis; + boolean animating; + int alpha = 0xFF; + + PicassoDrawable(Context context, Drawable placeholder, Bitmap bitmap, + Picasso.LoadedFrom loadedFrom, boolean noFade, boolean debugging) { + Resources res = context.getResources(); + + this.debugging = debugging; + this.density = res.getDisplayMetrics().density; + + this.loadedFrom = loadedFrom; + + this.image = new BitmapDrawable(res, bitmap); + + boolean fade = loadedFrom != MEMORY && !noFade; + if (fade) { + this.placeholder = placeholder; + animating = true; + startTimeMillis = SystemClock.uptimeMillis(); + } + } + + @Override public void draw(Canvas canvas) { + if (!animating) { + image.draw(canvas); + } else { + float normalized = (SystemClock.uptimeMillis() - startTimeMillis) / FADE_DURATION; + if (normalized >= 1f) { + animating = false; + placeholder = null; + image.draw(canvas); + } else { + if (placeholder != null) { + placeholder.draw(canvas); + } + + int partialAlpha = (int) (alpha * normalized); + image.setAlpha(partialAlpha); + image.draw(canvas); + image.setAlpha(alpha); + invalidateSelf(); + } + } + + if (debugging) { + drawDebugIndicator(canvas); + } + } + + @Override public int getIntrinsicWidth() { + return image.getIntrinsicWidth(); + } + + @Override public int getIntrinsicHeight() { + return image.getIntrinsicHeight(); + } + + @Override public void setAlpha(int alpha) { + this.alpha = alpha; + if (placeholder != null) { + placeholder.setAlpha(alpha); + } + image.setAlpha(alpha); + } + + @Override public void setColorFilter(ColorFilter cf) { + if (placeholder != null) { + placeholder.setColorFilter(cf); + } + image.setColorFilter(cf); + } + + @Override public int getOpacity() { + return image.getOpacity(); + } + + @Override protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + + image.setBounds(bounds); + if (placeholder != null) { + placeholder.setBounds(bounds); + } + } + + private void drawDebugIndicator(Canvas canvas) { + DEBUG_PAINT.setColor(WHITE); + Path path = getTrianglePath(new Point(0, 0), (int) (16 * density)); + canvas.drawPath(path, DEBUG_PAINT); + + DEBUG_PAINT.setColor(loadedFrom.debugColor); + path = getTrianglePath(new Point(0, 0), (int) (15 * density)); + canvas.drawPath(path, DEBUG_PAINT); + } + + private static Path getTrianglePath(Point p1, int width) { + Point p2 = new Point(p1.x + width, p1.y); + Point p3 = new Point(p1.x, p1.y + width); + + Path path = new Path(); + path.moveTo(p1.x, p1.y); + path.lineTo(p2.x, p2.y); + path.lineTo(p3.x, p3.y); + + return path; + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/PicassoExecutorService.java b/mobile/android/thirdparty/com/squareup/picasso/PicassoExecutorService.java new file mode 100644 index 000000000..875dd2dda --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/PicassoExecutorService.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.telephony.TelephonyManager; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * The default {@link java.util.concurrent.ExecutorService} used for new {@link Picasso} instances. + * <p/> + * Exists as a custom type so that we can differentiate the use of defaults versus a user-supplied + * instance. + */ +class PicassoExecutorService extends ThreadPoolExecutor { + private static final int DEFAULT_THREAD_COUNT = 3; + + PicassoExecutorService() { + super(DEFAULT_THREAD_COUNT, DEFAULT_THREAD_COUNT, 0, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<Runnable>(), new Utils.PicassoThreadFactory()); + } + + void adjustThreadCount(NetworkInfo info) { + if (info == null || !info.isConnectedOrConnecting()) { + setThreadCount(DEFAULT_THREAD_COUNT); + return; + } + switch (info.getType()) { + case ConnectivityManager.TYPE_WIFI: + case ConnectivityManager.TYPE_WIMAX: + case ConnectivityManager.TYPE_ETHERNET: + setThreadCount(4); + break; + case ConnectivityManager.TYPE_MOBILE: + switch (info.getSubtype()) { + case TelephonyManager.NETWORK_TYPE_LTE: // 4G + case TelephonyManager.NETWORK_TYPE_HSPAP: + case TelephonyManager.NETWORK_TYPE_EHRPD: + setThreadCount(3); + break; + case TelephonyManager.NETWORK_TYPE_UMTS: // 3G + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_EVDO_0: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_EVDO_B: + setThreadCount(2); + break; + case TelephonyManager.NETWORK_TYPE_GPRS: // 2G + case TelephonyManager.NETWORK_TYPE_EDGE: + setThreadCount(1); + break; + default: + setThreadCount(DEFAULT_THREAD_COUNT); + } + break; + default: + setThreadCount(DEFAULT_THREAD_COUNT); + } + } + + private void setThreadCount(int threadCount) { + setCorePoolSize(threadCount); + setMaximumPoolSize(threadCount); + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Request.java b/mobile/android/thirdparty/com/squareup/picasso/Request.java new file mode 100644 index 000000000..8e9c32460 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Request.java @@ -0,0 +1,307 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.net.Uri; +import java.util.ArrayList; +import java.util.List; + +import static java.util.Collections.unmodifiableList; + +/** Immutable data about an image and the transformations that will be applied to it. */ +public final class Request { + /** + * The image URI. + * <p> + * This is mutually exclusive with {@link #resourceId}. + */ + public final Uri uri; + /** + * The image resource ID. + * <p> + * This is mutually exclusive with {@link #uri}. + */ + public final int resourceId; + /** List of custom transformations to be applied after the built-in transformations. */ + public final List<Transformation> transformations; + /** Target image width for resizing. */ + public final int targetWidth; + /** Target image height for resizing. */ + public final int targetHeight; + /** + * True if the final image should use the 'centerCrop' scale technique. + * <p> + * This is mutually exclusive with {@link #centerInside}. + */ + public final boolean centerCrop; + /** + * True if the final image should use the 'centerInside' scale technique. + * <p> + * This is mutually exclusive with {@link #centerCrop}. + */ + public final boolean centerInside; + /** Amount to rotate the image in degrees. */ + public final float rotationDegrees; + /** Rotation pivot on the X axis. */ + public final float rotationPivotX; + /** Rotation pivot on the Y axis. */ + public final float rotationPivotY; + /** Whether or not {@link #rotationPivotX} and {@link #rotationPivotY} are set. */ + public final boolean hasRotationPivot; + + private Request(Uri uri, int resourceId, List<Transformation> transformations, int targetWidth, + int targetHeight, boolean centerCrop, boolean centerInside, float rotationDegrees, + float rotationPivotX, float rotationPivotY, boolean hasRotationPivot) { + this.uri = uri; + this.resourceId = resourceId; + if (transformations == null) { + this.transformations = null; + } else { + this.transformations = unmodifiableList(transformations); + } + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + this.centerCrop = centerCrop; + this.centerInside = centerInside; + this.rotationDegrees = rotationDegrees; + this.rotationPivotX = rotationPivotX; + this.rotationPivotY = rotationPivotY; + this.hasRotationPivot = hasRotationPivot; + } + + String getName() { + if (uri != null) { + return uri.getPath(); + } + return Integer.toHexString(resourceId); + } + + public boolean hasSize() { + return targetWidth != 0; + } + + boolean needsTransformation() { + return needsMatrixTransform() || hasCustomTransformations(); + } + + boolean needsMatrixTransform() { + return targetWidth != 0 || rotationDegrees != 0; + } + + boolean hasCustomTransformations() { + return transformations != null; + } + + public Builder buildUpon() { + return new Builder(this); + } + + /** Builder for creating {@link Request} instances. */ + public static final class Builder { + private Uri uri; + private int resourceId; + private int targetWidth; + private int targetHeight; + private boolean centerCrop; + private boolean centerInside; + private float rotationDegrees; + private float rotationPivotX; + private float rotationPivotY; + private boolean hasRotationPivot; + private List<Transformation> transformations; + + /** Start building a request using the specified {@link Uri}. */ + public Builder(Uri uri) { + setUri(uri); + } + + /** Start building a request using the specified resource ID. */ + public Builder(int resourceId) { + setResourceId(resourceId); + } + + Builder(Uri uri, int resourceId) { + this.uri = uri; + this.resourceId = resourceId; + } + + private Builder(Request request) { + uri = request.uri; + resourceId = request.resourceId; + targetWidth = request.targetWidth; + targetHeight = request.targetHeight; + centerCrop = request.centerCrop; + centerInside = request.centerInside; + rotationDegrees = request.rotationDegrees; + rotationPivotX = request.rotationPivotX; + rotationPivotY = request.rotationPivotY; + hasRotationPivot = request.hasRotationPivot; + if (request.transformations != null) { + transformations = new ArrayList<Transformation>(request.transformations); + } + } + + boolean hasImage() { + return uri != null || resourceId != 0; + } + + boolean hasSize() { + return targetWidth != 0; + } + + /** + * Set the target image Uri. + * <p> + * This will clear an image resource ID if one is set. + */ + public Builder setUri(Uri uri) { + if (uri == null) { + throw new IllegalArgumentException("Image URI may not be null."); + } + this.uri = uri; + this.resourceId = 0; + return this; + } + + /** + * Set the target image resource ID. + * <p> + * This will clear an image Uri if one is set. + */ + public Builder setResourceId(int resourceId) { + if (resourceId == 0) { + throw new IllegalArgumentException("Image resource ID may not be 0."); + } + this.resourceId = resourceId; + this.uri = null; + return this; + } + + /** Resize the image to the specified size in pixels. */ + public Builder resize(int targetWidth, int targetHeight) { + if (targetWidth <= 0) { + throw new IllegalArgumentException("Width must be positive number."); + } + if (targetHeight <= 0) { + throw new IllegalArgumentException("Height must be positive number."); + } + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + return this; + } + + /** Clear the resize transformation, if any. This will also clear center crop/inside if set. */ + public Builder clearResize() { + targetWidth = 0; + targetHeight = 0; + centerCrop = false; + centerInside = false; + return this; + } + + /** + * Crops an image inside of the bounds specified by {@link #resize(int, int)} rather than + * distorting the aspect ratio. This cropping technique scales the image so that it fills the + * requested bounds and then crops the extra. + */ + public Builder centerCrop() { + if (centerInside) { + throw new IllegalStateException("Center crop can not be used after calling centerInside"); + } + centerCrop = true; + return this; + } + + /** Clear the center crop transformation flag, if set. */ + public Builder clearCenterCrop() { + centerCrop = false; + return this; + } + + /** + * Centers an image inside of the bounds specified by {@link #resize(int, int)}. This scales + * the image so that both dimensions are equal to or less than the requested bounds. + */ + public Builder centerInside() { + if (centerCrop) { + throw new IllegalStateException("Center inside can not be used after calling centerCrop"); + } + centerInside = true; + return this; + } + + /** Clear the center inside transformation flag, if set. */ + public Builder clearCenterInside() { + centerInside = false; + return this; + } + + /** Rotate the image by the specified degrees. */ + public Builder rotate(float degrees) { + rotationDegrees = degrees; + return this; + } + + /** Rotate the image by the specified degrees around a pivot point. */ + public Builder rotate(float degrees, float pivotX, float pivotY) { + rotationDegrees = degrees; + rotationPivotX = pivotX; + rotationPivotY = pivotY; + hasRotationPivot = true; + return this; + } + + /** Clear the rotation transformation, if any. */ + public Builder clearRotation() { + rotationDegrees = 0; + rotationPivotX = 0; + rotationPivotY = 0; + hasRotationPivot = false; + return this; + } + + /** + * Add a custom transformation to be applied to the image. + * <p/> + * Custom transformations will always be run after the built-in transformations. + */ + public Builder transform(Transformation transformation) { + if (transformation == null) { + throw new IllegalArgumentException("Transformation must not be null."); + } + if (transformations == null) { + transformations = new ArrayList<Transformation>(2); + } + transformations.add(transformation); + return this; + } + + /** Create the immutable {@link Request} object. */ + public Request build() { + if (centerInside && centerCrop) { + throw new IllegalStateException("Center crop and center inside can not be used together."); + } + if (centerCrop && targetWidth == 0) { + throw new IllegalStateException("Center crop requires calling resize."); + } + if (centerInside && targetWidth == 0) { + throw new IllegalStateException("Center inside requires calling resize."); + } + return new Request(uri, resourceId, transformations, targetWidth, targetHeight, centerCrop, + centerInside, rotationDegrees, rotationPivotX, rotationPivotY, hasRotationPivot); + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/RequestCreator.java b/mobile/android/thirdparty/com/squareup/picasso/RequestCreator.java new file mode 100644 index 000000000..3a5ca3a9f --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/RequestCreator.java @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.widget.ImageView; +import java.io.IOException; + +import static com.squareup.picasso.BitmapHunter.forRequest; +import static com.squareup.picasso.Picasso.LoadedFrom.MEMORY; +import static com.squareup.picasso.Utils.checkNotMain; +import static com.squareup.picasso.Utils.createKey; + +/** Fluent API for building an image download request. */ +@SuppressWarnings("UnusedDeclaration") // Public API. +public class RequestCreator { + private final Picasso picasso; + private final Request.Builder data; + + private boolean skipMemoryCache; + private boolean noFade; + private boolean deferred; + private int placeholderResId; + private Drawable placeholderDrawable; + private int errorResId; + private Drawable errorDrawable; + + RequestCreator(Picasso picasso, Uri uri, int resourceId) { + if (picasso.shutdown) { + throw new IllegalStateException( + "Picasso instance already shut down. Cannot submit new requests."); + } + this.picasso = picasso; + this.data = new Request.Builder(uri, resourceId); + } + + /** + * A placeholder drawable to be used while the image is being loaded. If the requested image is + * not immediately available in the memory cache then this resource will be set on the target + * {@link ImageView}. + */ + public RequestCreator placeholder(int placeholderResId) { + if (placeholderResId == 0) { + throw new IllegalArgumentException("Placeholder image resource invalid."); + } + if (placeholderDrawable != null) { + throw new IllegalStateException("Placeholder image already set."); + } + this.placeholderResId = placeholderResId; + return this; + } + + /** + * A placeholder drawable to be used while the image is being loaded. If the requested image is + * not immediately available in the memory cache then this resource will be set on the target + * {@link ImageView}. + * <p> + * If you are not using a placeholder image but want to clear an existing image (such as when + * used in an {@link android.widget.Adapter adapter}), pass in {@code null}. + */ + public RequestCreator placeholder(Drawable placeholderDrawable) { + if (placeholderResId != 0) { + throw new IllegalStateException("Placeholder image already set."); + } + this.placeholderDrawable = placeholderDrawable; + return this; + } + + /** An error drawable to be used if the request image could not be loaded. */ + public RequestCreator error(int errorResId) { + if (errorResId == 0) { + throw new IllegalArgumentException("Error image resource invalid."); + } + if (errorDrawable != null) { + throw new IllegalStateException("Error image already set."); + } + this.errorResId = errorResId; + return this; + } + + /** An error drawable to be used if the request image could not be loaded. */ + public RequestCreator error(Drawable errorDrawable) { + if (errorDrawable == null) { + throw new IllegalArgumentException("Error image may not be null."); + } + if (errorResId != 0) { + throw new IllegalStateException("Error image already set."); + } + this.errorDrawable = errorDrawable; + return this; + } + + /** + * Attempt to resize the image to fit exactly into the target {@link ImageView}'s bounds. This + * will result in delayed execution of the request until the {@link ImageView} has been measured. + * <p/> + * <em>Note:</em> This method works only when your target is an {@link ImageView}. + */ + public RequestCreator fit() { + deferred = true; + return this; + } + + /** Internal use only. Used by {@link DeferredRequestCreator}. */ + RequestCreator unfit() { + deferred = false; + return this; + } + + /** Resize the image to the specified dimension size. */ + public RequestCreator resizeDimen(int targetWidthResId, int targetHeightResId) { + Resources resources = picasso.context.getResources(); + int targetWidth = resources.getDimensionPixelSize(targetWidthResId); + int targetHeight = resources.getDimensionPixelSize(targetHeightResId); + return resize(targetWidth, targetHeight); + } + + /** Resize the image to the specified size in pixels. */ + public RequestCreator resize(int targetWidth, int targetHeight) { + data.resize(targetWidth, targetHeight); + return this; + } + + /** + * Crops an image inside of the bounds specified by {@link #resize(int, int)} rather than + * distorting the aspect ratio. This cropping technique scales the image so that it fills the + * requested bounds and then crops the extra. + */ + public RequestCreator centerCrop() { + data.centerCrop(); + return this; + } + + /** + * Centers an image inside of the bounds specified by {@link #resize(int, int)}. This scales + * the image so that both dimensions are equal to or less than the requested bounds. + */ + public RequestCreator centerInside() { + data.centerInside(); + return this; + } + + /** Rotate the image by the specified degrees. */ + public RequestCreator rotate(float degrees) { + data.rotate(degrees); + return this; + } + + /** Rotate the image by the specified degrees around a pivot point. */ + public RequestCreator rotate(float degrees, float pivotX, float pivotY) { + data.rotate(degrees, pivotX, pivotY); + return this; + } + + /** + * Add a custom transformation to be applied to the image. + * <p/> + * Custom transformations will always be run after the built-in transformations. + */ + // TODO show example of calling resize after a transform in the javadoc + public RequestCreator transform(Transformation transformation) { + data.transform(transformation); + return this; + } + + /** + * Indicate that this action should not use the memory cache for attempting to load or save the + * image. This can be useful when you know an image will only ever be used once (e.g., loading + * an image from the filesystem and uploading to a remote server). + */ + public RequestCreator skipMemoryCache() { + skipMemoryCache = true; + return this; + } + + /** Disable brief fade in of images loaded from the disk cache or network. */ + public RequestCreator noFade() { + noFade = true; + return this; + } + + /** Synchronously fulfill this request. Must not be called from the main thread. */ + public Bitmap get() throws IOException { + checkNotMain(); + if (deferred) { + throw new IllegalStateException("Fit cannot be used with get."); + } + if (!data.hasImage()) { + return null; + } + + Request finalData = picasso.transformRequest(data.build()); + String key = createKey(finalData); + + Action action = new GetAction(picasso, finalData, skipMemoryCache, key); + return forRequest(picasso.context, picasso, picasso.dispatcher, picasso.cache, picasso.stats, + action, picasso.dispatcher.downloader).hunt(); + } + + /** + * Asynchronously fulfills the request without a {@link ImageView} or {@link Target}. This is + * useful when you want to warm up the cache with an image. + */ + public void fetch() { + if (deferred) { + throw new IllegalStateException("Fit cannot be used with fetch."); + } + if (data.hasImage()) { + Request finalData = picasso.transformRequest(data.build()); + String key = createKey(finalData); + + Action action = new FetchAction(picasso, finalData, skipMemoryCache, key); + picasso.enqueueAndSubmit(action); + } + } + + /** + * Asynchronously fulfills the request into the specified {@link Target}. In most cases, you + * should use this when you are dealing with a custom {@link android.view.View View} or view + * holder which should implement the {@link Target} interface. + * <p> + * Implementing on a {@link android.view.View View}: + * <blockquote><pre> + * public class ProfileView extends FrameLayout implements Target { + * {@literal @}Override public void onBitmapLoaded(Bitmap bitmap, LoadedFrom from) { + * setBackgroundDrawable(new BitmapDrawable(bitmap)); + * } + * + * {@literal @}Override public void onBitmapFailed() { + * setBackgroundResource(R.drawable.profile_error); + * } + * } + * </pre></blockquote> + * Implementing on a view holder object for use inside of an adapter: + * <blockquote><pre> + * public class ViewHolder implements Target { + * public FrameLayout frame; + * public TextView name; + * + * {@literal @}Override public void onBitmapLoaded(Bitmap bitmap, LoadedFrom from) { + * frame.setBackgroundDrawable(new BitmapDrawable(bitmap)); + * } + * + * {@literal @}Override public void onBitmapFailed() { + * frame.setBackgroundResource(R.drawable.profile_error); + * } + * } + * </pre></blockquote> + * <p> + * <em>Note:</em> This method keeps a weak reference to the {@link Target} instance and will be + * garbage collected if you do not keep a strong reference to it. To receive callbacks when an + * image is loaded use {@link #into(android.widget.ImageView, Callback)}. + */ + public void into(Target target) { + if (target == null) { + throw new IllegalArgumentException("Target must not be null."); + } + if (deferred) { + throw new IllegalStateException("Fit cannot be used with a Target."); + } + + Drawable drawable = + placeholderResId != 0 ? picasso.context.getResources().getDrawable(placeholderResId) + : placeholderDrawable; + + if (!data.hasImage()) { + picasso.cancelRequest(target); + target.onPrepareLoad(drawable); + return; + } + + Request finalData = picasso.transformRequest(data.build()); + String requestKey = createKey(finalData); + + if (!skipMemoryCache) { + Bitmap bitmap = picasso.quickMemoryCacheCheck(requestKey); + if (bitmap != null) { + picasso.cancelRequest(target); + target.onBitmapLoaded(bitmap, MEMORY); + return; + } + } + + target.onPrepareLoad(drawable); + + Action action = new TargetAction(picasso, target, finalData, skipMemoryCache, requestKey); + picasso.enqueueAndSubmit(action); + } + + /** + * Asynchronously fulfills the request into the specified {@link ImageView}. + * <p/> + * <em>Note:</em> This method keeps a weak reference to the {@link ImageView} instance and will + * automatically support object recycling. + */ + public void into(ImageView target) { + into(target, null); + } + + /** + * Asynchronously fulfills the request into the specified {@link ImageView} and invokes the + * target {@link Callback} if it's not {@code null}. + * <p/> + * <em>Note:</em> The {@link Callback} param is a strong reference and will prevent your + * {@link android.app.Activity} or {@link android.app.Fragment} from being garbage collected. If + * you use this method, it is <b>strongly</b> recommended you invoke an adjacent + * {@link Picasso#cancelRequest(android.widget.ImageView)} call to prevent temporary leaking. + */ + public void into(ImageView target, Callback callback) { + if (target == null) { + throw new IllegalArgumentException("Target must not be null."); + } + + if (!data.hasImage()) { + picasso.cancelRequest(target); + PicassoDrawable.setPlaceholder(target, placeholderResId, placeholderDrawable); + return; + } + + if (deferred) { + if (data.hasSize()) { + throw new IllegalStateException("Fit cannot be used with resize."); + } + int measuredWidth = target.getMeasuredWidth(); + int measuredHeight = target.getMeasuredHeight(); + if (measuredWidth == 0 || measuredHeight == 0) { + PicassoDrawable.setPlaceholder(target, placeholderResId, placeholderDrawable); + picasso.defer(target, new DeferredRequestCreator(this, target, callback)); + return; + } + data.resize(measuredWidth, measuredHeight); + } + + Request finalData = picasso.transformRequest(data.build()); + String requestKey = createKey(finalData); + + if (!skipMemoryCache) { + Bitmap bitmap = picasso.quickMemoryCacheCheck(requestKey); + if (bitmap != null) { + picasso.cancelRequest(target); + PicassoDrawable.setBitmap(target, picasso.context, bitmap, MEMORY, noFade, + picasso.debugging); + if (callback != null) { + callback.onSuccess(); + } + return; + } + } + + PicassoDrawable.setPlaceholder(target, placeholderResId, placeholderDrawable); + + Action action = + new ImageViewAction(picasso, target, finalData, skipMemoryCache, noFade, errorResId, + errorDrawable, requestKey, callback); + + picasso.enqueueAndSubmit(action); + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/ResourceBitmapHunter.java b/mobile/android/thirdparty/com/squareup/picasso/ResourceBitmapHunter.java new file mode 100644 index 000000000..fee76b200 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/ResourceBitmapHunter.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import java.io.IOException; + +import static com.squareup.picasso.Picasso.LoadedFrom.DISK; + +class ResourceBitmapHunter extends BitmapHunter { + private final Context context; + + ResourceBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache, + Stats stats, Action action) { + super(picasso, dispatcher, cache, stats, action); + this.context = context; + } + + @Override Bitmap decode(Request data) throws IOException { + Resources res = Utils.getResources(context, data); + int id = Utils.getResourceId(res, data); + return decodeResource(res, id, data); + } + + @Override Picasso.LoadedFrom getLoadedFrom() { + return DISK; + } + + private Bitmap decodeResource(Resources resources, int id, Request data) { + BitmapFactory.Options bitmapOptions = null; + if (data.hasSize()) { + bitmapOptions = new BitmapFactory.Options(); + bitmapOptions.inJustDecodeBounds = true; + BitmapFactory.decodeResource(resources, id, bitmapOptions); + calculateInSampleSize(data.targetWidth, data.targetHeight, bitmapOptions); + } + return BitmapFactory.decodeResource(resources, id, bitmapOptions); + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Stats.java b/mobile/android/thirdparty/com/squareup/picasso/Stats.java new file mode 100644 index 000000000..3eaac0249 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Stats.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; + +import static android.os.Process.THREAD_PRIORITY_BACKGROUND; + +class Stats { + private static final int CACHE_HIT = 0; + private static final int CACHE_MISS = 1; + private static final int BITMAP_DECODE_FINISHED = 2; + private static final int BITMAP_TRANSFORMED_FINISHED = 3; + + private static final String STATS_THREAD_NAME = Utils.THREAD_PREFIX + "Stats"; + + final HandlerThread statsThread; + final Cache cache; + final Handler handler; + + long cacheHits; + long cacheMisses; + long totalOriginalBitmapSize; + long totalTransformedBitmapSize; + long averageOriginalBitmapSize; + long averageTransformedBitmapSize; + int originalBitmapCount; + int transformedBitmapCount; + + Stats(Cache cache) { + this.cache = cache; + this.statsThread = new HandlerThread(STATS_THREAD_NAME, THREAD_PRIORITY_BACKGROUND); + this.statsThread.start(); + this.handler = new StatsHandler(statsThread.getLooper(), this); + } + + void dispatchBitmapDecoded(Bitmap bitmap) { + processBitmap(bitmap, BITMAP_DECODE_FINISHED); + } + + void dispatchBitmapTransformed(Bitmap bitmap) { + processBitmap(bitmap, BITMAP_TRANSFORMED_FINISHED); + } + + void dispatchCacheHit() { + handler.sendEmptyMessage(CACHE_HIT); + } + + void dispatchCacheMiss() { + handler.sendEmptyMessage(CACHE_MISS); + } + + void shutdown() { + statsThread.quit(); + } + + void performCacheHit() { + cacheHits++; + } + + void performCacheMiss() { + cacheMisses++; + } + + void performBitmapDecoded(long size) { + originalBitmapCount++; + totalOriginalBitmapSize += size; + averageOriginalBitmapSize = getAverage(originalBitmapCount, totalOriginalBitmapSize); + } + + void performBitmapTransformed(long size) { + transformedBitmapCount++; + totalTransformedBitmapSize += size; + averageTransformedBitmapSize = getAverage(originalBitmapCount, totalTransformedBitmapSize); + } + + synchronized StatsSnapshot createSnapshot() { + return new StatsSnapshot(cache.maxSize(), cache.size(), cacheHits, cacheMisses, + totalOriginalBitmapSize, totalTransformedBitmapSize, averageOriginalBitmapSize, + averageTransformedBitmapSize, originalBitmapCount, transformedBitmapCount, + System.currentTimeMillis()); + } + + private void processBitmap(Bitmap bitmap, int what) { + // Never send bitmaps to the handler as they could be recycled before we process them. + int bitmapSize = Utils.getBitmapBytes(bitmap); + handler.sendMessage(handler.obtainMessage(what, bitmapSize, 0)); + } + + private static long getAverage(int count, long totalSize) { + return totalSize / count; + } + + private static class StatsHandler extends Handler { + + private final Stats stats; + + public StatsHandler(Looper looper, Stats stats) { + super(looper); + this.stats = stats; + } + + @Override public void handleMessage(final Message msg) { + switch (msg.what) { + case CACHE_HIT: + stats.performCacheHit(); + break; + case CACHE_MISS: + stats.performCacheMiss(); + break; + case BITMAP_DECODE_FINISHED: + stats.performBitmapDecoded(msg.arg1); + break; + case BITMAP_TRANSFORMED_FINISHED: + stats.performBitmapTransformed(msg.arg1); + break; + default: + Picasso.HANDLER.post(new Runnable() { + @Override public void run() { + throw new AssertionError("Unhandled stats message." + msg.what); + } + }); + } + } + } +}
\ No newline at end of file diff --git a/mobile/android/thirdparty/com/squareup/picasso/StatsSnapshot.java b/mobile/android/thirdparty/com/squareup/picasso/StatsSnapshot.java new file mode 100644 index 000000000..5f276ebf2 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/StatsSnapshot.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.util.Log; +import java.io.PrintWriter; +import java.io.StringWriter; + +/** Represents all stats for a {@link Picasso} instance at a single point in time. */ +public class StatsSnapshot { + private static final String TAG = "Picasso"; + + public final int maxSize; + public final int size; + public final long cacheHits; + public final long cacheMisses; + public final long totalOriginalBitmapSize; + public final long totalTransformedBitmapSize; + public final long averageOriginalBitmapSize; + public final long averageTransformedBitmapSize; + public final int originalBitmapCount; + public final int transformedBitmapCount; + + public final long timeStamp; + + public StatsSnapshot(int maxSize, int size, long cacheHits, long cacheMisses, + long totalOriginalBitmapSize, long totalTransformedBitmapSize, long averageOriginalBitmapSize, + long averageTransformedBitmapSize, int originalBitmapCount, int transformedBitmapCount, + long timeStamp) { + this.maxSize = maxSize; + this.size = size; + this.cacheHits = cacheHits; + this.cacheMisses = cacheMisses; + this.totalOriginalBitmapSize = totalOriginalBitmapSize; + this.totalTransformedBitmapSize = totalTransformedBitmapSize; + this.averageOriginalBitmapSize = averageOriginalBitmapSize; + this.averageTransformedBitmapSize = averageTransformedBitmapSize; + this.originalBitmapCount = originalBitmapCount; + this.transformedBitmapCount = transformedBitmapCount; + this.timeStamp = timeStamp; + } + + /** Prints out this {@link StatsSnapshot} into log. */ + public void dump() { + StringWriter logWriter = new StringWriter(); + dump(new PrintWriter(logWriter)); + Log.i(TAG, logWriter.toString()); + } + + /** Prints out this {@link StatsSnapshot} with the the provided {@link PrintWriter}. */ + public void dump(PrintWriter writer) { + writer.println("===============BEGIN PICASSO STATS ==============="); + writer.println("Memory Cache Stats"); + writer.print(" Max Cache Size: "); + writer.println(maxSize); + writer.print(" Cache Size: "); + writer.println(size); + writer.print(" Cache % Full: "); + writer.println((int) Math.ceil((float) size / maxSize * 100)); + writer.print(" Cache Hits: "); + writer.println(cacheHits); + writer.print(" Cache Misses: "); + writer.println(cacheMisses); + writer.println("Bitmap Stats"); + writer.print(" Total Bitmaps Decoded: "); + writer.println(originalBitmapCount); + writer.print(" Total Bitmap Size: "); + writer.println(totalOriginalBitmapSize); + writer.print(" Total Transformed Bitmaps: "); + writer.println(transformedBitmapCount); + writer.print(" Total Transformed Bitmap Size: "); + writer.println(totalTransformedBitmapSize); + writer.print(" Average Bitmap Size: "); + writer.println(averageOriginalBitmapSize); + writer.print(" Average Transformed Bitmap Size: "); + writer.println(averageTransformedBitmapSize); + writer.println("===============END PICASSO STATS ==============="); + writer.flush(); + } + + @Override public String toString() { + return "StatsSnapshot{" + + "maxSize=" + + maxSize + + ", size=" + + size + + ", cacheHits=" + + cacheHits + + ", cacheMisses=" + + cacheMisses + + ", totalOriginalBitmapSize=" + + totalOriginalBitmapSize + + ", totalTransformedBitmapSize=" + + totalTransformedBitmapSize + + ", averageOriginalBitmapSize=" + + averageOriginalBitmapSize + + ", averageTransformedBitmapSize=" + + averageTransformedBitmapSize + + ", originalBitmapCount=" + + originalBitmapCount + + ", transformedBitmapCount=" + + transformedBitmapCount + + ", timeStamp=" + + timeStamp + + '}'; + } +}
\ No newline at end of file diff --git a/mobile/android/thirdparty/com/squareup/picasso/Target.java b/mobile/android/thirdparty/com/squareup/picasso/Target.java new file mode 100644 index 000000000..ad3ce6fcf --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Target.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +import static com.squareup.picasso.Picasso.LoadedFrom; + +/** + * Represents an arbitrary listener for image loading. + * <p/> + * Objects implementing this class <strong>must</strong> have a working implementation of + * {@link #equals(Object)} and {@link #hashCode()} for proper storage internally. Instances of this + * interface will also be compared to determine if view recycling is occurring. It is recommended + * that you add this interface directly on to a custom view type when using in an adapter to ensure + * correct recycling behavior. + */ +public interface Target { + /** + * Callback when an image has been successfully loaded. + * <p/> + * <strong>Note:</strong> You must not recycle the bitmap. + */ + void onBitmapLoaded(Bitmap bitmap, LoadedFrom from); + + /** Callback indicating the image could not be successfully loaded. */ + void onBitmapFailed(Drawable errorDrawable); + + /** Callback invoked right before your request is submitted. */ + void onPrepareLoad(Drawable placeHolderDrawable); +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/TargetAction.java b/mobile/android/thirdparty/com/squareup/picasso/TargetAction.java new file mode 100644 index 000000000..77a40f51d --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/TargetAction.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; + +final class TargetAction extends Action<Target> { + + TargetAction(Picasso picasso, Target target, Request data, boolean skipCache, String key) { + super(picasso, target, data, skipCache, false, 0, null, key); + } + + @Override void complete(Bitmap result, Picasso.LoadedFrom from) { + if (result == null) { + throw new AssertionError( + String.format("Attempted to complete action with no result!\n%s", this)); + } + Target target = getTarget(); + if (target != null) { + target.onBitmapLoaded(result, from); + if (result.isRecycled()) { + throw new IllegalStateException("Target callback must not recycle bitmap!"); + } + } + } + + @Override void error() { + Target target = getTarget(); + if (target != null) { + target.onBitmapFailed(errorDrawable); + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Transformation.java b/mobile/android/thirdparty/com/squareup/picasso/Transformation.java new file mode 100644 index 000000000..2c59f160c --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Transformation.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.graphics.Bitmap; + +/** Image transformation. */ +public interface Transformation { + /** + * Transform the source bitmap into a new bitmap. If you create a new bitmap instance, you must + * call {@link android.graphics.Bitmap#recycle()} on {@code source}. You may return the original + * if no transformation is required. + */ + Bitmap transform(Bitmap source); + + /** + * Returns a unique key for the transformation, used for caching purposes. If the transformation + * has parameters (e.g. size, scale factor, etc) then these should be part of the key. + */ + String key(); +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/UrlConnectionDownloader.java b/mobile/android/thirdparty/com/squareup/picasso/UrlConnectionDownloader.java new file mode 100644 index 000000000..50f9b2b98 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/UrlConnectionDownloader.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.content.Context; +import android.net.Uri; +import android.net.http.HttpResponseCache; +import android.os.Build; +import java.io.File; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; + +import static com.squareup.picasso.Utils.parseResponseSourceHeader; + +/** + * A {@link Downloader} which uses {@link HttpURLConnection} to download images. A disk cache of 2% + * of the total available space will be used (capped at 50MB) will automatically be installed in the + * application's cache directory, when available. + */ +public class UrlConnectionDownloader implements Downloader { + static final String RESPONSE_SOURCE = "X-Android-Response-Source"; + + private static final Object lock = new Object(); + static volatile Object cache; + + private final Context context; + + public UrlConnectionDownloader(Context context) { + this.context = context.getApplicationContext(); + } + + protected HttpURLConnection openConnection(Uri path) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(path.toString()).openConnection(); + connection.setConnectTimeout(Utils.DEFAULT_CONNECT_TIMEOUT); + connection.setReadTimeout(Utils.DEFAULT_READ_TIMEOUT); + return connection; + } + + @Override public Response load(Uri uri, boolean localCacheOnly) throws IOException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + installCacheIfNeeded(context); + } + + HttpURLConnection connection = openConnection(uri); + connection.setUseCaches(true); + if (localCacheOnly) { + connection.setRequestProperty("Cache-Control", "only-if-cached,max-age=" + Integer.MAX_VALUE); + } + + int responseCode = connection.getResponseCode(); + if (responseCode >= 300) { + connection.disconnect(); + throw new ResponseException(responseCode + " " + connection.getResponseMessage()); + } + + boolean fromCache = parseResponseSourceHeader(connection.getHeaderField(RESPONSE_SOURCE)); + + return new Response(connection.getInputStream(), fromCache); + } + + private static void installCacheIfNeeded(Context context) { + // DCL + volatile should be safe after Java 5. + if (cache == null) { + try { + synchronized (lock) { + if (cache == null) { + cache = ResponseCacheIcs.install(context); + } + } + } catch (IOException ignored) { + } + } + } + + private static class ResponseCacheIcs { + static Object install(Context context) throws IOException { + File cacheDir = Utils.createDefaultCacheDir(context); + HttpResponseCache cache = HttpResponseCache.getInstalled(); + if (cache == null) { + long maxSize = Utils.calculateDiskCacheSize(cacheDir); + cache = HttpResponseCache.install(cacheDir, maxSize); + } + return cache; + } + } +} diff --git a/mobile/android/thirdparty/com/squareup/picasso/Utils.java b/mobile/android/thirdparty/com/squareup/picasso/Utils.java new file mode 100644 index 000000000..bafe93f98 --- /dev/null +++ b/mobile/android/thirdparty/com/squareup/picasso/Utils.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso; + +import android.annotation.TargetApi; +import android.app.ActivityManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.os.Looper; +import android.os.Process; +import android.os.StatFs; +import android.provider.Settings; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.concurrent.ThreadFactory; + +import static android.content.Context.ACTIVITY_SERVICE; +import static android.content.pm.ApplicationInfo.FLAG_LARGE_HEAP; +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.HONEYCOMB; +import static android.os.Build.VERSION_CODES.HONEYCOMB_MR1; +import static android.os.Process.THREAD_PRIORITY_BACKGROUND; +import static android.provider.Settings.System.AIRPLANE_MODE_ON; + +final class Utils { + static final String THREAD_PREFIX = "Picasso-"; + static final String THREAD_IDLE_NAME = THREAD_PREFIX + "Idle"; + static final int DEFAULT_READ_TIMEOUT = 20 * 1000; // 20s + static final int DEFAULT_CONNECT_TIMEOUT = 15 * 1000; // 15s + private static final String PICASSO_CACHE = "picasso-cache"; + private static final int KEY_PADDING = 50; // Determined by exact science. + private static final int MIN_DISK_CACHE_SIZE = 5 * 1024 * 1024; // 5MB + private static final int MAX_DISK_CACHE_SIZE = 50 * 1024 * 1024; // 50MB + + /* WebP file header + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | 'R' | 'I' | 'F' | 'F' | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | File Size | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | 'W' | 'E' | 'B' | 'P' | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + */ + private static final int WEBP_FILE_HEADER_SIZE = 12; + private static final String WEBP_FILE_HEADER_RIFF = "RIFF"; + private static final String WEBP_FILE_HEADER_WEBP = "WEBP"; + + private Utils() { + // No instances. + } + + static int getBitmapBytes(Bitmap bitmap) { + int result; + if (SDK_INT >= HONEYCOMB_MR1) { + result = BitmapHoneycombMR1.getByteCount(bitmap); + } else { + result = bitmap.getRowBytes() * bitmap.getHeight(); + } + if (result < 0) { + throw new IllegalStateException("Negative size: " + bitmap); + } + return result; + } + + static void checkNotMain() { + if (Looper.getMainLooper().getThread() == Thread.currentThread()) { + throw new IllegalStateException("Method call should not happen from the main thread."); + } + } + + static String createKey(Request data) { + StringBuilder builder; + + if (data.uri != null) { + String path = data.uri.toString(); + builder = new StringBuilder(path.length() + KEY_PADDING); + builder.append(path); + } else { + builder = new StringBuilder(KEY_PADDING); + builder.append(data.resourceId); + } + builder.append('\n'); + + if (data.rotationDegrees != 0) { + builder.append("rotation:").append(data.rotationDegrees); + if (data.hasRotationPivot) { + builder.append('@').append(data.rotationPivotX).append('x').append(data.rotationPivotY); + } + builder.append('\n'); + } + if (data.targetWidth != 0) { + builder.append("resize:").append(data.targetWidth).append('x').append(data.targetHeight); + builder.append('\n'); + } + if (data.centerCrop) { + builder.append("centerCrop\n"); + } else if (data.centerInside) { + builder.append("centerInside\n"); + } + + if (data.transformations != null) { + //noinspection ForLoopReplaceableByForEach + for (int i = 0, count = data.transformations.size(); i < count; i++) { + builder.append(data.transformations.get(i).key()); + builder.append('\n'); + } + } + + return builder.toString(); + } + + static void closeQuietly(InputStream is) { + if (is == null) return; + try { + is.close(); + } catch (IOException ignored) { + } + } + + /** Returns {@code true} if header indicates the response body was loaded from the disk cache. */ + static boolean parseResponseSourceHeader(String header) { + if (header == null) { + return false; + } + String[] parts = header.split(" ", 2); + if ("CACHE".equals(parts[0])) { + return true; + } + if (parts.length == 1) { + return false; + } + try { + return "CONDITIONAL_CACHE".equals(parts[0]) && Integer.parseInt(parts[1]) == 304; + } catch (NumberFormatException e) { + return false; + } + } + + static Downloader createDefaultDownloader(Context context) { + return new UrlConnectionDownloader(context); + } + + static File createDefaultCacheDir(Context context) { + File cache = new File(context.getApplicationContext().getCacheDir(), PICASSO_CACHE); + if (!cache.exists()) { + cache.mkdirs(); + } + return cache; + } + + static long calculateDiskCacheSize(File dir) { + long size = MIN_DISK_CACHE_SIZE; + + try { + StatFs statFs = new StatFs(dir.getAbsolutePath()); + long available = ((long) statFs.getBlockCount()) * statFs.getBlockSize(); + // Target 2% of the total space. + size = available / 50; + } catch (IllegalArgumentException ignored) { + } + + // Bound inside min/max size for disk cache. + return Math.max(Math.min(size, MAX_DISK_CACHE_SIZE), MIN_DISK_CACHE_SIZE); + } + + static int calculateMemoryCacheSize(Context context) { + ActivityManager am = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); + boolean largeHeap = (context.getApplicationInfo().flags & FLAG_LARGE_HEAP) != 0; + int memoryClass = am.getMemoryClass(); + if (largeHeap && SDK_INT >= HONEYCOMB) { + memoryClass = ActivityManagerHoneycomb.getLargeMemoryClass(am); + } + // Target ~15% of the available heap. + return 1024 * 1024 * memoryClass / 7; + } + + static boolean isAirplaneModeOn(Context context) { + ContentResolver contentResolver = context.getContentResolver(); + return Settings.System.getInt(contentResolver, AIRPLANE_MODE_ON, 0) != 0; + } + + static boolean hasPermission(Context context, String permission) { + return context.checkCallingOrSelfPermission(permission) == PackageManager.PERMISSION_GRANTED; + } + + static byte[] toByteArray(InputStream input) throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024 * 4]; + int n = 0; + while (-1 != (n = input.read(buffer))) { + byteArrayOutputStream.write(buffer, 0, n); + } + return byteArrayOutputStream.toByteArray(); + } + + static boolean isWebPFile(InputStream stream) throws IOException { + byte[] fileHeaderBytes = new byte[WEBP_FILE_HEADER_SIZE]; + boolean isWebPFile = false; + if (stream.read(fileHeaderBytes, 0, WEBP_FILE_HEADER_SIZE) == WEBP_FILE_HEADER_SIZE) { + // If a file's header starts with RIFF and end with WEBP, the file is a WebP file + isWebPFile = WEBP_FILE_HEADER_RIFF.equals(new String(fileHeaderBytes, 0, 4, "US-ASCII")) + && WEBP_FILE_HEADER_WEBP.equals(new String(fileHeaderBytes, 8, 4, "US-ASCII")); + } + return isWebPFile; + } + + static int getResourceId(Resources resources, Request data) throws FileNotFoundException { + if (data.resourceId != 0 || data.uri == null) { + return data.resourceId; + } + + String pkg = data.uri.getAuthority(); + if (pkg == null) throw new FileNotFoundException("No package provided: " + data.uri); + + int id; + List<String> segments = data.uri.getPathSegments(); + if (segments == null || segments.isEmpty()) { + throw new FileNotFoundException("No path segments: " + data.uri); + } else if (segments.size() == 1) { + try { + id = Integer.parseInt(segments.get(0)); + } catch (NumberFormatException e) { + throw new FileNotFoundException("Last path segment is not a resource ID: " + data.uri); + } + } else if (segments.size() == 2) { + String type = segments.get(0); + String name = segments.get(1); + + id = resources.getIdentifier(name, type, pkg); + } else { + throw new FileNotFoundException("More than two path segments: " + data.uri); + } + return id; + } + + static Resources getResources(Context context, Request data) throws FileNotFoundException { + if (data.resourceId != 0 || data.uri == null) { + return context.getResources(); + } + + String pkg = data.uri.getAuthority(); + if (pkg == null) throw new FileNotFoundException("No package provided: " + data.uri); + try { + PackageManager pm = context.getPackageManager(); + return pm.getResourcesForApplication(pkg); + } catch (PackageManager.NameNotFoundException e) { + throw new FileNotFoundException("Unable to obtain resources for package: " + data.uri); + } + } + + @TargetApi(HONEYCOMB) + private static class ActivityManagerHoneycomb { + static int getLargeMemoryClass(ActivityManager activityManager) { + return activityManager.getLargeMemoryClass(); + } + } + + static class PicassoThreadFactory implements ThreadFactory { + @SuppressWarnings("NullableProblems") + public Thread newThread(Runnable r) { + return new PicassoThread(r); + } + } + + private static class PicassoThread extends Thread { + public PicassoThread(Runnable r) { + super(r); + } + + @Override public void run() { + Process.setThreadPriority(THREAD_PRIORITY_BACKGROUND); + super.run(); + } + } + + @TargetApi(HONEYCOMB_MR1) + private static class BitmapHoneycombMR1 { + static int getByteCount(Bitmap bitmap) { + return bitmap.getByteCount(); + } + } +} |