diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b26039ea8..35f7f83250 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/app/src/main/java/io/element/android/x/ElementXApplication.kt b/app/src/main/java/io/element/android/x/ElementXApplication.kt index fc6c1b8b8c..51a2071ea3 100644 --- a/app/src/main/java/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/java/io/element/android/x/ElementXApplication.kt @@ -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 { appComponent = DaggerAppComponent.factory().create(applicationContext) sessionComponentsOwner = bindings().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) diff --git a/app/src/main/java/io/element/android/x/MainActivity.kt b/app/src/main/java/io/element/android/x/MainActivity.kt index c852bea599..2c75a7aeef 100644 --- a/app/src/main/java/io/element/android/x/MainActivity.kt +++ b/app/src/main/java/io/element/android/x/MainActivity.kt @@ -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() { } 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() { 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") diff --git a/app/src/main/java/io/element/android/x/Navigation.kt b/app/src/main/java/io/element/android/x/Navigation.kt index 3bf0b95d27..6d0ec42774 100644 --- a/app/src/main/java/io/element/android/x/Navigation.kt +++ b/app/src/main/java/io/element/android/x/Navigation.kt @@ -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) { 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) { inclusive = true } } + }, + // Tmp entry point + onOpenRageShake = { + navigator.navigate(BugReportScreenNavigationDestination) } ) } @@ -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 + ) +} + diff --git a/app/src/main/java/io/element/android/x/initializer/CrashInitializer.kt b/app/src/main/java/io/element/android/x/initializer/CrashInitializer.kt new file mode 100644 index 0000000000..5f3ce99198 --- /dev/null +++ b/app/src/main/java/io/element/android/x/initializer/CrashInitializer.kt @@ -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 { + + override fun create(context: Context) { + VectorUncaughtExceptionHandler(context).activate() + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/app/src/main/java/io/element/android/x/initializer/TimberInitializer.kt b/app/src/main/java/io/element/android/x/initializer/TimberInitializer.kt index 6286d72ab0..9a0c852a19 100644 --- a/app/src/main/java/io/element/android/x/initializer/TimberInitializer.kt +++ b/app/src/main/java/io/element/android/x/initializer/TimberInitializer.kt @@ -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 { override fun create(context: Context) { - Timber.plant(Timber.DebugTree()) + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + Timber.plant(VectorFileLogger(context)) } - override fun dependencies(): List>> = - listOf(TimberInitializer::class.java) + override fun dependencies(): List>> = emptyList() } diff --git a/features/preferences/.gitignore b/features/preferences/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/preferences/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/preferences/build.gradle.kts b/features/preferences/build.gradle.kts new file mode 100644 index 0000000000..d5234773fa --- /dev/null +++ b/features/preferences/build.gradle.kts @@ -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) +} diff --git a/features/preferences/consumer-rules.pro b/features/preferences/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/preferences/proguard-rules.pro b/features/preferences/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/features/preferences/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/features/preferences/src/androidTest/java/io/element/android/x/features/preferences/ExampleInstrumentedTest.kt b/features/preferences/src/androidTest/java/io/element/android/x/features/preferences/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..29fe83a540 --- /dev/null +++ b/features/preferences/src/androidTest/java/io/element/android/x/features/preferences/ExampleInstrumentedTest.kt @@ -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) + } +} diff --git a/features/preferences/src/main/AndroidManifest.xml b/features/preferences/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e100076157 --- /dev/null +++ b/features/preferences/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt b/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt new file mode 100644 index 0000000000..e535a01e1a --- /dev/null +++ b/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt @@ -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() +} diff --git a/features/preferences/src/test/java/io/element/android/x/features/preferences/ExampleUnitTest.kt b/features/preferences/src/test/java/io/element/android/x/features/preferences/ExampleUnitTest.kt new file mode 100644 index 0000000000..13ce2c8586 --- /dev/null +++ b/features/preferences/src/test/java/io/element/android/x/features/preferences/ExampleUnitTest.kt @@ -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) + } +} diff --git a/features/rageshake/.gitignore b/features/rageshake/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/rageshake/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/rageshake/build.gradle.kts b/features/rageshake/build.gradle.kts new file mode 100644 index 0000000000..0791377690 --- /dev/null +++ b/features/rageshake/build.gradle.kts @@ -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) +} diff --git a/features/rageshake/consumer-rules.pro b/features/rageshake/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/rageshake/proguard-rules.pro b/features/rageshake/proguard-rules.pro new file mode 100644 index 0000000000..ff59496d81 --- /dev/null +++ b/features/rageshake/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/features/rageshake/src/main/AndroidManifest.xml b/features/rageshake/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e100076157 --- /dev/null +++ b/features/rageshake/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt new file mode 100644 index 0000000000..94006fac8e --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt @@ -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 + ) + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt new file mode 100644 index 0000000000..f10ba4794d --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt @@ -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(initialState) { + + companion object : + MavericksViewModelFactory 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) } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt new file mode 100644 index 0000000000..fd4420794b --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt @@ -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 = Uninitialized, +) : MavericksState { + val submitEnabled = + formState.description.length > 10 && sending !is Loading +} + +data class BugReportFormState( + val description: String, +) { + companion object { + val Default = BugReportFormState("") + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/CrashDataStore.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/CrashDataStore.kt new file mode 100644 index 0000000000..b97515d21c --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/CrashDataStore.kt @@ -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 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 { + return store.data.map { prefs -> + prefs[appHasCrashedKey].orFalse() + } + } + + fun crashInfo(): Flow { + return store.data.map { prefs -> + prefs[crashDataKey].orEmpty() + } + } + + suspend fun reset() { + store.edit { it.clear() } + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/VectorUncaughtExceptionHandler.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/VectorUncaughtExceptionHandler.kt new file mode 100644 index 0000000000..ba904c75fd --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/VectorUncaughtExceptionHandler.kt @@ -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) + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt new file mode 100644 index 0000000000..0503c155a4 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt @@ -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() + ) + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt new file mode 100644 index 0000000000..4a8ce2e2ab --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt @@ -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(initialState) { + + companion object : + MavericksViewModelFactory 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() + } + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt new file mode 100644 index 0000000000..3ebef0e474 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt @@ -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 diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt new file mode 100644 index 0000000000..853281d762 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt @@ -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() + ) + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt new file mode 100644 index 0000000000..f10e6d07fb --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt @@ -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(initialState) { + + companion object : + MavericksViewModelFactory 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) + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt new file mode 100644 index 0000000000..12fca14362 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt @@ -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 diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/LogFormatter.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/LogFormatter.kt new file mode 100644 index 0000000000..dcd8b41f64 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/LogFormatter.kt @@ -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 + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/VectorFileLogger.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/VectorFileLogger.kt new file mode 100644 index 0000000000..560c1cbdd2 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/logs/VectorFileLogger.kt @@ -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().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 { + 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()) + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt new file mode 100644 index 0000000000..1a4cf1fa7d --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt @@ -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() +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageShake.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageShake.kt new file mode 100644 index 0000000000..dac268b1c8 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageShake.kt @@ -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() + 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() + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageshakeDataStore.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageshakeDataStore.kt new file mode 100644 index 0000000000..a98fdf860f --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/rageshake/RageshakeDataStore.kt @@ -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 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 { + return store.data.map { prefs -> + prefs[enabledKey].orTrue() + } + } + + suspend fun setIsEnabled(isEnabled: Boolean) { + store.edit { prefs -> + prefs[enabledKey] = isEnabled + } + } + + fun sensitivity(): Flow { + 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() } + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporter.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporter.kt new file mode 100755 index 0000000000..2319266ced --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporter.kt @@ -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(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? = null, + listener: IMXBugReportListener? + ) { + // enumerate files to delete + val mBugReportFiles: MutableList = 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() + + 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") + } + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporterMultipartBody.java b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporterMultipartBody.java new file mode 100755 index 0000000000..7c408ff0f6 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/BugReporterMultipartBody.java @@ -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 mParts; + private long mContentLength = -1L; + + // listener + private WriteListener mWriteListener; + + // + private List mContentLengthSize = null; + + private BugReporterMultipartBody(ByteString boundary, List parts) { + mBoundary = boundary; + mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8()); + mParts = Util.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 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); + } + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/ReportType.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/ReportType.kt new file mode 100644 index 0000000000..09dc59ac66 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/reporter/ReportType.kt @@ -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, +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt new file mode 100644 index 0000000000..ad10b1aa17 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt @@ -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() + } +} diff --git a/features/rageshake/src/main/res/values/strings.xml b/features/rageshake/src/main/res/values/strings.xml new file mode 100644 index 0000000000..17d364656a --- /dev/null +++ b/features/rageshake/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + + + https://riot.im/bugreports/submit + riot-android + element-auto-uisi + + diff --git a/features/rageshake/src/test/java/io/element/android/x/features/login/ExampleUnitTest.kt b/features/rageshake/src/test/java/io/element/android/x/features/login/ExampleUnitTest.kt new file mode 100644 index 0000000000..f1768db5bc --- /dev/null +++ b/features/rageshake/src/test/java/io/element/android/x/features/login/ExampleUnitTest.kt @@ -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) + } +} diff --git a/features/roomlist/build.gradle.kts b/features/roomlist/build.gradle.kts index 6eb99fa7a3..857ed58b62 100644 --- a/features/roomlist/build.gradle.kts +++ b/features/roomlist/build.gradle.kts @@ -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) diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt index dff4871efe..c3cee0456d 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt @@ -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( 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( 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( filter = filter, onFilterChanged = onFilterChanged, onLogoutClicked = onLogoutClicked, + onOpenSettings = onOpenSettings, + onOpenRageShake = onOpenRageShake, scrollBehavior = scrollBehavior ) }, diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/components/RoomListTopBar.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/components/RoomListTopBar.kt index 667357cf73..0dc45a8061 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/components/RoomListTopBar.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/components/RoomListTopBar.kt @@ -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 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 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( filter: String, onFilterChanged: (String) -> Unit, onLogoutClicked: () -> Unit, + onOpenSettings: () -> Unit, + onOpenRageShake: () -> Unit, scrollBehavior: TopAppBarScrollBehavior ) { LogCompositions(tag = "RoomListScreen", msg = "TopBar") @@ -72,6 +78,8 @@ fun RoomListTopBar( DefaultRoomListTopBar( matrixUser = matrixUser, onLogoutClicked = onLogoutClicked, + onOpenSettings = onOpenSettings, + onOpenRageShake = onOpenRageShake, onSearchClicked = { searchWidgetStateIsOpened = true }, @@ -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( ) { 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 - ConfirmationDialog( - isDisplayed = openDialog.value, - title = "Log out", - content = "Do you confirm you want to log out?", - submitText = "Log out", - onSubmitClicked = onLogoutClicked, - onDismiss = { - openDialog.value = false - } - ) + if (openDialog.value) { + ConfirmationDialog( + title = "Log out", + content = "Do you confirm you want to log out?", + submitText = "Log out", + onSubmitClicked = { + openDialog.value = false + onLogoutClicked() + }, + onDismiss = { + openDialog.value = false + } + ) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b62ade122..c2faf58e20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 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 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" } diff --git a/libraries/core/src/main/AndroidManifest.xml b/libraries/core/src/main/AndroidManifest.xml index 568741e54f..563d4258ac 100644 --- a/libraries/core/src/main/AndroidManifest.xml +++ b/libraries/core/src/main/AndroidManifest.xml @@ -1,2 +1,6 @@ - \ No newline at end of file + + + + + diff --git a/libraries/core/src/main/java/io/element/android/x/core/bitmap/Bitmap.kt b/libraries/core/src/main/java/io/element/android/x/core/bitmap/Bitmap.kt new file mode 100644 index 0000000000..ac5f5a947e --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/bitmap/Bitmap.kt @@ -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() + } +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/bool/Booleans.kt b/libraries/core/src/main/java/io/element/android/x/core/bool/Booleans.kt new file mode 100644 index 0000000000..917f14e140 --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/bool/Booleans.kt @@ -0,0 +1,5 @@ +package io.element.android.x.core.bool + +fun Boolean?.orTrue() = this ?: true + +fun Boolean?.orFalse() = this ?: false diff --git a/libraries/core/src/main/java/io/element/android/x/core/extensions/BasicExtensions.kt b/libraries/core/src/main/java/io/element/android/x/core/extensions/BasicExtensions.kt new file mode 100644 index 0000000000..b35b5bd503 --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/extensions/BasicExtensions.kt @@ -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.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 Any?.takeAs(): R? { + return takeIf { it is R } as R? +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/file/compressFile.kt b/libraries/core/src/main/java/io/element/android/x/core/file/compressFile.kt new file mode 100644 index 0000000000..2842c43aff --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/file/compressFile.kt @@ -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 + } +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/hardware/vibrator.kt b/libraries/core/src/main/java/io/element/android/x/core/hardware/vibrator.kt new file mode 100644 index 0000000000..2860bffb10 --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/hardware/vibrator.kt @@ -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() ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(durationMillis) + } +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/mimetype/MimeTypes.kt b/libraries/core/src/main/java/io/element/android/x/core/mimetype/MimeTypes.kt new file mode 100644 index 0000000000..aca56c23cb --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/mimetype/MimeTypes.kt @@ -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() +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/screenshot/Screenshot.kt b/libraries/core/src/main/java/io/element/android/x/core/screenshot/Screenshot.kt new file mode 100644 index 0000000000..3ac3980630 --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/screenshot/Screenshot.kt @@ -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 +} diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 2fb20b4f6c..042cea371d 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -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) } } diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/LabelledCheckbox.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/LabelledCheckbox.kt new file mode 100644 index 0000000000..07170bfd34 --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/LabelledCheckbox.kt @@ -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", + ) +} diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ConfirmationDialog.kt index fb43ccd3ce..fcddc3af70 100644 --- a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ConfirmationDialog.kt @@ -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 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( text = { Text(content) }, - confirmButton = { + dismissButton = { Row( modifier = Modifier.padding(all = 8.dp), horizontalArrangement = Arrangement.Center ) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - onDismiss() - onSubmitClicked() + Column { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + 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( Button( modifier = Modifier.fillMaxWidth(), onClick = { - onDismiss() - }) { - Text(cancelText) + onSubmitClicked() + } + ) { + Text(submitText) } } - } + }, ) } @@ -70,8 +85,8 @@ fun ConfirmationDialog( @Preview fun ConfirmationDialogPreview() { ConfirmationDialog( - isDisplayed = true, title = "Title", content = "Content", + thirdButtonText = "Disable" ) } diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ErrorDialog.kt new file mode 100644 index 0000000000..f5d7811f02 --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/dialogs/ErrorDialog.kt @@ -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", + ) +} diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/Config.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/Config.kt new file mode 100644 index 0000000000..28a225168b --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/Config.kt @@ -0,0 +1,5 @@ +package io.element.android.x.designsystem.components.preferences + +import androidx.compose.ui.unit.dp + +internal val preferenceMinHeight = 80.dp diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceCategory.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceCategory.kt new file mode 100644 index 0000000000..a65b917ecd --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceCategory.kt @@ -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() + } +} diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt new file mode 100644 index 0000000000..e03e72ba30 --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt @@ -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() + } +} diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSlide.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSlide.kt new file mode 100644 index 0000000000..bb03bdb697 --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSlide.kt @@ -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 + ) +} diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSwitch.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSwitch.kt new file mode 100644 index 0000000000..5cc821a875 --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceSwitch.kt @@ -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 + ) +} diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceText.kt new file mode 100644 index 0000000000..099022146d --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceText.kt @@ -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", + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a135a4269a..096b474a4d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")