mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-12-18 15:22:07 +03:00
ExceptionHandler + Log in files for RageShake
This commit is contained in:
parent
bc467340c9
commit
be2dad9b17
14 changed files with 1649 additions and 738 deletions
|
@ -167,7 +167,11 @@ dependencies {
|
||||||
implementation 'androidx.paging:paging-runtime:2.0.0'
|
implementation 'androidx.paging:paging-runtime:2.0.0'
|
||||||
|
|
||||||
implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1'
|
implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1'
|
||||||
|
|
||||||
|
// Log
|
||||||
implementation 'com.jakewharton.timber:timber:4.7.1'
|
implementation 'com.jakewharton.timber:timber:4.7.1'
|
||||||
|
|
||||||
|
// Debug
|
||||||
implementation 'com.facebook.stetho:stetho:1.5.0'
|
implementation 'com.facebook.stetho:stetho:1.5.0'
|
||||||
|
|
||||||
// rx
|
// rx
|
||||||
|
|
|
@ -26,6 +26,8 @@ import com.jakewharton.threetenabp.AndroidThreeTen
|
||||||
import im.vector.matrix.android.api.Matrix
|
import im.vector.matrix.android.api.Matrix
|
||||||
import im.vector.riotredesign.core.di.AppModule
|
import im.vector.riotredesign.core.di.AppModule
|
||||||
import im.vector.riotredesign.features.home.HomeModule
|
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.log.EmptyLogger
|
||||||
import org.koin.standalone.StandAloneContext.startKoin
|
import org.koin.standalone.StandAloneContext.startKoin
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
@ -35,10 +37,17 @@ class Riot : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
VectorUncaughtExceptionHandler.activate(this)
|
||||||
|
|
||||||
|
// Log
|
||||||
|
VectorFileLogger.init(this)
|
||||||
|
Timber.plant(Timber.DebugTree(), VectorFileLogger)
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Timber.plant(Timber.DebugTree())
|
|
||||||
Stetho.initializeWithDefaults(this)
|
Stetho.initializeWithDefaults(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
AndroidThreeTen.init(this)
|
AndroidThreeTen.init(this)
|
||||||
BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
|
BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
|
||||||
val appModule = AppModule(applicationContext).definition
|
val appModule = AppModule(applicationContext).definition
|
||||||
|
|
|
@ -16,9 +16,11 @@
|
||||||
|
|
||||||
package im.vector.riotredesign.core.platform
|
package im.vector.riotredesign.core.platform
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
import androidx.annotation.*
|
import androidx.annotation.*
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import butterknife.BindView
|
import butterknife.BindView
|
||||||
|
@ -28,12 +30,15 @@ import com.airbnb.mvrx.BaseMvRxActivity
|
||||||
import com.bumptech.glide.util.Util
|
import com.bumptech.glide.util.Util
|
||||||
import im.vector.riotredesign.BuildConfig
|
import im.vector.riotredesign.BuildConfig
|
||||||
import im.vector.riotredesign.R
|
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.rageshake.RageShake
|
||||||
|
import im.vector.riotredesign.features.themes.ThemeUtils
|
||||||
import im.vector.riotredesign.receivers.DebugReceiver
|
import im.vector.riotredesign.receivers.DebugReceiver
|
||||||
import im.vector.ui.themes.ActivityOtherThemes
|
import im.vector.ui.themes.ActivityOtherThemes
|
||||||
import im.vector.ui.themes.ThemeUtils
|
|
||||||
import io.reactivex.disposables.CompositeDisposable
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
import io.reactivex.disposables.Disposable
|
import io.reactivex.disposables.Disposable
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
||||||
abstract class RiotActivity : BaseMvRxActivity() {
|
abstract class RiotActivity : BaseMvRxActivity() {
|
||||||
|
@ -113,10 +118,19 @@ abstract class RiotActivity : BaseMvRxActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
|
||||||
|
unBinder?.unbind()
|
||||||
|
unBinder = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
|
if (this !is BugReportActivity) {
|
||||||
rageShake?.start()
|
rageShake?.start()
|
||||||
|
}
|
||||||
|
|
||||||
DebugReceiver
|
DebugReceiver
|
||||||
.getIntentFilter(this)
|
.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
|
* MENU MANAGEMENT
|
||||||
* ========================================================================================== */
|
* ========================================================================================== */
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
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.RiotActivity
|
||||||
import im.vector.riotredesign.core.platform.ToolbarConfigurable
|
import im.vector.riotredesign.core.platform.ToolbarConfigurable
|
||||||
import im.vector.riotredesign.features.home.room.detail.LoadingRoomDetailFragment
|
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 kotlinx.android.synthetic.main.activity_home.*
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.android.scope.ext.android.bindScope
|
import org.koin.android.scope.ext.android.bindScope
|
||||||
|
@ -74,6 +77,21 @@ class HomeActivity : RiotActivity(), ToolbarConfigurable {
|
||||||
super.onDestroy()
|
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) {
|
override fun configure(toolbar: Toolbar) {
|
||||||
setSupportActionBar(toolbar)
|
setSupportActionBar(toolbar)
|
||||||
supportActionBar?.setHomeButtonEnabled(true)
|
supportActionBar?.setHomeButtonEnabled(true)
|
||||||
|
|
|
@ -70,8 +70,8 @@ class BugReportActivity : RiotActivity() {
|
||||||
override fun initUiAndData() {
|
override fun initUiAndData() {
|
||||||
configureToolbar()
|
configureToolbar()
|
||||||
|
|
||||||
if (BugReporter.getScreenshot() != null) {
|
if (BugReporter.screenshot != null) {
|
||||||
mScreenShotPreview.setImageBitmap(BugReporter.getScreenshot())
|
mScreenShotPreview.setImageBitmap(BugReporter.screenshot)
|
||||||
} else {
|
} else {
|
||||||
mScreenShotPreview.isVisible = false
|
mScreenShotPreview.isVisible = false
|
||||||
mIncludeScreenShotButton.isChecked = false
|
mIncludeScreenShotButton.isChecked = false
|
||||||
|
@ -189,7 +189,7 @@ class BugReportActivity : RiotActivity() {
|
||||||
|
|
||||||
@OnCheckedChanged(R.id.bug_report_button_include_screenshot)
|
@OnCheckedChanged(R.id.bug_report_button_include_screenshot)
|
||||||
internal fun onSendScreenshotChanged() {
|
internal fun onSendScreenshotChanged() {
|
||||||
mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.getScreenshot() != null
|
mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.screenshot != null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
697
app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.kt
Executable file
697
app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.kt
Executable 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -94,7 +94,7 @@ class RageShake(val activity: Activity) : ShakeDetector.Listener {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openBugReportScreen() {
|
private fun openBugReportScreen() {
|
||||||
BugReporter.sendBugReport(activity)
|
BugReporter.openBugReportScreen(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package im.vector.ui.themes
|
package im.vector.riotredesign.features.themes
|
||||||
|
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
@ -29,6 +29,7 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import im.vector.riotredesign.R
|
import im.vector.riotredesign.R
|
||||||
|
import im.vector.ui.themes.ActivityOtherThemes
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@ -36,8 +37,6 @@ import java.util.*
|
||||||
* Util class for managing themes.
|
* Util class for managing themes.
|
||||||
*/
|
*/
|
||||||
object ThemeUtils {
|
object ThemeUtils {
|
||||||
const val LOG_TAG = "ThemeUtils"
|
|
||||||
|
|
||||||
// preference key
|
// preference key
|
||||||
const val APPLICATION_THEME_KEY = "APPLICATION_THEME_KEY"
|
const val APPLICATION_THEME_KEY = "APPLICATION_THEME_KEY"
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue