diff --git a/app/build.gradle b/app/build.gradle index 2f4238e1..8dfa0371 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,7 +167,11 @@ dependencies { implementation 'androidx.paging:paging-runtime:2.0.0' implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1' + + // Log implementation 'com.jakewharton.timber:timber:4.7.1' + + // Debug implementation 'com.facebook.stetho:stetho:1.5.0' // rx diff --git a/app/src/main/java/im/vector/riotredesign/Riot.kt b/app/src/main/java/im/vector/riotredesign/Riot.kt index 73b7e150..14b19602 100644 --- a/app/src/main/java/im/vector/riotredesign/Riot.kt +++ b/app/src/main/java/im/vector/riotredesign/Riot.kt @@ -26,6 +26,8 @@ import com.jakewharton.threetenabp.AndroidThreeTen import im.vector.matrix.android.api.Matrix import im.vector.riotredesign.core.di.AppModule import im.vector.riotredesign.features.home.HomeModule +import im.vector.riotredesign.features.rageshake.VectorFileLogger +import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler import org.koin.log.EmptyLogger import org.koin.standalone.StandAloneContext.startKoin import timber.log.Timber @@ -35,10 +37,17 @@ class Riot : Application() { override fun onCreate() { super.onCreate() + + VectorUncaughtExceptionHandler.activate(this) + + // Log + VectorFileLogger.init(this) + Timber.plant(Timber.DebugTree(), VectorFileLogger) + if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) Stetho.initializeWithDefaults(this) } + AndroidThreeTen.init(this) BigImageViewer.initialize(GlideImageLoader.with(applicationContext)) val appModule = AppModule(applicationContext).definition diff --git a/app/src/main/java/im/vector/riotredesign/core/platform/RiotActivity.kt b/app/src/main/java/im/vector/riotredesign/core/platform/RiotActivity.kt index a4d202d8..2cfbe3b9 100644 --- a/app/src/main/java/im/vector/riotredesign/core/platform/RiotActivity.kt +++ b/app/src/main/java/im/vector/riotredesign/core/platform/RiotActivity.kt @@ -16,9 +16,11 @@ package im.vector.riotredesign.core.platform +import android.content.res.Configuration import android.os.Bundle import android.view.Menu import android.view.MenuItem +import android.view.View import androidx.annotation.* import androidx.appcompat.widget.Toolbar import butterknife.BindView @@ -28,12 +30,15 @@ import com.airbnb.mvrx.BaseMvRxActivity import com.bumptech.glide.util.Util import im.vector.riotredesign.BuildConfig import im.vector.riotredesign.R +import im.vector.riotredesign.features.rageshake.BugReportActivity +import im.vector.riotredesign.features.rageshake.BugReporter import im.vector.riotredesign.features.rageshake.RageShake +import im.vector.riotredesign.features.themes.ThemeUtils import im.vector.riotredesign.receivers.DebugReceiver import im.vector.ui.themes.ActivityOtherThemes -import im.vector.ui.themes.ThemeUtils import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable +import timber.log.Timber abstract class RiotActivity : BaseMvRxActivity() { @@ -113,10 +118,19 @@ abstract class RiotActivity : BaseMvRxActivity() { } } + override fun onDestroy() { + super.onDestroy() + + unBinder?.unbind() + unBinder = null + } + override fun onResume() { super.onResume() - rageShake?.start() + if (this !is BugReportActivity) { + rageShake?.start() + } DebugReceiver .getIntentFilter(this) @@ -138,6 +152,38 @@ abstract class RiotActivity : BaseMvRxActivity() { } } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + + if (hasFocus && displayInFullscreen()) { + setFullScreen() + } + } + + override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) { + super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig) + + Timber.w("onMultiWindowModeChanged. isInMultiWindowMode: $isInMultiWindowMode") + BugReporter.inMultiWindowMode = isInMultiWindowMode + } + + + /* ========================================================================================== + * PRIVATE METHODS + * ========================================================================================== */ + + /** + * Force to render the activity in fullscreen + */ + private fun setFullScreen() { + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) + } + /* ========================================================================================== * MENU MANAGEMENT * ========================================================================================== */ diff --git a/app/src/main/java/im/vector/riotredesign/core/utils/SystemUtils.kt b/app/src/main/java/im/vector/riotredesign/core/utils/SystemUtils.kt new file mode 100644 index 00000000..5cf3ca49 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/core/utils/SystemUtils.kt @@ -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() +} diff --git a/app/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt b/app/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt index c29396f2..c44b25ca 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.os.Bundle import android.view.MenuItem import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.Toolbar import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout @@ -34,6 +35,8 @@ import im.vector.riotredesign.core.platform.OnBackPressed import im.vector.riotredesign.core.platform.RiotActivity import im.vector.riotredesign.core.platform.ToolbarConfigurable import im.vector.riotredesign.features.home.room.detail.LoadingRoomDetailFragment +import im.vector.riotredesign.features.rageshake.BugReporter +import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler import kotlinx.android.synthetic.main.activity_home.* import org.koin.android.ext.android.inject import org.koin.android.scope.ext.android.bindScope @@ -74,6 +77,21 @@ class HomeActivity : RiotActivity(), ToolbarConfigurable { super.onDestroy() } + override fun onResume() { + super.onResume() + + if (VectorUncaughtExceptionHandler.didAppCrash(this)) { + VectorUncaughtExceptionHandler.clearAppCrashStatus(this) + + AlertDialog.Builder(this) + .setMessage(R.string.send_bug_report_app_crashed) + .setCancelable(false) + .setPositiveButton(R.string.yes) { _, _ -> BugReporter.openBugReportScreen(this) } + .setNegativeButton(R.string.no) { _, _ -> BugReporter.deleteCrashFile(this) } + .show() + } + } + override fun configure(toolbar: Toolbar) { setSupportActionBar(toolbar) supportActionBar?.setHomeButtonEnabled(true) diff --git a/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReportActivity.kt b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReportActivity.kt index 8ee90669..3865de95 100755 --- a/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReportActivity.kt +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReportActivity.kt @@ -70,8 +70,8 @@ class BugReportActivity : RiotActivity() { override fun initUiAndData() { configureToolbar() - if (BugReporter.getScreenshot() != null) { - mScreenShotPreview.setImageBitmap(BugReporter.getScreenshot()) + if (BugReporter.screenshot != null) { + mScreenShotPreview.setImageBitmap(BugReporter.screenshot) } else { mScreenShotPreview.isVisible = false mIncludeScreenShotButton.isChecked = false @@ -189,7 +189,7 @@ class BugReportActivity : RiotActivity() { @OnCheckedChanged(R.id.bug_report_button_include_screenshot) internal fun onSendScreenshotChanged() { - mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.getScreenshot() != null + mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.screenshot != null } override fun onBackPressed() { diff --git a/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.java b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.java deleted file mode 100755 index 5709cba0..00000000 --- a/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.java +++ /dev/null @@ -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() { - - // enumerate files to delete - final List 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 gzippedFiles = new ArrayList<>(); - - if (withDevicesLogs) { - // TODO Timber - /* - List files = org.matrix.androidsdk.util.Timber.addLogFiles(new ArrayList()); - - 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; - } -} diff --git a/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.kt b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.kt new file mode 100755 index 00000000..5f82cc97 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.kt @@ -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() { + + // enumerate files to delete + val mBugReportFiles: MutableList = 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() + + if (withDevicesLogs) { + val files = VectorFileLogger.addLogFiles(ArrayList()) + + 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(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 + } +} diff --git a/app/src/main/java/im/vector/riotredesign/features/rageshake/RageShake.kt b/app/src/main/java/im/vector/riotredesign/features/rageshake/RageShake.kt index 44bcd5cb..446d2f48 100644 --- a/app/src/main/java/im/vector/riotredesign/features/rageshake/RageShake.kt +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/RageShake.kt @@ -94,7 +94,7 @@ class RageShake(val activity: Activity) : ShakeDetector.Listener { } private fun openBugReportScreen() { - BugReporter.sendBugReport(activity) + BugReporter.openBugReportScreen(activity) } companion object { diff --git a/app/src/main/java/im/vector/riotredesign/features/rageshake/VectorFileLogger.kt b/app/src/main/java/im/vector/riotredesign/features/rageshake/VectorFileLogger.kt new file mode 100644 index 00000000..162a0ed4 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/VectorFileLogger.kt @@ -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): List { + 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()) + } +} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/rageshake/VectorUncaughtExceptionHandler.kt b/app/src/main/java/im/vector/riotredesign/features/rageshake/VectorUncaughtExceptionHandler.kt new file mode 100644 index 00000000..60959029 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/VectorUncaughtExceptionHandler.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/settings/FontScale.kt b/app/src/main/java/im/vector/riotredesign/features/settings/FontScale.kt new file mode 100644 index 00000000..2cd74ebe --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/settings/FontScale.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/settings/VectorLocale.kt b/app/src/main/java/im/vector/riotredesign/features/settings/VectorLocale.kt new file mode 100644 index 00000000..0c88f508 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/settings/VectorLocale.kt @@ -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() + 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>() + + 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 + } +} + diff --git a/app/src/main/java/im/vector/riotredesign/features/themes/ThemeUtils.kt b/app/src/main/java/im/vector/riotredesign/features/themes/ThemeUtils.kt index cacf4574..dd4cf898 100644 --- a/app/src/main/java/im/vector/riotredesign/features/themes/ThemeUtils.kt +++ b/app/src/main/java/im/vector/riotredesign/features/themes/ThemeUtils.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.ui.themes +package im.vector.riotredesign.features.themes import android.app.Activity @@ -29,6 +29,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.preference.PreferenceManager import im.vector.riotredesign.R +import im.vector.ui.themes.ActivityOtherThemes import timber.log.Timber import java.util.* @@ -36,8 +37,6 @@ import java.util.* * Util class for managing themes. */ object ThemeUtils { - const val LOG_TAG = "ThemeUtils" - // preference key const val APPLICATION_THEME_KEY = "APPLICATION_THEME_KEY"