27 сентября 2010

Как заставить работать Force Close Dialog на себя

... или Stacktrace or GTFO.

Задача.
Итак, первое приложение для Android написано и его бета достигла девайсов пользователей. Задача - при получении исчерпывающего сообщения от пользователя "Ничего не работает" (ну ладно, ладно, более конкретного - "Вылетает") знать, где и что не работает. Вообще это должно быть первым, что будет написано в приложении. Далее будет показано, как гарантированно поймать исключение и послать отчёт о нём в Redmine.

Примечание: большая часть информации относится скорее к Java, чем к Андроиду, однако повод и место применения намекают.

Стратегия.
Приложение совершило критический промах и укусило себя в глаз. Частный и самый распространённый случай этого - исключение (есть и другие варианты, я сталкивался с перезагрузкой телефона), которое принято отлавливать и что-то делать по факту. Если оно не отловлено - пользователь увидит т.н. Force Close Dialog. Начиная с версии 2.2 ещё и предложение собрать данные об ошибке и поспамить ими девелопера, НО надо понимать, что
1. специфика некоторых ошибок привязана к определённым моделям девайсов, на которых вполне определённые версии Андроида, и это не 2.2
2. специфика некоторых рынков в популярности определённых моделей девайсов, см. выше
так что на этот способ не рассчитываем (как на основной).

Необработанное исключение нужно прежде всего отловить. Для этого в Java есть обработчик исключений потока, Thread.UncaughtExceptionHandler, реализация Андроида как раз и показывает диалог и пишет стектрейс в LogCat. С отловленным исключением нужно что-то сделать (узнать о самом факте его возникновения) и... в случае с UI Thread Андроида отпустить дальше - передать умолчальной реализации обработчика, потому что в противном случае, если исключение утаить, получим вместо одного диалога об ошибке другой (Application Not Responding). Кроме того, пользователю тоже особо ничего сказать не получится - UI Thread-то уже загнулся.



public class CrashReporter implements UncaughtExceptionHandler {
private UncaughtExceptionHandler defaultHandler;
public CrashReporter(UncaughtExceptionHandler defaultHandler)
{
this.defaultHandler = defaultHandler;
}

public void uncaughtException(Thread thread, Throwable e) {
Log.e("CrashReporter", "Caught " + e.toString() + " in "
+ thread.toString() + "\n" + Log.getStackTraceString(e));
// allow system to kill us
if(defaultHandler != null)
defaultHandler.uncaughtException(thread, e);
}
}



На основе этого бесполезного пока (потом будет интереснее) класса и будет строится обработка ошибок.

Тактика. Андроид.

Поскольку UI Thread в Андроиде один на всех, то получить глобальную обработку ошибок очень просто, в главной Activity достаточно при создании указать этот класс как обработчик:

public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Thread.currentThread().setUncaughtExceptionHandler(
new CrashReporter(Thread.currentThread().getUncaughtExceptionHandler()));
}


Тактика. Продвинутые техники.
Однако не всё делается в UI потоке Андроида, тяжёлые вычисления требуется вынести за его пределы. В Андроиде есть AsyncTask, который мне не подошёл (да и вообще на мой взгляд подходит только для простейших вещей), я использую ThreadPoolExecutor. Чтобы создаваемые для него потоки имели нужный класс обработчика ошибок, придётся создать свою фабрику (да, вот он, Java way) и передавать её в конструктор ThreadPoolExecutor'а:


public class CrashReportingThreadFactory implements ThreadFactory{
public Thread newThread(Runnable r) {
Thread newThread = Executors.defaultThreadFactory().newThread(r);
newThread.setUncaughtExceptionHandler(
new CrashReporter(newThread.getUncaughtExceptionHandler()));
return newThread;
}
}


public class CrashExecutor extends ThreadPoolExecutor {
private CrashExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
new CrashReportingThreadFactory());
}


Но это полдела. Теперь хлопок другой ладонью - надо заставить Executor обрабатывать исключения, возникшие в задачах, в т.ч. отправленных на выполнение вызовом submit и/или обёрнутые в класс типа FutureTask (за объяснениями - в доки к ThreadPoolExecutor.afterExecute и литературу):



protected void afterExecute (Runnable r, Throwable t)
{
super.afterExecute(r, t);
// задача отправлена на выполнение через submit или изначально является Future
if (t == null && r instanceof Future) {
try {
// принудительный вызов исключения
((Future<?>) r).get();
} catch (CancellationException ce) {
// отмена задачи - нормальное событие
} catch (ExecutionException ee) {
t = ee.getCause();
} catch (InterruptedException ie) {
// прерывание потока извне - передача исключения выше
Thread.currentThread().interrupt(); // ignore/reset
}
}
if (t != null) {
Thread thread = Thread.currentThread();
thread.getUncaughtExceptionHandler().uncaughtException(thread, t);
}
}

Так задача гарантированно будет вызывать исключение (из-за вызова get()) а исключения от Runnable не будут проглатываться.

Тактика. Redmine.

Всё, исключение есть, пора настроить Redmine:
1. Создать роль типа Crash reporter, имеющую право только создавать issue
2. учётную запись с этой ролью
3. набор дополнительных полей для Issue - например номер билда, тип девайса, на котором произошло падение
4. добавить учётку репортера в проект
5. разрешить REST web service
6. дополнительно можно завести под это свой тип трекера, чтобы отделять автоматически созданные issue

теперь можно постить:


// post reports only from wild (end-user) devices
if(!ApplicationInfo.getInstance().isAppDebuggable()){
try {
StringBuilder postBody = new StringBuilder()
.append("<issue><project_id>20</project_id><subject>")
.append(e.toString())
.append("</subject><tracker_id>4</tracker_id><priority_id>10</priority_id><custom_field_values><1>")
.append(Uri.decode(Settings.deviceInfo))
.append("</1><2>")
.append(ApplicationInfo.getInstance().deviceuid)
.append("</2><3>")
.append(String.valueOf(ApplicationInfo.getInstance().getAppBuild()))
.append("</3></custom_field_values><description><![CDATA[")
.append(Log.getStackTraceString(e).replaceAll("]]>","]]>]]><![CDATA["))
.append("]]></description></issue>");
post("http://redmine.company.com/issues.xml", postBody.toString());
} catch (Exception e1) {/*silently die*/}
}

public static void post(String url, String postData) throws IOException {
HttpClient client = new DefaultHttpClient();
HttpPost request = new HttpPost(url);
request.addHeader(BasicScheme.authenticate(
new UsernamePasswordCredentials("crash", "password"), "latin1", false));
request.addHeader("Content-Type", "application/xml");
StringEntity se = new StringEntity(postData);
request.setEntity(se);
HttpResponse response = client.execute(request);
}


ApplicationInfo - собственный класс, который собирает основную инфу об устройстве. Упоминаемые в xml ID можно узнать если посмотреть на ссылки соответствующих вещей - трекера, дополнительных полей, проекта.

Можно собирать релизный билд. Главное - не забыть убрать логирование и возможность отладки (удивительно, как много приложений, в которыx это не сделано).