Browse Source

Add rageskahe module

feature/bma/flipper
Benoit Marty 2 years ago
parent
commit
3f7a83c519
  1. 2
      app/build.gradle.kts
  2. 4
      app/src/main/java/io/element/android/x/ElementXApplication.kt
  3. 20
      app/src/main/java/io/element/android/x/MainActivity.kt
  4. 28
      app/src/main/java/io/element/android/x/Navigation.kt
  5. 14
      app/src/main/java/io/element/android/x/initializer/CrashInitializer.kt
  6. 8
      app/src/main/java/io/element/android/x/initializer/TimberInitializer.kt
  7. 1
      features/preferences/.gitignore
  8. 29
      features/preferences/build.gradle.kts
  9. 0
      features/preferences/consumer-rules.pro
  10. 21
      features/preferences/proguard-rules.pro
  11. 22
      features/preferences/src/androidTest/java/io/element/android/x/features/preferences/ExampleInstrumentedTest.kt
  12. 4
      features/preferences/src/main/AndroidManifest.xml
  13. 42
      features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt
  14. 16
      features/preferences/src/test/java/io/element/android/x/features/preferences/ExampleUnitTest.kt
  15. 1
      features/rageshake/.gitignore
  16. 30
      features/rageshake/build.gradle.kts
  17. 0
      features/rageshake/consumer-rules.pro
  18. 21
      features/rageshake/proguard-rules.pro
  19. 4
      features/rageshake/src/main/AndroidManifest.xml
  20. 234
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt
  21. 159
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt
  22. 29
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt
  23. 58
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/CrashDataStore.kt
  24. 65
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/VectorUncaughtExceptionHandler.kt
  25. 61
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt
  26. 49
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt
  27. 7
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt
  28. 98
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt
  29. 174
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt
  30. 12
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt
  31. 48
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/LogFormatter.kt
  32. 158
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/VectorFileLogger.kt
  33. 53
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt
  34. 55
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageShake.kt
  35. 53
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageshakeDataStore.kt
  36. 533
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporter.kt
  37. 283
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporterMultipartBody.java
  38. 10
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/ReportType.kt
  39. 24
      features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt
  40. 9
      features/rageshake/src/main/res/values/strings.xml
  41. 16
      features/rageshake/src/test/java/io/element/android/x/features/login/ExampleUnitTest.kt
  42. 1
      features/roomlist/build.gradle.kts
  43. 10
      features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt
  44. 28
      features/roomlist/src/main/java/io/element/android/x/features/roomlist/components/RoomListTopBar.kt
  45. 7
      gradle/libs.versions.toml
  46. 6
      libraries/core/src/main/AndroidManifest.xml
  47. 11
      libraries/core/src/main/java/io/element/android/x/core/bitmap/Bitmap.kt
  48. 5
      libraries/core/src/main/java/io/element/android/x/core/bool/Booleans.kt
  49. 55
      libraries/core/src/main/java/io/element/android/x/core/extensions/BasicExtensions.kt
  50. 38
      libraries/core/src/main/java/io/element/android/x/core/file/compressFile.kt
  51. 33
      libraries/core/src/main/java/io/element/android/x/core/hardware/vibrator.kt
  52. 47
      libraries/core/src/main/java/io/element/android/x/core/mimetype/MimeTypes.kt
  53. 57
      libraries/core/src/main/java/io/element/android/x/core/screenshot/Screenshot.kt
  54. 1
      libraries/designsystem/build.gradle.kts
  55. 40
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/LabelledCheckbox.kt
  56. 43
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ConfirmationDialog.kt
  57. 58
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ErrorDialog.kt
  58. 5
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/Config.kt
  59. 43
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceCategory.kt
  60. 92
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt
  61. 65
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSlide.kt
  62. 51
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSwitch.kt
  63. 43
      libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceText.kt
  64. 2
      settings.gradle.kts

2
app/build.gradle.kts

@ -140,6 +140,8 @@ dependencies { @@ -140,6 +140,8 @@ dependencies {
implementation(project(":features:login"))
implementation(project(":features:roomlist"))
implementation(project(":features:messages"))
implementation(project(":features:rageshake"))
implementation(project(":features:preferences"))
implementation(project(":libraries:di"))
implementation(project(":anvilannotations"))
anvil(project(":anvilcodegen"))

4
app/src/main/java/io/element/android/x/ElementXApplication.kt

@ -9,8 +9,10 @@ import io.element.android.x.di.AppComponent @@ -9,8 +9,10 @@ import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.di.SessionComponentsOwner
import io.element.android.x.initializer.CoilInitializer
import io.element.android.x.initializer.CrashInitializer
import io.element.android.x.initializer.MatrixInitializer
import io.element.android.x.initializer.MavericksInitializer
import io.element.android.x.initializer.TimberInitializer
class ElementXApplication : Application(), DaggerComponentOwner {
@ -25,6 +27,8 @@ class ElementXApplication : Application(), DaggerComponentOwner { @@ -25,6 +27,8 @@ class ElementXApplication : Application(), DaggerComponentOwner {
appComponent = DaggerAppComponent.factory().create(applicationContext)
sessionComponentsOwner = bindings<AppBindings>().sessionComponentsOwner()
AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java)
initializeComponent(TimberInitializer::class.java)
initializeComponent(MatrixInitializer::class.java)
initializeComponent(CoilInitializer::class.java)
initializeComponent(MavericksInitializer::class.java)

20
app/src/main/java/io/element/android/x/MainActivity.kt

@ -45,6 +45,9 @@ import com.ramcosta.composedestinations.spec.Route @@ -45,6 +45,9 @@ import com.ramcosta.composedestinations.spec.Route
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import io.element.android.x.features.rageshake.bugreport.BugReportScreen
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionScreen
import io.element.android.x.features.rageshake.detection.RageshakeDetectionScreen
import kotlinx.coroutines.runBlocking
import timber.log.Timber
@ -99,6 +102,7 @@ class MainActivity : ComponentActivity() { @@ -99,6 +102,7 @@ class MainActivity : ComponentActivity() {
}
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
var isBugReportVisible by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
MainContent(
@ -109,6 +113,22 @@ class MainActivity : ComponentActivity() { @@ -109,6 +113,22 @@ class MainActivity : ComponentActivity() {
onCloseClicked = { isShowkaseButtonVisible = false },
onClick = { startActivity(Showkase.getBrowserIntent(this@MainActivity)) }
)
RageshakeDetectionScreen(
onOpenBugReport = {
isBugReportVisible = true
}
)
CrashDetectionScreen(
onOpenBugReport = {
isBugReportVisible = true
}
)
if (isBugReportVisible) {
// TODO Improve the navigation, when pressing back here, it closes the app.
BugReportScreen(
onDone = { isBugReportVisible = false }
)
}
}
OnLifecycleEvent { _, event ->
Timber.v("OnLifecycleEvent: $event")

28
app/src/main/java/io/element/android/x/Navigation.kt

@ -7,16 +7,20 @@ import com.ramcosta.composedestinations.annotation.RootNavGraph @@ -7,16 +7,20 @@ import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import io.element.android.x.core.di.bindings
import io.element.android.x.destinations.BugReportScreenNavigationDestination
import io.element.android.x.destinations.ChangeServerScreenNavigationDestination
import io.element.android.x.destinations.LoginScreenNavigationDestination
import io.element.android.x.destinations.MessagesScreenNavigationDestination
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import io.element.android.x.destinations.PreferencesScreenNavigationDestination
import io.element.android.x.destinations.RoomListScreenNavigationDestination
import io.element.android.x.di.AppBindings
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.login.changeserver.ChangeServerScreen
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.onboarding.OnBoardingScreen
import io.element.android.x.features.preferences.PreferencesScreen
import io.element.android.x.features.rageshake.bugreport.BugReportScreen
import io.element.android.x.features.roomlist.RoomListScreen
import io.element.android.x.matrix.core.RoomId
@ -72,6 +76,9 @@ fun RoomListScreenNavigation(navigator: DestinationsNavigator) { @@ -72,6 +76,9 @@ fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
onRoomClicked = { roomId: RoomId ->
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
},
onOpenSettings = {
navigator.navigate(PreferencesScreenNavigationDestination())
},
onSuccessLogout = {
sessionComponentsOwner.releaseActiveSession()
navigator.navigate(OnBoardingScreenNavigationDestination) {
@ -79,6 +86,10 @@ fun RoomListScreenNavigation(navigator: DestinationsNavigator) { @@ -79,6 +86,10 @@ fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
inclusive = true
}
}
},
// Tmp entry point
onOpenRageShake = {
navigator.navigate(BugReportScreenNavigationDestination)
}
)
}
@ -88,3 +99,20 @@ fun RoomListScreenNavigation(navigator: DestinationsNavigator) { @@ -88,3 +99,20 @@ fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) {
MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp)
}
@Destination
@Composable
fun BugReportScreenNavigation(navigator: DestinationsNavigator) {
BugReportScreen(
onDone = navigator::popBackStack
)
}
@Destination
@Composable
fun PreferencesScreenNavigation(navigator: DestinationsNavigator) {
PreferencesScreen(
onBackPressed = navigator::navigateUp
)
}

14
app/src/main/java/io/element/android/x/initializer/CrashInitializer.kt

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
package io.element.android.x.initializer
import android.content.Context
import androidx.startup.Initializer
import io.element.android.x.features.rageshake.crash.VectorUncaughtExceptionHandler
class CrashInitializer : Initializer<Unit> {
override fun create(context: Context) {
VectorUncaughtExceptionHandler(context).activate()
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

8
app/src/main/java/io/element/android/x/initializer/TimberInitializer.kt

@ -2,14 +2,18 @@ package io.element.android.x.initializer @@ -2,14 +2,18 @@ package io.element.android.x.initializer
import android.content.Context
import androidx.startup.Initializer
import io.element.android.x.BuildConfig
import io.element.android.x.features.rageshake.logs.VectorFileLogger
import timber.log.Timber
class TimberInitializer : Initializer<Unit> {
override fun create(context: Context) {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
Timber.plant(VectorFileLogger(context))
}
override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(TimberInitializer::class.java)
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

1
features/preferences/.gitignore vendored

@ -0,0 +1 @@ @@ -0,0 +1 @@
/build

29
features/preferences/build.gradle.kts

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.x.features.preferences"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(project(":anvilannotations"))
anvil(project(":anvilcodegen"))
implementation(project(":libraries:di"))
implementation(project(":libraries:core"))
implementation(project(":features:rageshake"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:elementresources"))
implementation(libs.mavericks.compose)
implementation(libs.datetime)
implementation(libs.accompanist.placeholder)
testImplementation(libs.test.junit)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)
}

0
features/preferences/consumer-rules.pro

21
features/preferences/proguard-rules.pro vendored

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

22
features/preferences/src/androidTest/java/io/element/android/x/features/preferences/ExampleInstrumentedTest.kt

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
package io.element.android.x.features.preferences
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.element.android.x.features.preferences.test", appContext.packageName)
}
}

4
features/preferences/src/main/AndroidManifest.xml

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

42
features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.x.features.preferences
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.x.designsystem.components.preferences.PreferenceScreen
import io.element.android.x.features.rageshake.preferences.RageshakePreferenceCategory
import io.element.android.x.element.resources.R as ElementR
@Composable
fun PreferencesScreen(
onBackPressed: () -> Unit = {},
) {
// TODO Hierarchy!
// TODO Move logout here
// Include pref from other modules
PreferencesContent(onBackPressed = onBackPressed)
}
@Composable
fun PreferencesContent(
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
PreferenceScreen(
modifier = modifier,
onBackPressed = onBackPressed,
title = stringResource(id = ElementR.string.settings)
) {
RageshakePreferenceCategory()
}
}
@Preview
@Composable
fun PreferencesContentPreview() {
PreferencesContent()
}

16
features/preferences/src/test/java/io/element/android/x/features/preferences/ExampleUnitTest.kt

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
package io.element.android.x.features.preferences
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

1
features/rageshake/.gitignore vendored

@ -0,0 +1 @@ @@ -0,0 +1 @@
/build

30
features/rageshake/build.gradle.kts

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.x.features.rageshake"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(project(":libraries:core"))
anvil(project(":anvilcodegen"))
implementation(project(":libraries:di"))
implementation(project(":anvilannotations"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:elementresources"))
implementation(libs.mavericks.compose)
implementation(libs.squareup.seismic)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil)
implementation(libs.coil.compose)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
androidTestImplementation(libs.test.junitext)
}

0
features/rageshake/consumer-rules.pro

21
features/rageshake/proguard-rules.pro vendored

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

4
features/rageshake/src/main/AndroidManifest.xml

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

234
features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt

@ -0,0 +1,234 @@ @@ -0,0 +1,234 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.x.features.rageshake.bugreport
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.designsystem.components.LabelledCheckbox
import io.element.android.x.designsystem.components.dialogs.ErrorDialog
import io.element.android.x.element.resources.R as ElementR
@Composable
fun BugReportScreen(
viewModel: BugReportViewModel = mavericksViewModel(),
onDone: () -> Unit = { },
) {
val state: BugReportViewState by viewModel.collectAsState()
val formState: BugReportFormState by viewModel.formState
LogCompositions(tag = "Rageshake", msg = "Root")
if (state.sending is Success) {
onDone()
}
BugReportContent(
state = state,
formState = formState,
onDescriptionChanged = viewModel::onSetDescription,
onSetSendLog = viewModel::onSetSendLog,
onSetSendCrashLog = viewModel::onSetSendCrashLog,
onSetCanContact = viewModel::onSetCanContact,
onSetSendScreenshot = viewModel::onSetSendScreenshot,
onSubmit = viewModel::onSubmit,
onFailureDialogClosed = viewModel::onFailureDialogClosed,
onDone = onDone,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BugReportContent(
state: BugReportViewState,
formState: BugReportFormState,
modifier: Modifier = Modifier,
onDescriptionChanged: (String) -> Unit = {},
onSetSendLog: (Boolean) -> Unit = {},
onSetSendCrashLog: (Boolean) -> Unit = {},
onSetCanContact: (Boolean) -> Unit = {},
onSetSendScreenshot: (Boolean) -> Unit = {},
onSubmit: () -> Unit = {},
onFailureDialogClosed: () -> Unit = { },
onDone: () -> Unit = { },
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
) {
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
) {
val isError = state.sending is Fail
val isFormEnabled = state.sending !is Loading
// Title
Text(
text = stringResource(id = ElementR.string.send_bug_report),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
)
// Form
Text(
text = stringResource(id = ElementR.string.send_bug_report_description),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
fontSize = 16.sp,
)
Column(
// modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = formState.description,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
enabled = isFormEnabled,
label = {
Text(text = stringResource(id = ElementR.string.send_bug_report_placeholder))
},
supportingText = {
Text(text = stringResource(id = ElementR.string.send_bug_report_description_in_english))
},
onValueChange = onDescriptionChanged,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
// TODO Error text too short
)
}
LabelledCheckbox(
checked = state.sendLogs,
onCheckedChange = onSetSendLog,
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.send_bug_report_include_logs)
)
if (state.hasCrashLogs) {
LabelledCheckbox(
checked = state.sendCrashLogs,
onCheckedChange = onSetSendCrashLog,
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.send_bug_report_include_crash_logs)
)
}
LabelledCheckbox(
checked = state.canContact,
onCheckedChange = onSetCanContact,
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.you_may_contact_me)
)
if (state.screenshotUri != null) {
LabelledCheckbox(
checked = state.sendScreenshot,
onCheckedChange = onSetSendScreenshot,
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.send_bug_report_include_screenshot)
)
if (state.sendScreenshot) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
val model = ImageRequest.Builder(context)
.data(state.screenshotUri)
.build()
AsyncImage(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
model = model,
contentDescription = null
)
}
}
}
// Submit
Button(
onClick = onSubmit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp)
) {
Text(text = stringResource(id = ElementR.string.action_send))
}
}
when (state.sending) {
Uninitialized -> Unit
is Loading -> {
CircularProgressIndicator(
progress = state.sendingProgress,
modifier = Modifier.align(Alignment.Center)
)
}
is Fail -> ErrorDialog(
content = state.sending.error.toString(),
onDismiss = onFailureDialogClosed,
)
is Success -> onDone()
}
}
}
}
@Composable
@Preview
fun BugReportContentPreview() {
ElementXTheme(darkTheme = false) {
BugReportContent(
state = BugReportViewState(),
formState = BugReportFormState.Default
)
}
}

159
features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt

@ -0,0 +1,159 @@ @@ -0,0 +1,159 @@
package io.element.android.x.features.rageshake.bugreport
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.core.net.toUri
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.di.AppScope
import io.element.android.x.features.rageshake.crash.CrashDataStore
import io.element.android.x.features.rageshake.logs.VectorFileLogger
import io.element.android.x.features.rageshake.reporter.BugReporter
import io.element.android.x.features.rageshake.reporter.ReportType
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ContributesViewModel(AppScope::class)
class BugReportViewModel @AssistedInject constructor(
@Assisted initialState: BugReportViewState,
private val bugReporter: BugReporter,
private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder,
private val appCoroutineScope: CoroutineScope
) :
MavericksViewModel<BugReportViewState>(initialState) {
companion object :
MavericksViewModelFactory<BugReportViewModel, BugReportViewState> by daggerMavericksViewModelFactory()
var formState = mutableStateOf(BugReportFormState.Default)
private set
init {
snapshotFlow { formState.value }
.onEach {
setState { copy(formState = it) }
}.launchIn(viewModelScope)
observerCrashDataStore()
setState {
copy(
screenshotUri = screenshotHolder.getFile()?.toUri()?.toString()
)
}
}
private fun observerCrashDataStore() {
viewModelScope.launch {
crashDataStore.crashInfo().collect {
setState {
copy(
hasCrashLogs = it.isNotEmpty()
)
}
}
}
}
private val listener: BugReporter.IMXBugReportListener = object : BugReporter.IMXBugReportListener {
override fun onUploadCancelled() {
setState {
copy(
sendingProgress = 0F,
sending = Uninitialized
)
}
}
override fun onUploadFailed(reason: String?) {
setState {
copy(
sendingProgress = 0F,
sending = Fail(Exception(reason))
)
}
}
override fun onProgress(progress: Int) {
setState {
copy(
sendingProgress = progress.toFloat() / 100,
sending = Loading()
)
}
}
override fun onUploadSucceed(reportUrl: String?) {
setState {
copy(
sendingProgress = 1F,
sending = Success(Unit)
)
}
}
}
override fun onCleared() {
// Use appCoroutineScope because we don't want this coroutine to be cancelled
appCoroutineScope.launch(Dispatchers.IO) {
screenshotHolder.reset()
crashDataStore.reset()
VectorFileLogger.getFromTimber().reset()
}
super.onCleared()
}
fun onSubmit() {
setState {
copy(
sendingProgress = 0F,
sending = Loading()
)
}
withState { state ->
bugReporter.sendBugReport(
coroutineScope = viewModelScope,
reportType = ReportType.BUG_REPORT,
withDevicesLogs = state.sendLogs,
withCrashLogs = state.hasCrashLogs && state.sendCrashLogs,
withKeyRequestHistory = false,
withScreenshot = state.sendScreenshot,
theBugDescription = state.formState.description,
serverVersion = "",
canContact = state.canContact,
customFields = emptyMap(),
listener = listener
)
}
}
fun onFailureDialogClosed() {
setState {
copy(
sendingProgress = 0F,
sending = Uninitialized
)
}
}
fun onSetDescription(str: String) {
formState.value = formState.value.copy(description = str)
setState { copy(sending = Uninitialized) }
}
fun onSetSendLog(value: Boolean) = setState { copy(sendLogs = value) }
fun onSetSendCrashLog(value: Boolean) = setState { copy(sendCrashLogs = value) }
fun onSetCanContact(value: Boolean) = setState { copy(canContact = value) }
fun onSetSendScreenshot(value: Boolean) = setState { copy(sendScreenshot = value) }
}

29
features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
package io.element.android.x.features.rageshake.bugreport
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
data class BugReportViewState(
val formState: BugReportFormState = BugReportFormState.Default,
val sendLogs: Boolean = true,
val hasCrashLogs: Boolean = false,
val sendCrashLogs: Boolean = true,
val canContact: Boolean = false,
val sendScreenshot: Boolean = false,
val screenshotUri: String? = null,
val sendingProgress: Float = 0F,
val sending: Async<Unit> = Uninitialized,
) : MavericksState {
val submitEnabled =
formState.description.length > 10 && sending !is Loading
}
data class BugReportFormState(
val description: String,
) {
companion object {
val Default = BugReportFormState("")
}
}

58
features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/CrashDataStore.kt

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
package io.element.android.x.features.rageshake.crash
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import io.element.android.x.core.bool.orFalse
import io.element.android.x.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash")
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed")
private val crashDataKey = stringPreferencesKey("crashData")
class CrashDataStore @Inject constructor(
@ApplicationContext context: Context
) {
private val store = context.dataStore
fun setCrashData(crashData: String) {
// Must block
runBlocking {
store.edit { prefs ->
prefs[appHasCrashedKey] = true
prefs[crashDataKey] = crashData
}
}
}
suspend fun resetAppHasCrashed() {
store.edit { prefs ->
prefs[appHasCrashedKey] = false
}
}
fun appHasCrashed(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[appHasCrashedKey].orFalse()
}
}
fun crashInfo(): Flow<String> {
return store.data.map { prefs ->
prefs[crashDataKey].orEmpty()
}
}
suspend fun reset() {
store.edit { it.clear() }
}
}

65
features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/VectorUncaughtExceptionHandler.kt

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
package io.element.android.x.features.rageshake.crash
import android.content.Context
import android.os.Build
import io.element.android.x.core.data.tryOrNull
import timber.log.Timber
import java.io.PrintWriter
import java.io.StringWriter
class VectorUncaughtExceptionHandler(
context: Context
) : Thread.UncaughtExceptionHandler {
private val crashDataStore = CrashDataStore(context)
private var previousHandler: Thread.UncaughtExceptionHandler? = null
/**
* Activate this handler.
*/
fun activate() {
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(this)
}
/**
* An uncaught exception has been triggered.
*
* @param thread the thread
* @param throwable the throwable
*/
@Suppress("PrintStackTrace")
override fun uncaughtException(thread: Thread, throwable: Throwable) {
Timber.v("Uncaught exception: $throwable")
val bugDescription = buildString {
val appName = "ElementX"
// append(appName + " Build : " + versionCodeProvider.getVersionCode() + "\n")
append("$appName Version : 1.0") // ${versionProvider.getVersion(longFormat = true)}\n")
// append("SDK Version : ${Matrix.getSdkVersion()}\n")
append("Phone : " + Build.MODEL.trim() + " (" + Build.VERSION.INCREMENTAL + " " + Build.VERSION.RELEASE + " " + Build.VERSION.CODENAME + ")\n")
append("Memory statuses \n")
var freeSize = 0L
var totalSize = 0L
var usedSize = -1L
tryOrNull {
val info = Runtime.getRuntime()
freeSize = info.freeMemory()
totalSize = info.totalMemory()
usedSize = totalSize - freeSize
}
append("usedSize " + usedSize / 1048576L + " MB\n")
append("freeSize " + freeSize / 1048576L + " MB\n")
append("totalSize " + totalSize / 1048576L + " MB\n")
append("Thread: ")
append(thread.name)
append(", Exception: ")
val sw = StringWriter()
val pw = PrintWriter(sw, true)
throwable.printStackTrace(pw)
append(sw.buffer.toString())
}
Timber.e("FATAL EXCEPTION $bugDescription")
crashDataStore.setCrashData(bugDescription)
// Show the classical system popup
previousHandler?.uncaughtException(thread, throwable)
}
}

61
features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
package io.element.android.x.features.rageshake.crash.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.x.element.resources.R as ElementR
@Composable
fun CrashDetectionScreen(
viewModel: CrashDetectionViewModel = mavericksViewModel(),
onOpenBugReport: () -> Unit = { },
) {
val state: CrashDetectionViewState by viewModel.collectAsState()
LogCompositions(tag = "Crash", msg = "CrashDetectionScreen")
if (state.crashDetected) {
CrashDetectionContent(
state,
onYesClicked = {
viewModel.onYes()
onOpenBugReport()
},
onNoClicked = viewModel::onPopupDismissed,
onDismiss = viewModel::onPopupDismissed,
)
}
}
@Composable
fun CrashDetectionContent(
state: CrashDetectionViewState,
onNoClicked: () -> Unit = { },
onYesClicked: () -> Unit = { },
onDismiss: () -> Unit = { },
) {
ConfirmationDialog(
title = stringResource(id = ElementR.string.send_bug_report),
content = stringResource(id = ElementR.string.send_bug_report_app_crashed),
submitText = stringResource(id = ElementR.string.yes),
cancelText = stringResource(id = ElementR.string.no),
onCancelClicked = onNoClicked,
onSubmitClicked = onYesClicked,
onDismiss = onDismiss,
)
}
@Preview
@Composable
fun CrashDetectionContentPreview() {
ElementXTheme {
CrashDetectionContent(
state = CrashDetectionViewState()
)
}
}

49
features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
package io.element.android.x.features.rageshake.crash.ui
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.di.AppScope
import io.element.android.x.features.rageshake.crash.CrashDataStore
import kotlinx.coroutines.launch
@ContributesViewModel(AppScope::class)
class CrashDetectionViewModel @AssistedInject constructor(
@Assisted initialState: CrashDetectionViewState,
private val crashDataStore: CrashDataStore,
) : MavericksViewModel<CrashDetectionViewState>(initialState) {
companion object :
MavericksViewModelFactory<CrashDetectionViewModel, CrashDetectionViewState> by daggerMavericksViewModelFactory()
init {
observeDataStore()
}
private fun observeDataStore() {
viewModelScope.launch {
crashDataStore.appHasCrashed().collect { appHasCrashed ->
setState {
copy(
crashDetected = appHasCrashed
)
}
}
}
}
fun onYes() {
viewModelScope.launch {
crashDataStore.resetAppHasCrashed()
}
}
fun onPopupDismissed() {
viewModelScope.launch {
crashDataStore.reset()
}
}
}

7
features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
package io.element.android.x.features.rageshake.crash.ui
import com.airbnb.mvrx.MavericksState
data class CrashDetectionViewState(
val crashDetected: Boolean = false,
) : MavericksState

98
features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt

@ -0,0 +1,98 @@ @@ -0,0 +1,98 @@
package io.element.android.x.features.rageshake.detection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.Lifecycle
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.core.hardware.vibrate
import io.element.android.x.core.screenshot.ImageResult
import io.element.android.x.core.screenshot.screenshot
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.x.element.resources.R as ElementR
@Composable
fun RageshakeDetectionScreen(
viewModel: RageshakeDetectionViewModel = mavericksViewModel(),
onOpenBugReport: () -> Unit = { },
) {
val state: RageshakeDetectionViewState by viewModel.collectAsState()
LogCompositions(tag = "Rageshake", msg = "RageshakeDetectionScreen")
val context = LocalContext.current
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> viewModel.start()
Lifecycle.Event.ON_PAUSE -> viewModel.stop()
else -> Unit
}
}
when {
state.takeScreenshot -> TakeScreenshot(
onScreenshotTaken = viewModel::onScreenshotTaken
)
state.showDialog -> {
LaunchedEffect(key1 = "RS_diag") {
context.vibrate()
}
RageshakeDialogContent(
state,
onNoClicked = viewModel::onNo,
onDisableClicked = {
viewModel.onEnableClicked(false)
},
onYesClicked = {
onOpenBugReport()
viewModel.onYes()
}
)
}
}
}
@Composable
private fun TakeScreenshot(
onScreenshotTaken: (ImageResult) -> Unit = {}
) {
val view = LocalView.current
view.screenshot {
onScreenshotTaken(it)
}
}
@Composable
fun RageshakeDialogContent(
state: RageshakeDetectionViewState,
onNoClicked: () -> Unit = { },
onDisableClicked: () -> Unit = { },
onYesClicked: () -> Unit = { },
) {
ConfirmationDialog(
title = stringResource(id = ElementR.string.send_bug_report),
content = stringResource(id = ElementR.string.send_bug_report_alert_message),
thirdButtonText = stringResource(id = ElementR.string.action_disable),
submitText = stringResource(id = ElementR.string.yes),
cancelText = stringResource(id = ElementR.string.no),
onThirdButtonClicked = onDisableClicked,
onSubmitClicked = onYesClicked,
onDismiss = onNoClicked,
)
}
@Preview
@Composable
fun RageshakeDialogContentPreview() {
ElementXTheme {
RageshakeDialogContent(
state = RageshakeDetectionViewState()
)
}
}

174
features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt

@ -0,0 +1,174 @@ @@ -0,0 +1,174 @@
package io.element.android.x.features.rageshake.detection
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.core.screenshot.ImageResult
import io.element.android.x.di.AppScope
import io.element.android.x.features.rageshake.rageshake.RageShake
import io.element.android.x.features.rageshake.rageshake.RageshakeDataStore
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesViewModel(AppScope::class)
class RageshakeDetectionViewModel @AssistedInject constructor(
@Assisted initialState: RageshakeDetectionViewState,
private val rageshakeDataStore: RageshakeDataStore,
private val screenshotHolder: ScreenshotHolder,
private val rageShake: RageShake,
) : MavericksViewModel<RageshakeDetectionViewState>(initialState) {
companion object :
MavericksViewModelFactory<RageshakeDetectionViewModel, RageshakeDetectionViewState> by daggerMavericksViewModelFactory()
init {
setState {
copy(
isSupported = rageShake.isAvailable()
)
}
observeDataStore()
observeState()
}
private fun observeDataStore() {
viewModelScope.launch {
rageshakeDataStore.isEnabled().collect { isEnabled ->
setState {
copy(
isEnabled = isEnabled
)
}
}
}
viewModelScope.launch {
rageshakeDataStore.sensitivity().collect { sensitivity ->
setState {
copy(
sensitivity = sensitivity
)
}
}
}
}
private fun observeState() {
viewModelScope.launch {
stateFlow
.map {
it.isSupported &&
it.isEnabled &&
it.isStarted &&
!it.takeScreenshot &&
!it.showDialog
}
.distinctUntilChanged()
.collect(::handleRageShake)
}
viewModelScope.launch {
stateFlow
.map {
it.sensitivity
}
.distinctUntilChanged()
.collect {
rageShake.setSensitivity(it)
}
}
}
private fun handleRageShake(shouldStart: Boolean) {
if (shouldStart) {
withState {
rageShake.start(it.sensitivity)
}
rageShake.interceptor = {
setState {
copy(
takeScreenshot = true
)
}
}
} else {
rageShake.stop()
rageShake.interceptor = null
}
}
fun onScreenshotTaken(imageResult: ImageResult) {
viewModelScope.launch(Dispatchers.IO) {
screenshotHolder.reset()
when (imageResult) {
is ImageResult.Error -> {
Timber.e(imageResult.exception, "Unable to write screenshot")
}
is ImageResult.Success -> {
screenshotHolder.writeBitmap(imageResult.data)
}
}
setState {
copy(
takeScreenshot = false,
showDialog = true,
)
}
}
}
fun start() {
setState {
copy(isStarted = true)
}
}
private fun onPopupDismissed() {
setState {
copy(
showDialog = false
)
}
}
fun onNo() {
onPopupDismissed()
}
fun onYes() {
onPopupDismissed()
}
fun onEnableClicked(enabled: Boolean) {
viewModelScope.launch {
rageshakeDataStore.setIsEnabled(enabled)
}
if (!enabled) {
onPopupDismissed()
}
}
fun onSensitivityChange(sensitivity: Float) {
viewModelScope.launch {
rageshakeDataStore.setSensitivity(sensitivity)
}
rageShake.setSensitivity(sensitivity)
}
fun stop() {
setState {
copy(isStarted = false)
}
}
override fun onCleared() {
super.onCleared()
stop()
handleRageShake(false)
}
}

12
features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
package io.element.android.x.features.rageshake.detection
import com.airbnb.mvrx.MavericksState
data class RageshakeDetectionViewState(
val takeScreenshot: Boolean = false,
val showDialog: Boolean = false,
val isEnabled: Boolean = true,
val isStarted: Boolean = false,
val isSupported: Boolean = false,
val sensitivity: Float = 0.5f,
) : MavericksState

48
features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/LogFormatter.kt

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
package io.element.android.x.features.rageshake.logs
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.logging.Formatter
import java.util.logging.LogRecord
internal 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 val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss*SSSZZZZ", Locale.US)
private var mIsTimeZoneSet = false
}
}

158
features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/VectorFileLogger.kt

@ -0,0 +1,158 @@ @@ -0,0 +1,158 @@
package io.element.android.x.features.rageshake.logs
import android.content.Context
import android.util.Log
import io.element.android.x.core.data.tryOrNull
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.util.logging.FileHandler
import java.util.logging.Level
import java.util.logging.Logger
/**
* Will be planted in Timber.
*/
class VectorFileLogger(
context: Context,
// private val vectorPreferences: VectorPreferences
) : Timber.Tree() {
companion object {
fun getFromTimber(): VectorFileLogger {
return Timber.forest().filterIsInstance<VectorFileLogger>().first()
}
private const val SIZE_20MB = 20 * 1024 * 1024
private const val SIZE_50MB = 50 * 1024 * 1024
}
/*
private val maxLogSizeByte = if (vectorPreferences.labAllowedExtendedLogging()) SIZE_50MB else SIZE_20MB
private val logRotationCount = if (vectorPreferences.labAllowedExtendedLogging()) 15 else 7
*/
private val maxLogSizeByte = SIZE_20MB
private val logRotationCount = 7
private val logger = Logger.getLogger(context.packageName).apply {
tryOrNull {
useParentHandlers = false
level = Level.ALL
}
}
private val fileHandler: FileHandler?
private val cacheDirectory = File(context.cacheDir, "logs")
private var fileNamePrefix = "logs"
private val prioPrefixes = mapOf(
Log.VERBOSE to "V/ ",
Log.DEBUG to "D/ ",
Log.INFO to "I/ ",
Log.WARN to "W/ ",
Log.ERROR to "E/ ",
Log.ASSERT to "WTF/ "
)
init {
if (!cacheDirectory.exists()) {
cacheDirectory.mkdirs()
}
for (i in 0..15) {
val file = File(cacheDirectory, "elementLogs.${i}.txt")
tryOrNull { file.delete() }
}
fileHandler = tryOrNull("Failed to initialize FileLogger") {
FileHandler(
cacheDirectory.absolutePath + "/" + fileNamePrefix + ".%g.txt",
maxLogSizeByte,
logRotationCount
)
.also { it.formatter = LogFormatter() }
.also { logger.addHandler(it) }
}
}
fun reset() {
// Delete all files
getLogFiles().map {
tryOrNull { it.delete() }
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
fileHandler ?: return
GlobalScope.launch(Dispatchers.IO) {
if (skipLog(priority)) return@launch
if (t != null) {
logToFile(t)
}
logToFile(prioPrefixes[priority] ?: "$priority ", tag ?: "Tag", message)
}
}
private fun skipLog(priority: Int): Boolean {
/*
return if (vectorPreferences.labAllowedExtendedLogging()) {
false
} else {
// Exclude verbose logs
priority < Log.DEBUG
}
*/
// Exclude verbose logs
return priority < Log.DEBUG
}
/**
* Adds our own log files to the provided list of files.
*
* @return The list of files with logs.
*/
fun getLogFiles(): List<File> {
return tryOrNull("## getLogFiles() failed") {
fileHandler
?.flush()
?.let { 0 until logRotationCount }
?.mapNotNull { index ->
File(cacheDirectory, "$fileNamePrefix.${index}.txt")
.takeIf { it.exists() }
}
}
.orEmpty()
}
/**
* Log an Throwable.
*
* @param throwable the throwable to log
*/
private fun logToFile(throwable: Throwable?) {
throwable ?: return
val errors = StringWriter()
throwable.printStackTrace(PrintWriter(errors))
logger.info(errors.toString())
}
private fun logToFile(level: String, tag: String, content: String) {
val b = StringBuilder()
b.append(Thread.currentThread().id)
b.append(" ")
b.append(level)
b.append("/")
b.append(tag)
b.append(": ")
b.append(content)
logger.info(b.toString())
}
}

53
features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
package io.element.android.x.features.rageshake.preferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.components.preferences.PreferenceCategory
import io.element.android.x.designsystem.components.preferences.PreferenceSlide
import io.element.android.x.designsystem.components.preferences.PreferenceSwitch
import io.element.android.x.designsystem.components.preferences.PreferenceText
import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewModel
import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewState
import io.element.android.x.element.resources.R as ElementR
@Composable
fun RageshakePreferenceCategory() {
RageshakePreferenceContent()
}
@Composable
fun RageshakePreferenceContent(
viewModel: RageshakeDetectionViewModel = mavericksViewModel()
) {
val state: RageshakeDetectionViewState by viewModel.collectAsState()
PreferenceCategory(title = stringResource(id = ElementR.string.settings_rageshake)) {
if (state.isSupported) {
PreferenceSwitch(
title = stringResource(id = ElementR.string.send_bug_report_rage_shake),
isChecked = state.isEnabled,
onCheckedChange = viewModel::onEnableClicked
)
if (state.isEnabled) {
PreferenceSlide(
title = stringResource(id = ElementR.string.settings_rageshake_detection_threshold),
// summary = stringResource(id = ElementR.string.settings_rageshake_detection_threshold_summary),
value = state.sensitivity,
steps = 3 /* 5 possible values - steps are in ]0, 1[ */,
onValueChange = viewModel::onSensitivityChange
)
}
} else {
PreferenceText(title = "Rageshaking is not supported by your device")
}
}
}
@Composable
@Preview
fun RageshakePreferenceCategoryPreview() {
RageshakePreferenceCategory()
}

55
features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageShake.kt

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
package io.element.android.x.features.rageshake.rageshake
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import androidx.core.content.getSystemService
import com.squareup.seismic.ShakeDetector
import io.element.android.x.di.AppScope
import io.element.android.x.di.ApplicationContext
import io.element.android.x.di.SingleIn
import javax.inject.Inject
@SingleIn(AppScope::class)
class RageShake @Inject constructor(
@ApplicationContext context: Context,
) : ShakeDetector.Listener {
private var sensorManager = context.getSystemService<SensorManager>()
private var shakeDetector: ShakeDetector? = null
var interceptor: (() -> Unit)? = null
/**
* Check if the feature is available on this device.
*/
fun isAvailable(): Boolean {
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
}
fun start(sensitivity: Float) {
sensorManager?.let {
shakeDetector = ShakeDetector(this).apply {
start(it, SensorManager.SENSOR_DELAY_GAME)
}
setSensitivity(sensitivity)
}
}
fun stop() {
shakeDetector?.stop()
}
/**
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)]
*/
fun setSensitivity(sensitivity: Float) {
shakeDetector?.setSensitivity(
ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt()
)
}
override fun hearShake() {
interceptor?.invoke()
}
}

53
features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageshakeDataStore.kt

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
package io.element.android.x.features.rageshake.rageshake
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import io.element.android.x.core.bool.orTrue
import io.element.android.x.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake")
private val enabledKey = booleanPreferencesKey("enabled")
private val sensitivityKey = floatPreferencesKey("sensitivity")
class RageshakeDataStore @Inject constructor(
@ApplicationContext context: Context
) {
private val store = context.dataStore
fun isEnabled(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[enabledKey].orTrue()
}
}
suspend fun setIsEnabled(isEnabled: Boolean) {
store.edit { prefs ->
prefs[enabledKey] = isEnabled
}
}
fun sensitivity(): Flow<Float> {
return store.data.map { prefs ->
prefs[sensitivityKey] ?: 0.5f
}
}
suspend fun setSensitivity(sensitivity: Float) {
store.edit { prefs ->
prefs[sensitivityKey] = sensitivity
}
}
suspend fun reset() {
store.edit { it.clear() }
}
}

533
features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporter.kt

@ -0,0 +1,533 @@ @@ -0,0 +1,533 @@
package io.element.android.x.features.rageshake.reporter
import android.content.Context
import android.os.Build
import io.element.android.x.core.extensions.toOnOff
import io.element.android.x.core.file.compressFile
import io.element.android.x.core.mimetype.MimeTypes
import io.element.android.x.di.ApplicationContext
import io.element.android.x.features.rageshake.R
import io.element.android.x.features.rageshake.crash.CrashDataStore
import io.element.android.x.features.rageshake.logs.VectorFileLogger
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.util.Locale
import javax.inject.Inject
/**
* BugReporter creates and sends the bug reports.
*/
class BugReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
/*
private val activeSessionHolder: ActiveSessionHolder,
private val versionProvider: VersionProvider,
private val vectorPreferences: VectorPreferences,
private val vectorFileLogger: VectorFileLogger,
private val systemLocaleProvider: SystemLocaleProvider,
private val matrix: Matrix,
private val buildMeta: BuildMeta,
private val processInfo: ProcessInfo,
private val sdkIntProvider: BuildVersionSdkIntProvider,
private val vectorLocale: VectorLocaleProvider,
*/
) {
var inMultiWindowMode = false
companion object {
// filenames
private const val LOG_CAT_ERROR_FILENAME = "logcatError.log"
private const val LOG_CAT_FILENAME = "logcat.log"
private const val KEY_REQUESTS_FILENAME = "keyRequests.log"
private const val BUFFER_SIZE = 1024 * 1024 * 50
}
// 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
/*
val adapter = MatrixJsonParser.getMoshi()
.adapter<JsonDict>(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java))
*/
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(reportUrl: String?)
}
/**
* Send a bug report.
*
* @param reportType The report type (bug, suggestion, feedback)
* @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs
* @param withKeyRequestHistory true to include the crash logs
* @param withScreenshot true to include the screenshot
* @param theBugDescription the bug description
* @param serverVersion version of the server
* @param canContact true if the user opt in to be contacted directly
* @param customFields fields which will be sent with the report
* @param listener the listener
*/
fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType,
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withKeyRequestHistory: Boolean,
withScreenshot: Boolean,
theBugDescription: String,
serverVersion: String,
canContact: Boolean = false,
customFields: Map<String, String>? = null,
listener: IMXBugReportListener?
) {
// enumerate files to delete
val mBugReportFiles: MutableList<File> = ArrayList()
coroutineScope.launch {
var serverError: String? = null
var reportURL: String? = null
withContext(Dispatchers.IO) {
var bugDescription = theBugDescription
val crashCallStack = crashDataStore.crashInfo().first()
if (crashCallStack.isNotEmpty() && withCrashLogs) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
bugDescription += crashCallStack
}
val gzippedFiles = ArrayList<File>()
val vectorFileLogger = VectorFileLogger.getFromTimber()
if (withDevicesLogs) {
val files = vectorFileLogger.getLogFiles()
files.mapNotNullTo(gzippedFiles) { f ->
if (!mIsCancelled) {
compressFile(f)
} else {
null
}
}
}
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
val gzippedLogcat = saveLogCat(false)
if (null != gzippedLogcat) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(gzippedLogcat)
} else {
gzippedFiles.add(0, gzippedLogcat)
}
}
}
/*
activeSessionHolder.getSafeActiveSession()
?.takeIf { !mIsCancelled && withKeyRequestHistory }
?.cryptoService()
?.getGossipingEvents()
?.let { GossipingEventsSerializer().serialize(it) }
?.toByteArray()
?.let { rawByteArray ->
File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME)
.also {
it.outputStream()
.use { os -> os.write(rawByteArray) }
}
}
?.let { compressFile(it) }
?.let { gzippedFiles.add(it) }
*/
var deviceId = "undefined"
var userId = "undefined"
var olmVersion = "undefined"
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
userId = session.myUserId
deviceId = session.sessionParams.deviceId ?: "undefined"
olmVersion = session.cryptoService().getCryptoVersion(context, true)
}
*/
if (!mIsCancelled) {
val text = when (reportType) {
ReportType.BUG_REPORT -> "[ElementX] $bugDescription"
ReportType.SUGGESTION -> "[ElementX] [Suggestion] $bugDescription"
ReportType.SPACE_BETA_FEEDBACK -> "[ElementX] [spaces-feedback] $bugDescription"
ReportType.THREADS_BETA_FEEDBACK -> "[ElementX] [threads-feedback] $bugDescription"
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> bugDescription
}
// build the multi part request
val builder = BugReporterMultipartBody.Builder()
.addFormDataPart("text", text)
.addFormDataPart("app", rageShakeAppNameForReport(reportType))
// .addFormDataPart("user_agent", matrix.getUserAgent())
.addFormDataPart("user_id", userId)
.addFormDataPart("can_contact", canContact.toString())
.addFormDataPart("device_id", deviceId)
// .addFormDataPart("version", versionProvider.getVersion(longFormat = true))
// .addFormDataPart("branch_name", buildMeta.gitBranchName)
// .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion())
.addFormDataPart("olm_version", olmVersion)
.addFormDataPart("device", Build.MODEL.trim())
// .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff())
.addFormDataPart("multi_window", inMultiWindowMode.toOnOff())
//.addFormDataPart(
// "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " +
// Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME
//)
.addFormDataPart("locale", Locale.getDefault().toString())
//.addFormDataPart("app_language", vectorLocale.applicationLocale.toString())
//.addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString())
//.addFormDataPart("theme", ThemeUtils.getApplicationTheme(context))
.addFormDataPart("server_version", serverVersion)
.apply {
customFields?.forEach { (name, value) ->
addFormDataPart(name, value)
}
}
// add the gzipped files
for (file in gzippedFiles) {
builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
}
mBugReportFiles.addAll(gzippedFiles)
if (withScreenshot) {
screenshotHolder.getFile()?.let { screenshotFile ->
try {
builder.addFormDataPart(
"file",
screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())
)
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to write screenshot")
}
}
}
// add some github labels
// builder.addFormDataPart("label", buildMeta.versionName)
// builder.addFormDataPart("label", buildMeta.flavorDescription)
// builder.addFormDataPart("label", buildMeta.gitBranchName)
// Special for ElementX
builder.addFormDataPart("label", "[ElementX]")
// Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release".
// builder.addFormDataPart("label", BuildConfig.BUILD_TYPE)
when (reportType) {
ReportType.BUG_REPORT -> {
/* nop */
}
ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]")
ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback")
ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback")
ReportType.AUTO_UISI -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-recipient")
}
ReportType.AUTO_UISI_SENDER -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-sender")
}
}
if (crashCallStack.isNotEmpty() && withCrashLogs) {
builder.addFormDataPart("label", "crash")
}
val requestBody = builder.build()
// add a progress listener
requestBody.setWriteListener { totalWritten, contentLength ->
val percentage = if (-1L != contentLength) {
if (totalWritten > contentLength) {
100
} else {
(totalWritten * 100 / contentLength).toInt()
}
} else {
0
}
if (mIsCancelled && null != mBugReportCall) {
mBugReportCall!!.cancel()
}
Timber.v("## onWrite() : $percentage%")
try {
listener?.onProgress(percentage)
} catch (e: Exception) {
Timber.e(e, "## onProgress() : failed")
}
}
// 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")
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 (response?.body == null) {
serverError = "Failed with error $responseCode"
} else {
try {
val inputStream = response.body!!.byteStream()
serverError = inputStream.use {
buildString {
var ch = it.read()
while (ch != -1) {
append(ch.toChar())
ch = it.read()
}
}
}
// check if the error message
serverError?.let {
try {
val responseJSON = JSONObject(it)
serverError = responseJSON.getString("error")
} catch (e: JSONException) {
Timber.e(e, "doInBackground ; Json conversion failed")
}
}
// should never happen
if (null == serverError) {
serverError = "Failed with error $responseCode"
}
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to parse error")
}
}
} else {
/*
reportURL = response?.body?.string()?.let { stringBody ->
adapter.fromJson(stringBody)?.get("report_url")?.toString()
}
*/
}
}
}
withContext(Dispatchers.Main) {
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 == serverError) {
listener.onUploadSucceed(reportURL)
} else {
listener.onUploadFailed(serverError)
}
} catch (e: Exception) {
Timber.e(e, "## onPostExecute() : failed")
}
}
}
}
}
/**
* Send a bug report either with email or with Vector.
*/
/* TODO Remove
fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) {
screenshot = takeScreenshot(activity)
logDbInfo()
logProcessInfo()
logOtherInfo()
activity.startActivity(BugReportActivity.intent(activity, reportType))
}
*/
//private fun logOtherInfo() {
// Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState())
//}
//private fun logDbInfo() {
// val dbInfo = matrix.debugService().getDbUsageInfo()
// Timber.i(dbInfo)
//}
//private fun logProcessInfo() {
// val pInfo = processInfo.getInfo()
// Timber.i(pInfo)
//}
private fun rageShakeAppNameForReport(reportType: ReportType): String {
// As per https://github.com/matrix-org/rageshake
// app: Identifier for the application (eg 'riot-web').
// Should correspond to a mapping configured in the configuration file for github issue reporting to work.
// (see R.string.bug_report_url for configured RS server)
return context.getString(
when (reportType) {
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name
else -> R.string.bug_report_app_name
}
)
}
// ==============================================================================================================
// Logcat management
// ==============================================================================================================
/**
* Save the logcat.
*
* @param isErrorLogcat true to save the error logcat
* @return the file if the operation succeeds
*/
private fun saveLogCat(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 {
logCatErrFile.writer().use {
getLogCatError(it, isErrorLogcat)
}
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
}
try {
val separator = System.getProperty("line.separator")
logcatProc.inputStream
.reader()
.buffered(BUFFER_SIZE)
.forEachLine { line ->
streamWriter.append(line)
streamWriter.append(separator)
}
} catch (e: IOException) {
Timber.e(e, "getLog fails")
}
}
}

283
features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporterMultipartBody.java

@ -0,0 +1,283 @@ @@ -0,0 +1,283 @@
package io.element.android.x.features.rageshake.reporter;
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<Part> mParts;
private long mContentLength = -1L;
// listener
private WriteListener mWriteListener;
//
private List<Long> mContentLengthSize = null;
private BugReporterMultipartBody(ByteString boundary, List<Part> parts) {
mBoundary = boundary;
mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8());
mParts = Util.toImmutableList(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(value, null));
}
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<Part> 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);
}
}
}

10
features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/ReportType.kt

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
package io.element.android.x.features.rageshake.reporter
enum class ReportType {
BUG_REPORT,
SUGGESTION,
SPACE_BETA_FEEDBACK,
THREADS_BETA_FEEDBACK,
AUTO_UISI,
AUTO_UISI_SENDER,
}

24
features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
package io.element.android.x.features.rageshake.screenshot
import android.content.Context
import android.graphics.Bitmap
import io.element.android.x.core.bitmap.writeBitmap
import io.element.android.x.di.ApplicationContext
import java.io.File
import javax.inject.Inject
class ScreenshotHolder @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val file = File(context.filesDir, "screenshot.png")
fun writeBitmap(data: Bitmap) {
file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85)
}
fun getFile() = file.takeIf { it.exists() && it.length() > 0 }
fun reset() {
file.delete()
}
}

9
features/rageshake/src/main/res/values/strings.xml

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Rageshake configuration -->
<string name="bug_report_url" translatable="false">https://riot.im/bugreports/submit</string>
<string name="bug_report_app_name" translatable="false">riot-android</string>
<string name="bug_report_auto_uisi_app_name" translatable="false">element-auto-uisi</string>
</resources>

16
features/rageshake/src/test/java/io/element/android/x/features/login/ExampleUnitTest.kt

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
package io.element.android.x.features.login
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

1
features/roomlist/build.gradle.kts

@ -19,6 +19,7 @@ dependencies { @@ -19,6 +19,7 @@ dependencies {
implementation(project(":libraries:core"))
implementation(project(":libraries:matrix"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:elementresources"))
implementation(libs.mavericks.compose)
implementation(libs.datetime)
implementation(libs.accompanist.placeholder)

10
features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt

@ -43,7 +43,9 @@ import kotlinx.collections.immutable.toImmutableList @@ -43,7 +43,9 @@ import kotlinx.collections.immutable.toImmutableList
fun RoomListScreen(
viewModel: RoomListViewModel = mavericksViewModel(),
onSuccessLogout: () -> Unit = { },
onRoomClicked: (RoomId) -> Unit = { }
onRoomClicked: (RoomId) -> Unit = { },
onOpenRageShake: () -> Unit = { },
onOpenSettings: () -> Unit = { },
) {
val logoutAction by viewModel.collectAsState(RoomListViewState::logoutAction)
val filter by viewModel.collectAsState(RoomListViewState::filter)
@ -59,6 +61,8 @@ fun RoomListScreen( @@ -59,6 +61,8 @@ fun RoomListScreen(
matrixUser = matrixUser(),
onRoomClicked = onRoomClicked,
onLogoutClicked = viewModel::logout,
onOpenSettings = onOpenSettings,
onOpenRageShake = onOpenRageShake,
isLoginOut = logoutAction is Loading,
filter = filter,
onFilterChanged = viewModel::filterRoom,
@ -76,6 +80,8 @@ fun RoomListContent( @@ -76,6 +80,8 @@ fun RoomListContent(
onRoomClicked: (RoomId) -> Unit = {},
onFilterChanged: (String) -> Unit = {},
onLogoutClicked: () -> Unit = {},
onOpenSettings: () -> Unit = {},
onOpenRageShake: () -> Unit = { },
onScrollOver: (IntRange) -> Unit = {},
) {
fun onRoomClicked(room: RoomListRoomSummary) {
@ -113,6 +119,8 @@ fun RoomListContent( @@ -113,6 +119,8 @@ fun RoomListContent(
filter = filter,
onFilterChanged = onFilterChanged,
onLogoutClicked = onLogoutClicked,
onOpenSettings = onOpenSettings,
onOpenRageShake = onOpenRageShake,
scrollBehavior = scrollBehavior
)
},

28
features/roomlist/src/main/java/io/element/android/x/features/roomlist/components/RoomListTopBar.kt

@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxWidth @@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.ContentAlpha
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -32,6 +34,7 @@ import androidx.compose.ui.focus.FocusRequester @@ -32,6 +34,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@ -40,6 +43,7 @@ import io.element.android.x.core.compose.textFieldState @@ -40,6 +43,7 @@ import io.element.android.x.core.compose.textFieldState
import io.element.android.x.designsystem.components.avatar.Avatar
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.x.features.roomlist.model.MatrixUser
import io.element.android.x.element.resources.R as ElementR
@Composable
fun RoomListTopBar(
@ -47,6 +51,8 @@ fun RoomListTopBar( @@ -47,6 +51,8 @@ fun RoomListTopBar(
filter: String,
onFilterChanged: (String) -> Unit,
onLogoutClicked: () -> Unit,
onOpenSettings: () -> Unit,
onOpenRageShake: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior
) {
LogCompositions(tag = "RoomListScreen", msg = "TopBar")
@ -72,6 +78,8 @@ fun RoomListTopBar( @@ -72,6 +78,8 @@ fun RoomListTopBar(
DefaultRoomListTopBar(
matrixUser = matrixUser,
onLogoutClicked = onLogoutClicked,
onOpenSettings = onOpenSettings,
onOpenRageShake = onOpenRageShake,
onSearchClicked = {
searchWidgetStateIsOpened = true
},
@ -161,6 +169,8 @@ fun SearchRoomListTopBar( @@ -161,6 +169,8 @@ fun SearchRoomListTopBar(
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
onLogoutClicked: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenSettings: () -> Unit,
onSearchClicked: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior
) {
@ -187,23 +197,37 @@ private fun DefaultRoomListTopBar( @@ -187,23 +197,37 @@ private fun DefaultRoomListTopBar(
) {
Icon(Icons.Default.Search, contentDescription = "search")
}
IconButton(
onClick = onOpenRageShake
) {
Icon(Icons.Default.BugReport, contentDescription = stringResource(id = ElementR.string.send_bug_report))
}
IconButton(
onClick = { openDialog.value = true }
) {
Icon(Icons.Default.Logout, contentDescription = "logout")
}
IconButton(
onClick = onOpenSettings
) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
},
scrollBehavior = scrollBehavior,
)
// Log out confirmation dialog
if (openDialog.value) {
ConfirmationDialog(
isDisplayed = openDialog.value,
title = "Log out",
content = "Do you confirm you want to log out?",
submitText = "Log out",
onSubmitClicked = onLogoutClicked,
onSubmitClicked = {
openDialog.value = false
onLogoutClicked()
},
onDismiss = {
openDialog.value = false
}
)
}
}

7
gradle/libs.versions.toml

@ -49,6 +49,7 @@ serialization_json = "1.4.1" @@ -49,6 +49,7 @@ serialization_json = "1.4.1"
showkase = "1.0.0-beta14"
compose_destinations = "1.7.23-beta"
jsoup = "1.15.3"
seismic = "1.0.3"
# DI
dagger = "2.43"
@ -72,6 +73,9 @@ androidx_datastore_datastore = { module = "androidx.datastore:datastore", versio @@ -72,6 +73,9 @@ androidx_datastore_datastore = { module = "androidx.datastore:datastore", versio
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx_lifecycle_compose = { module = "androidx.lifecycle:compose", version.ref = "lifecycle" }
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx_lifecycle_viewmodel_compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" }
androidx_fragment = {module = "androidx.fragment:fragment-ktx", version.ref = "fragment"}
@ -94,6 +98,9 @@ accompanist_pager = { module = "com.google.accompanist:accompanist-pager", versi @@ -94,6 +98,9 @@ accompanist_pager = { module = "com.google.accompanist:accompanist-pager", versi
accompanist_pagerindicator = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
# Libraries
squareup_seismic = { module = "com.squareup:seismic", version.ref = "seismic" }
# Test
test_junit = { module = "junit:junit", version.ref = "test_junit" }
test_runner = { module = "androidx.test:runner", version.ref = "test_runner" }

6
libraries/core/src/main/AndroidManifest.xml

@ -1,2 +1,6 @@ @@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>

11
libraries/core/src/main/java/io/element/android/x/core/bitmap/Bitmap.kt

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
package io.element.android.x.core.bitmap
import android.graphics.Bitmap
import java.io.File
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
outputStream().use { out ->
bitmap.compress(format, quality, out)
out.flush()
}
}

5
libraries/core/src/main/java/io/element/android/x/core/bool/Booleans.kt

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
package io.element.android.x.core.bool
fun Boolean?.orTrue() = this ?: true
fun Boolean?.orFalse() = this ?: false

55
libraries/core/src/main/java/io/element/android/x/core/extensions/BasicExtensions.kt

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
package io.element.android.x.core.extensions
import android.util.Patterns
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)
/**
* Check if a CharSequence is an email.
*/
fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches()
// fun CharSequence.isMatrixId() = MatrixPatterns.isUserId(this.toString())
/**
* Return empty CharSequence if the CharSequence is null.
*/
fun CharSequence?.orEmpty() = this ?: ""
/**
* Check if a CharSequence is a phone number.
*/
/*
fun CharSequence.isMsisdn(): Boolean {
return try {
PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null)
true
} catch (e: NumberParseException) {
false
}
}
*/
/**
* Useful to append a String at the end of a filename but before the extension if any
* Ex:
* - "file.txt".insertBeforeLast("_foo") will return "file_foo.txt"
* - "file".insertBeforeLast("_foo") will return "file_foo"
* - "fi.le.txt".insertBeforeLast("_foo") will return "fi.le_foo.txt"
* - null.insertBeforeLast("_foo") will return "_foo".
*/
fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String {
if (this == null) return insert
val idx = lastIndexOf(delimiter)
return if (idx == -1) {
this + insert
} else {
replaceRange(idx, idx, insert)
}
}
inline fun <reified R> Any?.takeAs(): R? {
return takeIf { it is R } as R?
}

38
libraries/core/src/main/java/io/element/android/x/core/file/compressFile.kt

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
package io.element.android.x.core.file
import timber.log.Timber
import java.io.File
import java.util.zip.GZIPOutputStream
/**
* GZip a file.
*
* @param file the input file
* @return the gzipped file
*/
fun compressFile(file: File): File? {
Timber.v("## compressFile() : compress ${file.name}")
val dstFile = file.resolveSibling(file.name + ".gz")
if (dstFile.exists()) {
dstFile.delete()
}
return try {
GZIPOutputStream(dstFile.outputStream()).use { gos ->
file.inputStream().use {
it.copyTo(gos, 2048)
}
}
Timber.v("## compressFile() : ${file.length()} compressed to ${dstFile.length()} bytes")
dstFile
} catch (e: Exception) {
Timber.e(e, "## compressFile() failed")
null
} catch (oom: OutOfMemoryError) {
Timber.e(oom, "## compressFile() failed")
null
}
}

33
libraries/core/src/main/java/io/element/android/x/core/hardware/vibrator.kt

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
/*
* Copyright 2020 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 io.element.android.x.core.hardware
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.getSystemService
fun Context.vibrate(durationMillis: Long = 100) {
val vibrator = getSystemService<Vibrator>() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(durationMillis)
}
}

47
libraries/core/src/main/java/io/element/android/x/core/mimetype/MimeTypes.kt

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 io.element.android.x.core.mimetype
import io.element.android.x.core.bool.orFalse
// The Android SDK does not provide constant for mime type, add some of them here
object MimeTypes {
const val Any: String = "*/*"
const val OctetStream = "application/octet-stream"
const val Apk = "application/vnd.android.package-archive"
const val Images = "image/*"
const val Png = "image/png"
const val BadJpg = "image/jpg"
const val Jpeg = "image/jpeg"
const val Gif = "image/gif"
const val Ogg = "audio/ogg"
const val PlainText = "text/plain"
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse()
fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse()
fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse()
fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse()
fun String?.isMimeTypeFile() = this?.startsWith("file/").orFalse()
fun String?.isMimeTypeText() = this?.startsWith("text/").orFalse()
fun String?.isMimeTypeAny() = this?.startsWith("*/").orFalse()
}

57
libraries/core/src/main/java/io/element/android/x/core/screenshot/Screenshot.kt

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
package io.element.android.x.core.screenshot
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.View
fun View.screenshot(bitmapCallback: (ImageResult) -> Unit) {
try {
val handler = Handler(Looper.getMainLooper())
val bitmap = Bitmap.createBitmap(
width,
height,
Bitmap.Config.ARGB_8888,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PixelCopy.request(
(this.context as Activity).window,
clipBounds,
bitmap,
{
when (it) {
PixelCopy.SUCCESS -> {
bitmapCallback.invoke(ImageResult.Success(bitmap))
}
else -> {
bitmapCallback.invoke(ImageResult.Error(Exception(it.toString())))
}
}
},
handler
)
} else {
handler.post {
val canvas = Canvas(bitmap)
.apply {
translate(-clipBounds.left.toFloat(), -clipBounds.top.toFloat())
}
this.draw(canvas)
canvas.setBitmap(null)
bitmapCallback.invoke(ImageResult.Success(bitmap))
}
}
} catch (e: Exception) {
bitmapCallback.invoke(ImageResult.Error(e))
}
}
sealed interface ImageResult {
data class Error(val exception: Exception) : ImageResult
data class Success(val data: Bitmap) : ImageResult
}

1
libraries/designsystem/build.gradle.kts

@ -10,6 +10,7 @@ android { @@ -10,6 +10,7 @@ android {
// Should not be there, but this is a POC
implementation(libs.coil.compose)
implementation(libs.accompanist.systemui)
implementation(project(":libraries:elementresources"))
ksp(libs.showkase.processor)
}
}

40
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/LabelledCheckbox.kt

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
package io.element.android.x.designsystem.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun LabelledCheckbox(
checked: Boolean,
text: String,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
Text(text = text)
}
}
@Preview
@Composable
fun LabelledCheckboxPreview() {
LabelledCheckbox(
checked = true,
text = "Some text",
)
}

43
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ConfirmationDialog.kt

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
package io.element.android.x.designsystem.components.dialogs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -9,21 +10,25 @@ import androidx.compose.material3.Button @@ -9,21 +10,25 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.x.element.resources.R as ElementR
@Composable
fun ConfirmationDialog(
isDisplayed: Boolean,
title: String,
content: String,
modifier: Modifier = Modifier,
submitText: String = "OK",
cancelText: String = "Cancel",
submitText: String = stringResource(id = ElementR.string.ok),
cancelText: String = stringResource(id = ElementR.string.action_cancel),
thirdButtonText: String? = null,
onSubmitClicked: () -> Unit = {},
onCancelClicked: () -> Unit = {},
onThirdButtonClicked: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
if (!isDisplayed) return
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
@ -33,23 +38,32 @@ fun ConfirmationDialog( @@ -33,23 +38,32 @@ fun ConfirmationDialog(
text = {
Text(content)
},
confirmButton = {
dismissButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Column {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDismiss()
onSubmitClicked()
onCancelClicked()
}) {
Text(cancelText)
}
if (thirdButtonText != null) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onThirdButtonClicked()
}) {
Text(thirdButtonText)
}
}
) {
Text(submitText)
}
}
},
dismissButton = {
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
@ -57,12 +71,13 @@ fun ConfirmationDialog( @@ -57,12 +71,13 @@ fun ConfirmationDialog(
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDismiss()
}) {
Text(cancelText)
onSubmitClicked()
}
) {
Text(submitText)
}
}
},
)
}
@ -70,8 +85,8 @@ fun ConfirmationDialog( @@ -70,8 +85,8 @@ fun ConfirmationDialog(
@Preview
fun ConfirmationDialogPreview() {
ConfirmationDialog(
isDisplayed = true,
title = "Title",
content = "Content",
thirdButtonText = "Disable"
)
}

58
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ErrorDialog.kt

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
package io.element.android.x.designsystem.components.dialogs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.x.element.resources.R as ElementR
@Composable
fun ErrorDialog(
content: String,
modifier: Modifier = Modifier,
title: String = stringResource(id = ElementR.string.dialog_title_error),
submitText: String = stringResource(id = ElementR.string.ok),
onDismiss: () -> Unit = {},
) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = {
Text(text = title)
},
text = {
Text(content)
},
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDismiss()
}
) {
Text(submitText)
}
}
},
)
}
@Composable
@Preview
fun ErrorDialogPreview() {
ErrorDialog(
content = "Content",
)
}

5
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/Config.kt

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
package io.element.android.x.designsystem.components.preferences
import androidx.compose.ui.unit.dp
internal val preferenceMinHeight = 80.dp

43
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceCategory.kt

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
package io.element.android.x.designsystem.components.preferences
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun PreferenceCategory(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
style = MaterialTheme.typography.titleSmall,
text = title
)
content()
}
}
@Composable
@Preview(showBackground = false)
fun PreferenceCategoryPreview() {
PreferenceCategory(
title = "Category title",
) {
PreferenceTextPreview()
PreferenceSwitchPreview()
PreferenceSlidePreview()
}
}

92
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
package io.element.android.x.designsystem.components.preferences
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PreferenceScreen(
title: String,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
PreferenceTopAppBar(
title = title,
onBackPressed = onBackPressed,
)
},
content = {
Column(
modifier = Modifier.padding(it)
) {
content()
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PreferenceTopAppBar(
title: String,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
@Composable
@Preview(showBackground = false)
fun PreferenceScreenPreview() {
PreferenceScreen(
title = "Preference screen"
) {
PreferenceCategoryPreview()
}
}

65
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSlide.kt

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
package io.element.android.x.designsystem.components.preferences
import androidx.annotation.FloatRange
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun PreferenceSlide(
title: String,
@FloatRange(0.0, 1.0)
value: Float,
modifier: Modifier = Modifier,
summary: String? = null,
steps: Int = 0,
onValueChange: (Float) -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = preferenceMinHeight),
contentAlignment = Alignment.CenterStart
) {
Column(
modifier = modifier
.fillMaxWidth(),
) {
Text(
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge,
text = title
)
summary?.let {
Text(
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
text = summary
)
}
Slider(
value = value,
steps = steps,
onValueChange = onValueChange
)
}
}
}
@Composable
@Preview(showBackground = false)
fun PreferenceSlidePreview() {
PreferenceSlide(
title = "Slide",
summary = "Summary",
value = 0.75F
)
}

51
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSwitch.kt

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
package io.element.android.x.designsystem.components.preferences
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun PreferenceSwitch(
title: String,
isChecked: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = preferenceMinHeight),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = modifier
.weight(1f),
style = MaterialTheme.typography.bodyLarge,
text = title
)
Checkbox(checked = isChecked, onCheckedChange = onCheckedChange)
}
}
}
@Composable
@Preview(showBackground = false)
fun PreferenceSwitchPreview() {
PreferenceSwitch(
title = "Switch",
isChecked = true
)
}

43
libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceText.kt

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
package io.element.android.x.designsystem.components.preferences
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun PreferenceText(
title: String,
// TODO subtitle
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = preferenceMinHeight)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Text(
modifier = Modifier
.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge,
text = title
)
}
}
@Composable
@Preview(showBackground = false)
fun PreferenceTextPreview() {
PreferenceText(
title = "Title",
)
}

2
settings.gradle.kts

@ -27,6 +27,8 @@ include(":features:onboarding") @@ -27,6 +27,8 @@ include(":features:onboarding")
include(":features:login")
include(":features:roomlist")
include(":features:messages")
include(":features:rageshake")
include(":features:preferences")
include(":libraries:designsystem")
include(":libraries:di")
include(":anvilannotations")

Loading…
Cancel
Save