package net.hockeyapp.android;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import net.hockeyapp.android.objects.CrashDetails;
import net.hockeyapp.android.objects.CrashManagerUserInput;
import net.hockeyapp.android.objects.CrashMetaData;
import net.hockeyapp.android.utils.HockeyLog;
import net.hockeyapp.android.utils.HttpURLConnectionBuilder;
import net.hockeyapp.android.utils.Util;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.Thread.UncaughtExceptionHandler;
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;
/**
* <h3>Description</h3>
*
* The crash manager sets an exception handler to catch all unhandled
* exceptions. The handler writes the stack trace and additional meta data to
* a file. If it finds one or more of these files at the next start, it shows
* an alert dialog to ask the user if he want the send the crash data to
* HockeyApp.
*
**/
public class CrashManager {
/**
* App identifier from HockeyApp.
*/
private static String identifier = null;
/**
* URL of HockeyApp service.
*/
private static String urlString = null;
/**
* Stack traces are currently submitted
*/
private static boolean submitting = false;
private static long initializeTimestamp;
private static boolean didCrashInLastSession = false;
/**
* Shared preferences key for always send dialog button.
*/
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;
/**
* Registers new crash manager and handles existing crash logs.
* HockeyApp App Identifier is read from configuration values in AndroidManifest.xml
*
* @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.
*/
public static void register(Context context) {
String appIdentifier = Util.getAppIdentifier(context);
register(context, appIdentifier);
}
/**
* Registers new crash manager and handles existing crash logs.
* HockeyApp App Identifier is read from configuration values in AndroidManifest.xml
*
* @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 for callback functions.
*/
public static void register(Context context, CrashManagerListener listener) {
String appIdentifier = Util.getAppIdentifier(context);
register(context, appIdentifier, listener);
}
/**
* Registers new crash manager and handles existing crash logs. If
* context is not an instance of Activity (or a subclass of it),
* crashes will be sent automatically.
*
* @param context The context to use. Usually your Activity object.
* @param appIdentifier App ID of your app on HockeyApp.
*/
public static void register(Context context, String appIdentifier) {
register(context, Constants.BASE_URL, appIdentifier, null);
}
/**
* Registers new crash manager and handles existing crash logs. If
* context is not an instance of Activity (or a subclass of it),
* crashes will be sent automatically.
*
* @param context The context to use. Usually your Activity object.
* @param appIdentifier App ID of your app on HockeyApp.
* @param listener Implement for callback functions.
*/
public static void register(Context context, String appIdentifier, CrashManagerListener listener) {
register(context, Constants.BASE_URL, appIdentifier, listener);
}
/**
* Registers new crash manager and handles existing crash logs. If
* context is not an instance of Activity (or a subclass of it),
* crashes will be sent automatically.
*
* @param context The context to use. Usually your Activity object.
* @param urlString URL of the HockeyApp server.
* @param appIdentifier App ID of your app on HockeyApp.
* @param listener Implement for callback functions.
*/
public static void register(Context context, String urlString, String appIdentifier, CrashManagerListener listener) {
initialize(context, urlString, appIdentifier, listener, false);
execute(context, listener);
}
/**
* Initializes the crash manager, but does not handle crash log. Use this
* method only if you want to split the process into two parts, i.e. when
* your app has multiple entry points. You need to call the method 'execute'
* at some point after this method.
*
* @param context The context to use. Usually your Activity object.
* @param appIdentifier App ID of your app on HockeyApp.
* @param listener Implement for callback functions.
*/
public static void initialize(Context context, String appIdentifier, CrashManagerListener listener) {
initialize(context, Constants.BASE_URL, appIdentifier, listener, true);
}
/**
* Initializes the crash manager, but does not handle crash log. Use this
* method only if you want to split the process into two parts, i.e. when
* your app has multiple entry points. You need to call the method 'execute'
* at some point after this method.
*
* @param context The context to use. Usually your Activity object.
* @param urlString URL of the HockeyApp server.
* @param appIdentifier App ID of your app on HockeyApp.
* @param listener Implement for callback functions.
*/
public static void initialize(Context context, String urlString, String appIdentifier, CrashManagerListener listener) {
initialize(context, urlString, appIdentifier, listener, true);
}
/**
* Executes the crash manager. You need to call this method if you have used
* the method 'initialize' before. If context is not an instance of Activity
* (or a subclass of it), crashes will be sent automatically.
*
* @param context The context to use. Usually your Activity object.
* @param listener Implement for callback functions.
*/
@SuppressWarnings("deprecation")
public static void execute(Context context, CrashManagerListener listener) {
Boolean ignoreDefaultHandler = (listener != null) && (listener.ignoreDefaultHandler());
WeakReference<Context> weakContext = new WeakReference<Context>(context);
int foundOrSend = hasStackTraces(weakContext);
if (foundOrSend == STACK_TRACES_FOUND_NEW) {
didCrashInLastSession = true;
Boolean autoSend = !(context instanceof Activity);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
autoSend |= prefs.getBoolean(ALWAYS_SEND_KEY, false);
if (listener != null) {
autoSend |= listener.shouldAutoUploadCrashes();
autoSend |= listener.onCrashesFound();
listener.onNewCrashesFound();
}
if (!autoSend) {
showDialog(weakContext, listener, ignoreDefaultHandler);
} else {
sendCrashes(weakContext, listener, ignoreDefaultHandler);
}
} else if (foundOrSend == STACK_TRACES_FOUND_CONFIRMED) {
if (listener != null) {
listener.onConfirmedCrashesFound();
}
sendCrashes(weakContext, listener, ignoreDefaultHandler);
} else {
registerHandler(weakContext, listener, ignoreDefaultHandler);
}
}
/**
* Checks if there are any saved stack traces in the files dir.
*
* @param weakContext 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 static int hasStackTraces(WeakReference<Context> weakContext) {
String[] filenames = searchForStackTraces();
List<String> confirmedFilenames = null;
int result = STACK_TRACES_FOUND_NONE;
if ((filenames != null) && (filenames.length > 0)) {
try {
confirmedFilenames = getConfirmedFilenames(weakContext);
} 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 static boolean didCrashInLastSession() {
return didCrashInLastSession;
}
public static CrashDetails 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;
CrashDetails result = null;
for (File file : files) {
if (file.lastModified() > lastModification) {
lastModification = file.lastModified();
lastModifiedFile = file;
}
}
if (lastModifiedFile != null && lastModifiedFile.exists()) {
try {
result = CrashDetails.fromFile(lastModifiedFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return result;
}
/**
* Submits all stack traces in the files dir to HockeyApp.
*
* @param weakContext The context to use. Usually your Activity object.
* @param listener Implement for callback functions.
*/
public static void submitStackTraces(WeakReference<Context> weakContext, CrashManagerListener listener) {
submitStackTraces(weakContext, listener, null);
}
/**
* Submits all stack traces in the files dir to HockeyApp.
*
* @param weakContext The context to use. Usually your Activity object.
* @param listener Implement for callback functions.
* @param crashMetaData The crashMetaData, provided by the user.
*/
public static void submitStackTraces(WeakReference<Context> weakContext, CrashManagerListener listener, CrashMetaData crashMetaData) {
String[] list = searchForStackTraces();
Boolean successful = false;
if ((list != null) && (list.length > 0)) {
HockeyLog.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 = contentsOfFile(weakContext, filename);
if (stacktrace.length() > 0) {
// Transmit stack trace with POST request
HockeyLog.debug("Transmitting crash data: \n" + stacktrace);
// Retrieve user ID and contact information if given
String userID = contentsOfFile(weakContext, filename.replace(".stacktrace", ".user"));
String contact = contentsOfFile(weakContext, filename.replace(".stacktrace", ".contact"));
if (crashMetaData != null) {
final String crashMetaDataUserID = crashMetaData.getUserID();
if (!TextUtils.isEmpty(crashMetaDataUserID)) {
userID = crashMetaDataUserID;
}
final String crashMetaDataContact = crashMetaData.getUserEmail();
if (!TextUtils.isEmpty(crashMetaDataContact)) {
contact = crashMetaDataContact;
}
}
// Append application log to user provided description if present, if not, just send application log
final String applicationLog = contentsOfFile(weakContext, filename.replace(".stacktrace", ".description"));
String description = crashMetaData != null ? crashMetaData.getUserDescription() : "";
if (!TextUtils.isEmpty(applicationLog)) {
if (!TextUtils.isEmpty(description)) {
description = String.format("%s\n\nLog:\n%s", description, applicationLog);
} else {
description = String.format("Log:\n%s", applicationLog);
}
}
Map<String, String> parameters = new HashMap<String, String>();
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) {
HockeyLog.debug("Transmission succeeded");
deleteStackTrace(weakContext, list[index]);
if (listener != null) {
listener.onCrashesSent();
deleteRetryCounter(weakContext, list[index], listener.getMaxRetryAttempts());
}
} else {
HockeyLog.debug("Transmission failed, will retry on next register() call");
if (listener != null) {
listener.onCrashesNotSent();
updateRetryCounter(weakContext, list[index], listener.getMaxRetryAttempts());
}
}
}
}
}
}
/**
* Deletes all stack traces and meta files from files dir.
*
* @param weakContext The context to use. Usually your Activity object.
*/
public static void deleteStackTraces(WeakReference<Context> weakContext) {
String[] list = searchForStackTraces();
if ((list != null) && (list.length > 0)) {
HockeyLog.debug("Found " + list.length + " stacktrace(s).");
for (int index = 0; index < list.length; index++) {
try {
Context context = null;
if (weakContext != null) {
HockeyLog.debug("Delete stacktrace " + list[index] + ".");
deleteStackTrace(weakContext, list[index]);
context = weakContext.get();
if (context != null) {
context.deleteFile(list[index]);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
/**
* 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 CrashManagerUserInput
* @see CrashMetaData
* @see CrashManagerListener
*/
public static boolean handleUserInput(final CrashManagerUserInput userInput,
final CrashMetaData userProvidedMetaData, final CrashManagerListener listener,
final WeakReference<Context> weakContext, final boolean ignoreDefaultHandler) {
switch (userInput) {
case CrashManagerUserInputDontSend:
if (listener != null) {
listener.onUserDeniedCrashes();
}
deleteStackTraces(weakContext);
registerHandler(weakContext, listener, ignoreDefaultHandler);
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(weakContext, listener, ignoreDefaultHandler, userProvidedMetaData);
return true;
case CrashManagerUserInputSend:
sendCrashes(weakContext, listener, ignoreDefaultHandler, userProvidedMetaData);
return true;
default:
return false;
}
}
/**
* Clears the preference to always send crashes. The next time the user
* sees a crash and restarts the app, they will see the dialog again to
* send the crash.
*
* @param weakContext The context to use. Usually your Activity object.
*/
public static void resetAlwaysSend(final WeakReference<Context> weakContext) {
Context context = null;
if (weakContext != null) {
context = weakContext.get();
if (context != null) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit().remove(ALWAYS_SEND_KEY).apply();
}
}
}
/**
* Private method to initialize the crash manager. This method has an
* additional parameter to decide whether to register the exception handler
* at the end or not.
*/
private static void initialize(Context context, String urlString, String appIdentifier, CrashManagerListener listener, boolean registerHandler) {
if (context != null) {
if (CrashManager.initializeTimestamp == 0) {
CrashManager.initializeTimestamp = System.currentTimeMillis();
}
CrashManager.urlString = urlString;
CrashManager.identifier = Util.sanitizeAppIdentifier(appIdentifier);
CrashManager.didCrashInLastSession = false;
Constants.loadFromContext(context);
if (CrashManager.identifier == null) {
CrashManager.identifier = Constants.APP_PACKAGE;
}
if (registerHandler) {
Boolean ignoreDefaultHandler = (listener != null) && (listener.ignoreDefaultHandler());
WeakReference<Context> weakContext = new WeakReference<Context>(context);
registerHandler(weakContext, listener, ignoreDefaultHandler);
}
}
}
/**
* Shows a dialog to ask the user whether he wants to send crash reports to
* HockeyApp or delete them.
*/
private static void showDialog(final WeakReference<Context> weakContext, final CrashManagerListener listener, final boolean ignoreDefaultHandler) {
Context context = null;
if (weakContext != null) {
context = weakContext.get();
}
if (context == null) {
return;
}
if (listener != null && listener.onHandleAlertView()) {
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(context);
String alertTitle = getAlertTitle(context);
builder.setTitle(alertTitle);
builder.setMessage(R.string.hockeyapp_crash_dialog_message);
builder.setNegativeButton(R.string.hockeyapp_crash_dialog_negative_button, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
handleUserInput(CrashManagerUserInput.CrashManagerUserInputDontSend, null, listener, weakContext, ignoreDefaultHandler);
}
});
builder.setNeutralButton(R.string.hockeyapp_crash_dialog_neutral_button, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
handleUserInput(CrashManagerUserInput.CrashManagerUserInputAlwaysSend, null, listener, weakContext, ignoreDefaultHandler);
}
});
builder.setPositiveButton(R.string.hockeyapp_crash_dialog_positive_button, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
handleUserInput(CrashManagerUserInput.CrashManagerUserInputSend, null, listener,
weakContext, ignoreDefaultHandler);
}
});
builder.create().show();
}
private static String getAlertTitle(Context context) {
String appTitle = Util.getAppName(context);
String message = context.getString(R.string.hockeyapp_crash_dialog_title);
return String.format(message, appTitle);
}
/**
* Starts thread to send crashes to HockeyApp, then registers the exception
* handler.
*/
private static void sendCrashes(final WeakReference<Context> weakContext, final CrashManagerListener listener, final boolean ignoreDefaultHandler) {
sendCrashes(weakContext, listener, ignoreDefaultHandler, null);
}
/**
* Starts thread to send crashes to HockeyApp, then registers the exception
* handler.
*/
private static void sendCrashes(final WeakReference<Context> weakContext, final CrashManagerListener listener, final boolean ignoreDefaultHandler, final CrashMetaData crashMetaData) {
saveConfirmedStackTraces(weakContext);
registerHandler(weakContext, listener, ignoreDefaultHandler);
Context ctx = weakContext.get();
if (ctx != null && !Util.isConnectedToNetwork(ctx)) {
// Not connected to network, not trying to submit stack traces
if(listener != null) {
listener.onCrashesNotSent();
}
return;
}
if (!submitting) {
submitting = true;
new Thread() {
@Override
public void run() {
submitStackTraces(weakContext, listener, crashMetaData);
submitting = false;
}
}.start();
}
}
/**
* Registers the exception handler.
*/
private static void registerHandler(WeakReference<Context> weakContext, CrashManagerListener listener, boolean ignoreDefaultHandler) {
if (!TextUtils.isEmpty(Constants.APP_VERSION) && !TextUtils.isEmpty(Constants.APP_PACKAGE)) {
// Get current handler
UncaughtExceptionHandler currentHandler = Thread.getDefaultUncaughtExceptionHandler();
if (currentHandler != null) {
HockeyLog.debug("Current handler class = " + currentHandler.getClass().getName());
}
// Update listener if already registered, otherwise set new handler
if (currentHandler instanceof ExceptionHandler) {
((ExceptionHandler) currentHandler).setListener(listener);
} else {
Thread.setDefaultUncaughtExceptionHandler(new ExceptionHandler(currentHandler, listener, ignoreDefaultHandler));
}
} else {
HockeyLog.debug("Exception handler not set because version or package is null.");
}
}
/**
* Returns the complete URL for the HockeyApp API.
*/
private static String getURLString() {
return urlString + "api/2/apps/" + identifier + "/crashes/";
}
/**
* Update the retry attempts count for this crash stacktrace.
*/
private static void updateRetryCounter(WeakReference<Context> weakContext, String filename, int maxRetryAttempts) {
if (maxRetryAttempts == -1) {
return;
}
Context context = null;
if (weakContext != null) {
context = weakContext.get();
if (context != null) {
SharedPreferences preferences = context.getSharedPreferences("HockeySDK", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
int retryCounter = preferences.getInt("RETRY_COUNT: " + filename, 0);
if (retryCounter >= maxRetryAttempts) {
deleteStackTrace(weakContext, filename);
deleteRetryCounter(weakContext, 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 static void deleteRetryCounter(WeakReference<Context> weakContext, String filename, int maxRetryAttempts) {
Context context = null;
if (weakContext != null) {
context = weakContext.get();
if (context != null) {
SharedPreferences preferences = context.getSharedPreferences("HockeySDK", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.remove("RETRY_COUNT: " + filename);
editor.apply();
}
}
}
/**
* Deletes the give filename and all corresponding files (same name,
* different extension).
*/
private static void deleteStackTrace(WeakReference<Context> weakContext, String filename) {
Context context = null;
if (weakContext != null) {
context = weakContext.get();
if (context != null) {
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);
}
}
}
/**
* Returns the content of a file as a string.
*/
private static String contentsOfFile(WeakReference<Context> weakContext, String filename) {
Context context = null;
if (weakContext != null) {
context = weakContext.get();
if (context != null) {
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();
}
}
return null;
}
/**
* Saves the list of the stack traces' file names in shared preferences.
*/
private static void saveConfirmedStackTraces(WeakReference<Context> weakContext) {
Context context = null;
if (weakContext != null) {
context = weakContext.get();
if (context != null) {
try {
String[] filenames = searchForStackTraces();
SharedPreferences preferences = context.getSharedPreferences("HockeySDK", Context.MODE_PRIVATE);
Editor editor = preferences.edit();
editor.putString("ConfirmedFilenames", joinArray(filenames, "|"));
editor.apply();
} catch (Exception e) {
// Just in case, we catch all exceptions here
}
}
}
}
/**
* Returns a string created by each element of the array, separated by
* delimiter.
*/
private 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();
}
/**
* Searches .stacktrace files and returns them as array.
*/
private static String[] searchForStackTraces() {
if (Constants.FILES_PATH != null) {
HockeyLog.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 {
HockeyLog.debug("Can't search for exception as file path is null.");
return null;
}
}
private static List<String> getConfirmedFilenames(WeakReference<Context> weakContext) {
List<String> result = null;
if (weakContext != null) {
Context context = weakContext.get();
if (context != null) {
SharedPreferences preferences = context.getSharedPreferences("HockeySDK", Context.MODE_PRIVATE);
result = Arrays.asList(preferences.getString("ConfirmedFilenames", "").split("\\|"));
}
}
return result;
}
public static long getInitializeTimestamp() {
return initializeTimestamp;
}
}