ExceptionHandler + Log in files for RageShake

This commit is contained in:
Benoit Marty 2019-03-28 17:28:20 +01:00
parent bc467340c9
commit be2dad9b17
14 changed files with 1649 additions and 738 deletions

View file

@ -167,7 +167,11 @@ dependencies {
implementation 'androidx.paging:paging-runtime:2.0.0'
implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1'
// Log
implementation 'com.jakewharton.timber:timber:4.7.1'
// Debug
implementation 'com.facebook.stetho:stetho:1.5.0'
// rx

View file

@ -26,6 +26,8 @@ import com.jakewharton.threetenabp.AndroidThreeTen
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.core.di.AppModule
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.rageshake.VectorFileLogger
import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler
import org.koin.log.EmptyLogger
import org.koin.standalone.StandAloneContext.startKoin
import timber.log.Timber
@ -35,10 +37,17 @@ class Riot : Application() {
override fun onCreate() {
super.onCreate()
VectorUncaughtExceptionHandler.activate(this)
// Log
VectorFileLogger.init(this)
Timber.plant(Timber.DebugTree(), VectorFileLogger)
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
Stetho.initializeWithDefaults(this)
}
AndroidThreeTen.init(this)
BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
val appModule = AppModule(applicationContext).definition

View file

@ -16,9 +16,11 @@
package im.vector.riotredesign.core.platform
import android.content.res.Configuration
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.annotation.*
import androidx.appcompat.widget.Toolbar
import butterknife.BindView
@ -28,12 +30,15 @@ import com.airbnb.mvrx.BaseMvRxActivity
import com.bumptech.glide.util.Util
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import im.vector.riotredesign.features.rageshake.BugReportActivity
import im.vector.riotredesign.features.rageshake.BugReporter
import im.vector.riotredesign.features.rageshake.RageShake
import im.vector.riotredesign.features.themes.ThemeUtils
import im.vector.riotredesign.receivers.DebugReceiver
import im.vector.ui.themes.ActivityOtherThemes
import im.vector.ui.themes.ThemeUtils
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import timber.log.Timber
abstract class RiotActivity : BaseMvRxActivity() {
@ -113,10 +118,19 @@ abstract class RiotActivity : BaseMvRxActivity() {
}
}
override fun onDestroy() {
super.onDestroy()
unBinder?.unbind()
unBinder = null
}
override fun onResume() {
super.onResume()
if (this !is BugReportActivity) {
rageShake?.start()
}
DebugReceiver
.getIntentFilter(this)
@ -138,6 +152,38 @@ abstract class RiotActivity : BaseMvRxActivity() {
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus && displayInFullscreen()) {
setFullScreen()
}
}
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) {
super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig)
Timber.w("onMultiWindowModeChanged. isInMultiWindowMode: $isInMultiWindowMode")
BugReporter.inMultiWindowMode = isInMultiWindowMode
}
/* ==========================================================================================
* PRIVATE METHODS
* ========================================================================================== */
/**
* Force to render the activity in fullscreen
*/
private fun setFullScreen() {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
}
/* ==========================================================================================
* MENU MANAGEMENT
* ========================================================================================== */

View file

@ -0,0 +1,186 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.utils
import android.annotation.TargetApi
import android.app.Activity
import android.content.*
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.fragment.app.Fragment
import im.vector.riotredesign.R
import im.vector.riotredesign.features.settings.VectorLocale
import timber.log.Timber
import java.util.*
/**
* Tells if the application ignores battery optimizations.
*
* Ignoring them allows the app to run in background to make background sync with the homeserver.
* This user option appears on Android M but Android O enforces its usage and kills apps not
* authorised by the user to run in background.
*
* @param context the context
* @return true if battery optimisations are ignored
*/
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
// no issue before Android M, battery optimisations did not exist
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| (context.getSystemService(Context.POWER_SERVICE) as PowerManager?)?.isIgnoringBatteryOptimizations(context.packageName) == true
}
/**
* display the system dialog for granting this permission. If previously granted, the
* system will not show it (so you should call this method).
*
* Note: If the user finally does not grant the permission, PushManager.isBackgroundSyncAllowed()
* will return false and the notification privacy will fallback to "LOW_DETAIL".
*/
@TargetApi(Build.VERSION_CODES.M)
fun requestDisablingBatteryOptimization(activity: Activity, fragment: Fragment?, requestCode: Int) {
val intent = Intent()
intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
intent.data = Uri.parse("package:" + activity.packageName)
if (fragment != null) {
fragment.startActivityForResult(intent, requestCode)
} else {
activity.startActivityForResult(intent, requestCode)
}
}
//==============================================================================================================
// Clipboard helper
//==============================================================================================================
/**
* Copy a text to the clipboard, and display a Toast when done
*
* @param context the context
* @param text the text to copy
*/
fun copyToClipboard(context: Context, text: CharSequence) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.primaryClip = ClipData.newPlainText("", text)
context.toast(R.string.copied_to_clipboard)
}
/**
* Provides the device locale
*
* @return the device locale
*/
fun getDeviceLocale(context: Context): Locale {
var locale: Locale
locale = try {
val packageManager = context.packageManager
val resources = packageManager.getResourcesForApplication("android")
resources.configuration.locale
} catch (e: Exception) {
Timber.e(e, "## getDeviceLocale() failed " + e.message)
// Fallback to application locale
VectorLocale.applicationLocale
}
return locale
}
/**
* Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings
*/
fun startNotificationSettingsIntent(fragment: Fragment, requestCode: Int) {
val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, fragment.context?.packageName)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra("app_package", fragment.context?.packageName)
intent.putExtra("app_uid", fragment.context?.applicationInfo?.uid)
} else {
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.addCategory(Intent.CATEGORY_DEFAULT);
val uri = Uri.fromParts("package", fragment.activity?.packageName, null)
intent.data = uri
}
fragment.startActivityForResult(intent, requestCode)
}
// TODO This comes from NotificationUtils
fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
/**
* Shows notification system settings for the given channel id.
*/
@TargetApi(Build.VERSION_CODES.O)
fun startNotificationChannelSettingsIntent(fragment: Fragment, channelID: String) {
if (!supportNotificationChannels()) return
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, fragment.context?.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelID)
}
fragment.startActivity(intent)
}
fun startAddGoogleAccountIntent(fragment: Fragment, requestCode: Int) {
try {
val intent = Intent(Settings.ACTION_ADD_ACCOUNT)
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google"))
fragment.startActivityForResult(intent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
fragment.activity?.toast(R.string.error_no_external_application_found)
}
}
fun startSharePlainTextIntent(fragment: Fragment, chooserTitle: String?, text: String, subject: String? = null) {
val share = Intent(Intent.ACTION_SEND)
share.type = "text/plain"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
share.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
} else {
share.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Add data to the intent, the receiving app will decide what to do with it.
share.putExtra(Intent.EXTRA_SUBJECT, subject)
share.putExtra(Intent.EXTRA_TEXT, text)
try {
fragment.startActivity(Intent.createChooser(share, chooserTitle))
} catch (activityNotFoundException: ActivityNotFoundException) {
fragment.activity?.toast(R.string.error_no_external_application_found)
}
}
fun startImportTextFromFileIntent(fragment: Fragment, requestCode: Int) {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "text/plain"
}
if (intent.resolveActivity(fragment.activity!!.packageManager) != null) {
fragment.startActivityForResult(intent, requestCode)
} else {
fragment.activity?.toast(R.string.error_no_external_application_found)
}
}
// Not in KTX anymore
fun Context.toast(resId: Int) {
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
}

View file

@ -21,6 +21,7 @@ import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
@ -34,6 +35,8 @@ import im.vector.riotredesign.core.platform.OnBackPressed
import im.vector.riotredesign.core.platform.RiotActivity
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.features.home.room.detail.LoadingRoomDetailFragment
import im.vector.riotredesign.features.rageshake.BugReporter
import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler
import kotlinx.android.synthetic.main.activity_home.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
@ -74,6 +77,21 @@ class HomeActivity : RiotActivity(), ToolbarConfigurable {
super.onDestroy()
}
override fun onResume() {
super.onResume()
if (VectorUncaughtExceptionHandler.didAppCrash(this)) {
VectorUncaughtExceptionHandler.clearAppCrashStatus(this)
AlertDialog.Builder(this)
.setMessage(R.string.send_bug_report_app_crashed)
.setCancelable(false)
.setPositiveButton(R.string.yes) { _, _ -> BugReporter.openBugReportScreen(this) }
.setNegativeButton(R.string.no) { _, _ -> BugReporter.deleteCrashFile(this) }
.show()
}
}
override fun configure(toolbar: Toolbar) {
setSupportActionBar(toolbar)
supportActionBar?.setHomeButtonEnabled(true)

View file

@ -70,8 +70,8 @@ class BugReportActivity : RiotActivity() {
override fun initUiAndData() {
configureToolbar()
if (BugReporter.getScreenshot() != null) {
mScreenShotPreview.setImageBitmap(BugReporter.getScreenshot())
if (BugReporter.screenshot != null) {
mScreenShotPreview.setImageBitmap(BugReporter.screenshot)
} else {
mScreenShotPreview.isVisible = false
mIncludeScreenShotButton.isChecked = false
@ -189,7 +189,7 @@ class BugReportActivity : RiotActivity() {
@OnCheckedChanged(R.id.bug_report_button_include_screenshot)
internal fun onSendScreenshotChanged() {
mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.getScreenshot() != null
mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.screenshot != null
}
override fun onBackPressed() {

View file

@ -1,728 +0,0 @@
/*
* Copyright 2016 OpenMarket Ltd
* Copyright 2017 Vector Creations Ltd
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.rageshake;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.AsyncTask;
import android.os.Build;
import android.text.TextUtils;
import android.view.View;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.zip.GZIPOutputStream;
import androidx.annotation.Nullable;
import im.vector.riotredesign.BuildConfig;
import im.vector.riotredesign.R;
import im.vector.riotredesign.core.extensions.BasicExtensionsKt;
import okhttp3.Call;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import timber.log.Timber;
/**
* BugReporter creates and sends the bug reports.
*/
public class BugReporter {
private static final String LOG_TAG = BugReporter.class.getSimpleName();
private static boolean sInMultiWindowMode;
public static void setMultiWindowMode(boolean inMultiWindowMode) {
sInMultiWindowMode = inMultiWindowMode;
}
/**
* Bug report upload listener
*/
public interface IMXBugReportListener {
/**
* The bug report has been cancelled
*/
void onUploadCancelled();
/**
* The bug report upload failed.
*
* @param reason the failure reason
*/
void onUploadFailed(String reason);
/**
* The upload progress (in percent)
*
* @param progress the upload progress
*/
void onProgress(int progress);
/**
* The bug report upload succeeded.
*/
void onUploadSucceed();
}
// filenames
private static final String LOG_CAT_ERROR_FILENAME = "logcatError.log";
private static final String LOG_CAT_FILENAME = "logcat.log";
private static final String LOG_CAT_SCREENSHOT_FILENAME = "screenshot.png";
private static final String CRASH_FILENAME = "crash.log";
// the http client
private static final OkHttpClient mOkHttpClient = new OkHttpClient();
// the pending bug report call
private static Call mBugReportCall = null;
// boolean to cancel the bug report
private static boolean mIsCancelled = false;
/**
* Send a bug report.
*
* @param context the application context
* @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs
* @param withScreenshot true to include the screenshot
* @param theBugDescription the bug description
* @param listener the listener
*/
public static void sendBugReport(final Context context,
final boolean withDevicesLogs,
final boolean withCrashLogs,
final boolean withScreenshot,
final String theBugDescription,
final IMXBugReportListener listener) {
new AsyncTask<Void, Integer, String>() {
// enumerate files to delete
final List<File> mBugReportFiles = new ArrayList<>();
@Override
protected String doInBackground(Void... voids) {
String bugDescription = theBugDescription;
String serverError = null;
String crashCallStack = getCrashDescription(context);
if (null != crashCallStack) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n";
bugDescription += crashCallStack;
}
List<File> gzippedFiles = new ArrayList<>();
if (withDevicesLogs) {
// TODO Timber
/*
List<File> files = org.matrix.androidsdk.util.Timber.addLogFiles(new ArrayList<File>());
for (File f : files) {
if (!mIsCancelled) {
File gzippedFile = compressFile(f);
if (null != gzippedFile) {
gzippedFiles.add(gzippedFile);
}
}
}
*/
}
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
File gzippedLogcat = saveLogCat(context, false);
if (null != gzippedLogcat) {
if (gzippedFiles.size() == 0) {
gzippedFiles.add(gzippedLogcat);
} else {
gzippedFiles.add(0, gzippedLogcat);
}
}
File crashDescription = getCrashFile(context);
if (crashDescription.exists()) {
File compressedCrashDescription = compressFile(crashDescription);
if (null != compressedCrashDescription) {
if (gzippedFiles.size() == 0) {
gzippedFiles.add(compressedCrashDescription);
} else {
gzippedFiles.add(0, compressedCrashDescription);
}
}
}
}
// TODO MXSession session = Matrix.getInstance(context).getDefaultSession();
String deviceId = "undefined";
String userId = "undefined";
String matrixSdkVersion = "undefined";
String olmVersion = "undefined";
/*
TODO
if (null != session) {
userId = session.getMyUserId();
deviceId = session.getCredentials().deviceId;
matrixSdkVersion = session.getVersion(true);
olmVersion = session.getCryptoVersion(context, true);
}
*/
if (!mIsCancelled) {
// build the multi part request
BugReporterMultipartBody.Builder builder = new BugReporterMultipartBody.Builder()
.addFormDataPart("text", "[RiotX] " + bugDescription)
.addFormDataPart("app", "riot-android")
// TODO .addFormDataPart("user_agent", RestClient.getUserAgent())
.addFormDataPart("user_id", userId)
.addFormDataPart("device_id", deviceId)
// TODO .addFormDataPart("version", Matrix.getInstance(context).getVersion(true, false))
.addFormDataPart("branch_name", context.getString(R.string.git_branch_name))
.addFormDataPart("matrix_sdk_version", matrixSdkVersion)
.addFormDataPart("olm_version", olmVersion)
.addFormDataPart("device", Build.MODEL.trim())
.addFormDataPart("lazy_loading", BasicExtensionsKt.toOnOff(true))
.addFormDataPart("multi_window", BasicExtensionsKt.toOnOff(sInMultiWindowMode))
.addFormDataPart("os", Build.VERSION.RELEASE + " (API " + Build.VERSION.SDK_INT + ") "
+ Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME)
.addFormDataPart("locale", Locale.getDefault().toString())
// TODO .addFormDataPart("app_language", VectorLocale.INSTANCE.getApplicationLocale().toString())
// TODO .addFormDataPart("default_app_language", SystemUtilsKt.getDeviceLocale(context).toString())
// TODO .addFormDataPart("theme", ThemeUtils.INSTANCE.getApplicationTheme(context))
;
String buildNumber = context.getString(R.string.build_number);
if (!TextUtils.isEmpty(buildNumber) && !buildNumber.equals("0")) {
builder.addFormDataPart("build_number", buildNumber);
}
// add the gzipped files
for (File file : gzippedFiles) {
builder.addFormDataPart("compressed-log", file.getName(), RequestBody.create(MediaType.parse("application/octet-stream"), file));
}
mBugReportFiles.addAll(gzippedFiles);
if (withScreenshot) {
Bitmap bitmap = mScreenshot;
if (null != bitmap) {
File logCatScreenshotFile = new File(context.getCacheDir().getAbsolutePath(), LOG_CAT_SCREENSHOT_FILENAME);
if (logCatScreenshotFile.exists()) {
logCatScreenshotFile.delete();
}
try {
FileOutputStream fos = new FileOutputStream(logCatScreenshotFile);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
fos.flush();
fos.close();
builder.addFormDataPart("file",
logCatScreenshotFile.getName(), RequestBody.create(MediaType.parse("application/octet-stream"), logCatScreenshotFile));
} catch (Exception e) {
Timber.e(e, "## sendBugReport() : fail to write screenshot" + e.toString());
}
}
}
mScreenshot = null;
// add some github labels
builder.addFormDataPart("label", BuildConfig.VERSION_NAME);
builder.addFormDataPart("label", BuildConfig.FLAVOR_DESCRIPTION);
builder.addFormDataPart("label", context.getString(R.string.git_branch_name));
// Special for RiotX
builder.addFormDataPart("label", "[RiotX]");
if (getCrashFile(context).exists()) {
builder.addFormDataPart("label", "crash");
deleteCrashFile(context);
}
BugReporterMultipartBody requestBody = builder.build();
// add a progress listener
requestBody.setWriteListener(new BugReporterMultipartBody.WriteListener() {
@Override
public void onWrite(long totalWritten, long contentLength) {
int percentage;
if (-1 != contentLength) {
if (totalWritten > contentLength) {
percentage = 100;
} else {
percentage = (int) (totalWritten * 100 / contentLength);
}
} else {
percentage = 0;
}
if (mIsCancelled && (null != mBugReportCall)) {
mBugReportCall.cancel();
}
Timber.d("## onWrite() : " + percentage + "%");
publishProgress(percentage);
}
});
// build the request
Request request = new Request.Builder()
.url(context.getString(R.string.bug_report_url))
.post(requestBody)
.build();
int responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR;
Response response = null;
String errorMessage = null;
// trigger the request
try {
mBugReportCall = mOkHttpClient.newCall(request);
response = mBugReportCall.execute();
responseCode = response.code();
} catch (Exception e) {
Timber.e(e, "response " + e.getMessage());
errorMessage = e.getLocalizedMessage();
}
// if the upload failed, try to retrieve the reason
if (responseCode != HttpURLConnection.HTTP_OK) {
if (null != errorMessage) {
serverError = "Failed with error " + errorMessage;
} else if ((null == response) || (null == response.body())) {
serverError = "Failed with error " + responseCode;
} else {
InputStream is = null;
try {
is = response.body().byteStream();
if (null != is) {
int ch;
StringBuilder b = new StringBuilder();
while ((ch = is.read()) != -1) {
b.append((char) ch);
}
serverError = b.toString();
is.close();
// check if the error message
try {
JSONObject responseJSON = new JSONObject(serverError);
serverError = responseJSON.getString("error");
} catch (JSONException e) {
Timber.e(e, "doInBackground ; Json conversion failed " + e.getMessage());
}
// should never happen
if (null == serverError) {
serverError = "Failed with error " + responseCode;
}
}
} catch (Exception e) {
Timber.e(e, "## sendBugReport() : failed to parse error " + e.getMessage());
} finally {
try {
if (null != is) {
is.close();
}
} catch (Exception e) {
Timber.e(e, "## sendBugReport() : failed to close the error stream " + e.getMessage());
}
}
}
}
}
return serverError;
}
@Override
protected void onProgressUpdate(Integer... progress) {
super.onProgressUpdate(progress);
if (null != listener) {
try {
listener.onProgress((null == progress) ? 0 : progress[0]);
} catch (Exception e) {
Timber.e(e, "## onProgress() : failed " + e.getMessage());
}
}
}
@Override
protected void onPostExecute(String reason) {
mBugReportCall = null;
// delete when the bug report has been successfully sent
for (File file : mBugReportFiles) {
file.delete();
}
if (null != listener) {
try {
if (mIsCancelled) {
listener.onUploadCancelled();
} else if (null == reason) {
listener.onUploadSucceed();
} else {
listener.onUploadFailed(reason);
}
} catch (Exception e) {
Timber.e(e, "## onPostExecute() : failed " + e.getMessage());
}
}
}
}.execute();
}
private static Bitmap mScreenshot = null;
/**
* Get current Screenshot
*
* @return screenshot or null if not available
*/
@Nullable
public static Bitmap getScreenshot() {
return mScreenshot;
}
/**
* Send a bug report either with email or with Vector.
*/
public static void sendBugReport(Activity activity) {
mScreenshot = takeScreenshot(activity);
Intent intent = new Intent(activity, BugReportActivity.class);
activity.startActivity(intent);
}
//==============================================================================================================
// crash report management
//==============================================================================================================
/**
* Provides the crash file
*
* @param context the context
* @return the crash file
*/
private static File getCrashFile(Context context) {
return new File(context.getCacheDir().getAbsolutePath(), CRASH_FILENAME);
}
/**
* Remove the crash file
*
* @param context
*/
public static void deleteCrashFile(Context context) {
File crashFile = getCrashFile(context);
if (crashFile.exists()) {
crashFile.delete();
}
// Also reset the screenshot
mScreenshot = null;
}
/**
* Save the crash report
*
* @param context the context
* @param crashDescription teh crash description
*/
public static void saveCrashReport(Context context, String crashDescription) {
File crashFile = getCrashFile(context);
if (crashFile.exists()) {
crashFile.delete();
}
if (!TextUtils.isEmpty(crashDescription)) {
try {
FileOutputStream fos = new FileOutputStream(crashFile);
OutputStreamWriter osw = new OutputStreamWriter(fos);
osw.write(crashDescription);
osw.close();
fos.flush();
fos.close();
} catch (Exception e) {
Timber.e(e, "## saveCrashReport() : fail to write " + e.toString());
}
}
}
/**
* Read the crash description file and return its content.
*
* @param context teh context
* @return the crash description
*/
private static String getCrashDescription(Context context) {
String crashDescription = null;
File crashFile = getCrashFile(context);
if (crashFile.exists()) {
try {
FileInputStream fis = new FileInputStream(crashFile);
InputStreamReader isr = new InputStreamReader(fis);
char[] buffer = new char[fis.available()];
int len = isr.read(buffer, 0, fis.available());
crashDescription = String.valueOf(buffer, 0, len);
isr.close();
fis.close();
} catch (Exception e) {
Timber.e(e, "## getCrashDescription() : fail to read " + e.toString());
}
}
return crashDescription;
}
//==============================================================================================================
// Screenshot management
//==============================================================================================================
/**
* Take a screenshot of the display.
*
* @return the screenshot
*/
private static Bitmap takeScreenshot(Activity activity) {
// get content view
View contentView = activity.findViewById(android.R.id.content);
if (contentView == null) {
Timber.e("Cannot find content view on " + activity + ". Cannot take screenshot.");
return null;
}
// get the root view to snapshot
View rootView = contentView.getRootView();
if (rootView == null) {
Timber.e("Cannot find root view on " + activity + ". Cannot take screenshot.");
return null;
}
// refresh it
rootView.setDrawingCacheEnabled(false);
rootView.setDrawingCacheEnabled(true);
try {
Bitmap bitmap = rootView.getDrawingCache();
// Make a copy, because if Activity is destroyed, the bitmap will be recycled
bitmap = Bitmap.createBitmap(bitmap);
return bitmap;
} catch (OutOfMemoryError oom) {
Timber.e(oom, "Cannot get drawing cache for " + activity + " OOM.");
} catch (Exception e) {
Timber.e(e, "Cannot get snapshot of screen: " + e);
}
return null;
}
//==============================================================================================================
// Logcat management
//==============================================================================================================
/**
* Save the logcat
*
* @param context the context
* @param isErrorLogcat true to save the error logcat
* @return the file if the operation succeeds
*/
private static File saveLogCat(Context context, boolean isErrorLogcat) {
File logCatErrFile = new File(context.getCacheDir().getAbsolutePath(), isErrorLogcat ? LOG_CAT_ERROR_FILENAME : LOG_CAT_FILENAME);
if (logCatErrFile.exists()) {
logCatErrFile.delete();
}
try {
FileOutputStream fos = new FileOutputStream(logCatErrFile);
OutputStreamWriter osw = new OutputStreamWriter(fos);
getLogCatError(osw, isErrorLogcat);
osw.close();
fos.flush();
fos.close();
return compressFile(logCatErrFile);
} catch (OutOfMemoryError error) {
Timber.e(error, "## saveLogCat() : fail to write logcat" + error.toString());
} catch (Exception e) {
Timber.e(e, "## saveLogCat() : fail to write logcat" + e.toString());
}
return null;
}
private static final int BUFFER_SIZE = 1024 * 1024 * 50;
private static final String[] LOGCAT_CMD_ERROR = new String[]{
"logcat", ///< Run 'logcat' command
"-d", ///< Dump the log rather than continue outputting it
"-v", // formatting
"threadtime", // include timestamps
"AndroidRuntime:E " + ///< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging
"libcommunicator:V " + ///< All libcommunicator logging
"DEBUG:V " + ///< All DEBUG logging - which includes native land crashes (seg faults, etc)
"*:S" ///< Everything else silent, so don't pick it..
};
private static final String[] LOGCAT_CMD_DEBUG = new String[]{
"logcat",
"-d",
"-v",
"threadtime",
"*:*"
};
/**
* Retrieves the logs
*
* @param streamWriter the stream writer
* @param isErrorLogCat true to save the error logs
*/
private static void getLogCatError(OutputStreamWriter streamWriter, boolean isErrorLogCat) {
Process logcatProc;
try {
logcatProc = Runtime.getRuntime().exec(isErrorLogCat ? LOGCAT_CMD_ERROR : LOGCAT_CMD_DEBUG);
} catch (IOException e1) {
return;
}
BufferedReader reader = null;
try {
String separator = System.getProperty("line.separator");
reader = new BufferedReader(new InputStreamReader(logcatProc.getInputStream()), BUFFER_SIZE);
String line;
while ((line = reader.readLine()) != null) {
streamWriter.append(line);
streamWriter.append(separator);
}
} catch (IOException e) {
Timber.e(e, "getLog fails with " + e.getLocalizedMessage());
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
Timber.e(e, "getLog fails with " + e.getLocalizedMessage());
}
}
}
}
//==============================================================================================================
// File compression management
//==============================================================================================================
/**
* GZip a file
*
* @param fin the input file
* @return the gzipped file
*/
private static File compressFile(File fin) {
Timber.d("## compressFile() : compress " + fin.getName());
File dstFile = new File(fin.getParent(), fin.getName() + ".gz");
if (dstFile.exists()) {
dstFile.delete();
}
FileOutputStream fos = null;
GZIPOutputStream gos = null;
InputStream inputStream = null;
try {
fos = new FileOutputStream(dstFile);
gos = new GZIPOutputStream(fos);
inputStream = new FileInputStream(fin);
int n;
byte[] buffer = new byte[2048];
while ((n = inputStream.read(buffer)) != -1) {
gos.write(buffer, 0, n);
}
gos.close();
inputStream.close();
Timber.d("## compressFile() : " + fin.length() + " compressed to " + dstFile.length() + " bytes");
return dstFile;
} catch (Exception e) {
Timber.e(e, "## compressFile() failed " + e.getMessage());
} catch (OutOfMemoryError oom) {
Timber.e(oom, "## compressFile() failed " + oom.getMessage());
} finally {
try {
if (null != fos) {
fos.close();
}
if (null != gos) {
gos.close();
}
if (null != inputStream) {
inputStream.close();
}
} catch (Exception e) {
Timber.e(e, "## compressFile() failed to close inputStream " + e.getMessage());
}
}
return null;
}
}

View file

@ -0,0 +1,697 @@
/*
* Copyright 2016 OpenMarket Ltd
* Copyright 2017 Vector Creations Ltd
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.rageshake
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.AsyncTask
import android.os.Build
import android.os.Environment
import android.text.TextUtils
import android.view.View
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.toOnOff
import im.vector.riotredesign.core.utils.getDeviceLocale
import im.vector.riotredesign.features.settings.VectorLocale
import im.vector.riotredesign.features.themes.ThemeUtils
import okhttp3.*
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.*
import java.net.HttpURLConnection
import java.util.*
import java.util.zip.GZIPOutputStream
/**
* BugReporter creates and sends the bug reports.
*/
object BugReporter {
var inMultiWindowMode = false
// filenames
private const val LOG_CAT_ERROR_FILENAME = "logcatError.log"
private const val LOG_CAT_FILENAME = "logcat.log"
private const val LOG_CAT_SCREENSHOT_FILENAME = "screenshot.png"
private const val CRASH_FILENAME = "crash.log"
// the http client
private val mOkHttpClient = OkHttpClient()
// the pending bug report call
private var mBugReportCall: Call? = null
// boolean to cancel the bug report
private val mIsCancelled = false
/**
* Get current Screenshot
*
* @return screenshot or null if not available
*/
var screenshot: Bitmap? = null
private set
private const val BUFFER_SIZE = 1024 * 1024 * 50
private val LOGCAT_CMD_ERROR = arrayOf("logcat", ///< Run 'logcat' command
"-d", ///< Dump the log rather than continue outputting it
"-v", // formatting
"threadtime", // include timestamps
"AndroidRuntime:E " + ///< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging
"libcommunicator:V " + ///< All libcommunicator logging
"DEBUG:V " + ///< All DEBUG logging - which includes native land crashes (seg faults, etc)
"*:S" ///< Everything else silent, so don't pick it..
)
private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
/**
* Bug report upload listener
*/
interface IMXBugReportListener {
/**
* The bug report has been cancelled
*/
fun onUploadCancelled()
/**
* The bug report upload failed.
*
* @param reason the failure reason
*/
fun onUploadFailed(reason: String?)
/**
* The upload progress (in percent)
*
* @param progress the upload progress
*/
fun onProgress(progress: Int)
/**
* The bug report upload succeeded.
*/
fun onUploadSucceed()
}
/**
* Send a bug report.
*
* @param context the application context
* @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs
* @param withScreenshot true to include the screenshot
* @param theBugDescription the bug description
* @param listener the listener
*/
@SuppressLint("StaticFieldLeak")
fun sendBugReport(context: Context,
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withScreenshot: Boolean,
theBugDescription: String,
listener: IMXBugReportListener?) {
object : AsyncTask<Void, Int, String>() {
// enumerate files to delete
val mBugReportFiles: MutableList<File> = ArrayList()
override fun doInBackground(vararg voids: Void): String? {
var bugDescription = theBugDescription
var serverError: String? = null
val crashCallStack = getCrashDescription(context)
if (null != crashCallStack) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
bugDescription += crashCallStack
}
val gzippedFiles = ArrayList<File>()
if (withDevicesLogs) {
val files = VectorFileLogger.addLogFiles(ArrayList<File>())
for (f in files) {
if (!mIsCancelled) {
val gzippedFile = compressFile(f)
if (null != gzippedFile) {
gzippedFiles.add(gzippedFile)
}
}
}
// TODO Delete the sent files?
}
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
val gzippedLogcat = saveLogCat(context, false)
if (null != gzippedLogcat) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(gzippedLogcat)
} else {
gzippedFiles.add(0, gzippedLogcat)
}
}
val crashDescription = getCrashFile(context)
if (crashDescription.exists()) {
val compressedCrashDescription = compressFile(crashDescription)
if (null != compressedCrashDescription) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(compressedCrashDescription)
} else {
gzippedFiles.add(0, compressedCrashDescription)
}
}
}
}
// TODO MXSession session = Matrix.getInstance(context).getDefaultSession();
val deviceId = "undefined"
val userId = "undefined"
val matrixSdkVersion = "undefined"
val olmVersion = "undefined"
/*
TODO
if (null != session) {
userId = session.getMyUserId();
deviceId = session.getCredentials().deviceId;
matrixSdkVersion = session.getVersion(true);
olmVersion = session.getCryptoVersion(context, true);
}
*/
if (!mIsCancelled) {
// build the multi part request
val builder = BugReporterMultipartBody.Builder()
.addFormDataPart("text", "[RiotX] $bugDescription")
.addFormDataPart("app", "riot-android")
// TODO .addFormDataPart("user_agent", RestClient.getUserAgent())
.addFormDataPart("user_id", userId)
.addFormDataPart("device_id", deviceId)
// TODO .addFormDataPart("version", Matrix.getInstance(context).getVersion(true, false))
.addFormDataPart("branch_name", context.getString(R.string.git_branch_name))
.addFormDataPart("matrix_sdk_version", matrixSdkVersion)
.addFormDataPart("olm_version", olmVersion)
.addFormDataPart("device", Build.MODEL.trim { it <= ' ' })
.addFormDataPart("lazy_loading", true.toOnOff())
.addFormDataPart("multi_window", inMultiWindowMode.toOnOff())
.addFormDataPart("os", Build.VERSION.RELEASE + " (API " + Build.VERSION.SDK_INT + ") "
+ Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME)
.addFormDataPart("locale", Locale.getDefault().toString())
.addFormDataPart("app_language", VectorLocale.applicationLocale.toString())
.addFormDataPart("default_app_language", getDeviceLocale(context).toString())
.addFormDataPart("theme", ThemeUtils.getApplicationTheme(context))
val buildNumber = context.getString(R.string.build_number)
if (!TextUtils.isEmpty(buildNumber) && buildNumber != "0") {
builder.addFormDataPart("build_number", buildNumber)
}
// add the gzipped files
for (file in gzippedFiles) {
builder.addFormDataPart("compressed-log", file.name, RequestBody.create(MediaType.parse("application/octet-stream"), file))
}
mBugReportFiles.addAll(gzippedFiles)
if (withScreenshot) {
val bitmap = screenshot
if (null != bitmap) {
val logCatScreenshotFile = File(context.cacheDir.absolutePath, LOG_CAT_SCREENSHOT_FILENAME)
if (logCatScreenshotFile.exists()) {
logCatScreenshotFile.delete()
}
try {
val fos = FileOutputStream(logCatScreenshotFile)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
fos.flush()
fos.close()
builder.addFormDataPart("file",
logCatScreenshotFile.name, RequestBody.create(MediaType.parse("application/octet-stream"), logCatScreenshotFile))
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to write screenshot$e")
}
}
}
screenshot = null
// add some github labels
builder.addFormDataPart("label", BuildConfig.VERSION_NAME)
builder.addFormDataPart("label", BuildConfig.FLAVOR_DESCRIPTION)
builder.addFormDataPart("label", context.getString(R.string.git_branch_name))
// Special for RiotX
builder.addFormDataPart("label", "[RiotX]")
if (getCrashFile(context).exists()) {
builder.addFormDataPart("label", "crash")
deleteCrashFile(context)
}
val requestBody = builder.build()
// add a progress listener
requestBody.setWriteListener { totalWritten, contentLength ->
val percentage: Int
if (-1L != contentLength) {
if (totalWritten > contentLength) {
percentage = 100
} else {
percentage = (totalWritten * 100 / contentLength).toInt()
}
} else {
percentage = 0
}
if (mIsCancelled && null != mBugReportCall) {
mBugReportCall!!.cancel()
}
Timber.d("## onWrite() : $percentage%")
publishProgress(percentage)
}
// build the request
val request = Request.Builder()
.url(context.getString(R.string.bug_report_url))
.post(requestBody)
.build()
var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR
var response: Response? = null
var errorMessage: String? = null
// trigger the request
try {
mBugReportCall = mOkHttpClient.newCall(request)
response = mBugReportCall!!.execute()
responseCode = response!!.code()
} catch (e: Exception) {
Timber.e(e, "response " + e.message)
errorMessage = e.localizedMessage
}
// if the upload failed, try to retrieve the reason
if (responseCode != HttpURLConnection.HTTP_OK) {
if (null != errorMessage) {
serverError = "Failed with error $errorMessage"
} else if (null == response || null == response.body()) {
serverError = "Failed with error $responseCode"
} else {
var `is`: InputStream? = null
try {
`is` = response.body()!!.byteStream()
if (null != `is`) {
var ch = `is`.read()
val b = StringBuilder()
while (ch != -1) {
b.append(ch.toChar())
ch = `is`.read()
}
serverError = b.toString()
`is`.close()
// check if the error message
try {
val responseJSON = JSONObject(serverError)
serverError = responseJSON.getString("error")
} catch (e: JSONException) {
Timber.e(e, "doInBackground ; Json conversion failed " + e.message)
}
// should never happen
if (null == serverError) {
serverError = "Failed with error $responseCode"
}
}
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to parse error " + e.message)
} finally {
try {
`is`?.close()
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to close the error stream " + e.message)
}
}
}
}
}
return serverError
}
override fun onProgressUpdate(vararg progress: Int?) {
if (null != listener) {
try {
listener.onProgress(progress?.get(0) ?: 0)
} catch (e: Exception) {
Timber.e(e, "## onProgress() : failed " + e.message)
}
}
}
override fun onPostExecute(reason: String?) {
mBugReportCall = null
// delete when the bug report has been successfully sent
for (file in mBugReportFiles) {
file.delete()
}
if (null != listener) {
try {
if (mIsCancelled) {
listener.onUploadCancelled()
} else if (null == reason) {
listener.onUploadSucceed()
} else {
listener.onUploadFailed(reason)
}
} catch (e: Exception) {
Timber.e(e, "## onPostExecute() : failed " + e.message)
}
}
}
}.execute()
}
/**
* Send a bug report either with email or with Vector.
*/
fun openBugReportScreen(activity: Activity) {
screenshot = takeScreenshot(activity)
val intent = Intent(activity, BugReportActivity::class.java)
activity.startActivity(intent)
}
//==============================================================================================================
// crash report management
//==============================================================================================================
/**
* Provides the crash file
*
* @param context the context
* @return the crash file
*/
private fun getCrashFile(context: Context): File {
return File(context.cacheDir.absolutePath, CRASH_FILENAME)
}
/**
* Remove the crash file
*
* @param context
*/
fun deleteCrashFile(context: Context) {
val crashFile = getCrashFile(context)
if (crashFile.exists()) {
crashFile.delete()
}
// Also reset the screenshot
screenshot = null
}
/**
* Save the crash report
*
* @param context the context
* @param crashDescription teh crash description
*/
fun saveCrashReport(context: Context, crashDescription: String) {
val crashFile = getCrashFile(context)
if (crashFile.exists()) {
crashFile.delete()
}
if (!TextUtils.isEmpty(crashDescription)) {
try {
val fos = FileOutputStream(crashFile)
val osw = OutputStreamWriter(fos)
osw.write(crashDescription)
osw.close()
fos.flush()
fos.close()
} catch (e: Exception) {
Timber.e(e, "## saveCrashReport() : fail to write $e")
}
}
}
/**
* Read the crash description file and return its content.
*
* @param context teh context
* @return the crash description
*/
private fun getCrashDescription(context: Context): String? {
var crashDescription: String? = null
val crashFile = getCrashFile(context)
if (crashFile.exists()) {
try {
val fis = FileInputStream(crashFile)
val isr = InputStreamReader(fis)
val buffer = CharArray(fis.available())
val len = isr.read(buffer, 0, fis.available())
crashDescription = String(buffer, 0, len)
isr.close()
fis.close()
} catch (e: Exception) {
Timber.e(e, "## getCrashDescription() : fail to read $e")
}
}
return crashDescription
}
//==============================================================================================================
// Screenshot management
//==============================================================================================================
/**
* Take a screenshot of the display.
*
* @return the screenshot
*/
private fun takeScreenshot(activity: Activity): Bitmap? {
// get content view
val contentView = activity.findViewById<View>(android.R.id.content)
if (contentView == null) {
Timber.e("Cannot find content view on $activity. Cannot take screenshot.")
return null
}
// get the root view to snapshot
val rootView = contentView.rootView
if (rootView == null) {
Timber.e("Cannot find root view on $activity. Cannot take screenshot.")
return null
}
// refresh it
rootView.isDrawingCacheEnabled = false
rootView.isDrawingCacheEnabled = true
try {
var bitmap = rootView.drawingCache
// Make a copy, because if Activity is destroyed, the bitmap will be recycled
bitmap = Bitmap.createBitmap(bitmap)
return bitmap
} catch (oom: OutOfMemoryError) {
Timber.e(oom, "Cannot get drawing cache for $activity OOM.")
} catch (e: Exception) {
Timber.e(e, "Cannot get snapshot of screen: $e")
}
return null
}
//==============================================================================================================
// Logcat management
//==============================================================================================================
/**
* Save the logcat
*
* @param context the context
* @param isErrorLogcat true to save the error logcat
* @return the file if the operation succeeds
*/
private fun saveLogCat(context: Context, isErrorLogcat: Boolean): File? {
val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME)
if (logCatErrFile.exists()) {
logCatErrFile.delete()
}
try {
val fos = FileOutputStream(logCatErrFile)
val osw = OutputStreamWriter(fos)
getLogCatError(osw, isErrorLogcat)
osw.close()
fos.flush()
fos.close()
return compressFile(logCatErrFile)
} catch (error: OutOfMemoryError) {
Timber.e(error, "## saveLogCat() : fail to write logcat$error")
} catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat$e")
}
return null
}
/**
* Retrieves the logs
*
* @param streamWriter the stream writer
* @param isErrorLogCat true to save the error logs
*/
private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) {
val logcatProc: Process
try {
logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG)
} catch (e1: IOException) {
return
}
var reader: BufferedReader? = null
try {
val separator = System.getProperty("line.separator")
reader = BufferedReader(InputStreamReader(logcatProc.inputStream), BUFFER_SIZE)
var line = reader.readLine()
while (line != null) {
streamWriter.append(line)
streamWriter.append(separator)
line = reader.readLine()
}
} catch (e: IOException) {
Timber.e(e, "getLog fails with " + e.localizedMessage)
} finally {
if (reader != null) {
try {
reader.close()
} catch (e: IOException) {
Timber.e(e, "getLog fails with " + e.localizedMessage)
}
}
}
}
//==============================================================================================================
// File compression management
//==============================================================================================================
/**
* GZip a file
*
* @param fin the input file
* @return the gzipped file
*/
private fun compressFile(fin: File): File? {
Timber.d("## compressFile() : compress " + fin.name)
val dstFile = File(fin.parent, fin.name + ".gz")
if (dstFile.exists()) {
dstFile.delete()
}
var fos: FileOutputStream? = null
var gos: GZIPOutputStream? = null
var inputStream: InputStream? = null
try {
fos = FileOutputStream(dstFile)
gos = GZIPOutputStream(fos)
inputStream = FileInputStream(fin)
val buffer = ByteArray(2048)
var n = inputStream.read(buffer)
while (n != -1) {
gos.write(buffer, 0, n)
n = inputStream.read(buffer)
}
gos.close()
inputStream.close()
Timber.d("## compressFile() : " + fin.length() + " compressed to " + dstFile.length() + " bytes")
return dstFile
} catch (e: Exception) {
Timber.e(e, "## compressFile() failed " + e.message)
} catch (oom: OutOfMemoryError) {
Timber.e(oom, "## compressFile() failed " + oom.message)
} finally {
try {
fos?.close()
gos?.close()
inputStream?.close()
} catch (e: Exception) {
Timber.e(e, "## compressFile() failed to close inputStream " + e.message)
}
}
return null
}
}

View file

@ -94,7 +94,7 @@ class RageShake(val activity: Activity) : ShakeDetector.Listener {
}
private fun openBugReportScreen() {
BugReporter.sendBugReport(activity)
BugReporter.openBugReportScreen(activity)
}
companion object {

View file

@ -0,0 +1,183 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.rageshake
import android.content.Context
import android.text.TextUtils
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.*
import java.util.logging.Formatter
object VectorFileLogger : Timber.DebugTree() {
private val LOG_SIZE_BYTES = 50 * 1024 * 1024 // 50MB
// relatively large rotation count because closing > opening the app rotates the log (!)
private val LOG_ROTATION_COUNT = 15
private val sLogger = Logger.getLogger("im.vector.riotredesign")
private lateinit var sFileHandler: FileHandler
private lateinit var sCacheDirectory: File
private var sFileName = "riotx"
fun init(context: Context) {
val logsDirectoryFile = context.cacheDir.absolutePath + "/logs"
setLogDirectory(File(logsDirectoryFile))
init("RiotXLog")
}
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (t != null) {
logToFile(t)
}
logToFile("$priority ", tag ?: "Tag", message)
}
/**
* Set the directory to put log files.
*
* @param cacheDir The directory, usually [android.content.ContextWrapper.getCacheDir]
*/
private fun setLogDirectory(cacheDir: File) {
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
sCacheDirectory = cacheDir
}
/**
* Initialises the logger. Should be called AFTER [Log.setLogDirectory].
*
* @param fileName the base file name
*/
private fun init(fileName: String) {
try {
if (!TextUtils.isEmpty(fileName)) {
sFileName = fileName
}
sFileHandler = FileHandler(sCacheDirectory.absolutePath + "/" + sFileName + ".%g.txt", LOG_SIZE_BYTES, LOG_ROTATION_COUNT)
sFileHandler.formatter = LogFormatter()
sLogger.useParentHandlers = false
sLogger.level = Level.ALL
sLogger.addHandler(sFileHandler)
} catch (e: IOException) {
}
}
/**
* Adds our own log files to the provided list of files.
*
* @param files The list of files to add to.
* @return The same list with more files added.
*/
fun addLogFiles(files: MutableList<File>): List<File> {
try {
// reported by GA
if (null != sFileHandler) {
sFileHandler.flush()
val absPath = sCacheDirectory.absolutePath
for (i in 0..LOG_ROTATION_COUNT) {
val filepath = "$absPath/$sFileName.$i.txt"
val file = File(filepath)
if (file.exists()) {
files.add(file)
}
}
}
} catch (e: Exception) {
Timber.e(e, "## addLogFiles() failed : " + e.message)
}
return files
}
class LogFormatter : Formatter() {
private val LINE_SEPARATOR = System.getProperty("line.separator") ?: "\n"
override fun format(r: LogRecord): String {
if (!mIsTimeZoneSet) {
DATE_FORMAT.timeZone = TimeZone.getTimeZone("UTC")
mIsTimeZoneSet = true
}
val thrown = r.thrown
if (thrown != null) {
val sw = StringWriter()
val pw = PrintWriter(sw)
sw.write(r.message)
sw.write(LINE_SEPARATOR)
thrown.printStackTrace(pw)
pw.flush()
return sw.toString()
} else {
val b = StringBuilder()
val date = DATE_FORMAT.format(Date(r.millis))
b.append(date)
b.append("Z ")
b.append(r.message)
b.append(LINE_SEPARATOR)
return b.toString()
}
}
companion object {
private val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
private var mIsTimeZoneSet = false
}
}
/**
* Log an Throwable
*
* @param throwable the throwable to log
*/
private fun logToFile(throwable: Throwable?) {
if (null == sCacheDirectory || throwable == null) {
return
}
val errors = StringWriter()
throwable.printStackTrace(PrintWriter(errors))
sLogger.info(errors.toString())
}
private fun logToFile(level: String, tag: String, content: String) {
if (null == sCacheDirectory) {
return
}
val b = StringBuilder()
b.append(Thread.currentThread().id)
b.append(" ")
b.append(level)
b.append("/")
b.append(tag)
b.append(": ")
b.append(content)
sLogger.info(b.toString())
}
}

View file

@ -0,0 +1,147 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.rageshake
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import im.vector.riotredesign.BuildConfig
import timber.log.Timber
import java.io.PrintWriter
import java.io.StringWriter
@SuppressLint("StaticFieldLeak")
object VectorUncaughtExceptionHandler : Thread.UncaughtExceptionHandler {
// key to save the crash status
private const val PREFS_CRASH_KEY = "PREFS_CRASH_KEY"
private var vectorVersion: String = ""
private var matrixSdkVersion: String = ""
private var previousHandler: Thread.UncaughtExceptionHandler? = null
private lateinit var context: Context
/**
* Activate this handler
*/
fun activate(context: Context) {
this.context = context
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(this)
}
/**
* An uncaught exception has been triggered
*
* @param thread the thread
* @param throwable the throwable
* @return the exception description
*/
override fun uncaughtException(thread: Thread, throwable: Throwable) {
if (context == null) {
previousHandler?.uncaughtException(thread, throwable)
return
}
PreferenceManager.getDefaultSharedPreferences(context).edit {
putBoolean(PREFS_CRASH_KEY, true)
}
val b = StringBuilder()
val appName = "RiotX" // TODO Matrix.getApplicationName()
b.append(appName + " Build : " + BuildConfig.VERSION_CODE + "\n")
b.append("$appName Version : $vectorVersion\n")
b.append("SDK Version : $matrixSdkVersion\n")
b.append("Phone : " + Build.MODEL.trim() + " (" + Build.VERSION.INCREMENTAL + " " + Build.VERSION.RELEASE + " " + Build.VERSION.CODENAME + ")\n")
b.append("Memory statuses \n")
var freeSize = 0L
var totalSize = 0L
var usedSize = -1L
try {
val info = Runtime.getRuntime()
freeSize = info.freeMemory()
totalSize = info.totalMemory()
usedSize = totalSize - freeSize
} catch (e: Exception) {
e.printStackTrace()
}
b.append("usedSize " + usedSize / 1048576L + " MB\n")
b.append("freeSize " + freeSize / 1048576L + " MB\n")
b.append("totalSize " + totalSize / 1048576L + " MB\n")
b.append("Thread: ")
b.append(thread.name)
/*
val a = VectorApp.getCurrentActivity()
if (a != null) {
b.append(", Activity:")
b.append(a.localClassName)
}
*/
b.append(", Exception: ")
val sw = StringWriter()
val pw = PrintWriter(sw, true)
throwable.printStackTrace(pw)
b.append(sw.buffer.toString())
Timber.e("FATAL EXCEPTION " + b.toString())
val bugDescription = b.toString()
BugReporter.saveCrashReport(context, bugDescription)
// Show the classical system popup
previousHandler?.uncaughtException(thread, throwable)
}
// TODO Call me
fun setVersions(vectorVersion: String, matrixSdkVersion: String) {
this.vectorVersion = vectorVersion
this.matrixSdkVersion = matrixSdkVersion
}
/**
* Tells if the application crashed
*
* @return true if the application crashed
*/
fun didAppCrash(context: Context): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(PREFS_CRASH_KEY, false)
}
/**
* Clear the crash status
*/
fun clearAppCrashStatus(context: Context) {
PreferenceManager.getDefaultSharedPreferences(context).edit {
remove(PREFS_CRASH_KEY)
}
}
}

View file

@ -0,0 +1,152 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.settings
import android.content.Context
import android.content.res.Configuration
import android.text.TextUtils
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import im.vector.riotredesign.R
/**
* Object to manage the Font Scale choice of the user
*/
object FontScale {
// Key for the SharedPrefs
private const val APPLICATION_FONT_SCALE_KEY = "APPLICATION_FONT_SCALE_KEY"
// Possible values for the SharedPrefs
private const val FONT_SCALE_TINY = "FONT_SCALE_TINY"
private const val FONT_SCALE_SMALL = "FONT_SCALE_SMALL"
private const val FONT_SCALE_NORMAL = "FONT_SCALE_NORMAL"
private const val FONT_SCALE_LARGE = "FONT_SCALE_LARGE"
private const val FONT_SCALE_LARGER = "FONT_SCALE_LARGER"
private const val FONT_SCALE_LARGEST = "FONT_SCALE_LARGEST"
private const val FONT_SCALE_HUGE = "FONT_SCALE_HUGE"
private val fontScaleToPrefValue = mapOf(
0.70f to FONT_SCALE_TINY,
0.85f to FONT_SCALE_SMALL,
1.00f to FONT_SCALE_NORMAL,
1.15f to FONT_SCALE_LARGE,
1.30f to FONT_SCALE_LARGER,
1.45f to FONT_SCALE_LARGEST,
1.60f to FONT_SCALE_HUGE
)
private val prefValueToNameResId = mapOf(
FONT_SCALE_TINY to R.string.tiny,
FONT_SCALE_SMALL to R.string.small,
FONT_SCALE_NORMAL to R.string.normal,
FONT_SCALE_LARGE to R.string.large,
FONT_SCALE_LARGER to R.string.larger,
FONT_SCALE_LARGEST to R.string.largest,
FONT_SCALE_HUGE to R.string.huge
)
/**
* Get the font scale value from SharedPrefs. Init the SharedPrefs if necessary
*
* @return the font scale
*/
fun getFontScalePrefValue(context: Context): String {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
var scalePreferenceValue: String
if (!preferences.contains(APPLICATION_FONT_SCALE_KEY)) {
val fontScale = context.resources.configuration.fontScale
scalePreferenceValue = FONT_SCALE_NORMAL
if (fontScaleToPrefValue.containsKey(fontScale)) {
scalePreferenceValue = fontScaleToPrefValue[fontScale] as String
}
preferences.edit {
putString(APPLICATION_FONT_SCALE_KEY, scalePreferenceValue)
}
} else {
scalePreferenceValue = preferences.getString(APPLICATION_FONT_SCALE_KEY, FONT_SCALE_NORMAL)
}
return scalePreferenceValue
}
/**
* Provides the font scale value
*
* @return the font scale
*/
fun getFontScale(context: Context): Float {
val fontScale = getFontScalePrefValue(context)
if (fontScaleToPrefValue.containsValue(fontScale)) {
for (entry in fontScaleToPrefValue) {
if (TextUtils.equals(entry.value, fontScale)) {
return entry.key
}
}
}
return 1.0f
}
/**
* Provides the font scale description
*
* @return the font description
*/
fun getFontScaleDescription(context: Context): String {
val fontScale = getFontScalePrefValue(context)
return if (prefValueToNameResId.containsKey(fontScale)) {
context.getString(prefValueToNameResId[fontScale] as Int)
} else context.getString(R.string.normal)
}
/**
* Update the font size from the locale description.
*
* @param fontScaleDescription the font scale description
*/
fun updateFontScale(context: Context, fontScaleDescription: String) {
for (entry in prefValueToNameResId) {
if (TextUtils.equals(context.getString(entry.value), fontScaleDescription)) {
saveFontScale(context, entry.key)
}
}
val config = Configuration(context.resources.configuration)
config.fontScale = getFontScale(context)
context.resources.updateConfiguration(config, context.resources.displayMetrics)
}
/**
* Save the new font scale
*
* @param scaleValue the text scale
*/
fun saveFontScale(context: Context, scaleValue: String) {
if (!TextUtils.isEmpty(scaleValue)) {
PreferenceManager.getDefaultSharedPreferences(context)
.edit {
putString(APPLICATION_FONT_SCALE_KEY, scaleValue)
}
}
}
}

View file

@ -0,0 +1,198 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.settings
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.preference.PreferenceManager
import android.text.TextUtils
import android.util.Pair
import androidx.core.content.edit
import im.vector.riotredesign.R
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.*
/**
* Object to manage the Locale choice of the user
*/
object VectorLocale {
private const val APPLICATION_LOCALE_COUNTRY_KEY = "APPLICATION_LOCALE_COUNTRY_KEY"
private const val APPLICATION_LOCALE_VARIANT_KEY = "APPLICATION_LOCALE_VARIANT_KEY"
private const val APPLICATION_LOCALE_LANGUAGE_KEY = "APPLICATION_LOCALE_LANGUAGE_KEY"
private val defaultLocale = Locale("en", "US")
/**
* The supported application languages
*/
var supportedLocales = ArrayList<Locale>()
private set
/**
* Provides the current application locale
*/
var applicationLocale = defaultLocale
private set
/**
* Init this object
*/
fun init(context: Context) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
if (preferences.contains(APPLICATION_LOCALE_LANGUAGE_KEY)) {
applicationLocale = Locale(preferences.getString(APPLICATION_LOCALE_LANGUAGE_KEY, ""),
preferences.getString(APPLICATION_LOCALE_COUNTRY_KEY, ""),
preferences.getString(APPLICATION_LOCALE_VARIANT_KEY, "")
)
} else {
applicationLocale = Locale.getDefault()
// detect if the default language is used
val defaultStringValue = getString(context, defaultLocale, R.string.resources_country_code)
if (TextUtils.equals(defaultStringValue, getString(context, applicationLocale, R.string.resources_country_code))) {
applicationLocale = defaultLocale
}
saveApplicationLocale(context, applicationLocale)
}
// init the known locales in background, using kotlin coroutines
GlobalScope.launch {
initApplicationLocales(context)
}
}
/**
* Save the new application locale.
*/
fun saveApplicationLocale(context: Context, locale: Locale) {
applicationLocale = locale
PreferenceManager.getDefaultSharedPreferences(context).edit {
val language = locale.language
if (TextUtils.isEmpty(language)) {
remove(APPLICATION_LOCALE_LANGUAGE_KEY)
} else {
putString(APPLICATION_LOCALE_LANGUAGE_KEY, language)
}
val country = locale.country
if (TextUtils.isEmpty(country)) {
remove(APPLICATION_LOCALE_COUNTRY_KEY)
} else {
putString(APPLICATION_LOCALE_COUNTRY_KEY, country)
}
val variant = locale.variant
if (TextUtils.isEmpty(variant)) {
remove(APPLICATION_LOCALE_VARIANT_KEY)
} else {
putString(APPLICATION_LOCALE_VARIANT_KEY, variant)
}
}
}
/**
* Get String from a locale
*
* @param context the context
* @param locale the locale
* @param resourceId the string resource id
* @return the localized string
*/
private fun getString(context: Context, locale: Locale, resourceId: Int): String {
var result: String
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
val config = Configuration(context.resources.configuration)
config.setLocale(locale)
try {
result = context.createConfigurationContext(config).getText(resourceId).toString()
} catch (e: Exception) {
Timber.e(e, "## getString() failed : " + e.message)
// use the default one
result = context.getString(resourceId)
}
} else {
val resources = context.resources
val conf = resources.configuration
val savedLocale = conf.locale
conf.locale = locale
resources.updateConfiguration(conf, null)
// retrieve resources from desired locale
result = resources.getString(resourceId)
// restore original locale
conf.locale = savedLocale
resources.updateConfiguration(conf, null)
}
return result
}
/**
* Provides the supported application locales list
*
* @param context the context
*/
private fun initApplicationLocales(context: Context) {
val knownLocalesSet = HashSet<Pair<String, String>>()
try {
val availableLocales = Locale.getAvailableLocales()
for (locale in availableLocales) {
knownLocalesSet.add(Pair(getString(context, locale, R.string.resources_language),
getString(context, locale, R.string.resources_country_code)))
}
} catch (e: Exception) {
Timber.e(e, "## getApplicationLocales() : failed " + e.message)
knownLocalesSet.add(Pair(context.getString(R.string.resources_language), context.getString(R.string.resources_country_code)))
}
supportedLocales.clear()
for (knownLocale in knownLocalesSet) {
supportedLocales.add(Locale(knownLocale.first, knownLocale.second))
}
// sort by human display names
supportedLocales.sortWith(Comparator { lhs, rhs -> localeToLocalisedString(lhs).compareTo(localeToLocalisedString(rhs)) })
}
/**
* Convert a locale to a string
*
* @param locale the locale to convert
* @return the string
*/
fun localeToLocalisedString(locale: Locale): String {
var res = locale.getDisplayLanguage(locale)
if (!TextUtils.isEmpty(locale.getDisplayCountry(locale))) {
res += " (" + locale.getDisplayCountry(locale) + ")"
}
return res
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.ui.themes
package im.vector.riotredesign.features.themes
import android.app.Activity
@ -29,6 +29,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.preference.PreferenceManager
import im.vector.riotredesign.R
import im.vector.ui.themes.ActivityOtherThemes
import timber.log.Timber
import java.util.*
@ -36,8 +37,6 @@ import java.util.*
* Util class for managing themes.
*/
object ThemeUtils {
const val LOG_TAG = "ThemeUtils"
// preference key
const val APPLICATION_THEME_KEY = "APPLICATION_THEME_KEY"