diff --git a/.gitignore b/.gitignore index f6b286cea9..1c5e79c122 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ out/ # Gradle files .gradle/ build/ +/*/build/ + # Local configuration file (sdk path, etc) local.properties @@ -35,6 +37,7 @@ captures/ # Intellij *.iml .idea/workspace.xml +.idea/ # Keystore files *.jks diff --git a/analytics/build.gradle b/analytics/build.gradle new file mode 100644 index 0000000000..396d547f71 --- /dev/null +++ b/analytics/build.gradle @@ -0,0 +1,45 @@ +apply plugin: 'com.android.library' +//apply plugin: 'checkstyle' + +def supportLibVersion = rootProject.ext.supportLibVersion + + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode rootProject.ext.versionCode + versionName rootProject.ext.versionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'proguard-rules.pro' + } + buildTypes { + release { + minifyEnabled false + } + } + lintOptions { + disable 'GoogleAppIndexingWarning' + } +} + +dependencies { + compile project(':base') + + //Mocking + androidTestCompile 'org.mockito:mockito-core:1.10.19' + androidTestCompile 'com.crittercism.dexmaker:dexmaker:1.4' + androidTestCompile 'com.crittercism.dexmaker:dexmaker-dx:1.4' + androidTestCompile 'com.crittercism.dexmaker:dexmaker-mockito:1.4' + + androidTestCompile 'org.hamcrest:hamcrest-core:1.3' + androidTestCompile 'org.hamcrest:hamcrest-library:1.3' + testCompile 'junit:junit:4.12' + + androidTestCompile "com.android.support:support-annotations:$supportLibVersion" + androidTestCompile 'com.android.support.test:runner:0.5' + androidTestCompile 'com.android.support.test:rules:0.5' +} \ No newline at end of file diff --git a/analytics/proguard-rules.pro b/analytics/proguard-rules.pro new file mode 100644 index 0000000000..7f265ec541 --- /dev/null +++ b/analytics/proguard-rules.pro @@ -0,0 +1,4 @@ +# The following options are set by default. +# Make sure they are always set, even if the default proguard config changes. +-dontskipnonpubliclibraryclasses +-verbose \ No newline at end of file diff --git a/analytics/src/androidTest/java/avalanche/analytics/ApplicationTest.java b/analytics/src/androidTest/java/avalanche/analytics/ApplicationTest.java new file mode 100644 index 0000000000..394f33b1ab --- /dev/null +++ b/analytics/src/androidTest/java/avalanche/analytics/ApplicationTest.java @@ -0,0 +1,13 @@ +package avalanche.analytics; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * Testing Fundamentals + */ +public class ApplicationTest extends ApplicationTestCase { + public ApplicationTest() { + super(Application.class); + } +} \ No newline at end of file diff --git a/analytics/src/main/AndroidManifest.xml b/analytics/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cb96606808 --- /dev/null +++ b/analytics/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/analytics/src/main/java/avalanche/analytics/ingestion/models/EventLog.java b/analytics/src/main/java/avalanche/analytics/ingestion/models/EventLog.java new file mode 100644 index 0000000000..8a14d9d7b7 --- /dev/null +++ b/analytics/src/main/java/avalanche/analytics/ingestion/models/EventLog.java @@ -0,0 +1,55 @@ +package avalanche.analytics.ingestion.models; + +import avalanche.base.ingestion.models.InSessionLog; + +/** + * Event log. + */ +public class EventLog extends InSessionLog { + + /** + * Unique identifier for this event. + */ + private String id; + + /** + * Name of the event. + */ + private String name; + + /** + * Get the id value. + * + * @return the id value + */ + public String getId() { + return this.id; + } + + /** + * Set the id value. + * + * @param id the id value to set + */ + public void setId(String id) { + this.id = id; + } + + /** + * Get the name value. + * + * @return the name value + */ + public String getName() { + return this.name; + } + + /** + * Set the name value. + * + * @param name the name value to set + */ + public void setName(String name) { + this.name = name; + } +} diff --git a/analytics/src/main/java/avalanche/analytics/ingestion/models/PageLog.java b/analytics/src/main/java/avalanche/analytics/ingestion/models/PageLog.java new file mode 100644 index 0000000000..f7b99c021b --- /dev/null +++ b/analytics/src/main/java/avalanche/analytics/ingestion/models/PageLog.java @@ -0,0 +1,32 @@ +package avalanche.analytics.ingestion.models; + +import avalanche.base.ingestion.models.InSessionLog; + +/** + * Page log. + */ +public class PageLog extends InSessionLog { + + /** + * Name of the page. + */ + private String name; + + /** + * Get the name value. + * + * @return the name value + */ + public String getName() { + return this.name; + } + + /** + * Set the name value. + * + * @param name the name value to set + */ + public void setName(String name) { + this.name = name; + } +} diff --git a/analytics/src/main/java/avalanche/analytics/ingestion/models/SessionLog.java b/analytics/src/main/java/avalanche/analytics/ingestion/models/SessionLog.java new file mode 100644 index 0000000000..b314f43755 --- /dev/null +++ b/analytics/src/main/java/avalanche/analytics/ingestion/models/SessionLog.java @@ -0,0 +1,57 @@ +package avalanche.analytics.ingestion.models; + +import avalanche.base.ingestion.models.Log; + +/** + * Session log. + */ +public class SessionLog extends Log { + + /** + * Unique session identifier. The same identifier must be used for end and + * start session. + */ + private String sid; + + /** + * `true` to mark the end of the session, `false` if it the start of the + * session. + */ + private Boolean end; + + /** + * Get the sid value. + * + * @return the sid value + */ + public String getSid() { + return this.sid; + } + + /** + * Set the sid value. + * + * @param sid the sid value to set + */ + public void setSid(String sid) { + this.sid = sid; + } + + /** + * Get the end value. + * + * @return the end value + */ + public Boolean getEnd() { + return this.end; + } + + /** + * Set the end value. + * + * @param end the end value to set + */ + public void setEnd(Boolean end) { + this.end = end; + } +} diff --git a/analytics/src/main/res/values/strings.xml b/analytics/src/main/res/values/strings.xml new file mode 100644 index 0000000000..3b1b0facb0 --- /dev/null +++ b/analytics/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + analytics + diff --git a/analytics/src/test/java/avalanche/analytics/ExampleUnitTest.java b/analytics/src/test/java/avalanche/analytics/ExampleUnitTest.java new file mode 100644 index 0000000000..20ec876da7 --- /dev/null +++ b/analytics/src/test/java/avalanche/analytics/ExampleUnitTest.java @@ -0,0 +1,15 @@ +package avalanche.analytics; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * To work on unit tests, switch the Test Artifact in the Build Variants view. + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/base/build.gradle b/base/build.gradle new file mode 100644 index 0000000000..f9267aca6f --- /dev/null +++ b/base/build.gradle @@ -0,0 +1,40 @@ +apply plugin: 'com.android.library' +//apply plugin: 'checkstyle' + +def supportLibVersion = rootProject.ext.supportLibVersion + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode rootProject.ext.versionCode + versionName rootProject.ext.versionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'proguard-rules.pro' + } + buildTypes { + release { + minifyEnabled false + } + } +} + +dependencies { + + //Mocking + androidTestCompile 'org.mockito:mockito-core:2.0.59-beta' + androidTestCompile 'com.crittercism.dexmaker:dexmaker:1.4' + androidTestCompile 'com.crittercism.dexmaker:dexmaker-dx:1.4' + androidTestCompile 'com.crittercism.dexmaker:dexmaker-mockito:1.4' + + androidTestCompile 'org.hamcrest:hamcrest-core:1.3' + androidTestCompile 'org.hamcrest:hamcrest-library:1.3' + testCompile 'junit:junit:4.12' + + androidTestCompile "com.android.support:support-annotations:$supportLibVersion" + androidTestCompile 'com.android.support.test:runner:0.5' + androidTestCompile 'com.android.support.test:rules:0.5' +} diff --git a/base/proguard-rules.pro b/base/proguard-rules.pro new file mode 100644 index 0000000000..7cae2dbab0 --- /dev/null +++ b/base/proguard-rules.pro @@ -0,0 +1,4 @@ +# The following options are set by default. +# Make sure they are always set, even if the default proguard config changes. +-dontskipnonpubliclibraryclasses +-verbose diff --git a/base/src/main/AndroidManifest.xml b/base/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0c8246388e --- /dev/null +++ b/base/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/base/src/main/java/avalanche/base/AvalancheDataInterface.java b/base/src/main/java/avalanche/base/AvalancheDataInterface.java new file mode 100644 index 0000000000..38af659c2b --- /dev/null +++ b/base/src/main/java/avalanche/base/AvalancheDataInterface.java @@ -0,0 +1,12 @@ +package avalanche.base; + +/** + * Created by benny on 6/17/16. + */ + +public interface AvalancheDataInterface { + + //TODO could also be an enum or whatever + public boolean isHighPriority(); + +} diff --git a/base/src/main/java/avalanche/base/AvalancheFeature.java b/base/src/main/java/avalanche/base/AvalancheFeature.java new file mode 100644 index 0000000000..cc3dd39e2e --- /dev/null +++ b/base/src/main/java/avalanche/base/AvalancheFeature.java @@ -0,0 +1,9 @@ +package avalanche.base; + +import android.app.Application; + +public interface AvalancheFeature extends Application.ActivityLifecycleCallbacks { + + String getName(); + +} diff --git a/base/src/main/java/avalanche/base/AvalancheHub.java b/base/src/main/java/avalanche/base/AvalancheHub.java new file mode 100644 index 0000000000..6f383177b5 --- /dev/null +++ b/base/src/main/java/avalanche/base/AvalancheHub.java @@ -0,0 +1,245 @@ +package avalanche.base; + +import android.app.Application; + +import avalanche.base.utils.Util; + +import java.lang.ref.WeakReference; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class AvalancheHub { + + public static final String FEATURE_CRASH = "avalanche.crash.Crashes"; + + private static AvalancheHub sharedInstance; + private final Set mFeatures; + private String mAppIdentifier; + private WeakReference mApplicationWeakReference; + + protected AvalancheHub() { + mFeatures = new HashSet<>(); + } + + public static AvalancheHub getSharedInstance() { + if (sharedInstance == null) { + sharedInstance = new AvalancheHub(); + } + return sharedInstance; + } + + /** + * The most convenient way to set up the SDK. It will gather all available features and automatically instantiate and use them. + * Instantiation and registration of the features is handled on opinionated basis - the features will register themselves in + * the most suitable way for them. The app identifier will be read from your manifest. + * + * @param application Your application object. + * @return The AvalancheHub SDK, fully configured with all features, which are available. + */ + public static AvalancheHub use(Application application) { + return use(application, true); + } + + /** + * The second-most convenient way to set up the SDK. Offers to option to skip auto configuration of the features. + * The app identifier will be read from your manifest. + * + * @param application Your application object. + * @param autoConfigure Whether to auto-use all available features. If false, only the SDK will be set up. + * @return The AvalancheHub SDK, ready to use. + */ + public static AvalancheHub use(Application application, boolean autoConfigure) { + if (!autoConfigure) { + return use(application, new AvalancheFeature[0]); + } + String[] allFeatureNames = {FEATURE_CRASH}; + List> features = new ArrayList<>(); + for (String featureName : allFeatureNames) { + Class clazz = getClassForFeature(featureName); + if (clazz != null) { + features.add(clazz); + } + } + //noinspection unchecked + return use(application, features.toArray(new Class[features.size()])); + } + + /** + * Set up the SDK and provide a varargs list of feature classes you would like to have enabled and auto-configured. + * The app identifier will be read from your manifest. + * + * @param application Your application object. + * @param features Vararg list of feature classes to auto-use. + * @return The AvalancheHub SDK, configured with your selected features. + */ + @SafeVarargs + public static AvalancheHub use(Application application, Class... features) { + return use(application, Util.getAppIdentifier(application), features); + } + + /** + * Set up the SDK and provide a varargs list of feature classes you would like to have enabled and auto-configured. + * + * @param application Your application object. + * @param appIdentifier The app identifier to use. + * @param features Vararg list of feature classes to auto-use. + * @return The AvalancheHub SDK, configured with your selected features. + */ + @SafeVarargs + public static AvalancheHub use(Application application, String appIdentifier, Class... features) { + List featureList = new ArrayList<>(); + if (features != null && features.length > 0) { + for (Class featureClass : features) { + AvalancheFeature feature = instantiateFeature(featureClass); + if (feature != null) { + featureList.add(feature); + } + } + } + + return use(application, appIdentifier, featureList.toArray(new AvalancheFeature[featureList.size()])); + } + + /** + * The most flexible way to set up the SDK. Configure your features first and then pass them in here to enable them in the SDK. + * The app identifier will be read from your manifest. + * + * @param application Your application object. + * @param features Vararg list of configured features to enable. + * @return The AvalancheHub SDK, configured with the selected feature instances. + */ + public static AvalancheHub use(Application application, AvalancheFeature... features) { + return use(application, Util.getAppIdentifier(application), features); + } + + /** + * The most flexible way to set up the SDK. Configure your features first and then pass them in here to enable them in the SDK. + * + * @param application Your application object. + * @param appIdentifier The app identifier to use. + * @param features Vararg list of configured features to enable. + * @return The AvalancheHub SDK, configured with the selected feature instances. + */ + public static AvalancheHub use(Application application, String appIdentifier, AvalancheFeature... features) { + AvalancheHub avalancheHub = getSharedInstance().initialize(application, appIdentifier); + + if (features != null && features.length > 0) { + for (AvalancheFeature feature : features) { + avalancheHub.addFeature(feature); + } + } + + return avalancheHub; + } + + /** + * Checks whether a feature is available at runtime or not. + * + * @param featureName The name of the feature you want to check for. + * @return Whether the feature is available. + */ + public static boolean isFeatureAvailable(String featureName) { + return getClassForFeature(featureName) != null; + } + + private static Class getClassForFeature(String featureName) { + try { + //noinspection unchecked + return (Class) Class.forName(featureName); + } catch (ClassCastException e) { + // If the class can be resolved but can't be cast to AvalancheFeature, this is no valid feature + return null; + } catch (ClassNotFoundException e) { + // If the class can not be resolved, the feature in question is not available. + return null; + } + } + + private static AvalancheFeature instantiateFeature(Class clazz) { + //noinspection TryWithIdenticalCatches + try { + Method getSharedInstanceMethod = clazz.getMethod("getInstance"); + return (AvalancheFeature) getSharedInstanceMethod.invoke(null); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + } + + private AvalancheHub initialize(Application application, String appIdentifier) { + mAppIdentifier = appIdentifier; + mApplicationWeakReference = new WeakReference<>(application); + mFeatures.clear(); + + Constants.loadFromContext(application.getApplicationContext()); + + return this; + } + + /** + * Add and enable a configured feature. + * + * @param feature + */ + public void addFeature(AvalancheFeature feature) { + + Application application = getApplication(); + if (application != null) { + application.registerActivityLifecycleCallbacks(feature); + mFeatures.add(feature); + } + } + + /** + * Get the configured application object. + * @return The application instance or null if not set. + */ + public Application getApplication() { + return mApplicationWeakReference.get(); + } + + /** + * Check whether a feature is enabled. + * + * @param feature Name of the feature to check. + * @return Whether the feature is enabled. + */ + public boolean isFeatureEnabled(String feature) { + Class clazz = getClassForFeature(feature); + if (clazz != null) { + return isFeatureEnabled(clazz); + } + return false; + } + + /** + * Check whether a feature class is enabled. + * + * @param feature The feature class to check for. + * @return Whether the feature is enabled. + */ + public boolean isFeatureEnabled(Class feature) { + for (AvalancheFeature aFeature : + mFeatures) { + if (aFeature.getClass().equals(feature)) { + return true; + } + } + return false; + } + + /** + * Get the configured app identifier. + * @return The app identifier or null if not set. + */ + public String getAppIdentifier() { + return mAppIdentifier; + } +} diff --git a/base/src/main/java/avalanche/base/Channel.java b/base/src/main/java/avalanche/base/Channel.java new file mode 100644 index 0000000000..030a803d87 --- /dev/null +++ b/base/src/main/java/avalanche/base/Channel.java @@ -0,0 +1,29 @@ +package avalanche.base; + + +//TODO this class is just a dumb pipe that forwards +public class Channel { + + private static Channel sharedInstance = null; + + protected Channel() {} + + public static Channel getInstance() { + if (sharedInstance == null) { + sharedInstance = new Channel(); + } + return sharedInstance; + } + + public void handle(AvalancheDataInterface data) { + //TODO forward data to Sending/Journalling pipeline + if(data.isHighPriority()) { + + } + else { + + } + } + + +} diff --git a/base/src/main/java/avalanche/base/Constants.java b/base/src/main/java/avalanche/base/Constants.java new file mode 100644 index 0000000000..241280b6d1 --- /dev/null +++ b/base/src/main/java/avalanche/base/Constants.java @@ -0,0 +1,304 @@ +package avalanche.base; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.provider.Settings; +import android.text.TextUtils; + + + +import avalanche.base.utils.AvalancheLog; + +import java.io.File; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.UUID; + +/** + *

Description

+ * + * Various constants and meta information loaded from the context. + **/ +public class Constants { + + /** + * AvalancheHub API URL. + */ + public static final String BASE_URL = "https://sdk.hockeyapp.net/"; + /** + * Name of this SDK. + */ + public static final String SDK_NAME = "AvalancheSDK"; + /** + * Version of the SDK - retrieved from the build configuration. + */ + public static final String SDK_VERSION = BuildConfig.VERSION_NAME; + + public static final String FILES_DIRECTORY_NAME = "AvalancheHub"; + + /** + * The user agent string the SDK will send with every AvalancheHub API request. + */ + public static final String SDK_USER_AGENT = "AvalancheSDK/Android " + BuildConfig.VERSION_NAME; + + private static final String BUNDLE_BUILD_NUMBER = "buildNumber"; + /** + * Path where crash logs and temporary files are stored. + */ + public static String FILES_PATH = null; + /** + * The app's version code. + */ + public static String APP_VERSION = null; + /** + * The app's version name. + */ + public static String APP_VERSION_NAME = null; + /** + * The app's package name. + */ + public static String APP_PACKAGE = null; + /** + * The device's OS version. + */ + public static String ANDROID_VERSION = null; + /** + * The device's OS build. + */ + public static String ANDROID_BUILD = null; + + /** + * The device's model name. + */ + public static String PHONE_MODEL = null; + /** + * The device's model manufacturer name. + */ + public static String PHONE_MANUFACTURER = null; + /** + * Unique identifier for crash reports. This is package and device specific. + */ + public static String CRASH_IDENTIFIER = null; + /** + * Unique identifier for device, not dependent on package or device. + */ + public static String DEVICE_IDENTIFIER = null; + + /** + * Initializes constants from the given context. The context is used to set + * the package name, version code, and the files path. + * + * @param context The context to use. Usually your Activity object. + */ + public static void loadFromContext(Context context) { + Constants.ANDROID_VERSION = Build.VERSION.RELEASE; + Constants.ANDROID_BUILD = Build.DISPLAY; + Constants.PHONE_MODEL = Build.MODEL; + Constants.PHONE_MANUFACTURER = Build.MANUFACTURER; + + loadFilesPath(context); + loadPackageData(context); + loadCrashIdentifier(context); + loadDeviceIdentifier(context); + } + + /** + * Returns a file representing the folder in which screenshots are stored. + * + * @return A file representing the screenshot folder. + */ + public static File getAvalancheStorageDir() { + File externalStorage = Environment.getExternalStorageDirectory(); + + File dir = new File(externalStorage.getAbsolutePath() + "/" + Constants.FILES_DIRECTORY_NAME); + boolean success = dir.exists() || dir.mkdirs(); + if (!success) { + AvalancheLog.warn("Couldn't create AvalancheHub Storage dir"); + } + return dir; + } + + /** + * Helper method to set the files path. If an exception occurs, the files + * path will be null! + * + * @param context The context to use. Usually your Activity object. + */ + private static void loadFilesPath(Context context) { + if (context != null) { + try { + File file = context.getFilesDir(); + + // The file shouldn't be null, but apparently it still can happen, see + // http://code.google.com/p/android/issues/detail?id=8886 + if (file != null) { + Constants.FILES_PATH = file.getAbsolutePath(); + } + } catch (Exception e) { + AvalancheLog.error("Exception thrown when accessing the files dir:"); + e.printStackTrace(); + } + } + } + + /** + * Helper method to set the package name and version code. If an exception + * occurs, these values will be null! + * + * @param context The context to use. Usually your Activity object. + */ + private static void loadPackageData(Context context) { + if (context != null) { + try { + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0); + Constants.APP_PACKAGE = packageInfo.packageName; + Constants.APP_VERSION = "" + packageInfo.versionCode; + Constants.APP_VERSION_NAME = packageInfo.versionName; + + int buildNumber = loadBuildNumber(context, packageManager); + if ((buildNumber != 0) && (buildNumber > packageInfo.versionCode)) { + Constants.APP_VERSION = "" + buildNumber; + } + } catch (PackageManager.NameNotFoundException e) { + AvalancheLog.error("Exception thrown when accessing the package info:"); + e.printStackTrace(); + } + } + } + + /** + * Helper method to load the build number from the AndroidManifest. + * + * @param context the context to use. Usually your Activity object. + * @param packageManager an instance of PackageManager + */ + private static int loadBuildNumber(Context context, PackageManager packageManager) { + try { + ApplicationInfo appInfo = packageManager.getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + Bundle metaData = appInfo.metaData; + if (metaData != null) { + return metaData.getInt(BUNDLE_BUILD_NUMBER, 0); + } + } catch (PackageManager.NameNotFoundException e) { + AvalancheLog.error("Exception thrown when accessing the application info:"); + e.printStackTrace(); + } + + return 0; + } + + /** + * Helper method to load the crash identifier. + * + * @param context the context to use. Usually your Activity object. + */ + private static void loadCrashIdentifier(Context context) { + String deviceIdentifier = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + if (!TextUtils.isEmpty(Constants.APP_PACKAGE) && !TextUtils.isEmpty(deviceIdentifier)) { + String combined = Constants.APP_PACKAGE + ":" + deviceIdentifier + ":" + createSalt(context); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + byte[] bytes = combined.getBytes("UTF-8"); + digest.update(bytes, 0, bytes.length); + bytes = digest.digest(); + + Constants.CRASH_IDENTIFIER = bytesToHex(bytes); + } catch (Throwable e) { + AvalancheLog.error("Couldn't create CrashIdentifier with Exception:" + e.toString()); + //TODO handle the exception + } + } + } + + /** + * Helper method to generate a device identifier for telemetry and crashes, + * + * @param context The context to use. Usually your Activity object. + */ + private static void loadDeviceIdentifier(Context context) { + // get device ID + ContentResolver resolver = context.getContentResolver(); + String deviceIdentifier = Settings.Secure.getString(resolver, Settings.Secure.ANDROID_ID); + if (deviceIdentifier != null) { + String deviceIdentifierAnonymized = tryHashStringSha256(context, deviceIdentifier); + // if anonymized device identifier is null we should use a random UUID + Constants.DEVICE_IDENTIFIER = deviceIdentifierAnonymized != null ? deviceIdentifierAnonymized : UUID.randomUUID().toString(); + } + } + + /** + * Get a SHA-256 hash of the input string if the algorithm is available. If the algorithm is + * unavailable, return empty string. + * + * @param input the string to hash. + * @return a SHA-256 hash of the input or null if SHA-256 is not available (should never happen). + */ + private static String tryHashStringSha256(Context context, String input) { + String salt = createSalt(context); + try { + // Get a Sha256 digest + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + hash.reset(); + hash.update(input.getBytes()); + hash.update(salt.getBytes()); + byte[] hashedBytes = hash.digest(); + + return bytesToHex(hashedBytes); + } catch (NoSuchAlgorithmException e) { + // All android devices should support SHA256, but if unavailable return null + return null; + } + } + + /** + * Helper method to create a salt for the crash identifier. + * + * @param context the context to use. Usually your Activity object. + */ + @SuppressLint("InlinedApi") + @SuppressWarnings("deprecation") + private static String createSalt(Context context) { + String abiString; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + abiString = Build.SUPPORTED_ABIS[0]; + } else { + abiString = Build.CPU_ABI; + } + + String fingerprint = "HA" + (Build.BOARD.length() % 10) + (Build.BRAND.length() % 10) + + (abiString.length() % 10) + (Build.PRODUCT.length() % 10); + String serial = ""; + try { + serial = Build.class.getField("SERIAL").get(null).toString(); + } catch (Throwable t) { + } + + return fingerprint + ":" + serial; + } + + /** + * Helper method to convert a byte array to the hex string. + * Based on http://stackoverflow.com/questions/9655181/convert-from-byte-array-to-hex-string-in-java + * + * @param bytes a byte array + */ + private static String bytesToHex(byte[] bytes) { + final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + char[] hex = new char[bytes.length * 2]; + for (int index = 0; index < bytes.length; index++) { + int value = bytes[index] & 0xFF; + hex[index * 2] = HEX_ARRAY[value >>> 4]; + hex[index * 2 + 1] = HEX_ARRAY[value & 0x0F]; + } + String result = new String(hex); + return result.replaceAll("(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", "$1-$2-$3-$4-$5"); + } +} diff --git a/base/src/main/java/avalanche/base/DefaultAvalancheFeature.java b/base/src/main/java/avalanche/base/DefaultAvalancheFeature.java new file mode 100644 index 0000000000..8f7414c350 --- /dev/null +++ b/base/src/main/java/avalanche/base/DefaultAvalancheFeature.java @@ -0,0 +1,47 @@ +package avalanche.base; + +import android.app.Activity; +import android.os.Bundle; + +public abstract class DefaultAvalancheFeature implements AvalancheFeature { + + @Override + public String getName() { + return getClass().getName(); + } + + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + + } + + @Override + public void onActivityStarted(Activity activity) { + + } + + @Override + public void onActivityResumed(Activity activity) { + + } + + @Override + public void onActivityPaused(Activity activity) { + + } + + @Override + public void onActivityStopped(Activity activity) { + + } + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) { + + } + + @Override + public void onActivityDestroyed(Activity activity) { + + } +} diff --git a/base/src/main/java/avalanche/base/ingestion/models/DeviceLog.java b/base/src/main/java/avalanche/base/ingestion/models/DeviceLog.java new file mode 100644 index 0000000000..337fba1337 --- /dev/null +++ b/base/src/main/java/avalanche/base/ingestion/models/DeviceLog.java @@ -0,0 +1,307 @@ +package avalanche.base.ingestion.models; + +/** + * Device characteristic log. + */ +public class DeviceLog extends Log { + + /** + * Version of the SDK. + */ + private String sdkVersion; + + /** + * Device model (example: iPad2,3). + */ + private String model; + + /** + * Device manufacturer (example: HTC). + */ + private String oemName; + + /** + * OS name (example: iOS). + */ + private String osName; + + /** + * OS version (example: 9.3.0). + */ + private String osVersion; + + /** + * API level when applicable like in Android (example: 15). + */ + private Integer osApiLevel; + + /** + * Language code (example: en_US). + */ + private String locale; + + /** + * The offset in minutes from UTC for the device time zone, including + * daylight savings time. + */ + private String timeZoneOffset; + + /** + * Screen size of the device in pixels (example: 640x480). + */ + private String screenSize; + + /** + * Application version name. + */ + private String appVersion; + + /** + * Application version code. + */ + private String appCode; + + /** + * Carrier name (for mobile devices). + */ + private String carrierName; + + /** + * Carrier country code (for mobile devices). + */ + private String carrierCountry; + + /** + * Get the sdkVersion value. + * + * @return the sdkVersion value + */ + public String getSdkVersion() { + return this.sdkVersion; + } + + /** + * Set the sdkVersion value. + * + * @param sdkVersion the sdkVersion value to set + */ + public void setSdkVersion(String sdkVersion) { + this.sdkVersion = sdkVersion; + } + + /** + * Get the model value. + * + * @return the model value + */ + public String getModel() { + return this.model; + } + + /** + * Set the model value. + * + * @param model the model value to set + */ + public void setModel(String model) { + this.model = model; + } + + /** + * Get the oemName value. + * + * @return the oemName value + */ + public String getOemName() { + return this.oemName; + } + + /** + * Set the oemName value. + * + * @param oemName the oemName value to set + */ + public void setOemName(String oemName) { + this.oemName = oemName; + } + + /** + * Get the osName value. + * + * @return the osName value + */ + public String getOsName() { + return this.osName; + } + + /** + * Set the osName value. + * + * @param osName the osName value to set + */ + public void setOsName(String osName) { + this.osName = osName; + } + + /** + * Get the osVersion value. + * + * @return the osVersion value + */ + public String getOsVersion() { + return this.osVersion; + } + + /** + * Set the osVersion value. + * + * @param osVersion the osVersion value to set + */ + public void setOsVersion(String osVersion) { + this.osVersion = osVersion; + } + + /** + * Get the osApiLevel value. + * + * @return the osApiLevel value + */ + public Integer getOsApiLevel() { + return this.osApiLevel; + } + + /** + * Set the osApiLevel value. + * + * @param osApiLevel the osApiLevel value to set + */ + public void setOsApiLevel(Integer osApiLevel) { + this.osApiLevel = osApiLevel; + } + + /** + * Get the locale value. + * + * @return the locale value + */ + public String getLocale() { + return this.locale; + } + + /** + * Set the locale value. + * + * @param locale the locale value to set + */ + public void setLocale(String locale) { + this.locale = locale; + } + + /** + * Get the timeZoneOffset value. + * + * @return the timeZoneOffset value + */ + public String getTimeZoneOffset() { + return this.timeZoneOffset; + } + + /** + * Set the timeZoneOffset value. + * + * @param timeZoneOffset the timeZoneOffset value to set + */ + public void setTimeZoneOffset(String timeZoneOffset) { + this.timeZoneOffset = timeZoneOffset; + } + + /** + * Get the screenSize value. + * + * @return the screenSize value + */ + public String getScreenSize() { + return this.screenSize; + } + + /** + * Set the screenSize value. + * + * @param screenSize the screenSize value to set + */ + public void setScreenSize(String screenSize) { + this.screenSize = screenSize; + } + + /** + * Get the appVersion value. + * + * @return the appVersion value + */ + public String getAppVersion() { + return this.appVersion; + } + + /** + * Set the appVersion value. + * + * @param appVersion the appVersion value to set + */ + public void setAppVersion(String appVersion) { + this.appVersion = appVersion; + } + + /** + * Get the appCode value. + * + * @return the appCode value + */ + public String getAppCode() { + return this.appCode; + } + + /** + * Set the appCode value. + * + * @param appCode the appCode value to set + */ + public void setAppCode(String appCode) { + this.appCode = appCode; + } + + /** + * Get the carrierName value. + * + * @return the carrierName value + */ + public String getCarrierName() { + return this.carrierName; + } + + /** + * Set the carrierName value. + * + * @param carrierName the carrierName value to set + */ + public void setCarrierName(String carrierName) { + this.carrierName = carrierName; + } + + /** + * Get the carrierCountry value. + * + * @return the carrierCountry value + */ + public String getCarrierCountry() { + return this.carrierCountry; + } + + /** + * Set the carrierCountry value. + * + * @param carrierCountry the carrierCountry value to set + */ + public void setCarrierCountry(String carrierCountry) { + this.carrierCountry = carrierCountry; + } +} diff --git a/base/src/main/java/avalanche/base/ingestion/models/InSessionLog.java b/base/src/main/java/avalanche/base/ingestion/models/InSessionLog.java new file mode 100644 index 0000000000..272eb1c97d --- /dev/null +++ b/base/src/main/java/avalanche/base/ingestion/models/InSessionLog.java @@ -0,0 +1,55 @@ +package avalanche.base.ingestion.models; + +import java.util.Map; + +/** + * The InSessionLog model. + */ +public abstract class InSessionLog extends Log { + + /** + * Additional key/value pair parameters. + */ + private Map properties; + + /** + * The session identifier that was provided when the session was started. + */ + private String sid; + + /** + * Get the properties value. + * + * @return the properties value + */ + public Map getProperties() { + return this.properties; + } + + /** + * Set the properties value. + * + * @param properties the properties value to set + */ + public void setProperties(Map properties) { + this.properties = properties; + } + + /** + * Get the sid value. + * + * @return the sid value + */ + public String getSid() { + return this.sid; + } + + /** + * Set the sid value. + * + * @param sid the sid value to set + */ + public void setSid(String sid) { + this.sid = sid; + } +} diff --git a/base/src/main/java/avalanche/base/ingestion/models/Log.java b/base/src/main/java/avalanche/base/ingestion/models/Log.java new file mode 100644 index 0000000000..60617232b0 --- /dev/null +++ b/base/src/main/java/avalanche/base/ingestion/models/Log.java @@ -0,0 +1,32 @@ +package avalanche.base.ingestion.models; + + +/** + * The Log model. + */ +public abstract class Log { + + /** + * Corresponds to the number of milliseconds elapsed between the time the + * request is sent and the time the log is emitted. + */ + private long toffset; + + /** + * Get the toffset value. + * + * @return the toffset value + */ + public long getToffset() { + return this.toffset; + } + + /** + * Set the toffset value. + * + * @param toffset the toffset value to set + */ + public void setToffset(long toffset) { + this.toffset = toffset; + } +} diff --git a/base/src/main/java/avalanche/base/ingestion/models/LogContainer.java b/base/src/main/java/avalanche/base/ingestion/models/LogContainer.java new file mode 100644 index 0000000000..24a1df68dd --- /dev/null +++ b/base/src/main/java/avalanche/base/ingestion/models/LogContainer.java @@ -0,0 +1,78 @@ +package avalanche.base.ingestion.models; + +import java.util.List; + +/** + * The LogContainer model. + */ +public class LogContainer { + + /** + * Unique install identifier. + */ + private String installId; + + /** + * Application identifier. + */ + private String appId; + + /** + * The list of logs. + */ + private List logs; + + /** + * Get the installId value. + * + * @return the installId value + */ + public String getInstallId() { + return this.installId; + } + + /** + * Set the installId value. + * + * @param installId the installId value to set + */ + public void setInstallId(String installId) { + this.installId = installId; + } + + /** + * Get the appId value. + * + * @return the appId value + */ + public String getAppId() { + return this.appId; + } + + /** + * Set the appId value. + * + * @param appId the appId value to set + */ + public void setAppId(String appId) { + this.appId = appId; + } + + /** + * Get the logs value. + * + * @return the logs value + */ + public List getLogs() { + return this.logs; + } + + /** + * Set the logs value. + * + * @param logs the logs value to set + */ + public void setLogs(List logs) { + this.logs = logs; + } +} diff --git a/base/src/main/java/avalanche/base/utils/AsyncTaskUtils.java b/base/src/main/java/avalanche/base/utils/AsyncTaskUtils.java new file mode 100644 index 0000000000..353333760d --- /dev/null +++ b/base/src/main/java/avalanche/base/utils/AsyncTaskUtils.java @@ -0,0 +1,29 @@ +package avalanche.base.utils; + +import android.os.AsyncTask; + +import java.util.concurrent.Executor; + +/** + *

Description

+ * + * Either calls execute or executeOnExecutor on an AsyncTask depending on the + * API level. + */ +public class AsyncTaskUtils { + + private static Executor sCustomExecutor; + + public static void execute(AsyncTask asyncTask) { + asyncTask.executeOnExecutor(sCustomExecutor != null ? sCustomExecutor : AsyncTask.THREAD_POOL_EXECUTOR); + } + + public static Executor getCustomExecutor() { + return sCustomExecutor; + } + + public static void setCustomExecutor(Executor customExecutor) { + sCustomExecutor = customExecutor; + } + +} diff --git a/base/src/main/java/avalanche/base/utils/AvalancheLog.java b/base/src/main/java/avalanche/base/utils/AvalancheLog.java new file mode 100644 index 0000000000..656e8017b2 --- /dev/null +++ b/base/src/main/java/avalanche/base/utils/AvalancheLog.java @@ -0,0 +1,285 @@ +package avalanche.base.utils; + + +import android.util.Log; + +/** + *

Description

+ * + * Wrapper class for logging in the SDK as well as + * setting the desired log level for end users. + * Log levels correspond to those of android.util.Log. + * + * @see Log + */ +public class AvalancheLog { + public static final String AVALANCHE_TAG = "AvalancheHub"; + + private static int sLogLevel = Log.ERROR; + + /** + * Get the loglevel to find out how much data the AvalancheSDK spews into LogCat. The Default will be + * LOG_LEVEL.ERROR so only errors show up in LogCat. + * + * @return the log level + */ + public static int getLogLevel() { + return sLogLevel; + } + + /** + * Set the log level to determine the amount of info the AvalancheSDK spews info into LogCat. + * + * @param avalancheLogLevel The log level for AvalancheSDK logging + */ + public static void setLogLevel(int avalancheLogLevel) { + sLogLevel = avalancheLogLevel; + } + + + /** + * Log a message with level VERBOSE with the default tag + * + * @param message the log message + */ + public static void verbose(String message) { + verbose(null, message); + } + + /** + * Log a message with level VERBOSE + * + * @param tag the log tag for your message + * @param message the log message + */ + public static void verbose(String tag, String message) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.VERBOSE) { + Log.v(tag, message); + } + } + + /** + * Log a message with level VERBOSE with the default tag + * + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void verbose(String message, Throwable throwable) { + verbose(null, message, throwable); + } + + /** + * Log a message with level VERBOSE + * + * @param tag the log tag for your message + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void verbose(String tag, String message, Throwable throwable) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.VERBOSE) { + Log.v(tag, message, throwable); + } + } + + /** + * Log a message with level DEBUG with the default tag + * + * @param message the log message + */ + public static void debug(String message) { + debug(null, message); + } + + /** + * Log a message with level DEBUG + * + * @param tag the log tag for your message + * @param message the log message + */ + public static void debug(String tag, String message) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.DEBUG) { + Log.d(tag, message); + } + } + + /** + * Log a message with level DEBUG with the default tag + * + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void debug(String message, Throwable throwable) { + debug(null, message, throwable); + } + + /** + * Log a message with level DEBUG + * + * @param tag the log tag for your message + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void debug(String tag, String message, Throwable throwable) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.DEBUG) { + Log.d(tag, message, throwable); + } + } + + /** + * Log a message with level INFO with the default tag + * + * @param message the log message + */ + public static void info(String message) { + info(null, message); + } + + /** + * Log a message with level INFO + * + * @param tag the log tag for your message + * @param message the log message + */ + public static void info(String tag, String message) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.INFO) { + Log.i(tag, message); + } + } + + /** + * Log a message with level INFO with the default tag + * + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void info(String message, Throwable throwable) { + info(message, throwable); + } + + /** + * Log a message with level INFO + * + * @param tag the log tag for your message + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void info(String tag, String message, Throwable throwable) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.INFO) { + Log.i(tag, message, throwable); + } + } + + /** + * Log a message with level WARN with the default tag + * + * @param message the log message + */ + public static void warn(String message) { + warn(null, message); + } + + /** + * Log a message with level WARN + * + * @param tag the TAG + * @param message the log message + */ + public static void warn(String tag, String message) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.WARN) { + Log.w(tag, message); + } + } + + /** + * Log a message with level WARN with the default tag + * + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void warn(String message, Throwable throwable) { + warn(null, message, throwable); + } + + /** + * Log a message with level WARN + * + * @param tag the log tag for your message + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void warn(String tag, String message, Throwable throwable) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.WARN) { + Log.w(tag, message, throwable); + } + } + + /** + * Log a message with level ERROR with the default tag + * + * @param message the log message + */ + public static void error(String message) { + error(null, message); + } + + /** + * Log a message with level ERROR + * + * @param tag the log tag for your message + * @param message the log message + */ + public static void error(String tag, String message) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.ERROR) { + Log.e(tag, message); + } + } + + /** + * Log a message with level ERROR with the default tag + * + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void error(String message, Throwable throwable) { + error(null, message, throwable); + } + + /** + * Log a message with level ERROR + * + * @param tag the log tag for your message + * @param message the log message + * @param throwable the throwable you want to log + */ + public static void error(String tag, String message, Throwable throwable) { + tag = sanitizeTag(tag); + if (sLogLevel <= Log.ERROR) { + Log.e(tag, message, throwable); + } + } + + /** + * Sanitize a TAG string + * + * @param tag the log tag for your message for the logging + * @return a sanitized TAG, defaults to 'AvalancheHub' in case the log tag for your message is null, empty or longer than + * 23 characters. + */ + static String sanitizeTag(String tag) { + if ((tag == null) || (tag.length() == 0) || (tag.length() > 23)) { + tag = AVALANCHE_TAG; + } + + return tag; + } + +} diff --git a/base/src/main/java/avalanche/base/utils/Base64.java b/base/src/main/java/avalanche/base/utils/Base64.java new file mode 100644 index 0000000000..984320e73a --- /dev/null +++ b/base/src/main/java/avalanche/base/utils/Base64.java @@ -0,0 +1,745 @@ +package avalanche.base.utils; + +import java.io.UnsupportedEncodingException; + +/** + *

Description

+ * + * Utilities for encoding and decoding the Base64 representation of + * binary data. See RFCs 2045 and 3548. + * + */ +public class Base64 { + private static final String TAG = "BASE64"; + + //REMOVE AS ANDROID + + + /** + * Default values for encoder/decoder flags. + */ + public static final int DEFAULT = 0; + + /** + * Encoder flag bit to omit the padding '=' characters at the end + * of the output (if any). + */ + public static final int NO_PADDING = 1; + + /** + * Encoder flag bit to omit all line terminators (i.e., the output + * will be on one long line). + */ + public static final int NO_WRAP = 2; + + /** + * Encoder flag bit to indicate lines should be terminated with a + * CRLF pair instead of just an LF. Has no effect if {@code + * NO_WRAP} is specified as well. + */ + public static final int CRLF = 4; + + /** + * Encoder/decoder flag bit to indicate using the "URL and + * filename safe" variant of Base64 (see RFC 3548 section 4) where + * {@code -} and {@code _} are used in place of {@code +} and + * {@code /}. + */ + public static final int URL_SAFE = 8; + + // -------------------------------------------------------- + // shared code + // -------------------------------------------------------- + + /* package */ static abstract class Coder { + public byte[] output; + public int op; + + /** + * Encode/decode another block of input data. this.output is + * provided by the caller, and must be big enough to hold all + * the coded data. On exit, this.opwill be set to the length + * of the coded data. + * + * @param finish true if this is the final call to process for + * this object. Will finalize the coder state and + * include any final bytes in the output. + * @return true if the input so far is good; false if some + * error has been detected in the input stream.. + */ + public abstract boolean process(byte[] input, int offset, int len, boolean finish); + + /** + * @return the maximum number of bytes a call to process() + * could produce for the given number of input bytes. This may + * be an overestimate. + */ + public abstract int maxOutputSize(int len); + } + + // -------------------------------------------------------- + // decoding + // -------------------------------------------------------- + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param str the input String to decode, which is converted to + * bytes using the default charset + * @param flags controls certain features of the decoded output. + * Pass {@code DEFAULT} to decode standard Base64. + * @return the decoded data as a byte array + * @throws IllegalArgumentException if the input contains + * incorrect padding + */ + public static byte[] decode(String str, int flags) { + return decode(str.getBytes(), flags); + } + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param input the input array to decode + * @param flags controls certain features of the decoded output. + * Pass {@code DEFAULT} to decode standard Base64. + * @return the decoded data as a byte array + * @throws IllegalArgumentException if the input contains + * incorrect padding + */ + public static byte[] decode(byte[] input, int flags) { + return decode(input, 0, input.length, flags); + } + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param input the data to decode + * @param offset the position within the input array at which to start + * @param len the number of bytes of input to decode + * @param flags controls certain features of the decoded output. + * Pass {@code DEFAULT} to decode standard Base64. + * @return the decoded data as a byte array + * @throws IllegalArgumentException if the input contains + * incorrect padding + */ + public static byte[] decode(byte[] input, int offset, int len, int flags) { + // Allocate space for the most data the input could represent. + // (It could contain less if it contains whitespace, etc.) + Decoder decoder = new Decoder(flags, new byte[len * 3 / 4]); + + if (!decoder.process(input, offset, len, true)) { + throw new IllegalArgumentException("bad base-64"); + } + + // Maybe we got lucky and allocated exactly enough output space. + if (decoder.op == decoder.output.length) { + return decoder.output; + } + + // Need to shorten the array, so allocate a new one of the + // right size and copy. + byte[] temp = new byte[decoder.op]; + System.arraycopy(decoder.output, 0, temp, 0, decoder.op); + return temp; + } + + /* package */ static class Decoder extends Coder { + /** + * Lookup table for turning bytes into their position in the + * Base64 alphabet. + */ + private static final int DECODE[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + /** + * Decode lookup table for the "web safe" variant (RFC 3548 + * sec. 4) where - and _ replace + and /. + */ + private static final int DECODE_WEBSAFE[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + /** + * Non-data values in the DECODE arrays. + */ + private static final int SKIP = -1; + private static final int EQUALS = -2; + + /** + * States 0-3 are reading through the next input tuple. + * State 4 is having read one '=' and expecting exactly + * one more. + * State 5 is expecting no more data or padding characters + * in the input. + * State 6 is the error state; an error has been detected + * in the input and no future input can "fix" it. + */ + private int state; // state number (0 to 6) + private int value; + + final private int[] alphabet; + + public Decoder(int flags, byte[] output) { + this.output = output; + + alphabet = ((flags & URL_SAFE) == 0) ? DECODE : DECODE_WEBSAFE; + state = 0; + value = 0; + } + + /** + * @return an overestimate for the number of bytes {@code + * len} bytes could decode to. + */ + public int maxOutputSize(int len) { + return len * 3 / 4 + 10; + } + + /** + * Decode another block of input data. + * + * @return true if the state machine is still healthy. false if + * bad base-64 data has been detected in the input stream. + */ + public boolean process(byte[] input, int offset, int len, boolean finish) { + if (this.state == 6) return false; + + int p = offset; + len += offset; + + // Using local variables makes the decoder about 12% + // faster than if we manipulate the member variables in + // the loop. (Even alphabet makes a measurable + // difference, which is somewhat surprising to me since + // the member variable is final.) + int state = this.state; + int value = this.value; + int op = 0; + final byte[] output = this.output; + final int[] alphabet = this.alphabet; + + while (p < len) { + // Try the fast path: we're starting a new tuple and the + // next four bytes of the input stream are all data + // bytes. This corresponds to going through states + // 0-1-2-3-0. We expect to use this method for most of + // the data. + // + // If any of the next four bytes of input are non-data + // (whitespace, etc.), value will end up negative. (All + // the non-data values in decode are small negative + // numbers, so shifting any of them up and or'ing them + // together will result in a value with its top bit set.) + // + // You can remove this whole block and the output should + // be the same, just slower. + if (state == 0) { + while (p + 4 <= len && + (value = ((alphabet[input[p] & 0xff] << 18) | + (alphabet[input[p + 1] & 0xff] << 12) | + (alphabet[input[p + 2] & 0xff] << 6) | + (alphabet[input[p + 3] & 0xff]))) >= 0) { + output[op + 2] = (byte) value; + output[op + 1] = (byte) (value >> 8); + output[op] = (byte) (value >> 16); + op += 3; + p += 4; + } + if (p >= len) break; + } + + // The fast path isn't available -- either we've read a + // partial tuple, or the next four input bytes aren't all + // data, or whatever. Fall back to the slower state + // machine implementation. + + int d = alphabet[input[p++] & 0xff]; + + switch (state) { + case 0: + if (d >= 0) { + value = d; + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 1: + if (d >= 0) { + value = (value << 6) | d; + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 2: + if (d >= 0) { + value = (value << 6) | d; + ++state; + } else if (d == EQUALS) { + // Emit the last (partial) output tuple; + // expect exactly one more padding character. + output[op++] = (byte) (value >> 4); + state = 4; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 3: + if (d >= 0) { + // Emit the output triple and return to state 0. + value = (value << 6) | d; + output[op + 2] = (byte) value; + output[op + 1] = (byte) (value >> 8); + output[op] = (byte) (value >> 16); + op += 3; + state = 0; + } else if (d == EQUALS) { + // Emit the last (partial) output tuple; + // expect no further data or padding characters. + output[op + 1] = (byte) (value >> 2); + output[op] = (byte) (value >> 10); + op += 2; + state = 5; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 4: + if (d == EQUALS) { + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 5: + if (d != SKIP) { + this.state = 6; + return false; + } + break; + } + } + + if (!finish) { + // We're out of input, but a future call could provide + // more. + this.state = state; + this.value = value; + this.op = op; + return true; + } + + // Done reading input. Now figure out where we are left in + // the state machine and finish up. + + switch (state) { + case 0: + // Output length is a multiple of three. Fine. + break; + case 1: + // Read one extra input byte, which isn't enough to + // make another output byte. Illegal. + this.state = 6; + return false; + case 2: + // Read two extra input bytes, enough to emit 1 more + // output byte. Fine. + output[op++] = (byte) (value >> 4); + break; + case 3: + // Read three extra input bytes, enough to emit 2 more + // output bytes. Fine. + output[op++] = (byte) (value >> 10); + output[op++] = (byte) (value >> 2); + break; + case 4: + // Read one padding '=' when we expected 2. Illegal. + this.state = 6; + return false; + case 5: + // Read all the padding '='s we expected and no more. + // Fine. + break; + } + + this.state = state; + this.op = op; + return true; + } + } + + // -------------------------------------------------------- + // encoding + // -------------------------------------------------------- + + /** + * Base64-encode the given data and return a newly allocated + * String with the result. + * + * @param input the data to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + * @return the encoded data as a string + */ + public static String encodeToString(byte[] input, int flags) { + try { + return new String(encode(input, flags), "US-ASCII"); + } catch (UnsupportedEncodingException e) { + // US-ASCII is guaranteed to be available. + throw new AssertionError(e); + } + } + + /** + * Base64-encode the given data and return a newly allocated + * String with the result. + * + * @param input the data to encode + * @param offset the position within the input array at which to + * start + * @param len the number of bytes of input to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + * @return the encoded data as a string + */ + public static String encodeToString(byte[] input, int offset, int len, int flags) { + try { + return new String(encode(input, offset, len, flags), "US-ASCII"); + } catch (UnsupportedEncodingException e) { + // US-ASCII is guaranteed to be available. + throw new AssertionError(e); + } + } + + /** + * Base64-encode the given data and return a newly allocated + * byte[] with the result. + * + * @param input the data to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + * @return the encoded data as a byte array + */ + public static byte[] encode(byte[] input, int flags) { + return encode(input, 0, input.length, flags); + } + + /** + * Base64-encode the given data and return a newly allocated + * byte[] with the result. + * + * @param input the data to encode + * @param offset the position within the input array at which to + * start + * @param len the number of bytes of input to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + * @return the encoded data as a byte array + */ + public static byte[] encode(byte[] input, int offset, int len, int flags) { + Encoder encoder = new Encoder(flags, null); + + // Compute the exact length of the array we will produce. + int output_len = len / 3 * 4; + + // Account for the tail of the data and the padding bytes, if any. + if (encoder.do_padding) { + if (len % 3 > 0) { + output_len += 4; + } + } else { + switch (len % 3) { + case 0: + break; + case 1: + output_len += 2; + break; + case 2: + output_len += 3; + break; + } + } + + // Account for the newlines, if any. + if (encoder.do_newline && len > 0) { + output_len += (((len - 1) / (3 * Encoder.LINE_GROUPS)) + 1) * + (encoder.do_cr ? 2 : 1); + } + + encoder.output = new byte[output_len]; + encoder.process(input, offset, len, true); + + if (encoder.op != output_len) { + throw new AssertionError(); + } + + return encoder.output; + } + + /* package */ static class Encoder extends Coder { + /** + * Emit a new line every this many output tuples. Corresponds to + * a 76-character line length (the maximum allowable according to + * RFC 2045). + */ + public static final int LINE_GROUPS = 19; + + /** + * Lookup table for turning Base64 alphabet positions (6 bits) + * into output bytes. + */ + private static final byte ENCODE[] = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/', + }; + + /** + * Lookup table for turning Base64 alphabet positions (6 bits) + * into output bytes. + */ + private static final byte ENCODE_WEBSAFE[] = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_', + }; + + final private byte[] tail; + /* package */ int tailLen; + private int count; + + final public boolean do_padding; + final public boolean do_newline; + final public boolean do_cr; + final private byte[] alphabet; + + public Encoder(int flags, byte[] output) { + this.output = output; + + do_padding = (flags & NO_PADDING) == 0; + do_newline = (flags & NO_WRAP) == 0; + do_cr = (flags & CRLF) != 0; + alphabet = ((flags & URL_SAFE) == 0) ? ENCODE : ENCODE_WEBSAFE; + + tail = new byte[2]; + tailLen = 0; + + count = do_newline ? LINE_GROUPS : -1; + } + + /** + * @return an overestimate for the number of bytes {@code + * len} bytes could encode to. + */ + public int maxOutputSize(int len) { + return len * 8 / 5 + 10; + } + + public boolean process(byte[] input, int offset, int len, boolean finish) { + // Using local variables makes the encoder about 9% faster. + final byte[] alphabet = this.alphabet; + final byte[] output = this.output; + int op = 0; + int count = this.count; + + int p = offset; + len += offset; + int v = -1; + + // First we need to concatenate the tail of the previous call + // with any input bytes available now and see if we can empty + // the tail. + + switch (tailLen) { + case 0: + // There was no tail. + break; + + case 1: + if (p + 2 <= len) { + // A 1-byte tail with at least 2 bytes of + // input available now. + v = ((tail[0] & 0xff) << 16) | + ((input[p++] & 0xff) << 8) | + (input[p++] & 0xff); + tailLen = 0; + } + break; + + case 2: + if (p + 1 <= len) { + // A 2-byte tail with at least 1 byte of input. + v = ((tail[0] & 0xff) << 16) | + ((tail[1] & 0xff) << 8) | + (input[p++] & 0xff); + tailLen = 0; + } + break; + } + + if (v != -1) { + output[op++] = alphabet[(v >> 18) & 0x3f]; + output[op++] = alphabet[(v >> 12) & 0x3f]; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (--count == 0) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + count = LINE_GROUPS; + } + } + + // At this point either there is no tail, or there are fewer + // than 3 bytes of input available. + + // The main loop, turning 3 input bytes into 4 output bytes on + // each iteration. + while (p + 3 <= len) { + v = ((input[p] & 0xff) << 16) | + ((input[p + 1] & 0xff) << 8) | + (input[p + 2] & 0xff); + output[op] = alphabet[(v >> 18) & 0x3f]; + output[op + 1] = alphabet[(v >> 12) & 0x3f]; + output[op + 2] = alphabet[(v >> 6) & 0x3f]; + output[op + 3] = alphabet[v & 0x3f]; + p += 3; + op += 4; + if (--count == 0) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + count = LINE_GROUPS; + } + } + + if (finish) { + // Finish up the tail of the input. Note that we need to + // consume any bytes in tail before any bytes + // remaining in input; there should be at most two bytes + // total. + + if (p - tailLen == len - 1) { + int t = 0; + v = ((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 4; + tailLen -= t; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (do_padding) { + output[op++] = '='; + output[op++] = '='; + } + if (do_newline) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + } + } else if (p - tailLen == len - 2) { + int t = 0; + v = (((tailLen > 1 ? tail[t++] : input[p++]) & 0xff) << 10) | + (((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 2); + tailLen -= t; + output[op++] = alphabet[(v >> 12) & 0x3f]; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (do_padding) { + output[op++] = '='; + } + if (do_newline) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + } + } else if (do_newline && op > 0 && count != LINE_GROUPS) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + } + + if (tailLen != 0) { + AvalancheLog.error(TAG, "Error during encoding"); + //throw new AssertionError(); + } + if (p != len) { + AvalancheLog.error(TAG, "Error during encoding"); + //throw new AssertionError(); + } + } else { + // Save the leftovers in tail to be consumed on the next + // call to encodeInternal. + + if (p == len - 1) { + tail[tailLen++] = input[p]; + } else if (p == len - 2) { + tail[tailLen++] = input[p]; + tail[tailLen++] = input[p + 1]; + } + } + + this.op = op; + this.count = count; + + return true; + } + } + + private Base64() { + } // don't instantiate +} diff --git a/base/src/main/java/avalanche/base/utils/HttpURLConnectionBuilder.java b/base/src/main/java/avalanche/base/utils/HttpURLConnectionBuilder.java new file mode 100644 index 0000000000..4b0a37492f --- /dev/null +++ b/base/src/main/java/avalanche/base/utils/HttpURLConnectionBuilder.java @@ -0,0 +1,162 @@ +package avalanche.base.utils; + +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; +import avalanche.base.Constants; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + *

Description

+ * + * Builder class for HttpURLConnection. + * + **/ +public class HttpURLConnectionBuilder { + + private static final int DEFAULT_TIMEOUT = 2 * 60 * 1000; + public static final String DEFAULT_CHARSET = "UTF-8"; + + private final String mUrlString; + + private String mRequestMethod; + private String mRequestBody; + private SimpleMultipartEntity mMultipartEntity; + private int mTimeout = DEFAULT_TIMEOUT; + + private final Map mHeaders; + + public HttpURLConnectionBuilder(String urlString) { + mUrlString = urlString; + mHeaders = new HashMap(); + mHeaders.put("User-Agent", Constants.SDK_USER_AGENT); + } + + public HttpURLConnectionBuilder setRequestMethod(String requestMethod) { + mRequestMethod = requestMethod; + return this; + } + + public HttpURLConnectionBuilder setRequestBody(String requestBody) { + mRequestBody = requestBody; + return this; + } + + public HttpURLConnectionBuilder writeFormFields(Map fields) { + try { + String formString = getFormString(fields, DEFAULT_CHARSET); + setHeader("Content-Type", "application/x-www-form-urlencoded"); + setRequestBody(formString); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + return this; + } + + public HttpURLConnectionBuilder writeMultipartData(Map fields, Context context, List attachmentUris) { + try { + mMultipartEntity = new SimpleMultipartEntity(); + mMultipartEntity.writeFirstBoundaryIfNeeds(); + + for (String key : fields.keySet()) { + mMultipartEntity.addPart(key, fields.get(key)); + } + + for (int i = 0; i < attachmentUris.size(); i++) { + Uri attachmentUri = attachmentUris.get(i); + boolean lastFile = (i == attachmentUris.size() - 1); + + InputStream input = context.getContentResolver().openInputStream(attachmentUri); + String filename = attachmentUri.getLastPathSegment(); + mMultipartEntity.addPart("attachment" + i, filename, input, lastFile); + } + mMultipartEntity.writeLastBoundaryIfNeeds(); + + setHeader("Content-Type", "multipart/form-data; boundary=" + mMultipartEntity.getBoundary()); + + } catch (IOException e) { + throw new RuntimeException(e); + } + + return this; + } + + public HttpURLConnectionBuilder setTimeout(int timeout) { + if (timeout < 0) { + throw new IllegalArgumentException("Timeout has to be positive."); + } + mTimeout = timeout; + return this; + } + + public HttpURLConnectionBuilder setHeader(String name, String value) { + mHeaders.put(name, value); + return this; + } + + public HttpURLConnectionBuilder setBasicAuthorization(String username, String password) { + String authString = "Basic " + avalanche.base.utils.Base64.encodeToString( + (username + ":" + password).getBytes(), android.util.Base64.NO_WRAP); + + setHeader("Authorization", authString); + return this; + } + + public HttpURLConnection build() throws IOException { + HttpURLConnection connection; + URL url = new URL(mUrlString); + connection = (HttpURLConnection) url.openConnection(); + + connection.setConnectTimeout(mTimeout); + connection.setReadTimeout(mTimeout); + + if (!TextUtils.isEmpty(mRequestMethod)) { + connection.setRequestMethod(mRequestMethod); + if (!TextUtils.isEmpty(mRequestBody) || mRequestMethod.equalsIgnoreCase("POST") || mRequestMethod.equalsIgnoreCase("PUT")) { + connection.setDoOutput(true); + } + } + + for (String name : mHeaders.keySet()) { + connection.setRequestProperty(name, mHeaders.get(name)); + } + + if (!TextUtils.isEmpty(mRequestBody)) { + OutputStream outputStream = connection.getOutputStream(); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, DEFAULT_CHARSET)); + writer.write(mRequestBody); + writer.flush(); + writer.close(); + } + + if (mMultipartEntity != null) { + connection.setRequestProperty("Content-Length", String.valueOf(mMultipartEntity.getContentLength())); + BufferedOutputStream outputStream = new BufferedOutputStream(connection.getOutputStream()); + outputStream.write(mMultipartEntity.getOutputStream().toByteArray()); + outputStream.flush(); + outputStream.close(); + } + + return connection; + } + + private static String getFormString(Map params, String charset) throws UnsupportedEncodingException { + List protoList = new ArrayList(); + for (String key : params.keySet()) { + String value = params.get(key); + key = URLEncoder.encode(key, charset); + value = URLEncoder.encode(value, charset); + protoList.add(key + "=" + value); + } + return TextUtils.join("&", protoList); + } + +} diff --git a/base/src/main/java/avalanche/base/utils/SimpleMultipartEntity.java b/base/src/main/java/avalanche/base/utils/SimpleMultipartEntity.java new file mode 100644 index 0000000000..94994c610c --- /dev/null +++ b/base/src/main/java/avalanche/base/utils/SimpleMultipartEntity.java @@ -0,0 +1,134 @@ +package avalanche.base.utils; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Random; + +/** + *

Description

+ * + * To avoid external apache library "httpmime" this is a simple implementation for a MultipartEntity. + * Please note that first all key value pairs have to be written and then at least one file part has to be added. + * Otherwise the boundaries are not written correctly. + * + */ +public class SimpleMultipartEntity { + + private final static char[] BOUNDARY_CHARS = "-_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + + private boolean mIsSetLast; + + private boolean mIsSetFirst; + + private ByteArrayOutputStream mOut; + + private String mBoundary; + + public SimpleMultipartEntity() { + this.mIsSetFirst = false; + this.mIsSetLast = false; + this.mOut = new ByteArrayOutputStream(); + + /** Create boundary String */ + final StringBuffer buffer = new StringBuffer(); + final Random rand = new Random(); + + for (int i = 0; i < 30; i++) { + buffer.append(BOUNDARY_CHARS[rand.nextInt(BOUNDARY_CHARS.length)]); + } + this.mBoundary = buffer.toString(); + } + + public String getBoundary() { + return mBoundary; + } + + public void writeFirstBoundaryIfNeeds() throws IOException { + if (!mIsSetFirst) { + mOut.write(("--" + mBoundary + "\r\n").getBytes()); + } + mIsSetFirst = true; + } + + public void writeLastBoundaryIfNeeds() { + if (mIsSetLast) { + return; + } + try { + mOut.write(("\r\n--" + mBoundary + "--\r\n").getBytes()); + + } catch (final IOException e) { + e.printStackTrace(); + } + mIsSetLast = true; + } + + public void addPart(final String key, final String value) throws IOException { + writeFirstBoundaryIfNeeds(); + + mOut.write(("Content-Disposition: form-data; name=\"" + key + "\"\r\n").getBytes()); + mOut.write("Content-Type: text/plain; charset=UTF-8\r\n".getBytes()); + mOut.write("Content-Transfer-Encoding: 8bit\r\n\r\n".getBytes()); + mOut.write(value.getBytes()); + mOut.write(("\r\n--" + mBoundary + "\r\n").getBytes()); + } + + public void addPart(final String key, final File value, boolean lastFile) throws IOException { + addPart(key, value.getName(), new FileInputStream(value), lastFile); + } + + public void addPart(final String key, final String fileName, final InputStream fin, boolean lastFile) throws IOException { + addPart(key, fileName, fin, "application/octet-stream", lastFile); + } + + public void addPart(final String key, final String fileName, final InputStream fin, String type, boolean lastFile) throws IOException { + writeFirstBoundaryIfNeeds(); + try { + type = "Content-Type: " + type + "\r\n"; + mOut.write(("Content-Disposition: form-data; name=\"" + key + "\"; filename=\"" + fileName + "\"\r\n").getBytes()); + mOut.write(type.getBytes()); + mOut.write("Content-Transfer-Encoding: binary\r\n\r\n".getBytes()); + + final byte[] tmp = new byte[4096]; + int l = 0; + while ((l = fin.read(tmp)) != -1) { + mOut.write(tmp, 0, l); + } + mOut.flush(); + + if (lastFile) { + /** This is the last file: write last boundary. */ + writeLastBoundaryIfNeeds(); + + } else { + /** Another file will follow: write normal boundary. */ + mOut.write(("\r\n--" + mBoundary + "\r\n").getBytes()); + } + + } finally { + try { + fin.close(); + } catch (final IOException e) { + e.printStackTrace(); + } + } + } + + public long getContentLength() { + writeLastBoundaryIfNeeds(); + return mOut.toByteArray().length; + } + + public String getContentType() { + return "multipart/form-data; boundary=" + getBoundary(); + } + + public ByteArrayOutputStream getOutputStream() { + writeLastBoundaryIfNeeds(); + return mOut; + } + +} diff --git a/base/src/main/java/avalanche/base/utils/Util.java b/base/src/main/java/avalanche/base/utils/Util.java new file mode 100644 index 0000000000..6b2aab03b9 --- /dev/null +++ b/base/src/main/java/avalanche/base/utils/Util.java @@ -0,0 +1,404 @@ +package avalanche.base.utils; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; +import java.lang.reflect.Method; +import java.net.URLEncoder; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Util { + + public static final String PREFS_FEEDBACK_TOKEN = "com.microsoft.android.prefs_feedback_token"; + public static final String PREFS_KEY_FEEDBACK_TOKEN = "com.microsoft.android.prefs_key_feedback_token"; + + public static final String PREFS_NAME_EMAIL_SUBJECT = "com.microsoft.android.prefs_name_email"; + public static final String PREFS_KEY_NAME_EMAIL_SUBJECT = "com.microsoft.android.prefs_key_name_email"; + public static final String APP_IDENTIFIER_PATTERN = "[0-9a-f]+"; + public static final int APP_IDENTIFIER_LENGTH = 32; + public static final String APP_IDENTIFIER_KEY = "com.microsoft.android.appIdentifier"; + public static final String LOG_IDENTIFIER = "AvalancheHub"; + private static final String APP_SECRET_KEY = "com.microsoft.android.appSecret"; + private static final Pattern appIdentifierPattern = Pattern.compile(APP_IDENTIFIER_PATTERN, Pattern.CASE_INSENSITIVE); + + private static final String SDK_VERSION_KEY = "com.microsoft.android.sdkVersion"; + + private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + + private static final ThreadLocal DATE_FORMAT_THREAD_LOCAL = new ThreadLocal() { + @Override + protected DateFormat initialValue() { + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + return dateFormat; + } + }; + + /** + * Returns the given param URL-encoded. + * + * @param param a string to encode + * @return the encoded param + */ + public static String encodeParam(String param) { + try { + return URLEncoder.encode(param, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // UTF-8 should be available, so just in case + e.printStackTrace(); + return ""; + } + } + + /** + * Returns true if value is a valid email. + * + * @param value a string + * @return true if value is a valid email + */ + public final static boolean isValidEmail(String value) { + return !TextUtils.isEmpty(value) && android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches(); + } + + /** + * Returns true if the Fragment API is supported (should be on Android 3.0+). + * + * @return true if the Fragment API is supported + */ + @SuppressLint("NewApi") + public static Boolean fragmentsSupported() { + try { + return classExists("android.app.Fragment"); + } catch (NoClassDefFoundError e) { + return false; + } + } + + /** + * Returns true if the app runs on large or very large screens (i.e. tablets). + * + * @param weakActivity the context to use + * @return true if the app runs on large or very large screens + */ + public static Boolean runsOnTablet(WeakReference weakActivity) { + if (weakActivity != null) { + Activity activity = weakActivity.get(); + if (activity != null) { + Configuration configuration = activity.getResources().getConfiguration(); + + return (((configuration.screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_LARGE) || + ((configuration.screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE)); + } + } + + return false; + } + + /** + * Sanitizes an app identifier or throws an exception if it can't be sanitized. + * + * @param appIdentifier the app identifier to sanitize + * @return the sanitized app identifier + * @throws IllegalArgumentException if the app identifier can't be sanitized because of unrecoverable input character errors + */ + public static String sanitizeAppIdentifier(String appIdentifier) throws IllegalArgumentException { + + if (appIdentifier == null) { + throw new IllegalArgumentException("App ID must not be null."); + } + + String sAppIdentifier = appIdentifier.trim(); + + Matcher matcher = appIdentifierPattern.matcher(sAppIdentifier); + + if (sAppIdentifier.length() != APP_IDENTIFIER_LENGTH) { + throw new IllegalArgumentException("App ID length must be " + APP_IDENTIFIER_LENGTH + " characters."); + } else if (!matcher.matches()) { + throw new IllegalArgumentException("App ID must match regex pattern /" + APP_IDENTIFIER_PATTERN + "/i"); + } + + return sAppIdentifier; + } + + /** + * Converts a map of parameters to a HTML form entity. + * + * @param params the parameters + * @return an URL-encoded form string ready for use in a HTTP post + * @throws UnsupportedEncodingException when your system does not know how to handle the UTF-8 charset + */ + public static String getFormString(Map params) throws UnsupportedEncodingException { + List protoList = new ArrayList(); + for (String key : params.keySet()) { + String value = params.get(key); + key = URLEncoder.encode(key, "UTF-8"); + value = URLEncoder.encode(value, "UTF-8"); + protoList.add(key + "=" + value); + } + return TextUtils.join("&", protoList); + } + + /** + * Helper method to safely check whether a class exists at runtime. + * + * @param className the full-qualified class name to check for + * @return whether the class exists + */ + public static boolean classExists(String className) { + try { + return Class.forName(className) != null; + } catch (ClassNotFoundException e) { + return false; + } + } + + /** + * Checks if the Notification.Builder API is supported. + * + * @return if builder API is supported + */ + public static boolean isNotificationBuilderSupported() { + return (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) && classExists("android.app.Notification.Builder"); + } + + /** + * Creates a notification on API levels from 9 to 23 + * + * @param context the context to use, e.g. your Activity + * @param pendingIntent the Intent to call + * @param title the title string for the notification + * @param text the text content for the notificationcrash + * @param iconId the icon resource ID for the notification + * @return the created notification + */ + public static Notification createNotification(Context context, PendingIntent pendingIntent, String title, String text, int iconId) { + Notification notification; + if (Util.isNotificationBuilderSupported()) { + notification = buildNotificationWithBuilder(context, pendingIntent, title, text, iconId); + } else { + notification = buildNotificationPreHoneycomb(context, pendingIntent, title, text, iconId); + } + return notification; + } + + @SuppressWarnings("deprecation") + private static Notification buildNotificationPreHoneycomb(Context context, PendingIntent pendingIntent, String title, String text, int iconId) { + Notification notification = new Notification(iconId, "", System.currentTimeMillis()); + try { + // try to call "setLatestEventInfo" if available + Method m = notification.getClass().getMethod("setLatestEventInfo", Context.class, CharSequence.class, CharSequence.class, PendingIntent.class); + m.invoke(notification, context, title, text, pendingIntent); + } catch (Exception e) { + // do nothing + } + return notification; + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + @SuppressWarnings("deprecation") + private static Notification buildNotificationWithBuilder(Context context, PendingIntent pendingIntent, String title, String text, int iconId) { + Notification.Builder builder = new Notification.Builder(context) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(pendingIntent) + .setSmallIcon(iconId); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { + return builder.getNotification(); + } else { + return builder.build(); + } + } + + /** + * Retrieve the AvalancheHub AppIdentifier from the Manifest + * + * @param context usually your Activity + * @return the AvalancheHub AppIdentifier + */ + public static String getAppIdentifier(Context context) { + return getManifestString(context, APP_IDENTIFIER_KEY); + } + + /** + * Retrieve the AvalancheHub appSecret from the Manifest + * + * @param context usually your Activity + * @return the AvalancheHub appSecret + */ + public static String getAppSecret(Context context) { + return getManifestString(context, APP_SECRET_KEY); + } + + public static String getManifestString(Context context, String key) { + return getBundle(context).getString(key); + } + + private static Bundle getBundle(Context context) { + Bundle bundle; + try { + bundle = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA).metaData; + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException(e); + } + return bundle; + } + + public static boolean isConnectedToNetwork(Context context) { + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivityManager != null) { + NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo(); + return activeNetwork != null && activeNetwork.isConnected(); + } + return false; + } + + public static String getAppName(Context context) { + if (context == null) { + return ""; + } + + PackageManager packageManager = context.getPackageManager(); + ApplicationInfo applicationInfo = null; + try { + applicationInfo = packageManager.getApplicationInfo(context.getApplicationInfo().packageName, 0); + } catch (final PackageManager.NameNotFoundException e) { + } + String appTitle = (applicationInfo != null ? (String) packageManager.getApplicationLabel(applicationInfo) + : context.getString(0)); + return appTitle; + } + + public static String getSdkVersionFromManifest(Context context) { + return getManifestString(context, SDK_VERSION_KEY); + } + + /** + * Sanitizes an app identifier and adds dashes to it so that it conforms to the instrumentation + * key format of Application Insights. + * + * @param appIdentifier the app identifier to sanitize and convert + * @return the converted appIdentifier + * @throws IllegalArgumentException if the app identifier can't be converted because + * of unrecoverable input character errors + */ + public static String convertAppIdentifierToGuid(String appIdentifier) throws + IllegalArgumentException { + String sanitizedAppIdentifier = null; + String guid = null; + + try { + sanitizedAppIdentifier = sanitizeAppIdentifier(appIdentifier); + } catch (IllegalArgumentException e) { + throw e; + } + + if (sanitizedAppIdentifier != null) { + StringBuffer idBuf = new StringBuffer(sanitizedAppIdentifier); + idBuf.insert(20, '-'); + idBuf.insert(16, '-'); + idBuf.insert(12, '-'); + idBuf.insert(8, '-'); + guid = idBuf.toString(); + } + return guid; + } + + /** + * Determines whether the app is running on aan emulator or on a real device. + * + * @return YES if the app is running on an emulator, NO if it is running on a real device + */ + public static boolean isEmulator() { + return Build.BRAND.equalsIgnoreCase("generic"); + } + + /** + * Convert a date object to an ISO 8601 formatted string + * + * @param date the date object to be formatted + * @return an ISO 8601 string representation of the date + */ + public static String dateToISO8601(Date date) { + Date localDate = date; + if (localDate == null) { + localDate = new Date(); + } + return DATE_FORMAT_THREAD_LOCAL.get().format(localDate); + } + + /** + * Returns a string created by each element of the array, separated by + * delimiter. + */ + public static String joinArray(String[] array, String delimiter) { + StringBuffer buffer = new StringBuffer(); + for (int index = 0; index < array.length; index++) { + buffer.append(array[index]); + if (index < array.length - 1) { + buffer.append(delimiter); + } + } + return buffer.toString(); + } + + /** + * Returns the content of a file as a string. + */ + public static String contentsOfFile(Context context, String filename) { + StringBuilder contents = new StringBuilder(); + BufferedReader reader = null; + try { + reader = new BufferedReader(new InputStreamReader(context.openFileInput(filename))); + String line = null; + while ((line = reader.readLine()) != null) { + contents.append(line); + contents.append(System.getProperty("line.separator")); + } + } catch (FileNotFoundException e) { + } catch (IOException e) { + e.printStackTrace(); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException ignored) { + } + } + } + + return contents.toString(); + } + + public static boolean isMainActivity(Activity activity) { + return activity.getIntent().getAction().equals(Intent.ACTION_MAIN); + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml new file mode 100644 index 0000000000..a9ddb621b1 --- /dev/null +++ b/base/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + This app + + + OK + Cancel + Error + An error has occurred + + + Your device is not connected to the internet. Please resolve connectivity issues and try again. + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..a667898f8e --- /dev/null +++ b/build.gradle @@ -0,0 +1,58 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:2.1.2' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + } + + apply plugin: 'checkstyle' + + //add checkstyle to each module + task checkstyle(type: Checkstyle) { + configFile = new File(rootDir, "config/checkstyle/checkstyle.xml") //use same config file for each module + source 'src/' + include '**/*.java' + exclude '**/gen/**' + classpath = files() + reports { + xml { + destination "build/outputs/reports/checkstyle-results.xml" + } + } + } + + task checkstyleReport(dependsOn: 'checkstyle') << { + if (file("build/outputs/reports/checkstyle-results.xml").exists()) { + ant.xslt(in: "build/outputs/reports/checkstyle-results.xml", + style: "config/checkstyle/checkstyle.xsl", + out: "build/outputs/reports/checkstyle-results.html" + ) + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + +ext { + versionCode = 1 + versionName = '1.0.0-alpha.1' + minSdkVersion = 15 + targetSdkVersion = 23 + compileSdkVersion = 23 + buildToolsVersion = '23.0.3' + supportLibVersion = '24.0.0' +} \ No newline at end of file diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000000..6adbd02d2b --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/checkstyle/checkstyle.xsl b/config/checkstyle/checkstyle.xsl new file mode 100644 index 0000000000..73338c343c --- /dev/null +++ b/config/checkstyle/checkstyle.xsl @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

CheckStyle Audit

+
Designed for use with + CheckStyle + andAnt. +
+
+ + + +
+ + + +
+ + + + +
+ + + + +
+ + + +

Files

+ + + + + + + + + + + + + + +
NameErrors
+ + + + + +
+
+ + + + +

File + +

+ + + + + + + + + + + + + + +
Error DescriptionLine
+ + + +
+ Back to top +
+ + + +

Summary

+ + + + + + + + + + + + +
FilesErrors
+ + + +
+
+ + + + a + b + + +
diff --git a/crash/build.gradle b/crash/build.gradle new file mode 100644 index 0000000000..396d547f71 --- /dev/null +++ b/crash/build.gradle @@ -0,0 +1,45 @@ +apply plugin: 'com.android.library' +//apply plugin: 'checkstyle' + +def supportLibVersion = rootProject.ext.supportLibVersion + + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode rootProject.ext.versionCode + versionName rootProject.ext.versionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'proguard-rules.pro' + } + buildTypes { + release { + minifyEnabled false + } + } + lintOptions { + disable 'GoogleAppIndexingWarning' + } +} + +dependencies { + compile project(':base') + + //Mocking + androidTestCompile 'org.mockito:mockito-core:1.10.19' + androidTestCompile 'com.crittercism.dexmaker:dexmaker:1.4' + androidTestCompile 'com.crittercism.dexmaker:dexmaker-dx:1.4' + androidTestCompile 'com.crittercism.dexmaker:dexmaker-mockito:1.4' + + androidTestCompile 'org.hamcrest:hamcrest-core:1.3' + androidTestCompile 'org.hamcrest:hamcrest-library:1.3' + testCompile 'junit:junit:4.12' + + androidTestCompile "com.android.support:support-annotations:$supportLibVersion" + androidTestCompile 'com.android.support.test:runner:0.5' + androidTestCompile 'com.android.support.test:rules:0.5' +} \ No newline at end of file diff --git a/crash/proguard-rules.pro b/crash/proguard-rules.pro new file mode 100644 index 0000000000..7f265ec541 --- /dev/null +++ b/crash/proguard-rules.pro @@ -0,0 +1,4 @@ +# The following options are set by default. +# Make sure they are always set, even if the default proguard config changes. +-dontskipnonpubliclibraryclasses +-verbose \ No newline at end of file diff --git a/crash/src/main/AndroidManifest.xml b/crash/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..4d1bdc602e --- /dev/null +++ b/crash/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/crash/src/main/java/avalanche/crash/Crashes.java b/crash/src/main/java/avalanche/crash/Crashes.java new file mode 100644 index 0000000000..de26fee0a1 --- /dev/null +++ b/crash/src/main/java/avalanche/crash/Crashes.java @@ -0,0 +1,647 @@ +package avalanche.crash; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import avalanche.base.AvalancheHub; +import avalanche.base.Constants; +import avalanche.base.DefaultAvalancheFeature; + + + +import avalanche.crash.model.CrashReport; +import avalanche.crash.model.CrashesUserInput; +import avalanche.crash.model.CrashMetaData; +import avalanche.base.utils.AvalancheLog; +import avalanche.base.utils.HttpURLConnectionBuilder; +import avalanche.base.utils.Util; + +import java.io.File; +import java.io.FilenameFilter; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.net.HttpURLConnection; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static android.text.TextUtils.isEmpty; + + +public class Crashes extends DefaultAvalancheFeature { + + private static final String ALWAYS_SEND_KEY = "always_send_crash_reports"; + + private static final int STACK_TRACES_FOUND_NONE = 0; + private static final int STACK_TRACES_FOUND_NEW = 1; + private static final int STACK_TRACES_FOUND_CONFIRMED = 2; + private static Crashes sharedInstance = null; + private CrashesListener mListener; + private String mEndpointUrl; + private WeakReference mContextWeakReference; + private boolean mLazyExecution; + private boolean mIsSubmitting = false; + private long mInitializeTimestamp; + private boolean mDidCrashInLastSession = false; + + protected Crashes() { + } + + public static Crashes getInstance() { + if (sharedInstance == null) { + sharedInstance = new Crashes(); + } + return sharedInstance; + } + + /** + * Searches .stacktrace files and returns them as array. + */ + private static String[] searchForStackTraces() { + if (Constants.FILES_PATH != null) { + AvalancheLog.debug("Looking for exceptions in: " + Constants.FILES_PATH); + + // Try to create the files folder if it doesn't exist + File dir = new File(Constants.FILES_PATH + "/"); + boolean created = dir.mkdir(); + if (!created && !dir.exists()) { + return new String[0]; + } + + // Filter for ".stacktrace" files + FilenameFilter filter = new FilenameFilter() { + public boolean accept(File dir, String name) { + return name.endsWith(".stacktrace"); + } + }; + return dir.list(filter); + } else { + AvalancheLog.debug("Can't search for exception as file path is null."); + return null; + } + } + + private static List getConfirmedFilenames(Context context) { + List result = null; + if (context != null) { + SharedPreferences preferences = context.getSharedPreferences("AvalancheSDK", Context.MODE_PRIVATE); + result = Arrays.asList(preferences.getString("ConfirmedFilenames", "").split("\\|")); + } + return result; + } + + private static String getAlertTitle(Context context) { + String appTitle = Util.getAppName(context); + + String message = context.getString(R.string.avalanche_crash_dialog_title); + return String.format(message, appTitle); + } + + @Override + public void onActivityResumed(Activity activity) { + super.onActivityResumed(activity); + if (mContextWeakReference == null && Util.isMainActivity(activity)) { + // Opinionated approach -> per default we will want to activate the crash reporting with the very first of your activities. + register(activity); + } + } + + /** + * Registers the crash manager and handles existing crash logs. + * AvalancheHub app identifier is read from configuration values in manifest. + * + * @param context The context to use. Usually your Activity object. If + * context is not an instance of Activity (or a subclass of it), + * crashes will be sent automatically. + * @return The configured crash manager for method chaining. + */ + public Crashes register(Context context) { + return register(context, null); + } + + /** + * Registers the crash manager and handles existing crash logs. + * AvalancheHub app identifier is read from configuration values in manifest. + * + * @param context The context to use. Usually your Activity object. If + * context is not an instance of Activity (or a subclass of it), + * crashes will be sent automatically. + * @param listener Implement this for callback functions. + * @return The configured crash manager for method chaining. + */ + public Crashes register(Context context, CrashesListener listener) { + return register(context, Constants.BASE_URL, listener); + } + + /** + * Registers the crash manager and handles existing crash logs. + * AvalancheHub app identifier is read from configuration values in manifest. + * + * @param context The context to use. Usually your Activity object. If + * context is not an instance of Activity (or a subclass of it), + * crashes will be sent automatically. + * @param endpointUrl URL of the AvalancheHub endpoint to use. + * @param listener Implement this for callback functions. + * @return The configured crash manager for method chaining. + */ + public Crashes register(Context context, String endpointUrl, CrashesListener listener) { + return register(context, endpointUrl, listener, false); + } + + /** + * Registers the crash manager and handles existing crash logs. + * AvalancheHub app identifier is read from configuration values in manifest. + * + * @param context The context to use. Usually your Activity object. If + * context is not an instance of Activity (or a subclass of it), + * crashes will be sent automatically. + * @param endpointUrl URL of the AvalancheHub endpoint to use. + * @param listener Implement this for callback functions. + * @param lazyExecution Whether the manager should execute lazily, e.g. not check for crashes right away. + * @return + */ + public Crashes register(Context context, String endpointUrl, CrashesListener listener, boolean lazyExecution) { + mContextWeakReference = new WeakReference<>(context); + mListener = listener; + mEndpointUrl = endpointUrl; + mLazyExecution = lazyExecution; + + initialize(); + + return this; + } + + private void initialize() { + Context context = mContextWeakReference.get(); + + if (context != null) { + if (mInitializeTimestamp == 0) { + mInitializeTimestamp = System.currentTimeMillis(); + } + + Constants.loadFromContext(context); + + if (!mLazyExecution) { + execute(); + } + } + } + + /** + * Allows you to execute the crash manager later on-demand. + */ + public void execute() { + Context context = mContextWeakReference.get(); + if (context == null) { + return; + } + + int foundOrSend = hasStackTraces(context); + + if (foundOrSend == STACK_TRACES_FOUND_NEW) { + mDidCrashInLastSession = true; + Boolean autoSend = !(context instanceof Activity); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + autoSend |= prefs.getBoolean(ALWAYS_SEND_KEY, false); + + if (mListener != null) { + autoSend |= mListener.shouldAutoUploadCrashes(); + autoSend |= mListener.onCrashesFound(); + + mListener.onNewCrashesFound(); + } + + if (!autoSend) { + showDialog(mContextWeakReference); + } else { + sendCrashes(); + } + } else if (foundOrSend == STACK_TRACES_FOUND_CONFIRMED) { + if (mListener != null) { + mListener.onConfirmedCrashesFound(); + } + + sendCrashes(); + } else { + registerExceptionHandler(); + } + } + + private void registerExceptionHandler() { + if (!isEmpty(Constants.APP_VERSION) && !isEmpty(Constants.APP_PACKAGE)) { + // Get current handler + Thread.UncaughtExceptionHandler currentHandler = Thread.getDefaultUncaughtExceptionHandler(); + if (currentHandler != null) { + AvalancheLog.debug("Current handler class = " + currentHandler.getClass().getName()); + } + + // Update listener if already registered, otherwise set new handler + if (currentHandler instanceof ExceptionHandler) { + ((ExceptionHandler) currentHandler).setListener(mListener); + } else { + Thread.setDefaultUncaughtExceptionHandler(new ExceptionHandler(this, currentHandler, mListener, isIgnoreDefaultHandler())); + } + } else { + AvalancheLog.warn("Exception handler not set because app version or package is null."); + } + } + + private void sendCrashes() { + sendCrashes(null); + } + + private void sendCrashes(final CrashMetaData crashMetaData) { + saveConfirmedStackTraces(); + registerExceptionHandler(); + + Context context = mContextWeakReference.get(); + if (context != null && !Util.isConnectedToNetwork(context)) { + // Not connected to network, not trying to submit stack traces + return; + } + + if (!mIsSubmitting) { + mIsSubmitting = true; + + new Thread() { + @Override + public void run() { + submitStackTraces(crashMetaData); + mIsSubmitting = false; + } + }.start(); + } + } + + /** + * Submits all stack traces in the files dir to AvalancheHub. + * + * @param crashMetaData The crashMetaData, provided by the user. + */ + public void submitStackTraces(CrashMetaData crashMetaData) { + String[] list = searchForStackTraces(); + Boolean successful = false; + + Context context = mContextWeakReference.get(); + + if ((list != null) && (list.length > 0)) { + AvalancheLog.debug("Found " + list.length + " stacktrace(s)."); + + for (int index = 0; index < list.length; index++) { + HttpURLConnection urlConnection = null; + try { + // Read contents of stack trace + String filename = list[index]; + String stacktrace = Util.contentsOfFile(context, filename); + if (stacktrace.length() > 0) { + // Transmit stack trace with POST request + + AvalancheLog.debug("Transmitting crash data: \n" + stacktrace); + + // Retrieve user ID and contact information if given + String userID = Util.contentsOfFile(context, filename.replace(".stacktrace", ".user")); + String contact = Util.contentsOfFile(context, filename.replace(".stacktrace", ".contact")); + + if (crashMetaData != null) { + final String crashMetaDataUserID = crashMetaData.getUserID(); + if (!isEmpty(crashMetaDataUserID)) { + userID = crashMetaDataUserID; + } + final String crashMetaDataContact = crashMetaData.getUserEmail(); + if (!isEmpty(crashMetaDataContact)) { + contact = crashMetaDataContact; + } + } + + // Append application log to user provided description if present, if not, just send application log + final String applicationLog = Util.contentsOfFile(context, filename.replace(".stacktrace", ".description")); + String description = crashMetaData != null ? crashMetaData.getUserDescription() : ""; + if (!isEmpty(applicationLog)) { + if (!isEmpty(description)) { + description = String.format("%s\n\nLog:\n%s", description, applicationLog); + } else { + description = String.format("Log:\n%s", applicationLog); + } + } + + Map parameters = new HashMap(); + + parameters.put("raw", stacktrace); + parameters.put("userID", userID); + parameters.put("contact", contact); + parameters.put("description", description); + parameters.put("sdk", Constants.SDK_NAME); + parameters.put("sdk_version", BuildConfig.VERSION_NAME); + + urlConnection = new HttpURLConnectionBuilder(getURLString()) + .setRequestMethod("POST") + .writeFormFields(parameters) + .build(); + + int responseCode = urlConnection.getResponseCode(); + + successful = (responseCode == HttpURLConnection.HTTP_ACCEPTED || responseCode == HttpURLConnection.HTTP_CREATED); + + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (urlConnection != null) { + urlConnection.disconnect(); + } + if (successful) { + AvalancheLog.debug("Transmission succeeded"); + deleteStackTrace(list[index]); + + if (mListener != null) { + mListener.onCrashesSent(); + deleteRetryCounter(list[index], mListener.getMaxRetryAttempts()); + } + } else { + AvalancheLog.debug("Transmission failed, will retry on next register() call"); + if (mListener != null) { + mListener.onCrashesNotSent(); + updateRetryCounter(list[index], mListener.getMaxRetryAttempts()); + } + } + } + } + } + } + + /** + * Shows a dialog to ask the user whether he wants to send crash reports to + * AvalancheHub or delete them. + */ + private void showDialog(final WeakReference weakContext) { + Context context = null; + if (weakContext != null) { + context = weakContext.get(); + } + + if (context == null) { + return; + } + + if (mListener != null && mListener.onHandleAlertView()) { + return; + } + + final boolean ignoreDefaultHandler = isIgnoreDefaultHandler(); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + String alertTitle = getAlertTitle(context); + builder.setTitle(alertTitle); + builder.setMessage(R.string.avalanche_crash_dialog_message); + + builder.setNegativeButton(R.string.avalanche_crash_dialog_negative_button, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + handleUserInput(CrashesUserInput.CrashManagerUserInputDontSend, null, mListener, weakContext, ignoreDefaultHandler); + } + }); + + builder.setNeutralButton(R.string.avalanche_crash_dialog_neutral_button, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + handleUserInput(CrashesUserInput.CrashManagerUserInputAlwaysSend, null, mListener, weakContext, ignoreDefaultHandler); + } + }); + + builder.setPositiveButton(R.string.avalanche_crash_dialog_positive_button, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + handleUserInput(CrashesUserInput.CrashManagerUserInputSend, null, mListener, + weakContext, ignoreDefaultHandler); + } + }); + + builder.create().show(); + } + + /** + * Provides an interface to pass user input from a custom alert to a crash report + * + * @param userInput Defines the users action whether to send, always send, or not to send the crash report. + * @param userProvidedMetaData The content of this optional CrashMetaData instance will be attached to the crash report + * and allows to ask the user for e.g. additional comments or info. + * @param listener an optional crash manager listener to use. + * @param weakContext The context to use. Usually your Activity object. + * @param ignoreDefaultHandler whether to ignore the default exception handler. + * @return true if the input is a valid option and successfully triggered further processing of the crash report. + * @see CrashesUserInput + * @see CrashMetaData + * @see CrashesListener + */ + public boolean handleUserInput(final CrashesUserInput userInput, + final CrashMetaData userProvidedMetaData, final CrashesListener listener, + final WeakReference weakContext, final boolean ignoreDefaultHandler) { + switch (userInput) { + case CrashManagerUserInputDontSend: + if (listener != null) { + listener.onUserDeniedCrashes(); + } + + deleteStackTraces(); + registerExceptionHandler(); + return true; + case CrashManagerUserInputAlwaysSend: + Context context = null; + if (weakContext != null) { + context = weakContext.get(); + } + + if (context == null) { + return false; + } + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putBoolean(ALWAYS_SEND_KEY, true).apply(); + + sendCrashes(userProvidedMetaData); + return true; + case CrashManagerUserInputSend: + sendCrashes(userProvidedMetaData); + return true; + default: + return false; + } + } + + long getInitializeTimestamp() { + return mInitializeTimestamp; + } + + /** + * Checks if there are any saved stack traces in the files dir. + * + * @param context The context to use. Usually your Activity object. + * @return STACK_TRACES_FOUND_NONE if there are no stack traces, + * STACK_TRACES_FOUND_NEW if there are any new stack traces, + * STACK_TRACES_FOUND_CONFIRMED if there only are confirmed stack traces. + */ + public int hasStackTraces(Context context) { + String[] filenames = searchForStackTraces(); + List confirmedFilenames = null; + int result = STACK_TRACES_FOUND_NONE; + if ((filenames != null) && (filenames.length > 0)) { + try { + confirmedFilenames = getConfirmedFilenames(context); + + } catch (Exception e) { + // Just in case, we catch all exceptions here + } + + if (confirmedFilenames != null) { + result = STACK_TRACES_FOUND_CONFIRMED; + + for (String filename : filenames) { + if (!confirmedFilenames.contains(filename)) { + result = STACK_TRACES_FOUND_NEW; + break; + } + } + } else { + result = STACK_TRACES_FOUND_NEW; + } + } + + return result; + } + + public boolean didCrashInLastSession() { + return mDidCrashInLastSession; + } + + public CrashReport getLastCrashDetails() { + if (Constants.FILES_PATH == null || !didCrashInLastSession()) { + return null; + } + + File dir = new File(Constants.FILES_PATH + "/"); + File[] files = dir.listFiles(new FilenameFilter() { + @Override + public boolean accept(File dir, String filename) { + return filename.endsWith(".stacktrace"); + } + }); + + long lastModification = 0; + File lastModifiedFile = null; + CrashReport result = null; + for (File file : files) { + if (file.lastModified() > lastModification) { + lastModification = file.lastModified(); + lastModifiedFile = file; + } + } + + if (lastModifiedFile != null && lastModifiedFile.exists()) { + try { + result = CrashReport.fromFile(lastModifiedFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + return result; + } + + private void saveConfirmedStackTraces() { + Context context = mContextWeakReference.get(); + if (context != null) { + try { + String[] filenames = searchForStackTraces(); + SharedPreferences preferences = context.getSharedPreferences("AvalancheSDK", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = preferences.edit(); + editor.putString("ConfirmedFilenames", Util.joinArray(filenames, "|")); + editor.apply(); + } catch (Exception e) { + // Just in case, we catch all exceptions here + } + } + } + + public void deleteStackTraces() { + String[] list = searchForStackTraces(); + + if ((list != null) && (list.length > 0)) { + AvalancheLog.debug("Found " + list.length + " stacktrace(s)."); + + for (int index = 0; index < list.length; index++) { + try { + Context context = mContextWeakReference.get(); + AvalancheLog.debug("Delete stacktrace " + list[index] + "."); + deleteStackTrace(list[index]); + + if (context != null) { + context.deleteFile(list[index]); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + + /** + * Deletes the given filename and all corresponding files (same name, + * different extension). + */ + private void deleteStackTrace(String filename) { + Context context = mContextWeakReference.get(); + context.deleteFile(filename); + + String user = filename.replace(".stacktrace", ".user"); + context.deleteFile(user); + + String contact = filename.replace(".stacktrace", ".contact"); + context.deleteFile(contact); + + String description = filename.replace(".stacktrace", ".description"); + context.deleteFile(description); + } + + /** + * Update the retry attempts count for this crash stacktrace. + */ + private void updateRetryCounter(String filename, int maxRetryAttempts) { + if (maxRetryAttempts == -1) { + return; + } + + Context context = mContextWeakReference.get(); + SharedPreferences preferences = context.getSharedPreferences("AvalancheSDK", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = preferences.edit(); + + int retryCounter = preferences.getInt("RETRY_COUNT: " + filename, 0); + if (retryCounter >= maxRetryAttempts) { + deleteStackTrace(filename); + deleteRetryCounter(filename, maxRetryAttempts); + } else { + editor.putInt("RETRY_COUNT: " + filename, retryCounter + 1); + editor.apply(); + } + } + + /** + * Delete the retry counter if stacktrace is uploaded or retry limit is + * reached. + */ + private void deleteRetryCounter(String filename, int maxRetryAttempts) { + Context context = mContextWeakReference.get(); + SharedPreferences preferences = context.getSharedPreferences("AvalancheSDK", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = preferences.edit(); + editor.remove("RETRY_COUNT: " + filename); + editor.apply(); + } + + private boolean isIgnoreDefaultHandler() { + return mListener != null && mListener.ignoreDefaultHandler(); + } + + private String getURLString() { + return mEndpointUrl + "api/2/apps/" + AvalancheHub.getSharedInstance().getAppIdentifier() + "/crashes/"; + } +} diff --git a/crash/src/main/java/avalanche/crash/CrashesListener.java b/crash/src/main/java/avalanche/crash/CrashesListener.java new file mode 100644 index 0000000000..917b770e00 --- /dev/null +++ b/crash/src/main/java/avalanche/crash/CrashesListener.java @@ -0,0 +1,155 @@ +package avalanche.crash; + +/** + *

Description

+ * + * Abstract class for callbacks to be invoked from the Crashes. + * + **/ +public abstract class CrashesListener { + /** + * Return true to ignore the default exception handler, i.e. the user will not + * get the alert dialog with the "Force Close" button. + * + * @return if true, the default handler is ignored + */ + public boolean ignoreDefaultHandler() { + return false; + } + + /** + * Return false to remove the device data (OS version, manufacturer, model) + * from the crash log, e.g. if some of your testers are using unreleased + * devices. + * + * @return if true, the crash report will include device data + */ + public boolean includeDeviceData() { + return true; + } + + /** + * Return false to remove the stable device identifier from the + * crash log, e.g. if there are privacy concerns. + * + * @return if true, the crash report will include a stable device identifier + */ + public boolean includeDeviceIdentifier() { + return true; + } + + /** + * Return true to include information about the crashed thread if available. + * + * @return if true, the crash report will include thread id and name if available + */ + public boolean includeThreadDetails() { + return true; + } + + /** + * Return contact data or similar; note that this has privacy implications, + * so you might want to return nil for release builds! The string will be + * limited to 255 characters. + * + * @return the contact string + */ + public String getContact() { + return null; + } + + /** + * Return additional data, i.e. parts of the system log, the last server + * response or similar. This string is not limited to a certain size. + * + * @return a description + */ + public String getDescription() { + return null; + } + + /** + * Return a user ID or similar; note that this has privacy implications, + * so you might want to return nil for release builds! The string will be + * limited to 255 characters. + * + * @return the user's ID + */ + public String getUserID() { + return null; + } + + /** + * Called when the crash manager found one or more crashes. Return true + * if you want to auto-send crashes (i.e. not ask the user) + * + * @return if true, crashes are sent automatically + * @deprecated Replace this method with onNewCrashesFound, + * onConfirmedCrashesFound, and shouldAutoUploadCrashReport. + */ + public boolean onCrashesFound() { + return false; + } + + /** + * Return true if you want to auto-send crashes. Note that this method + * is only called if new crashes were found. + * + * @return if true, crashes are sent automatically + */ + public boolean shouldAutoUploadCrashes() { + return false; + } + + /** + * Called when the crash manager has found new crash logs. + */ + public void onNewCrashesFound() { + } + + /** + * Called when the crash manager has found crash logs that were already + * confirmed by the user or should have been auto uploaded, but the upload + * failed, e.g. in case of a network failure. + */ + public void onConfirmedCrashesFound() { + } + + /** + * Called when the crash manager has sent crashes to AvalancheHub. + */ + public void onCrashesSent() { + } + + /** + * Called when the crash manager failed to send crashes to AvalancheHub, e.g. + * because the device has no network connections. + */ + public void onCrashesNotSent() { + } + + /** + * Called when the user denied to send crashes to AvalancheHub. + */ + public void onUserDeniedCrashes() { + } + + /** + * Get the number of max retry attempts to send crashes to AvalancheHub. + * Infinite retries if this value is set to -1 + * + * @return the max number of retry attempts + */ + public int getMaxRetryAttempts() { + return 1; + } + + /** + * Called when dialog should be displayed to inform the user about crash. + * + * @return if true, alert-view is handled by user + */ + public boolean onHandleAlertView() { + return false; + } +} diff --git a/crash/src/main/java/avalanche/crash/ExceptionHandler.java b/crash/src/main/java/avalanche/crash/ExceptionHandler.java new file mode 100644 index 0000000000..bcdcfe9102 --- /dev/null +++ b/crash/src/main/java/avalanche/crash/ExceptionHandler.java @@ -0,0 +1,227 @@ +package avalanche.crash; + +import android.text.TextUtils; +import avalanche.base.Constants; +import avalanche.base.Channel; +import avalanche.crash.model.CrashReport; +import avalanche.base.utils.AvalancheLog; + +import java.io.*; +import java.lang.Thread.UncaughtExceptionHandler; +import java.util.Date; +import java.util.UUID; + +/** + *

Description

+ * Helper class to catch exceptions. Saves the stack trace + * as a file and executes callback methods to ask the app for + * additional information and meta data (see CrashesListener). + * + **/ +public class ExceptionHandler implements UncaughtExceptionHandler { + private final Crashes mCrashes; + private boolean mIgnoreDefaultHandler = false; + private CrashesListener mCrashesListener; + private UncaughtExceptionHandler mDefaultExceptionHandler; + + public ExceptionHandler(Crashes crashes, UncaughtExceptionHandler defaultExceptionHandler, CrashesListener listener, boolean ignoreDefaultHandler) { + mCrashes = crashes; + mDefaultExceptionHandler = defaultExceptionHandler; + mIgnoreDefaultHandler = ignoreDefaultHandler; + mCrashesListener = listener; + } + + public void setListener(CrashesListener listener) { + mCrashesListener = listener; + } + + + + /** + * Save a caught exception to disk. + * + * @param exception Exception to save. + * @param thread Thread that crashed. + * @param listener Custom Crashes listener instance. + */ + public void saveException(Throwable exception, Thread thread, CrashesListener listener) { + final Date now = new Date(); + final Date startDate = new Date(mCrashes.getInitializeTimestamp()); + final Writer result = new StringWriter(); + final PrintWriter printWriter = new PrintWriter(result); + exception.printStackTrace(printWriter); + + String filename = UUID.randomUUID().toString(); + + CrashReport crashReport = new CrashReport(filename, exception); + crashReport.setAppPackage(Constants.APP_PACKAGE); + crashReport.setAppVersionCode(Constants.APP_VERSION); + crashReport.setAppVersionName(Constants.APP_VERSION_NAME); + crashReport.setAppStartDate(startDate); + crashReport.setAppCrashDate(now); + + if ((listener == null) || (listener.includeDeviceData())) { + crashReport.setOsVersion(Constants.ANDROID_VERSION); + crashReport.setOsBuild(Constants.ANDROID_BUILD); + crashReport.setDeviceManufacturer(Constants.PHONE_MANUFACTURER); + crashReport.setDeviceModel(Constants.PHONE_MODEL); + } + + if (thread != null && ((listener == null) || (listener.includeThreadDetails()))) { + crashReport.setThreadName(thread.getName() + "-" + thread.getId()); + } + + if (Constants.CRASH_IDENTIFIER != null && (listener == null || listener.includeDeviceIdentifier())) { + crashReport.setReporterKey(Constants.CRASH_IDENTIFIER); + } + + crashReport.writeCrashReport(); + + if (listener != null) { + try { + writeValueToFile(limitedString(listener.getUserID()), filename + ".user"); + writeValueToFile(limitedString(listener.getContact()), filename + ".contact"); + writeValueToFile(listener.getDescription(), filename + ".description"); + } catch (IOException e) { + AvalancheLog.error("Error saving crash meta data!", e); + } + + } + } + + /** + * Save java exception(s) caught by XamarinSDK to disk. + * + * @param exception The native java exception to save. + * @param managedExceptionString String representation of the full exception including the managed exception. + * @param thread Thread that crashed. + * @param listener Custom Crashes listener instance. + */ + @SuppressWarnings("unused") + public void saveNativeException(Throwable exception, String managedExceptionString, Thread thread, CrashesListener listener) { + // the throwable will a "native" Java exception. In this case managedExceptionString contains the full, "unconverted" exception + // which contains information about the managed exception, too. We don't want to loose that part. Sadly, passing a managed + // exception as an additional throwable strips that info, so we pass in the full managed exception as a string + // and extract the first part that contains the info about the managed code that was calling the java code. + // In case there is no managedExceptionString, we just forward the java exception + if (!TextUtils.isEmpty(managedExceptionString)) { + String[] splits = managedExceptionString.split("--- End of managed exception stack trace ---", 2); + if (splits != null && splits.length > 0) { + managedExceptionString = splits[0]; + } + } + + saveXamarinException(exception, thread, managedExceptionString, false, listener); + } + + /** + * Save managed exception(s) caught by XamarinSDK to disk. + * + * @param exception The managed exception to save. + * @param thread Thread that crashed. + * @param listener Custom Crashes listener instance. + */ + @SuppressWarnings("unused") + public void saveManagedException(Throwable exception, Thread thread, CrashesListener listener) { + saveXamarinException(exception, thread, null, true, listener); + } + + //TODO refacture so we don't have duplicate code + private void saveXamarinException(Throwable exception, Thread thread, String additionalManagedException, Boolean isManagedException, CrashesListener listener) { + final Date startDate = new Date(mCrashes.getInitializeTimestamp()); + String filename = UUID.randomUUID().toString(); + final Date now = new Date(); + + final Writer result = new StringWriter(); + final PrintWriter printWriter = new PrintWriter(result); + if (exception != null) { + exception.printStackTrace(printWriter); + } + + + //TODO move this to a Factory class + CrashReport crashReport = new CrashReport(filename, exception, additionalManagedException, isManagedException); + crashReport.setAppPackage(Constants.APP_PACKAGE); + crashReport.setAppVersionCode(Constants.APP_VERSION); + crashReport.setAppVersionName(Constants.APP_VERSION_NAME); + crashReport.setAppStartDate(startDate); + crashReport.setAppCrashDate(now); + + if ((listener == null) || (listener.includeDeviceData())) { + crashReport.setOsVersion(Constants.ANDROID_VERSION); + crashReport.setOsBuild(Constants.ANDROID_BUILD); + crashReport.setDeviceManufacturer(Constants.PHONE_MANUFACTURER); + crashReport.setDeviceModel(Constants.PHONE_MODEL); + } + + if (thread != null && ((listener == null) || (listener.includeThreadDetails()))) { + crashReport.setThreadName(thread.getName() + "-" + thread.getId()); + } + + if (Constants.CRASH_IDENTIFIER != null && (listener == null || listener.includeDeviceIdentifier())) { + crashReport.setReporterKey(Constants.CRASH_IDENTIFIER); + } + + crashReport.writeCrashReport(); + + Channel.getInstance().handle(crashReport); + + if (listener != null) { + try { + writeValueToFile(limitedString(listener.getUserID()), filename + ".user"); + writeValueToFile(limitedString(listener.getContact()), filename + ".contact"); + writeValueToFile(listener.getDescription(), filename + ".description"); + } catch (IOException e) { + AvalancheLog.error("Error saving crash meta data!", e); + } + + } + } + + //TODO: this should be the only method here, no persisting logic in here. + public void uncaughtException(Thread thread, Throwable exception) { + if (Constants.FILES_PATH == null) { + // If the files path is null, the exception can't be stored + // Always call the default handler instead + mDefaultExceptionHandler.uncaughtException(thread, exception); + } else { + saveException(exception, thread, mCrashesListener); + + if (!mIgnoreDefaultHandler) { + mDefaultExceptionHandler.uncaughtException(thread, exception); + } else { + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(10); + } + } + } + + //TODO: this should be part of the pipleine in the base module + private static void writeValueToFile(String value, String filename) throws IOException { + if (TextUtils.isEmpty(value)) { + return; + } + BufferedWriter writer = null; + try { + String path = Constants.FILES_PATH + "/" + filename; + if (!TextUtils.isEmpty(value) && TextUtils.getTrimmedLength(value) > 0) { + writer = new BufferedWriter(new FileWriter(path)); + writer.write(value); + writer.flush(); + } + } catch (IOException e) { + // TODO: Handle exception here + } finally { + if (writer != null) { + writer.close(); + } + } + } + + private static String limitedString(String string) { + if (!TextUtils.isEmpty(string) && string.length() > 255) { + string = string.substring(0, 255); + } + return string; + } +} diff --git a/crash/src/main/java/avalanche/crash/ingestion/models/Binary.java b/crash/src/main/java/avalanche/crash/ingestion/models/Binary.java new file mode 100755 index 0000000000..f4fa6adccc --- /dev/null +++ b/crash/src/main/java/avalanche/crash/ingestion/models/Binary.java @@ -0,0 +1,82 @@ +package avalanche.crash.ingestion.models; + +public class Binary { + + private String startAddress = null; + + private String endAddress = null; + + private String name = null; + + private String cpuType = null; + + private String cpuSubType = null; + + private String uuid = null; + + private String path = null; + + + public String getStartAddress() { + return startAddress; + } + + public void setStartAddress(String startAddress) { + this.startAddress = startAddress; + } + + + public String getEndAddress() { + return endAddress; + } + + public void setEndAddress(String endAddress) { + this.endAddress = endAddress; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + + public String getCpuType() { + return cpuType; + } + + public void setCpuType(String cpuType) { + this.cpuType = cpuType; + } + + + public String getCpuSubType() { + return cpuSubType; + } + + public void setCpuSubType(String cpuSubType) { + this.cpuSubType = cpuSubType; + } + + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + /** + **/ + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + +} diff --git a/crash/src/main/java/avalanche/crash/ingestion/models/CrashLog.java b/crash/src/main/java/avalanche/crash/ingestion/models/CrashLog.java new file mode 100755 index 0000000000..8edc01044d --- /dev/null +++ b/crash/src/main/java/avalanche/crash/ingestion/models/CrashLog.java @@ -0,0 +1,165 @@ +package avalanche.crash.ingestion.models; + +import avalanche.base.ingestion.models.InSessionLog; + +import java.util.List; +import java.util.Map; + + +public class CrashLog extends InSessionLog { + + private String type = null; + private Map properties = null; + private String sid = null; + private String id = null; + private String process = null; + private Integer processId = null; + private String parentProcess = null; + private Integer parentProcessId = null; + private Integer crashThread = null; + private String applicationPath = null; + private String exceptionType = null; + private String exceptionCode = null; + private String exceptionAddress = null; + private String exceptionReason = null; + private List threads = null; + private List binaries = null; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Map getProperties() { + return properties; + } + + public void setProperties(Map properties) { + this.properties = properties; + } + + + public String getSid() { + return sid; + } + + public void setSid(String sid) { + this.sid = sid; + } + + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + + public String getProcess() { + return process; + } + + public void setProcess(String process) { + this.process = process; + } + + public Integer getProcessId() { + return processId; + } + + public void setProcessId(Integer processId) { + this.processId = processId; + } + + public String getParentProcess() { + return parentProcess; + } + + public void setParentProcess(String parentProcess) { + this.parentProcess = parentProcess; + } + + public Integer getParentProcessId() { + return parentProcessId; + } + + public void setParentProcessId(Integer parentProcessId) { + this.parentProcessId = parentProcessId; + } + + public Integer getCrashThread() { + return crashThread; + } + + public void setCrashThread(Integer crashThread) { + this.crashThread = crashThread; + } + + + public String getApplicationPath() { + return applicationPath; + } + + public void setApplicationPath(String applicationPath) { + this.applicationPath = applicationPath; + } + + + public String getExceptionType() { + return exceptionType; + } + + public void setExceptionType(String exceptionType) { + this.exceptionType = exceptionType; + } + + + public String getExceptionCode() { + return exceptionCode; + } + + public void setExceptionCode(String exceptionCode) { + this.exceptionCode = exceptionCode; + } + + + public String getExceptionAddress() { + return exceptionAddress; + } + + public void setExceptionAddress(String exceptionAddress) { + this.exceptionAddress = exceptionAddress; + } + + + public String getExceptionReason() { + return exceptionReason; + } + + public void setExceptionReason(String exceptionReason) { + this.exceptionReason = exceptionReason; + } + + + public List getThreads() { + return threads; + } + + public void setThreads(List threads) { + this.threads = threads; + } + + + public List getBinaries() { + return binaries; + } + + public void setBinaries(List binaries) { + this.binaries = binaries; + } +} diff --git a/crash/src/main/java/avalanche/crash/ingestion/models/Thread.java b/crash/src/main/java/avalanche/crash/ingestion/models/Thread.java new file mode 100755 index 0000000000..c40826a4b3 --- /dev/null +++ b/crash/src/main/java/avalanche/crash/ingestion/models/Thread.java @@ -0,0 +1,27 @@ +package avalanche.crash.ingestion.models; + +import java.util.List; + + +public class Thread { + + private Integer id = null; + private List frames = null; + + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public List getFrames() { + return frames; + } + + public void setFrames(List frames) { + this.frames = frames; + } +} diff --git a/crash/src/main/java/avalanche/crash/ingestion/models/ThreadFrame.java b/crash/src/main/java/avalanche/crash/ingestion/models/ThreadFrame.java new file mode 100755 index 0000000000..fd34b953eb --- /dev/null +++ b/crash/src/main/java/avalanche/crash/ingestion/models/ThreadFrame.java @@ -0,0 +1,39 @@ +package avalanche.crash.ingestion.models; + + +public class ThreadFrame { + + private String address = null; + private String symbol = null; + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getSymbol() { + return symbol; + } + + public void setSymbol(String symbol) { + this.symbol = symbol; + } + + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ThreadFrame threadFrame = (ThreadFrame) o; + return (address == null ? threadFrame.address == null : address.equals(threadFrame.address)) && + (symbol == null ? threadFrame.symbol == null : symbol.equals(threadFrame.symbol)); + } + +} diff --git a/crash/src/main/java/avalanche/crash/model/CrashMetaData.java b/crash/src/main/java/avalanche/crash/model/CrashMetaData.java new file mode 100644 index 0000000000..0fb1147294 --- /dev/null +++ b/crash/src/main/java/avalanche/crash/model/CrashMetaData.java @@ -0,0 +1,44 @@ +package avalanche.crash.model; + +/** + * This class provides properties that can be attached to a crash report via a custom alert view flow + * + */ +public class CrashMetaData { + private String mUserDescription; + private String mUserEmail; + private String mUserID; + + public String getUserDescription() { + return mUserDescription; + } + + public void setUserDescription(final String userDescription) { + this.mUserDescription = userDescription; + } + + public String getUserEmail() { + return mUserEmail; + } + + public void setUserEmail(final String userEmail) { + this.mUserEmail = userEmail; + } + + public String getUserID() { + return mUserID; + } + + public void setUserID(final String userID) { + this.mUserID = userID; + } + + @Override + public String toString() { + return "\n" + CrashMetaData.class.getSimpleName() + + "\n" + "userDescription " + mUserDescription + + "\n" + "userEmail " + mUserEmail + + "\n" + "userID " + mUserID + ; + } +} diff --git a/crash/src/main/java/avalanche/crash/model/CrashReport.java b/crash/src/main/java/avalanche/crash/model/CrashReport.java new file mode 100644 index 0000000000..1ec385060f --- /dev/null +++ b/crash/src/main/java/avalanche/crash/model/CrashReport.java @@ -0,0 +1,368 @@ +package avalanche.crash.model; + +import android.text.TextUtils; + +import avalanche.base.AvalancheDataInterface; +import avalanche.base.Constants; +import avalanche.base.utils.AvalancheLog; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Reader; +import java.io.StringWriter; +import java.io.Writer; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public class CrashReport implements AvalancheDataInterface { + + public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy", Locale.US); + + private static final String FIELD_CRASH_REPORTER_KEY = "CrashReporter Key"; + private static final String FIELD_APP_START_DATE = "Start Date"; + private static final String FIELD_APP_CRASH_DATE = "Date"; + private static final String FIELD_OS_VERSION = "Android"; + private static final String FIELD_OS_BUILD = "Android Build"; + private static final String FIELD_DEVICE_MANUFACTURER = "Manufacturer"; + private static final String FIELD_DEVICE_MODEL = "Model"; + private static final String FIELD_APP_PACKAGE = "Package"; + private static final String FIELD_APP_VERSION_NAME = "Version Name"; + private static final String FIELD_APP_VERSION_CODE = "Version Code"; + private static final String FIELD_THREAD_NAME = "Thread"; + + private static final String FIELD_FORMAT = "Format"; + private static final String FIELD_FORMAT_VALUE = "Xamarin"; + private static final String FIELD_XAMARIN_CAUSED_BY = "Xamarin caused by: "; //Field that marks a Xamarin Exception + + private final String crashIdentifier; + + private String reporterKey; + + private Date appStartDate; + private Date appCrashDate; + + private String osVersion; + private String osBuild; + private String deviceManufacturer; + private String deviceModel; + + private String appPackage; + private String appVersionName; + private String appVersionCode; + + private String threadName; + + private String throwableStackTrace; + + private Boolean isXamarinException; + + private String format; + + public CrashReport(String crashIdentifier) { + this.crashIdentifier = crashIdentifier; + } + + public CrashReport(String crashIdentifier, Throwable throwable) { + this(crashIdentifier); + + isXamarinException = false; + + final Writer stackTraceResult = new StringWriter(); + final PrintWriter printWriter = new PrintWriter(stackTraceResult); + throwable.printStackTrace(printWriter); + throwableStackTrace = stackTraceResult.toString(); + } + + public CrashReport(String crashIdentifier, Throwable throwable, String managedExceptionString, Boolean isManagedException) { + this(crashIdentifier); + + final Writer stackTraceResult = new StringWriter(); + final PrintWriter printWriter = new PrintWriter(stackTraceResult); + + isXamarinException = true; + + //Add the header field "Format" to the crash + //the value is "Xamarin", for now there are no other values and it's only set in case we have an exception coming from + //the Xamarin SDK. It can be a java exception, a managed exception, or a mixed one. + setFormat(FIELD_FORMAT_VALUE); + + if (isManagedException) { + //add "Xamarin Caused By" before the managed stacktrace. No new line after it. + printWriter.print(FIELD_XAMARIN_CAUSED_BY); + + //print the managed exception + throwable.printStackTrace(printWriter); + } else { + //If we have managedExceptionString, we hava a MIXED (Java & C#) + //exception, The throwable will be the Java exception. + if (!TextUtils.isEmpty(managedExceptionString)) { + //Print the java exception + throwable.printStackTrace(printWriter); + + //Add "Xamarin Caused By" before the managed stacktrace. No new line after it. + printWriter.print(FIELD_XAMARIN_CAUSED_BY); + //print the stacktrace of the managed exception + printWriter.print(managedExceptionString); + } else { + //we have a java exception, no "Xamarin Caused By:" + throwable.printStackTrace(printWriter); + } + } + + throwableStackTrace = stackTraceResult.toString(); + } + + + public static CrashReport fromFile(File file) throws IOException { + String crashIdentifier = file.getName().substring(0, file.getName().indexOf(".stacktrace")); + return fromReader(crashIdentifier, new FileReader(file)); + } + + public static CrashReport fromReader(String crashIdentifier, Reader in) throws IOException { + BufferedReader bufferedReader = new BufferedReader(in); + + CrashReport result = new CrashReport(crashIdentifier); + + String readLine, headerName, headerValue; + boolean headersProcessed = false; + StringBuilder stackTraceBuilder = new StringBuilder(); + while ((readLine = bufferedReader.readLine()) != null) { + if (!headersProcessed) { + + if (readLine.isEmpty()) { + // empty line denotes break between headers and stack trace + headersProcessed = true; + continue; + } + + int colonIndex = readLine.indexOf(":"); + if (colonIndex < 0) { + AvalancheLog.error("Malformed header line when parsing crash details: \"" + readLine + "\""); + } + + headerName = readLine.substring(0, colonIndex).trim(); + headerValue = readLine.substring(colonIndex + 1, readLine.length()).trim(); + + if (headerName.equals(FIELD_CRASH_REPORTER_KEY)) { + result.setReporterKey(headerValue); + } else if (headerName.equals(FIELD_APP_START_DATE)) { + try { + result.setAppStartDate(DATE_FORMAT.parse(headerValue)); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } else if (headerName.equals(FIELD_APP_CRASH_DATE)) { + try { + result.setAppCrashDate(DATE_FORMAT.parse(headerValue)); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } else if (headerName.equals(FIELD_OS_VERSION)) { + result.setOsVersion(headerValue); + } else if (headerName.equals(FIELD_OS_BUILD)) { + result.setOsBuild(headerValue); + } else if (headerName.equals(FIELD_DEVICE_MANUFACTURER)) { + result.setDeviceManufacturer(headerValue); + } else if (headerName.equals(FIELD_DEVICE_MODEL)) { + result.setDeviceModel(headerValue); + } else if (headerName.equals(FIELD_APP_PACKAGE)) { + result.setAppPackage(headerValue); + } else if (headerName.equals(FIELD_APP_VERSION_NAME)) { + result.setAppVersionName(headerValue); + } else if (headerName.equals(FIELD_APP_VERSION_CODE)) { + result.setAppVersionCode(headerValue); + } else if (headerName.equals(FIELD_THREAD_NAME)) { + result.setThreadName(headerValue); + } else if (headerName.equals(FIELD_FORMAT)) { + result.setFormat(headerValue); + } + + } else { + stackTraceBuilder.append(readLine).append("\n"); + } + } + result.setThrowableStackTrace(stackTraceBuilder.toString()); + + return result; + } + + //TODO this should not be in the model class + public void writeCrashReport() { + String path = Constants.FILES_PATH + "/" + crashIdentifier + ".stacktrace"; + AvalancheLog.debug("Writing unhandled exception to: " + path); + + BufferedWriter writer = null; + + try { + writer = new BufferedWriter(new FileWriter(path)); + + writeHeader(writer, FIELD_APP_PACKAGE, appPackage); + writeHeader(writer, FIELD_APP_VERSION_CODE, appVersionCode); + writeHeader(writer, FIELD_APP_VERSION_NAME, appVersionName); + writeHeader(writer, FIELD_OS_VERSION, osVersion); + writeHeader(writer, FIELD_OS_BUILD, osBuild); + writeHeader(writer, FIELD_DEVICE_MANUFACTURER, deviceManufacturer); + writeHeader(writer, FIELD_DEVICE_MODEL, deviceModel); + writeHeader(writer, FIELD_THREAD_NAME, threadName); + writeHeader(writer, FIELD_CRASH_REPORTER_KEY, reporterKey); + + writeHeader(writer, FIELD_APP_START_DATE, DATE_FORMAT.format(appStartDate)); + writeHeader(writer, FIELD_APP_CRASH_DATE, DATE_FORMAT.format(appCrashDate)); + + if (isXamarinException) { + writeHeader(writer, FIELD_FORMAT, FIELD_FORMAT_VALUE); + } + + writer.write("\n"); + writer.write(throwableStackTrace); + + writer.flush(); + + } catch (IOException e) { + AvalancheLog.error("Error saving crash report!", e); + } finally { + try { + if (writer != null) { + writer.close(); + } + } catch (IOException e1) { + AvalancheLog.error("Error saving crash report!", e1); + } + } + + + } + + private void writeHeader(Writer writer, String name, String value) throws IOException { + writer.write(name + ": " + value + "\n"); + } + + public String getCrashIdentifier() { + return crashIdentifier; + } + + public String getReporterKey() { + return reporterKey; + } + + public void setReporterKey(String reporterKey) { + this.reporterKey = reporterKey; + } + + public Date getAppStartDate() { + return appStartDate; + } + + public void setAppStartDate(Date appStartDate) { + this.appStartDate = appStartDate; + } + + public Date getAppCrashDate() { + return appCrashDate; + } + + public void setAppCrashDate(Date appCrashDate) { + this.appCrashDate = appCrashDate; + } + + public String getOsVersion() { + return osVersion; + } + + public void setOsVersion(String osVersion) { + this.osVersion = osVersion; + } + + public String getOsBuild() { + return osBuild; + } + + public void setOsBuild(String osBuild) { + this.osBuild = osBuild; + } + + public String getDeviceManufacturer() { + return deviceManufacturer; + } + + public void setDeviceManufacturer(String deviceManufacturer) { + this.deviceManufacturer = deviceManufacturer; + } + + public String getDeviceModel() { + return deviceModel; + } + + public void setDeviceModel(String deviceModel) { + this.deviceModel = deviceModel; + } + + public String getAppPackage() { + return appPackage; + } + + public void setAppPackage(String appPackage) { + this.appPackage = appPackage; + } + + public String getAppVersionName() { + return appVersionName; + } + + public void setAppVersionName(String appVersionName) { + this.appVersionName = appVersionName; + } + + public String getAppVersionCode() { + return appVersionCode; + } + + public void setAppVersionCode(String appVersionCode) { + this.appVersionCode = appVersionCode; + } + + public String getThreadName() { + return threadName; + } + + public void setThreadName(String threadName) { + this.threadName = threadName; + } + + public String getThrowableStackTrace() { + return throwableStackTrace; + } + + public void setThrowableStackTrace(String throwableStackTrace) { + this.throwableStackTrace = throwableStackTrace; + } + + public Boolean getIsXamarinException() { + return isXamarinException; + } + + public void setIsXamarinException(Boolean isXamarinException) { + this.isXamarinException = isXamarinException; + } + + //We could to without a Format property and getters/setters, but we will eventually use this + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + @Override + public boolean isHighPriority() { + return true; + } +} diff --git a/crash/src/main/java/avalanche/crash/model/CrashesUserInput.java b/crash/src/main/java/avalanche/crash/model/CrashesUserInput.java new file mode 100644 index 0000000000..dc540e2693 --- /dev/null +++ b/crash/src/main/java/avalanche/crash/model/CrashesUserInput.java @@ -0,0 +1,30 @@ +package avalanche.crash.model; + +/** + * Crash Manager user input + * + */ +public enum CrashesUserInput { + /** + * User chose not to send the crash report + */ + CrashManagerUserInputDontSend(0), + /** + * User wants the crash report to be sent + */ + CrashManagerUserInputSend(1), + /** + * User chose to always send crash reports + */ + CrashManagerUserInputAlwaysSend(2); + + private final int mValue; + + CrashesUserInput(int value) { + this.mValue = value; + } + + public int getValue() { + return mValue; + } +} diff --git a/crash/src/main/res/values/strings.xml b/crash/src/main/res/values/strings.xml new file mode 100644 index 0000000000..eda1797c67 --- /dev/null +++ b/crash/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + %s Unexpectedly Quit + Would you like to send an anonymous report so we can fix the problem? + Don\'t Send + Always Send + Send Report + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000000..6af8bfba6a --- /dev/null +++ b/gradle.properties @@ -0,0 +1,18 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +org.gradle.daemon=true + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..13372aef5e Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..122a0dca2e --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Dec 28 10:00:20 PST 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000000..9d82f78915 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000000..aec99730b4 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sasquatch/.gitignore b/sasquatch/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/sasquatch/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sasquatch/build.gradle b/sasquatch/build.gradle new file mode 100644 index 0000000000..bbab99f834 --- /dev/null +++ b/sasquatch/build.gradle @@ -0,0 +1,37 @@ +apply plugin: 'com.android.application' +//apply plugin: 'checkstyle' + +def supportLibVersion = rootProject.ext.supportLibVersion + + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + defaultConfig { + applicationId "com.microsoft.android.avalanchesdk" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode rootProject.ext.versionCode + versionName rootProject.ext.versionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile "com.android.support:appcompat-v7:$supportLibVersion" + testCompile 'junit:junit:4.12' + androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2' + androidTestCompile 'com.android.support.test:runner:0.5' + androidTestCompile "com.android.support:support-annotations:$supportLibVersion" + + compile project(':base') + compile project(':crash') + +} \ No newline at end of file diff --git a/sasquatch/proguard-rules.pro b/sasquatch/proguard-rules.pro new file mode 100644 index 0000000000..6e06453f42 --- /dev/null +++ b/sasquatch/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/benny/Library/Android/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/sasquatch/src/androidTest/java/avalanche/sasquatch/ExampleInstrumentationTest.java b/sasquatch/src/androidTest/java/avalanche/sasquatch/ExampleInstrumentationTest.java new file mode 100644 index 0000000000..cd8486c9da --- /dev/null +++ b/sasquatch/src/androidTest/java/avalanche/sasquatch/ExampleInstrumentationTest.java @@ -0,0 +1,29 @@ +package avalanche.sasquatch; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.MediumTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + + +import static org.junit.Assert.*; + +/** + * Instrumentation test, which will execute on an Android device. + * + * @see Testing documentation + */ +@MediumTest +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentationTest { + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getTargetContext(); + + assertEquals("com.microsoft.android.avalanchesdk", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/sasquatch/src/main/AndroidManifest.xml b/sasquatch/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..99da50ebf9 --- /dev/null +++ b/sasquatch/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sasquatch/src/main/java/avalanche/sasquatch/MainActivity.java b/sasquatch/src/main/java/avalanche/sasquatch/MainActivity.java new file mode 100644 index 0000000000..1fbfe3beec --- /dev/null +++ b/sasquatch/src/main/java/avalanche/sasquatch/MainActivity.java @@ -0,0 +1,44 @@ +package avalanche.sasquatch; + +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import avalanche.base.AvalancheHub; +import avalanche.base.utils.AvalancheLog; + +import java.util.ArrayList; +import java.util.List; + +public class MainActivity extends AppCompatActivity { + + private Button mCrashButton; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + AvalancheHub.use(getApplication()); + + AvalancheLog.setLogLevel(Log.INFO); + + boolean crashManagerAvailable = AvalancheHub.isFeatureAvailable(AvalancheHub.FEATURE_CRASH); + + AvalancheLog.info("crash available: " + crashManagerAvailable); + + boolean crashManagerEnabled = AvalancheHub.getSharedInstance().isFeatureEnabled(AvalancheHub.FEATURE_CRASH); + + AvalancheLog.info("crash enabled: " + crashManagerEnabled); + + mCrashButton = (Button) findViewById(R.id.button_crash); + mCrashButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + List fakeList = new ArrayList<>(); + fakeList.get(1000); + } + }); + } +} diff --git a/sasquatch/src/main/res/layout/activity_main.xml b/sasquatch/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..79a813a488 --- /dev/null +++ b/sasquatch/src/main/res/layout/activity_main.xml @@ -0,0 +1,24 @@ + + + + + +