/*
* Copyright (c) 2015, Nils Braden
*
* This file is part of ttrss-reader-fork. This program is free software; you
* can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation;
* either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details. You should have received a
* copy of the GNU General Public License along with this program; If
* not, see http://www.gnu.org/licenses/.
*/
package org.ttrssreader.utils;
import org.ttrssreader.controllers.Controller;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.database.sqlite.SQLiteException;
import android.os.Build;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.Thread.UncaughtExceptionHandler;
import java.lang.reflect.Field;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
/**
* Exception report delivery via email and user interaction. Avoids giving an app the
* permission to access the Internet.
*
* @author Ryan Fischbach <br>
* Blackmoon Info Tech Services<br>
*
* Source has been released to the public as is and without any warranty.
*/
@SuppressWarnings("StringConcatenationInsideStringBufferAppend")
public class PostMortemReportExceptionHandler implements UncaughtExceptionHandler, Runnable {
private static final String TAG = PostMortemReportExceptionHandler.class.getSimpleName();
private static final String EXCEPTION_REPORT_FILENAME = "postmortem.trace";
private static final String EXCEPTION_REPORT_EXCLUDE_PREFIX = "postmortem.exclude.";
// "app label + this tag" = email subject
private static final String MSG_SUBJECT_TAG = "Exception Report";
// email will be sent to this account the following may be something you wish to consider localizing
private static final String MSG_SENDTO = "ttrss@nilsbraden.de";
private static final String MSG_BODY = "Please help by sending this email. "
+ "No personal information is being sent (you can check by reading the rest of the email).";
private Thread.UncaughtExceptionHandler mDefaultUEH;
private Activity mAct = null;
public PostMortemReportExceptionHandler(Activity aAct) {
mDefaultUEH = Thread.getDefaultUncaughtExceptionHandler();
mAct = aAct;
}
/**
* Call this method after creation to start protecting all code thereafter.
*/
public void initialize() {
if (mAct == null) throw new NullPointerException();
// Ignore reports if
// - app is not signed with the key of Nils Braden
// - app is not installed from play store
// - app is running in an emulator
// - app is running with debuggable=true
if (!Controller.getInstance().isValidInstallation()) {
Log.i(TAG, "Error reporting disabled, invalid installation.");
return;
}
// Ignore crashreport if user has chosen to ignore it
if (Controller.getInstance().isNoCrashreports()) {
Log.i(TAG, "User has disabled error reporting.");
return;
}
// Ignore crashreport if this version isn't the newest from market
int latest = Controller.getInstance().appLatestVersion();
int current = Utils.getAppVersionCode(mAct);
if (latest > current) {
Log.i(TAG, "App is not updated, error reports are disabled.");
return;
}
sendDebugReportToAuthor(); // in case a previous error did not get sent to the email app
Thread.setDefaultUncaughtExceptionHandler(this);
}
/**
* Call this method at the end of the protected code, usually in {@link #finalize()}.
*/
public void restoreOriginalHandler() {
if (Thread.getDefaultUncaughtExceptionHandler().equals(this))
Thread.setDefaultUncaughtExceptionHandler(mDefaultUEH);
}
@Override
protected void finalize() throws Throwable {
restoreOriginalHandler();
super.finalize();
}
@Override
public void uncaughtException(Thread t, Throwable e) {
boolean handleException = true;
if (e instanceof SecurityException) {
// Cannot be reproduced, seems to be related to Cyanogenmod with Android 4.0.4 on some devices:
// http://stackoverflow.com/questions/11025182/webview-java-lang-securityexception-no-permission-to
// -modify-given-thread
if (e.getMessage().toLowerCase(Locale.ENGLISH).contains("no permission to modify given thread")) {
Log.w(TAG, "Error-Reporting for Exception \"no permission to modify given thread\" is disabled.");
handleException = false;
}
}
if (e instanceof SQLiteException) {
if (e.getMessage().toLowerCase(Locale.ENGLISH).contains("database is locked")) {
Log.w(TAG, "Error-Reporting for Exception \"database is locked\" is disabled.");
handleException = false;
}
}
if (handleException) submit(e);
// do not forget to pass this exception through up the chain
bubbleUncaughtException(t, e);
}
/**
* Send the Exception up the chain, skipping other handlers of this type so only 1 report is sent.
*
* @param t - thread object
* @param e - exception being handled
*/
private void bubbleUncaughtException(Thread t, Throwable e) {
if (mDefaultUEH != null) {
if (mDefaultUEH instanceof PostMortemReportExceptionHandler)
((PostMortemReportExceptionHandler) mDefaultUEH).bubbleUncaughtException(t, e);
else mDefaultUEH.uncaughtException(t, e);
}
}
/**
* Return a string containing the device environment.
*
* @return Returns a string with the device info used for debugging.
*/
public String getDeviceEnvironment() {
// app environment
PackageManager pm = mAct.getPackageManager();
PackageInfo pi;
try {
pi = pm.getPackageInfo(mAct.getPackageName(), 0);
} catch (NameNotFoundException nnfe) {
// doubt this will ever run since we want info about our own package
pi = new PackageInfo();
pi.versionName = "unknown";
pi.versionCode = 69;
}
Date theDate = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("dd.MM.yyyy_HH.mm.ss_zzz", Locale.ENGLISH);
StringBuilder s = new StringBuilder();
s.append("--- Application ---------------------\n");
s.append("Version = " + Controller.getInstance().getLastVersionRun() + "\n");
s.append("VersionCode = " + (pi != null ? pi.versionCode : "null") + "\n");
s.append("-------------------------------------\n\n");
s.append("--- Environment ---------------------\n");
s.append("Time = " + sdf.format(theDate) + "\n");
try {
Field theMfrField = Build.class.getField("MANUFACTURER");
s.append("Make = " + theMfrField.get(null) + "\n");
} catch (Exception e) {
// Empty!
}
s.append("Brand = " + Build.BRAND + "\n");
s.append("Device = " + Build.DEVICE + "\n");
s.append("Model = " + Build.MODEL + "\n");
s.append("Id = " + Build.ID + "\n");
s.append("Fingerprint = " + Build.FINGERPRINT + "\n");
s.append("Product = " + Build.PRODUCT + "\n");
s.append("Locale = " + mAct.getResources().getConfiguration().locale.getDisplayName() + "\n");
s.append("Res = " + mAct.getResources().getDisplayMetrics().toString() + "\n");
s.append("-------------------------------------\n\n");
s.append("--- Firmware -----------------------\n");
s.append("SDK = " + Build.VERSION.SDK_INT + "\n");
s.append("Release = " + Build.VERSION.RELEASE + "\n");
s.append("Inc = " + Build.VERSION.INCREMENTAL + "\n");
s.append("-------------------------------------\n\n");
return s.toString();
}
/**
* Return the application's friendly name.
*
* @return Returns the application name as defined by the android:name attribute.
*/
public CharSequence getAppName() {
PackageManager pm = mAct.getPackageManager();
PackageInfo pi;
try {
pi = pm.getPackageInfo(mAct.getPackageName(), 0);
return pi.applicationInfo.loadLabel(pm);
} catch (NameNotFoundException nnfe) {
// doubt this will ever run since we want info about our own package
return mAct.getPackageName();
}
}
/**
* If subactivities create their own report handler, report all Activities as a trace list.
* A separate line is included if a calling activity/package is detected with the Intent it supplied.
*
* @param aTrace - pass in null to force a new list to be created
* @return Returns the list of Activities in the handler chain.
*/
private LinkedList<CharSequence> getActivityTrace(LinkedList<CharSequence> aTrace) {
if (aTrace == null) aTrace = new LinkedList<>();
aTrace.add(mAct.getLocalClassName() + " (" + mAct.getTitle() + ")");
if (mAct.getCallingActivity() != null)
aTrace.add(mAct.getCallingActivity().toString() + " (" + mAct.getIntent().toString() + ")");
else if (mAct.getCallingPackage() != null)
aTrace.add(mAct.getCallingPackage() + " (" + mAct.getIntent().toString() + ")");
if (mDefaultUEH != null && mDefaultUEH instanceof PostMortemReportExceptionHandler)
((PostMortemReportExceptionHandler) mDefaultUEH).getActivityTrace(aTrace);
return aTrace;
}
/**
* Create a report based on the given exception.
*
* @param aException - exception to report on
* @return Returns a string with a lot of debug information.
*/
private String[] getDebugReport(Throwable aException) {
StringBuilder theErrReport = new StringBuilder();
theErrReport.append(getDeviceEnvironment());
theErrReport.append(getAppName() + " generated the following exception:\n");
theErrReport.append(aException.toString() + "\n\n");
String exceptionHash = String.valueOf(aException.toString().hashCode());
// activity stack trace
List<CharSequence> theActivityTrace = getActivityTrace(null);
if (theActivityTrace != null && theActivityTrace.size() > 0) {
theErrReport.append("--- Activity Stacktrace -------------\n");
for (int i = 0; i < theActivityTrace.size(); i++) {
theErrReport.append(" " + theActivityTrace.get(i) + "\n");
}
theErrReport.append("-------------------------------------\n\n");
}
// instruction stack trace
StackTraceElement[] theStackTrace = aException.getStackTrace();
if (theStackTrace.length > 0) {
theErrReport.append("--- Instruction Stacktrace ----------\n");
for (StackTraceElement se : theStackTrace) {
theErrReport.append(" " + se.toString() + "\n");
}
theErrReport.append("-------------------------------------\n\n");
}
// if the exception was thrown in a background thread inside
// AsyncTask, then the actual exception can be found with getCause
Throwable theCause = aException.getCause();
if (theCause != null) {
theErrReport.append("--- Cause ---------------------------\n");
theErrReport.append(theCause.toString() + "\n\n");
theStackTrace = theCause.getStackTrace();
for (StackTraceElement se : theStackTrace) {
theErrReport.append(" " + se.toString() + "\n");
}
theErrReport.append("-------------------------------------\n\n");
}
theErrReport.append("END REPORT.");
return new String[] {theErrReport.toString(), exceptionHash};
}
/**
* Write the given debug report to the file system.
*
* @param aReport - the debug report
*/
private void saveDebugReport(String[] aReport) {
// save report to file
try {
FileOutputStream theFile = mAct.openFileOutput(EXCEPTION_REPORT_FILENAME, Context.MODE_PRIVATE);
theFile.write(aReport[0].getBytes());
theFile.close();
setReportHasBeenSent(aReport[1]);
} catch (IOException ioe) {
// error during error report needs to be ignored, do not wish to start infinite loop
}
}
/**
* Read in saved debug report and send to email app.
*/
private void sendDebugReportToAuthor() {
String theLine;
String theTrace = "";
try {
BufferedReader theReader = new BufferedReader(
new InputStreamReader(mAct.openFileInput(EXCEPTION_REPORT_FILENAME)));
while ((theLine = theReader.readLine()) != null) {
theTrace += theLine + "\n";
}
if (sendDebugReportToAuthor(theTrace)) {
mAct.deleteFile(EXCEPTION_REPORT_FILENAME);
}
} catch (IOException eIo) {
// Empty!
}
}
/**
* Send the given report to email app.
*
* @param aReport - the debug report to send
* @return Returns true if the email app was launched regardless if the email was sent.
*/
private Boolean sendDebugReportToAuthor(String aReport) {
if (aReport != null) {
Intent theIntent = new Intent(Intent.ACTION_SEND);
String theSubject = getAppName() + " " + MSG_SUBJECT_TAG;
String theBody = "\n" + MSG_BODY + "\n\n" + aReport + "\n\n";
theIntent.putExtra(Intent.EXTRA_EMAIL, new String[] {MSG_SENDTO});
theIntent.putExtra(Intent.EXTRA_TEXT, theBody);
theIntent.putExtra(Intent.EXTRA_SUBJECT, theSubject);
theIntent.setType("message/rfc822");
Boolean hasSendRecipients = (mAct.getPackageManager().queryIntentActivities(theIntent, 0).size() > 0);
if (hasSendRecipients) {
mAct.startActivity(theIntent);
return true;
} else {
return false;
}
} else {
return true;
}
}
@Override
public void run() {
sendDebugReportToAuthor();
}
/**
* Create an exception report and start an email with the contents of the report.
*
* @param e - the exception
*/
private void submit(Throwable e) {
String[] report = getDebugReport(e);
if (!hasReportBeenSent(report[1])) {
saveDebugReport(report);
// try to send file contents via email (need to do so via the UI thread)
mAct.runOnUiThread(this);
}
}
private boolean hasReportBeenSent(String hash) {
File file = new File(mAct.getFilesDir(), EXCEPTION_REPORT_EXCLUDE_PREFIX + hash);
return file.exists();
}
private void setReportHasBeenSent(String hash) {
try {
// Store file with name postmortem.exclude.xyz to indicate that this report has been sent already
FileOutputStream sentReport = mAct
.openFileOutput(EXCEPTION_REPORT_EXCLUDE_PREFIX + hash, Context.MODE_PRIVATE);
sentReport.write("1".getBytes());
sentReport.close();
} catch (IOException e) {
// Empty
}
}
}