diff --git a/app/build.gradle b/app/build.gradle index 9db7a601e9..8dfa037133 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 @@ -183,6 +187,9 @@ dependencies { // FP implementation "io.arrow-kt:arrow-core:$arrow_version" + // Pref + implementation 'androidx.preference:preference:1.0.0' + // UI implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'com.google.android.material:material:1.1.0-alpha02' @@ -190,6 +197,13 @@ dependencies { implementation "ru.noties.markwon:core:$markwon_version" implementation "ru.noties.markwon:html:$markwon_version" + // Butterknife + implementation 'com.jakewharton:butterknife:10.1.0' + kapt 'com.jakewharton:butterknife-compiler:10.1.0' + + // Shake detection + implementation 'com.squareup:seismic:1.0.2' + // Image Loading implementation "com.github.piasy:BigImageViewer:$big_image_viewer_version" implementation "com.github.piasy:GlideImageLoader:$big_image_viewer_version" diff --git a/app/src/debug/java/im/vector/riotredesign/receivers/DebugReceiver.kt b/app/src/debug/java/im/vector/riotredesign/receivers/DebugReceiver.kt new file mode 100644 index 0000000000..ff6e1d5395 --- /dev/null +++ b/app/src/debug/java/im/vector/riotredesign/receivers/DebugReceiver.kt @@ -0,0 +1,73 @@ +/* + * 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.receivers + +import android.content.* +import android.preference.PreferenceManager +import androidx.core.content.edit +import im.vector.riotredesign.core.utils.lsFiles +import timber.log.Timber + +/** + * Receiver to handle some command from ADB + */ +class DebugReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + Timber.d("Received debug action: ${intent.action}") + + intent.action?.let { + when { + it.endsWith(DEBUG_ACTION_DUMP_FILESYSTEM) -> lsFiles(context) + it.endsWith(DEBUG_ACTION_DUMP_PREFERENCES) -> dumpPreferences(context) + it.endsWith(DEBUG_ACTION_ALTER_SCALAR_TOKEN) -> alterScalarToken(context) + } + } + } + + private fun dumpPreferences(context: Context) { + logPrefs("DefaultSharedPreferences", PreferenceManager.getDefaultSharedPreferences(context)) + } + + private fun logPrefs(name: String, sharedPreferences: SharedPreferences?) { + Timber.d("SharedPreferences $name:") + + sharedPreferences?.let { prefs -> + prefs.all.keys.forEach { key -> + Timber.d("$key : ${prefs.all[key]}") + } + } + } + + private fun alterScalarToken(context: Context) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + // putString("SCALAR_TOKEN_PREFERENCE_KEY" + Matrix.getInstance(context).defaultSession.myUserId, "bad_token") + } + } + + companion object { + private const val DEBUG_ACTION_DUMP_FILESYSTEM = ".DEBUG_ACTION_DUMP_FILESYSTEM" + private const val DEBUG_ACTION_DUMP_PREFERENCES = ".DEBUG_ACTION_DUMP_PREFERENCES" + private const val DEBUG_ACTION_ALTER_SCALAR_TOKEN = ".DEBUG_ACTION_ALTER_SCALAR_TOKEN" + + fun getIntentFilter(context: Context) = IntentFilter().apply { + addAction(context.packageName + DEBUG_ACTION_DUMP_FILESYSTEM) + addAction(context.packageName + DEBUG_ACTION_DUMP_PREFERENCES) + addAction(context.packageName + DEBUG_ACTION_ALTER_SCALAR_TOKEN) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5958432633..9155aa00d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -7,12 +8,13 @@ + android:theme="@style/AppTheme.Light" + tools:replace="android:allowBackup"> + \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/Riot.kt b/app/src/main/java/im/vector/riotredesign/Riot.kt index 73b7e150cf..14b1960205 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/di/AppModule.kt b/app/src/main/java/im/vector/riotredesign/core/di/AppModule.kt index f67c9c11d8..9c14a508c0 100644 --- a/app/src/main/java/im/vector/riotredesign/core/di/AppModule.kt +++ b/app/src/main/java/im/vector/riotredesign/core/di/AppModule.kt @@ -65,7 +65,7 @@ class AppModule(private val context: Context) { } factory { - Matrix.getInstance().currentSession + Matrix.getInstance().currentSession!! } diff --git a/app/src/main/java/im/vector/riotredesign/core/extensions/BasicExtensions.kt b/app/src/main/java/im/vector/riotredesign/core/extensions/BasicExtensions.kt new file mode 100644 index 0000000000..50c9b4d474 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/core/extensions/BasicExtensions.kt @@ -0,0 +1,27 @@ +/* + * 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.extensions + +import android.os.Bundle +import androidx.fragment.app.Fragment + +fun Boolean.toOnOff() = if (this) "ON" else "OFF" + +/** + * Apply argument to a Fragment + */ +fun T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bundle().apply(block) } \ No newline at end of file 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 db1aa476fb..06b1d3640f 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,18 +16,56 @@ package im.vector.riotredesign.core.platform +import android.content.res.Configuration import android.os.Bundle -import androidx.annotation.MainThread +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.annotation.* +import androidx.appcompat.widget.Toolbar +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.Unbinder 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 io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable +import timber.log.Timber + abstract class RiotActivity : BaseMvRxActivity() { + /* ========================================================================================== + * UI + * ========================================================================================== */ + + @Nullable + @BindView(R.id.toolbar) + protected lateinit var toolbar: Toolbar + + /* ========================================================================================== + * DATA + * ========================================================================================== */ + + private var unBinder: Unbinder? = null + + private var savedInstanceState: Bundle? = null + + // For debug only + private var debugReceiver: DebugReceiver? = null private val uiDisposables = CompositeDisposable() private val restorables = ArrayList() + private var rageShake: RageShake? = null + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) restorables.forEach { it.onSaveInstanceState(outState) } @@ -50,4 +88,185 @@ abstract class RiotActivity : BaseMvRxActivity() { return this } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Shake detector + rageShake = RageShake(this) + + ThemeUtils.setActivityTheme(this, getOtherThemes()) + + doBeforeSetContentView() + + if (getLayoutRes() != -1) { + setContentView(getLayoutRes()) + } + + unBinder = ButterKnife.bind(this) + + this.savedInstanceState = savedInstanceState + + initUiAndData() + + val titleRes = getTitleRes() + if (titleRes != -1) { + supportActionBar?.let { + it.setTitle(titleRes) + } ?: run { + setTitle(titleRes) + } + } + } + + override fun onDestroy() { + super.onDestroy() + + unBinder?.unbind() + unBinder = null + } + + override fun onResume() { + super.onResume() + + if (this !is BugReportActivity) { + rageShake?.start() + } + + DebugReceiver + .getIntentFilter(this) + .takeIf { BuildConfig.DEBUG } + ?.let { + debugReceiver = DebugReceiver() + registerReceiver(debugReceiver, it) + } + } + + override fun onPause() { + super.onPause() + + rageShake?.stop() + + debugReceiver?.let { + unregisterReceiver(debugReceiver) + debugReceiver = null + } + } + + 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 + * ========================================================================================== */ + + final override fun onCreateOptionsMenu(menu: Menu): Boolean { + val menuRes = getMenuRes() + + if (menuRes != -1) { + menuInflater.inflate(menuRes, menu) + ThemeUtils.tintMenuIcons(menu, ThemeUtils.getColor(this, getMenuTint())) + return true + } + + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + setResult(RESULT_CANCELED) + finish() + return true + } + + return super.onOptionsItemSelected(item) + } + + /* ========================================================================================== + * PROTECTED METHODS + * ========================================================================================== */ + + /** + * Get the saved instance state. + * Ensure {@link isFirstCreation()} returns false before calling this + * + * @return + */ + protected fun getSavedInstanceState(): Bundle { + return savedInstanceState!! + } + + /** + * Is first creation + * + * @return true if Activity is created for the first time (and not restored by the system) + */ + protected fun isFirstCreation() = savedInstanceState == null + + /** + * Configure the Toolbar. It MUST be present in your layout with id "toolbar" + */ + protected fun configureToolbar() { + setSupportActionBar(toolbar) + + supportActionBar?.let { + it.setDisplayShowHomeEnabled(true) + it.setDisplayHomeAsUpEnabled(true) + } + } + + /* ========================================================================================== + * OPEN METHODS + * ========================================================================================== */ + + @LayoutRes + open fun getLayoutRes() = -1 + + open fun displayInFullscreen() = false + + open fun doBeforeSetContentView() = Unit + + open fun initUiAndData() = Unit + + @StringRes + open fun getTitleRes() = -1 + + @MenuRes + open fun getMenuRes() = -1 + + @AttrRes + open fun getMenuTint() = R.attr.vctr_icon_tint_on_dark_action_bar_color + + /** + * Return a object containing other themes for this activity + */ + open fun getOtherThemes(): ActivityOtherThemes = ActivityOtherThemes.Default } \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/core/utils/FileUtils.kt b/app/src/main/java/im/vector/riotredesign/core/utils/FileUtils.kt new file mode 100644 index 0000000000..c0a759701f --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/core/utils/FileUtils.kt @@ -0,0 +1,89 @@ +/* + * 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.content.Context +import timber.log.Timber +import java.io.File + +// Implementation should return true in case of success +typealias ActionOnFile = (file: File) -> Boolean + +/* ========================================================================================== + * Delete + * ========================================================================================== */ + +fun deleteAllFiles(context: Context) { + Timber.v("Delete cache dir:") + recursiveActionOnFile(context.cacheDir, ::deleteAction) + + Timber.v("Delete files dir:") + recursiveActionOnFile(context.filesDir, ::deleteAction) +} + +private fun deleteAction(file: File): Boolean { + if (file.exists()) { + Timber.v("deleteFile: $file") + return file.delete() + } + + return true +} + +/* ========================================================================================== + * Log + * ========================================================================================== */ + +fun lsFiles(context: Context) { + Timber.v("Content of cache dir:") + recursiveActionOnFile(context.cacheDir, ::logAction) + + Timber.v("Content of files dir:") + recursiveActionOnFile(context.filesDir, ::logAction) +} + +private fun logAction(file: File): Boolean { + if (file.isDirectory) { + Timber.d(file.toString()) + } else { + Timber.d(file.toString() + " " + file.length() + " bytes") + } + return true +} + +/* ========================================================================================== + * Private + * ========================================================================================== */ + +/** + * Return true in case of success + */ +private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean { + if (file.isDirectory) { + file.list().forEach { + val result = recursiveActionOnFile(File(file, it), action) + + if (!result) { + // Break the loop + return false + } + } + } + + return action.invoke(file) +} + 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 0000000000..5cf3ca492f --- /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/AvatarRenderer.kt b/app/src/main/java/im/vector/riotredesign/features/home/AvatarRenderer.kt index 275d76136e..55bf748f71 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/AvatarRenderer.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/AvatarRenderer.kt @@ -90,7 +90,7 @@ object AvatarRenderer { // PRIVATE API ********************************************************************************* private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?, size: Int): GlideRequest { - val resolvedUrl = Matrix.getInstance().currentSession + val resolvedUrl = Matrix.getInstance().currentSession!! .contentUrlResolver() .resolveThumbnail(avatarUrl, size, size, ContentUrlResolver.ThumbnailMethod.SCALE) 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 c29396f2ed..c44b25ca79 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/home/HomeActivityViewModel.kt b/app/src/main/java/im/vector/riotredesign/features/home/HomeActivityViewModel.kt index 1f72d3824e..c1a4fef041 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/HomeActivityViewModel.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/HomeActivityViewModel.kt @@ -41,7 +41,7 @@ class HomeActivityViewModel(state: EmptyState, @JvmStatic override fun create(viewModelContext: ViewModelContext, state: EmptyState): HomeActivityViewModel? { - val session = Matrix.getInstance().currentSession + val session = Matrix.getInstance().currentSession!! val roomSelectionRepository = viewModelContext.activity.get() return HomeActivityViewModel(state, session, roomSelectionRepository) } diff --git a/app/src/main/java/im/vector/riotredesign/features/login/LoginActivity.kt b/app/src/main/java/im/vector/riotredesign/features/login/LoginActivity.kt index 799818b6c9..9998e1be51 100644 --- a/app/src/main/java/im/vector/riotredesign/features/login/LoginActivity.kt +++ b/app/src/main/java/im/vector/riotredesign/features/login/LoginActivity.kt @@ -63,8 +63,7 @@ class LoginActivity : RiotActivity() { progressBar.visibility = View.VISIBLE authenticator.authenticate(homeServerConnectionConfig, login, password, object : MatrixCallback { override fun onSuccess(data: Session) { - Matrix.getInstance().currentSession = data - Matrix.getInstance().currentSession.open() + Matrix.getInstance().currentSession = data.apply { open() } goToHome() } diff --git a/app/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt b/app/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt index 86501199c3..6af3307001 100644 --- a/app/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt +++ b/app/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt @@ -49,7 +49,7 @@ object MediaContentRenderer { val (width, height) = processSize(data, mode) imageView.layoutParams.height = height imageView.layoutParams.width = width - val contentUrlResolver = Matrix.getInstance().currentSession.contentUrlResolver() + val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver() val resolvedUrl = when (mode) { Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) @@ -64,7 +64,7 @@ object MediaContentRenderer { fun render(data: Data, imageView: BigImageView) { val (width, height) = processSize(data, Mode.THUMBNAIL) - val contentUrlResolver = Matrix.getInstance().currentSession.contentUrlResolver() + val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver() val fullSize = contentUrlResolver.resolveFullSize(data.url) val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) imageView.showImage( diff --git a/app/src/main/java/im/vector/riotredesign/features/media/MediaViewerActivity.kt b/app/src/main/java/im/vector/riotredesign/features/media/MediaViewerActivity.kt index 2b3ae6f697..403f242b96 100644 --- a/app/src/main/java/im/vector/riotredesign/features/media/MediaViewerActivity.kt +++ b/app/src/main/java/im/vector/riotredesign/features/media/MediaViewerActivity.kt @@ -21,7 +21,6 @@ package im.vector.riotredesign.features.media import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.MenuItem import androidx.appcompat.widget.Toolbar import com.github.piasy.biv.indicator.progresspie.ProgressPieIndicator import com.github.piasy.biv.view.GlideImageViewFactory @@ -54,17 +53,6 @@ class MediaViewerActivity : RiotActivity() { } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finish() - return true - } - } - return true - } - - companion object { private const val EXTRA_MEDIA_DATA = "EXTRA_MEDIA_DATA" 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 new file mode 100755 index 0000000000..3865de957d --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReportActivity.kt @@ -0,0 +1,209 @@ +/* + * 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.text.TextUtils +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.* +import androidx.core.view.isVisible +import butterknife.BindView +import butterknife.OnCheckedChanged +import butterknife.OnTextChanged +import im.vector.riotredesign.R +import im.vector.riotredesign.core.platform.RiotActivity +import timber.log.Timber + +/** + * Form to send a bug report + */ +class BugReportActivity : RiotActivity() { + + /* ========================================================================================== + * UI + * ========================================================================================== */ + + @BindView(R.id.bug_report_edit_text) + lateinit var mBugReportText: EditText + + @BindView(R.id.bug_report_button_include_logs) + lateinit var mIncludeLogsButton: CheckBox + + @BindView(R.id.bug_report_button_include_crash_logs) + lateinit var mIncludeCrashLogsButton: CheckBox + + @BindView(R.id.bug_report_button_include_screenshot) + lateinit var mIncludeScreenShotButton: CheckBox + + @BindView(R.id.bug_report_screenshot_preview) + lateinit var mScreenShotPreview: ImageView + + @BindView(R.id.bug_report_progress_view) + lateinit var mProgressBar: ProgressBar + + @BindView(R.id.bug_report_progress_text_view) + lateinit var mProgressTextView: TextView + + @BindView(R.id.bug_report_scrollview) + lateinit var mScrollView: View + + @BindView(R.id.bug_report_mask_view) + lateinit var mMaskView: View + + override fun getLayoutRes() = R.layout.activity_bug_report + + override fun initUiAndData() { + configureToolbar() + + if (BugReporter.screenshot != null) { + mScreenShotPreview.setImageBitmap(BugReporter.screenshot) + } else { + mScreenShotPreview.isVisible = false + mIncludeScreenShotButton.isChecked = false + mIncludeScreenShotButton.isEnabled = false + } + } + + override fun getMenuRes() = R.menu.bug_report + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + menu.findItem(R.id.ic_action_send_bug_report)?.let { + val isValid = mBugReportText.text.toString().trim().length > 10 + && !mMaskView.isVisible + + it.isEnabled = isValid + it.icon.alpha = if (isValid) 255 else 100 + } + + return super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.ic_action_send_bug_report -> { + sendBugReport() + return true + } + } + return super.onOptionsItemSelected(item) + } + + + /** + * Send the bug report + */ + private fun sendBugReport() { + mScrollView.alpha = 0.3f + mMaskView.isVisible = true + + invalidateOptionsMenu() + + mProgressTextView.isVisible = true + mProgressTextView.text = getString(R.string.send_bug_report_progress, 0.toString() + "") + + mProgressBar.isVisible = true + mProgressBar.progress = 0 + + BugReporter.sendBugReport(this, + mIncludeLogsButton.isChecked, + mIncludeCrashLogsButton.isChecked, + mIncludeScreenShotButton.isChecked, + mBugReportText.text.toString(), + object : BugReporter.IMXBugReportListener { + override fun onUploadFailed(reason: String?) { + try { + if (!TextUtils.isEmpty(reason)) { + Toast.makeText(this@BugReportActivity, + getString(R.string.send_bug_report_failed, reason), Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Timber.e(e, "## onUploadFailed() : failed to display the toast " + e.message) + } + + mMaskView.isVisible = false + mProgressBar.isVisible = false + mProgressTextView.isVisible = false + mScrollView.alpha = 1.0f + + invalidateOptionsMenu() + } + + override fun onUploadCancelled() { + onUploadFailed(null) + } + + override fun onProgress(progress: Int) { + var progress = progress + if (progress > 100) { + Timber.e("## onProgress() : progress > 100") + progress = 100 + } else if (progress < 0) { + Timber.e("## onProgress() : progress < 0") + progress = 0 + } + + mProgressBar.progress = progress + mProgressTextView.text = getString(R.string.send_bug_report_progress, progress.toString() + "") + } + + override fun onUploadSucceed() { + try { + Toast.makeText(this@BugReportActivity, R.string.send_bug_report_sent, Toast.LENGTH_LONG).show() + } catch (e: Exception) { + Timber.e(e, "## onUploadSucceed() : failed to dismiss the toast " + e.message) + } + + try { + finish() + } catch (e: Exception) { + Timber.e(e, "## onUploadSucceed() : failed to dismiss the dialog " + e.message) + } + + } + }) + } + + /* ========================================================================================== + * UI Event + * ========================================================================================== */ + + @OnTextChanged(R.id.bug_report_edit_text) + internal fun textChanged() { + invalidateOptionsMenu() + } + + @OnCheckedChanged(R.id.bug_report_button_include_screenshot) + internal fun onSendScreenshotChanged() { + mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.screenshot != null + } + + override fun onBackPressed() { + // Ensure there is no crash status remaining, which will be sent later on by mistake + BugReporter.deleteCrashFile(this) + + super.onBackPressed() + } + + /* ========================================================================================== + * Companion + * ========================================================================================== */ + + companion object { + private val LOG_TAG = BugReportActivity::class.java.simpleName + } +} 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 0000000000..a5e31425e7 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporter.kt @@ -0,0 +1,687 @@ +/* + * 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.text.TextUtils +import android.view.View +import im.vector.matrix.android.api.Matrix +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.getLogFiles() + + for (f in files) { + if (!mIsCancelled) { + val gzippedFile = compressFile(f) + + if (null != gzippedFile) { + gzippedFiles.add(gzippedFile) + } + } + } + } + + 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) + } + } + } + } + + var deviceId = "undefined" + var userId = "undefined" + var matrixSdkVersion = "undefined" + var olmVersion = "undefined" + + Matrix.getInstance().currentSession?.let { session -> + userId = session.sessionParams.credentials.userId + deviceId = session.sessionParams.credentials.deviceId ?: "undefined" + // TODO matrixSdkVersion = session.getVersion(true); + // TODO olmVersion = session.getCryptoVersion(context, true); + } + + if (!mIsCancelled) { + // build the multi part request + val builder = BugReporterMultipartBody.Builder() + .addFormDataPart("text", "[RiotX] $bugDescription") + .addFormDataPart("app", "riot-android") + .addFormDataPart("user_agent", Matrix.getInstance().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", 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/BugReporterMultipartBody.java b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporterMultipartBody.java new file mode 100755 index 0000000000..48796efa11 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/BugReporterMultipartBody.java @@ -0,0 +1,300 @@ +/* + * 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 java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okhttp3.internal.Util; +import okio.Buffer; +import okio.BufferedSink; +import okio.ByteString; + +// simplified version of MultipartBody (OkHttp 3.6.0) +public class BugReporterMultipartBody extends RequestBody { + + /** + * Listener + */ + public interface WriteListener { + /** + * Upload listener + * + * @param totalWritten total written bytes + * @param contentLength content length + */ + void onWrite(long totalWritten, long contentLength); + } + + private static final MediaType FORM = MediaType.parse("multipart/form-data"); + + private static final byte[] COLONSPACE = {':', ' '}; + private static final byte[] CRLF = {'\r', '\n'}; + private static final byte[] DASHDASH = {'-', '-'}; + + private final ByteString mBoundary; + private final MediaType mContentType; + private final List mParts; + private long mContentLength = -1L; + + // listener + private WriteListener mWriteListener; + + // + private List mContentLengthSize = null; + + private BugReporterMultipartBody(ByteString boundary, List parts) { + mBoundary = boundary; + mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8()); + mParts = Util.immutableList(parts); + } + + @Override + public MediaType contentType() { + return mContentType; + } + + @Override + public long contentLength() throws IOException { + long result = mContentLength; + if (result != -1L) return result; + return mContentLength = writeOrCountBytes(null, true); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + writeOrCountBytes(sink, false); + } + + /** + * Set the listener + * + * @param listener the + */ + public void setWriteListener(WriteListener listener) { + mWriteListener = listener; + } + + /** + * Warn the listener that some bytes have been written + * + * @param totalWrittenBytes the total written bytes + */ + private void onWrite(long totalWrittenBytes) { + if ((null != mWriteListener) && (mContentLength > 0)) { + mWriteListener.onWrite(totalWrittenBytes, mContentLength); + } + } + + /** + * Either writes this request to {@code sink} or measures its content length. We have one method + * do double-duty to make sure the counting and content are consistent, particularly when it comes + * to awkward operations like measuring the encoded length of header strings, or the + * length-in-digits of an encoded integer. + */ + private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException { + long byteCount = 0L; + + Buffer byteCountBuffer = null; + if (countBytes) { + sink = byteCountBuffer = new Buffer(); + mContentLengthSize = new ArrayList<>(); + } + + for (int p = 0, partCount = mParts.size(); p < partCount; p++) { + Part part = mParts.get(p); + Headers headers = part.headers; + RequestBody body = part.body; + + sink.write(DASHDASH); + sink.write(mBoundary); + sink.write(CRLF); + + if (headers != null) { + for (int h = 0, headerCount = headers.size(); h < headerCount; h++) { + sink.writeUtf8(headers.name(h)) + .write(COLONSPACE) + .writeUtf8(headers.value(h)) + .write(CRLF); + } + } + + MediaType contentType = body.contentType(); + if (contentType != null) { + sink.writeUtf8("Content-Type: ") + .writeUtf8(contentType.toString()) + .write(CRLF); + } + + int contentLength = (int) body.contentLength(); + if (contentLength != -1) { + sink.writeUtf8("Content-Length: ") + .writeUtf8(contentLength + "") + .write(CRLF); + } else if (countBytes) { + // We can't measure the body's size without the sizes of its components. + byteCountBuffer.clear(); + return -1L; + } + + sink.write(CRLF); + + if (countBytes) { + byteCount += contentLength; + mContentLengthSize.add(byteCount); + } else { + body.writeTo(sink); + + // warn the listener of upload progress + // sink.buffer().size() does not give the right value + // assume that some data are popped + if ((null != mContentLengthSize) && (p < mContentLengthSize.size())) { + onWrite(mContentLengthSize.get(p)); + } + } + sink.write(CRLF); + } + + sink.write(DASHDASH); + sink.write(mBoundary); + sink.write(DASHDASH); + sink.write(CRLF); + + if (countBytes) { + byteCount += byteCountBuffer.size(); + byteCountBuffer.clear(); + } + + return byteCount; + } + + private static void appendQuotedString(StringBuilder target, String key) { + target.append('"'); + for (int i = 0, len = key.length(); i < len; i++) { + char ch = key.charAt(i); + switch (ch) { + case '\n': + target.append("%0A"); + break; + case '\r': + target.append("%0D"); + break; + case '"': + target.append("%22"); + break; + default: + target.append(ch); + break; + } + } + target.append('"'); + } + + public static final class Part { + public static Part create(Headers headers, RequestBody body) { + if (body == null) { + throw new NullPointerException("body == null"); + } + if (headers != null && headers.get("Content-Type") != null) { + throw new IllegalArgumentException("Unexpected header: Content-Type"); + } + if (headers != null && headers.get("Content-Length") != null) { + throw new IllegalArgumentException("Unexpected header: Content-Length"); + } + return new Part(headers, body); + } + + public static Part createFormData(String name, String value) { + return createFormData(name, null, RequestBody.create(null, value)); + } + + public static Part createFormData(String name, String filename, RequestBody body) { + if (name == null) { + throw new NullPointerException("name == null"); + } + StringBuilder disposition = new StringBuilder("form-data; name="); + appendQuotedString(disposition, name); + + if (filename != null) { + disposition.append("; filename="); + appendQuotedString(disposition, filename); + } + + return create(Headers.of("Content-Disposition", disposition.toString()), body); + } + + final Headers headers; + final RequestBody body; + + private Part(Headers headers, RequestBody body) { + this.headers = headers; + this.body = body; + } + } + + public static final class Builder { + private final ByteString boundary; + private final List parts = new ArrayList<>(); + + public Builder() { + this(UUID.randomUUID().toString()); + } + + public Builder(String boundary) { + this.boundary = ByteString.encodeUtf8(boundary); + } + + /** + * Add a form data part to the body. + */ + public Builder addFormDataPart(String name, String value) { + return addPart(Part.createFormData(name, value)); + } + + /** + * Add a form data part to the body. + */ + public Builder addFormDataPart(String name, String filename, RequestBody body) { + return addPart(Part.createFormData(name, filename, body)); + } + + /** + * Add a part to the body. + */ + public Builder addPart(Part part) { + if (part == null) throw new NullPointerException("part == null"); + parts.add(part); + return this; + } + + /** + * Assemble the specified parts into a request body. + */ + public BugReporterMultipartBody build() { + if (parts.isEmpty()) { + throw new IllegalStateException("Multipart body must have at least one part."); + } + return new BugReporterMultipartBody(boundary, parts); + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..446d2f4807 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/RageShake.kt @@ -0,0 +1,118 @@ +/* + * 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.app.Activity +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorManager +import android.preference.PreferenceManager +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import com.squareup.seismic.ShakeDetector +import im.vector.riotredesign.R + +class RageShake(val activity: Activity) : ShakeDetector.Listener { + + private var shakeDetector: ShakeDetector? = null + + private var dialogDisplayed = false + + fun start() { + if (!isEnable(activity)) { + return + } + + + val sensorManager = activity.getSystemService(AppCompatActivity.SENSOR_SERVICE) as? SensorManager + + if (sensorManager == null) { + return + } + + shakeDetector = ShakeDetector(this).apply { + start(sensorManager) + } + } + + fun stop() { + shakeDetector?.stop() + } + + /** + * Enable the feature, and start it + */ + fun enable() { + PreferenceManager.getDefaultSharedPreferences(activity).edit { + putBoolean(SETTINGS_USE_RAGE_SHAKE_KEY, true) + } + + start() + } + + /** + * Disable the feature, and stop it + */ + fun disable() { + PreferenceManager.getDefaultSharedPreferences(activity).edit { + putBoolean(SETTINGS_USE_RAGE_SHAKE_KEY, false) + } + + stop() + } + + override fun hearShake() { + if (dialogDisplayed) { + // Filtered! + return + } + + dialogDisplayed = true + + AlertDialog.Builder(activity) + .setMessage(R.string.send_bug_report_alert_message) + .setPositiveButton(R.string.yes) { _, _ -> openBugReportScreen() } + .setNeutralButton(R.string.disable) { _, _ -> disable() } + .setOnDismissListener { dialogDisplayed = false } + .setNegativeButton(R.string.no, null) + .show() + } + + private fun openBugReportScreen() { + BugReporter.openBugReportScreen(activity) + } + + companion object { + private const val SETTINGS_USE_RAGE_SHAKE_KEY = "SETTINGS_USE_RAGE_SHAKE_KEY" + + /** + * Check if the feature is available + */ + fun isAvailable(context: Context): Boolean { + return (context.getSystemService(AppCompatActivity.SENSOR_SERVICE) as? SensorManager) + ?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + } + + /** + * Check if the feature is enable (enabled by default) + */ + private fun isEnable(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_USE_RAGE_SHAKE_KEY, true) + } + } +} \ No newline at end of file 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 0000000000..eac958872e --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/rageshake/VectorFileLogger.kt @@ -0,0 +1,186 @@ +/* + * 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 +import kotlin.collections.ArrayList + +object VectorFileLogger : Timber.DebugTree() { + + private const val LOG_SIZE_BYTES = 50 * 1024 * 1024 // 50MB + + // relatively large rotation count because closing > opening the app rotates the log (!) + private const 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 getLogFiles(): List { + val files = ArrayList() + + 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() { + + 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 LINE_SEPARATOR = System.getProperty("line.separator") ?: "\n" + 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 0000000000..609590298e --- /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 0000000000..ad8a450689 --- /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 0000000000..0c88f508bb --- /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/ActivityOtherThemes.kt b/app/src/main/java/im/vector/riotredesign/features/themes/ActivityOtherThemes.kt new file mode 100644 index 0000000000..da17de4962 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/themes/ActivityOtherThemes.kt @@ -0,0 +1,77 @@ +/* + * 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.ui.themes + +import androidx.annotation.StyleRes +import im.vector.riotredesign.R + +/** + * Class to manage Activity other possible themes. + * Note that style for light theme is default and is declared in the Android Manifest + */ +sealed class ActivityOtherThemes(@StyleRes val dark: Int, + @StyleRes val black: Int, + @StyleRes val status: Int) { + + object Default : ActivityOtherThemes( + R.style.AppTheme_Dark, + R.style.AppTheme_Black, + R.style.AppTheme_Status + ) + + object NoActionBarFullscreen : ActivityOtherThemes( + R.style.AppTheme_NoActionBar_FullScreen_Dark, + R.style.AppTheme_NoActionBar_FullScreen_Black, + R.style.AppTheme_NoActionBar_FullScreen_Status + ) + + object Home : ActivityOtherThemes( + R.style.HomeActivityTheme_Dark, + R.style.HomeActivityTheme_Black, + R.style.HomeActivityTheme_Status + ) + + object Group : ActivityOtherThemes( + R.style.GroupAppTheme_Dark, + R.style.GroupAppTheme_Black, + R.style.GroupAppTheme_Status + ) + + object Picker : ActivityOtherThemes( + R.style.CountryPickerTheme_Dark, + R.style.CountryPickerTheme_Black, + R.style.CountryPickerTheme_Status + ) + + object Lock : ActivityOtherThemes( + R.style.Theme_Vector_Lock_Dark, + R.style.Theme_Vector_Lock_Light, + R.style.Theme_Vector_Lock_Status + ) + + object Search : ActivityOtherThemes( + R.style.SearchesAppTheme_Dark, + R.style.SearchesAppTheme_Black, + R.style.SearchesAppTheme_Status + ) + + object Call : ActivityOtherThemes( + R.style.CallActivityTheme_Dark, + R.style.CallActivityTheme_Black, + R.style.CallActivityTheme_Status + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..06b3e47c44 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/themes/ThemeUtils.kt @@ -0,0 +1,226 @@ +/* + * 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.themes + + +import android.app.Activity +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.TextUtils +import android.util.TypedValue +import android.view.Menu +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +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.* + +/** + * Util class for managing themes. + */ +object ThemeUtils { + // preference key + const val APPLICATION_THEME_KEY = "APPLICATION_THEME_KEY" + + // the theme possible values + private const val THEME_DARK_VALUE = "dark" + private const val THEME_LIGHT_VALUE = "light" + private const val THEME_BLACK_VALUE = "black" + private const val THEME_STATUS_VALUE = "status" + + private val mColorByAttr = HashMap() + + /** + * Provides the selected application theme + * + * @param context the context + * @return the selected application theme + */ + fun getApplicationTheme(context: Context): String { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString(APPLICATION_THEME_KEY, THEME_LIGHT_VALUE) + } + + /** + * Update the application theme + * + * @param aTheme the new theme + */ + fun setApplicationTheme(context: Context, aTheme: String) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putString(APPLICATION_THEME_KEY, aTheme) + .apply() + + /* TODO + when (aTheme) { + THEME_DARK_VALUE -> VectorApp.getInstance().setTheme(R.style.AppTheme_Dark) + THEME_BLACK_VALUE -> VectorApp.getInstance().setTheme(R.style.AppTheme_Black) + THEME_STATUS_VALUE -> VectorApp.getInstance().setTheme(R.style.AppTheme_Status) + else -> VectorApp.getInstance().setTheme(R.style.AppTheme_Light) + } + */ + + mColorByAttr.clear() + } + + /** + * Set the activity theme according to the selected one. + * + * @param activity the activity + */ + fun setActivityTheme(activity: Activity, otherThemes: ActivityOtherThemes) { + when (getApplicationTheme(activity)) { + THEME_DARK_VALUE -> activity.setTheme(otherThemes.dark) + THEME_BLACK_VALUE -> activity.setTheme(otherThemes.black) + THEME_STATUS_VALUE -> activity.setTheme(otherThemes.status) + } + + mColorByAttr.clear() + } + + /** + * Set the TabLayout colors. + * It seems that there is no proper way to manage it with the manifest file. + * + * @param activity the activity + * @param layout the layout + */ + /* + fun setTabLayoutTheme(activity: Activity, layout: TabLayout) { + if (activity is VectorGroupDetailsActivity) { + val textColor: Int + val underlineColor: Int + val backgroundColor: Int + + if (TextUtils.equals(getApplicationTheme(activity), THEME_LIGHT_VALUE)) { + textColor = ContextCompat.getColor(activity, android.R.color.white) + underlineColor = textColor + backgroundColor = ContextCompat.getColor(activity, R.color.tab_groups) + } else if (TextUtils.equals(getApplicationTheme(activity), THEME_STATUS_VALUE)) { + textColor = ContextCompat.getColor(activity, android.R.color.white) + underlineColor = textColor + backgroundColor = getColor(activity, R.attr.colorPrimary) + } else { + textColor = ContextCompat.getColor(activity, R.color.tab_groups) + underlineColor = textColor + backgroundColor = getColor(activity, R.attr.colorPrimary) + } + + layout.setTabTextColors(textColor, textColor) + layout.setSelectedTabIndicatorColor(underlineColor) + layout.setBackgroundColor(backgroundColor) + } + } */ + + /** + * Translates color attributes to colors + * + * @param c Context + * @param colorAttribute Color Attribute + * @return Requested Color + */ + @ColorInt + fun getColor(c: Context, @AttrRes colorAttribute: Int): Int { + if (mColorByAttr.containsKey(colorAttribute)) { + return mColorByAttr[colorAttribute] as Int + } + + var matchedColor: Int + + try { + val color = TypedValue() + c.theme.resolveAttribute(colorAttribute, color, true) + matchedColor = color.data + } catch (e: Exception) { + Timber.e(e, "Unable to get color") + matchedColor = ContextCompat.getColor(c, android.R.color.holo_red_dark) + } + + mColorByAttr[colorAttribute] = matchedColor + + return matchedColor + } + + /** + * Get the resource Id applied to the current theme + * + * @param c the context + * @param resourceId the resource id + * @return the resource Id for the current theme + */ + fun getResourceId(c: Context, resourceId: Int): Int { + if (TextUtils.equals(getApplicationTheme(c), THEME_LIGHT_VALUE) + || TextUtils.equals(getApplicationTheme(c), THEME_STATUS_VALUE)) { + return when (resourceId) { + R.drawable.line_divider_dark -> R.drawable.line_divider_light + R.style.Floating_Actions_Menu -> R.style.Floating_Actions_Menu_Light + else -> resourceId + } + } + return resourceId + } + + /** + * Update the menu icons colors + * + * @param menu the menu + * @param color the color + */ + fun tintMenuIcons(menu: Menu, color: Int) { + for (i in 0 until menu.size()) { + val item = menu.getItem(i) + val drawable = item.icon + if (drawable != null) { + val wrapped = DrawableCompat.wrap(drawable) + drawable.mutate() + DrawableCompat.setTint(wrapped, color) + item.icon = drawable + } + } + } + + /** + * Tint the drawable with a theme attribute + * + * @param context the context + * @param drawable the drawable to tint + * @param attribute the theme color + * @return the tinted drawable + */ + fun tintDrawable(context: Context, drawable: Drawable, @AttrRes attribute: Int): Drawable { + return tintDrawableWithColor(drawable, getColor(context, attribute)) + } + + /** + * Tint the drawable with a color integer + * + * @param drawable the drawable to tint + * @param color the color + * @return the tinted drawable + */ + fun tintDrawableWithColor(drawable: Drawable, @ColorInt color: Int): Drawable { + val tinted = DrawableCompat.wrap(drawable) + drawable.mutate() + DrawableCompat.setTint(tinted, color) + return tinted + } +} diff --git a/app/src/main/res/color/button_text_color_selector.xml b/app/src/main/res/color/button_text_color_selector.xml new file mode 100644 index 0000000000..ff2ab3dba4 --- /dev/null +++ b/app/src/main/res/color/button_text_color_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/home_bottom_nav_view_tint.xml b/app/src/main/res/color/home_bottom_nav_view_tint.xml new file mode 100644 index 0000000000..ad4b5ff429 --- /dev/null +++ b/app/src/main/res/color/home_bottom_nav_view_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/primary_text_color_selector_dark.xml b/app/src/main/res/color/primary_text_color_selector_dark.xml new file mode 100644 index 0000000000..7c4c853b58 --- /dev/null +++ b/app/src/main/res/color/primary_text_color_selector_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/primary_text_color_selector_light.xml b/app/src/main/res/color/primary_text_color_selector_light.xml new file mode 100644 index 0000000000..8d4c0d06c4 --- /dev/null +++ b/app/src/main/res/color/primary_text_color_selector_light.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/primary_text_color_selector_status.xml b/app/src/main/res/color/primary_text_color_selector_status.xml new file mode 100644 index 0000000000..9bbc84c321 --- /dev/null +++ b/app/src/main/res/color/primary_text_color_selector_status.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxhdpi/ic_material_send_black.png b/app/src/main/res/drawable-xxhdpi/ic_material_send_black.png new file mode 100755 index 0000000000..761929f431 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_material_send_black.png differ diff --git a/app/src/main/res/drawable/bg_tombstone_predecessor.xml b/app/src/main/res/drawable/bg_tombstone_predecessor.xml new file mode 100644 index 0000000000..65c214d2cd --- /dev/null +++ b/app/src/main/res/drawable/bg_tombstone_predecessor.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/call_header_transparent_bg.xml b/app/src/main/res/drawable/call_header_transparent_bg.xml new file mode 100644 index 0000000000..17408c8b8f --- /dev/null +++ b/app/src/main/res/drawable/call_header_transparent_bg.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/direct_chat_circle_black.xml b/app/src/main/res/drawable/direct_chat_circle_black.xml new file mode 100644 index 0000000000..3c45c0231f --- /dev/null +++ b/app/src/main/res/drawable/direct_chat_circle_black.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/direct_chat_circle_dark.xml b/app/src/main/res/drawable/direct_chat_circle_dark.xml new file mode 100644 index 0000000000..1e9a4500f4 --- /dev/null +++ b/app/src/main/res/drawable/direct_chat_circle_dark.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/direct_chat_circle_light.xml b/app/src/main/res/drawable/direct_chat_circle_light.xml new file mode 100644 index 0000000000..88bb178a9b --- /dev/null +++ b/app/src/main/res/drawable/direct_chat_circle_light.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/direct_chat_circle_status.xml b/app/src/main/res/drawable/direct_chat_circle_status.xml new file mode 100644 index 0000000000..2d527d0b4d --- /dev/null +++ b/app/src/main/res/drawable/direct_chat_circle_status.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/line_divider_dark.xml b/app/src/main/res/drawable/line_divider_dark.xml new file mode 100644 index 0000000000..ee2a3a0972 --- /dev/null +++ b/app/src/main/res/drawable/line_divider_dark.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/line_divider_light.xml b/app/src/main/res/drawable/line_divider_light.xml new file mode 100644 index 0000000000..cfaebbda7d --- /dev/null +++ b/app/src/main/res/drawable/line_divider_light.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_background_bing.xml b/app/src/main/res/drawable/pill_background_bing.xml new file mode 100644 index 0000000000..70a2bda8a4 --- /dev/null +++ b/app/src/main/res/drawable/pill_background_bing.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_background_room_alias_dark.xml b/app/src/main/res/drawable/pill_background_room_alias_dark.xml new file mode 100644 index 0000000000..b86cbd3b78 --- /dev/null +++ b/app/src/main/res/drawable/pill_background_room_alias_dark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_background_room_alias_light.xml b/app/src/main/res/drawable/pill_background_room_alias_light.xml new file mode 100644 index 0000000000..9a67df1182 --- /dev/null +++ b/app/src/main/res/drawable/pill_background_room_alias_light.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_background_room_alias_status.xml b/app/src/main/res/drawable/pill_background_room_alias_status.xml new file mode 100644 index 0000000000..9a67df1182 --- /dev/null +++ b/app/src/main/res/drawable/pill_background_room_alias_status.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_background_user_id_dark.xml b/app/src/main/res/drawable/pill_background_user_id_dark.xml new file mode 100644 index 0000000000..b86cbd3b78 --- /dev/null +++ b/app/src/main/res/drawable/pill_background_user_id_dark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_background_user_id_light.xml b/app/src/main/res/drawable/pill_background_user_id_light.xml new file mode 100644 index 0000000000..9a67df1182 --- /dev/null +++ b/app/src/main/res/drawable/pill_background_user_id_light.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_background_user_id_status.xml b/app/src/main/res/drawable/pill_background_user_id_status.xml new file mode 100644 index 0000000000..9a67df1182 --- /dev/null +++ b/app/src/main/res/drawable/pill_background_user_id_status.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/riot_animated_logo.xml b/app/src/main/res/drawable/riot_animated_logo.xml new file mode 100644 index 0000000000..ceedc6aea1 --- /dev/null +++ b/app/src/main/res/drawable/riot_animated_logo.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/searches_cursor_background.xml b/app/src/main/res/drawable/searches_cursor_background.xml new file mode 100644 index 0000000000..c9d1d88498 --- /dev/null +++ b/app/src/main/res/drawable/searches_cursor_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shadow_bottom_dark.xml b/app/src/main/res/drawable/shadow_bottom_dark.xml new file mode 100644 index 0000000000..f56addee06 --- /dev/null +++ b/app/src/main/res/drawable/shadow_bottom_dark.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shadow_bottom_light.xml b/app/src/main/res/drawable/shadow_bottom_light.xml new file mode 100755 index 0000000000..c86eaf3b3b --- /dev/null +++ b/app/src/main/res/drawable/shadow_bottom_light.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shadow_top_dark.xml b/app/src/main/res/drawable/shadow_top_dark.xml new file mode 100644 index 0000000000..7b2d95e2b6 --- /dev/null +++ b/app/src/main/res/drawable/shadow_top_dark.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shadow_top_light.xml b/app/src/main/res/drawable/shadow_top_light.xml new file mode 100755 index 0000000000..03412fc414 --- /dev/null +++ b/app/src/main/res/drawable/shadow_top_light.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/splash.xml b/app/src/main/res/drawable/splash.xml new file mode 100644 index 0000000000..4d60be2f30 --- /dev/null +++ b/app/src/main/res/drawable/splash.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sticker_description_background.xml b/app/src/main/res/drawable/sticker_description_background.xml new file mode 100644 index 0000000000..faa6f5b6f4 --- /dev/null +++ b/app/src/main/res/drawable/sticker_description_background.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sticker_description_triangle.xml b/app/src/main/res/drawable/sticker_description_triangle.xml new file mode 100644 index 0000000000..199c2e7313 --- /dev/null +++ b/app/src/main/res/drawable/sticker_description_triangle.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_background_fab_label.xml b/app/src/main/res/drawable/vector_background_fab_label.xml new file mode 100644 index 0000000000..2c13ba76ee --- /dev/null +++ b/app/src/main/res/drawable/vector_background_fab_label.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_background_fab_label_light.xml b/app/src/main/res/drawable/vector_background_fab_label_light.xml new file mode 100644 index 0000000000..b65b9b00cf --- /dev/null +++ b/app/src/main/res/drawable/vector_background_fab_label_light.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_medias_picker_button_background.xml b/app/src/main/res/drawable/vector_medias_picker_button_background.xml new file mode 100644 index 0000000000..8adc855a90 --- /dev/null +++ b/app/src/main/res/drawable/vector_medias_picker_button_background.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_background_dark.xml b/app/src/main/res/drawable/vector_tabbar_background_dark.xml new file mode 100644 index 0000000000..74b4a8c4c3 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_background_dark.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_background_group_light.xml b/app/src/main/res/drawable/vector_tabbar_background_group_light.xml new file mode 100644 index 0000000000..3166002c08 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_background_group_light.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_background_light.xml b/app/src/main/res/drawable/vector_tabbar_background_light.xml new file mode 100644 index 0000000000..432be45bbe --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_background_light.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_background_status.xml b/app/src/main/res/drawable/vector_tabbar_background_status.xml new file mode 100644 index 0000000000..f0f38a6439 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_background_status.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_selected_background_dark.xml b/app/src/main/res/drawable/vector_tabbar_selected_background_dark.xml new file mode 100644 index 0000000000..ce8ba4eb8d --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_selected_background_dark.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_selected_background_group_light.xml b/app/src/main/res/drawable/vector_tabbar_selected_background_group_light.xml new file mode 100644 index 0000000000..a46d469734 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_selected_background_group_light.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_selected_background_light.xml b/app/src/main/res/drawable/vector_tabbar_selected_background_light.xml new file mode 100644 index 0000000000..9625ac483f --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_selected_background_light.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_selected_background_status.xml b/app/src/main/res/drawable/vector_tabbar_selected_background_status.xml new file mode 100644 index 0000000000..ee338a5a39 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_selected_background_status.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_unselected_background_dark.xml b/app/src/main/res/drawable/vector_tabbar_unselected_background_dark.xml new file mode 100644 index 0000000000..cb302f5111 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_unselected_background_dark.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_unselected_background_group_light.xml b/app/src/main/res/drawable/vector_tabbar_unselected_background_group_light.xml new file mode 100644 index 0000000000..b2d360f2e0 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_unselected_background_group_light.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_unselected_background_light.xml b/app/src/main/res/drawable/vector_tabbar_unselected_background_light.xml new file mode 100644 index 0000000000..a7a286e724 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_unselected_background_light.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_tabbar_unselected_background_status.xml b/app/src/main/res/drawable/vector_tabbar_unselected_background_status.xml new file mode 100644 index 0000000000..e2c7613c50 --- /dev/null +++ b/app/src/main/res/drawable/vector_tabbar_unselected_background_status.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_bug_report.xml b/app/src/main/res/layout/activity_bug_report.xml new file mode 100644 index 0000000000..82916ee69a --- /dev/null +++ b/app/src/main/res/layout/activity_bug_report.xml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/bug_report.xml b/app/src/main/res/menu/bug_report.xml new file mode 100755 index 0000000000..c37895bb8d --- /dev/null +++ b/app/src/main/res/menu/bug_report.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..17cb3d7564 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 90e3e22a0b..2a1fc86806 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -18,8 +18,4 @@ #a5a5a5 #61708B - #FFC7C7C7 - #FF999999 - #FFF56679 - diff --git a/app/src/main/res/values/colors_riot.xml b/app/src/main/res/values/colors_riot.xml new file mode 100644 index 0000000000..f60d4991f3 --- /dev/null +++ b/app/src/main/res/values/colors_riot.xml @@ -0,0 +1,150 @@ + + + + + #70BF56 + #ff4b55 + #ff4b55 + + + #ff4b55 + #FFC7C7C7 + #FF999999 + + + #BD79CC + #744C7F + #F8A15F + #D97051 + @color/accent_color_light + #5EA584 + #a6d0e5 + #81bddb + + + #7F03b381 + #7F03b381 + #7F03b381 + #7F586C7B + + + + + #FFFFFFFF + + #FF181B21 + + #F000 + #FFEEF2F5 + + + #FF1A2027 + + #FF27303A + + #03b381 + + + #FF0D0E10 + + #FF15171B + + #03b381 + + + #000 + + #FF060708 + + #FF465561 + #FF586C7B + #FF586C7B + + + #EEEFEF + + #FF61708B + + #FF22262E + + @color/primary_color_light + @color/primary_color_dark + @color/primary_color_status + + @color/primary_color_light + @color/primary_color_dark + @color/primary_color_status + + + #FFFFFF + #FFFFFF + + #903C3C3C + #CCDDDDDD + + + + #FF2E2F32 + #FF9E9E9E + + #FF9E9E9E + @color/riot_primary_text_color_light + + + #FFEDF3FF + #FFA1B2D1 + + #FFA1B2D1 + @color/riot_primary_text_color_dark + + + #FF70808D + #7F70808D + + #7F70808D + @color/riot_primary_text_color_status + + + #FFDDDDDD + @android:color/transparent + + + #2f9edb + @color/vector_fuchsia_color + + + #03b381 + #368bd6 + #ac3ba8 + + + #FFF56679 + #FFFFC666 + #FFF8E71C + #FF7AC9A1 + #FF9E9E9E + + + #FFFFFFFF + #FF7F7F7F + + + #368bd6 + #ac3ba8 + #03b381 + #e64f7a + #ff812d + #2dc2c5 + #5c56f5 + #74d12c + + + #368BD6 + #368BD6 + #368BD6 + + + #368BD6 + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml deleted file mode 100644 index 25fe7ea98a..0000000000 --- a/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/values/styles_riot.xml b/app/src/main/res/values/styles_riot.xml new file mode 100644 index 0000000000..9bfb9af87a --- /dev/null +++ b/app/src/main/res/values/styles_riot.xml @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/theme_black.xml b/app/src/main/res/values/theme_black.xml new file mode 100644 index 0000000000..f816b9befa --- /dev/null +++ b/app/src/main/res/values/theme_black.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/theme_dark.xml b/app/src/main/res/values/theme_dark.xml new file mode 100644 index 0000000000..9359d0549b --- /dev/null +++ b/app/src/main/res/values/theme_dark.xml @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/theme_light.xml b/app/src/main/res/values/theme_light.xml new file mode 100644 index 0000000000..190c838a43 --- /dev/null +++ b/app/src/main/res/values/theme_light.xml @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +