Add rageskahe module

This commit is contained in:
Benoit Marty 2022-12-21 14:36:57 +01:00
parent 0644a5822f
commit 3f7a83c519
64 changed files with 3191 additions and 35 deletions

View file

@ -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"))

View file

@ -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<AppBindings>().sessionComponentsOwner()
AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java)
initializeComponent(TimberInitializer::class.java)
initializeComponent(MatrixInitializer::class.java)
initializeComponent(CoilInitializer::class.java)
initializeComponent(MavericksInitializer::class.java)

View file

@ -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")

View file

@ -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
)
}

View file

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

View file

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

1
features/preferences/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -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)
}

View file

21
features/preferences/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -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)
}
}

View file

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

View file

@ -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()
}

View file

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

1
features/rageshake/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -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)
}

View file

21
features/rageshake/proguard-rules.pro vendored Normal file
View file

@ -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

View file

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

View file

@ -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
)
}
}

View file

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

View file

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

View file

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

View file

@ -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)
}
}

View file

@ -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()
)
}
}

View file

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

View file

@ -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

View file

@ -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()
)
}
}

View file

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

View file

@ -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

View file

@ -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
}
}

View file

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

View file

@ -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()
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,283 @@
package io.element.android.x.features.rageshake.reporter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import okhttp3.Headers;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.internal.Util;
import okio.Buffer;
import okio.BufferedSink;
import okio.ByteString;
// simplified version of MultipartBody (OkHttp 3.6.0)
public class BugReporterMultipartBody extends RequestBody {
/**
* Listener
*/
public interface WriteListener {
/**
* Upload listener
*
* @param totalWritten total written bytes
* @param contentLength content length
*/
void onWrite(long totalWritten, long contentLength);
}
private static final MediaType FORM = MediaType.parse("multipart/form-data");
private static final byte[] COLONSPACE = {':', ' '};
private static final byte[] CRLF = {'\r', '\n'};
private static final byte[] DASHDASH = {'-', '-'};
private final ByteString mBoundary;
private final MediaType mContentType;
private final List<Part> mParts;
private long mContentLength = -1L;
// listener
private WriteListener mWriteListener;
//
private List<Long> mContentLengthSize = null;
private BugReporterMultipartBody(ByteString boundary, List<Part> parts) {
mBoundary = boundary;
mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8());
mParts = Util.toImmutableList(parts);
}
@Override
public MediaType contentType() {
return mContentType;
}
@Override
public long contentLength() throws IOException {
long result = mContentLength;
if (result != -1L) return result;
return mContentLength = writeOrCountBytes(null, true);
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
writeOrCountBytes(sink, false);
}
/**
* Set the listener
*
* @param listener the
*/
public void setWriteListener(WriteListener listener) {
mWriteListener = listener;
}
/**
* Warn the listener that some bytes have been written
*
* @param totalWrittenBytes the total written bytes
*/
private void onWrite(long totalWrittenBytes) {
if ((null != mWriteListener) && (mContentLength > 0)) {
mWriteListener.onWrite(totalWrittenBytes, mContentLength);
}
}
/**
* Either writes this request to {@code sink} or measures its content length. We have one method
* do double-duty to make sure the counting and content are consistent, particularly when it comes
* to awkward operations like measuring the encoded length of header strings, or the
* length-in-digits of an encoded integer.
*/
private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException {
long byteCount = 0L;
Buffer byteCountBuffer = null;
if (countBytes) {
sink = byteCountBuffer = new Buffer();
mContentLengthSize = new ArrayList<>();
}
for (int p = 0, partCount = mParts.size(); p < partCount; p++) {
Part part = mParts.get(p);
Headers headers = part.headers;
RequestBody body = part.body;
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(CRLF);
if (headers != null) {
for (int h = 0, headerCount = headers.size(); h < headerCount; h++) {
sink.writeUtf8(headers.name(h))
.write(COLONSPACE)
.writeUtf8(headers.value(h))
.write(CRLF);
}
}
MediaType contentType = body.contentType();
if (contentType != null) {
sink.writeUtf8("Content-Type: ")
.writeUtf8(contentType.toString())
.write(CRLF);
}
int contentLength = (int) body.contentLength();
if (contentLength != -1) {
sink.writeUtf8("Content-Length: ")
.writeUtf8(contentLength + "")
.write(CRLF);
} else if (countBytes) {
// We can't measure the body's size without the sizes of its components.
byteCountBuffer.clear();
return -1L;
}
sink.write(CRLF);
if (countBytes) {
byteCount += contentLength;
mContentLengthSize.add(byteCount);
} else {
body.writeTo(sink);
// warn the listener of upload progress
// sink.buffer().size() does not give the right value
// assume that some data are popped
if ((null != mContentLengthSize) && (p < mContentLengthSize.size())) {
onWrite(mContentLengthSize.get(p));
}
}
sink.write(CRLF);
}
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(DASHDASH);
sink.write(CRLF);
if (countBytes) {
byteCount += byteCountBuffer.size();
byteCountBuffer.clear();
}
return byteCount;
}
private static void appendQuotedString(StringBuilder target, String key) {
target.append('"');
for (int i = 0, len = key.length(); i < len; i++) {
char ch = key.charAt(i);
switch (ch) {
case '\n':
target.append("%0A");
break;
case '\r':
target.append("%0D");
break;
case '"':
target.append("%22");
break;
default:
target.append(ch);
break;
}
}
target.append('"');
}
public static final class Part {
public static Part create(Headers headers, RequestBody body) {
if (body == null) {
throw new NullPointerException("body == null");
}
if (headers != null && headers.get("Content-Type") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Type");
}
if (headers != null && headers.get("Content-Length") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Length");
}
return new Part(headers, body);
}
public static Part createFormData(String name, String value) {
return createFormData(name, null, RequestBody.create(value, null));
}
public static Part createFormData(String name, String filename, RequestBody body) {
if (name == null) {
throw new NullPointerException("name == null");
}
StringBuilder disposition = new StringBuilder("form-data; name=");
appendQuotedString(disposition, name);
if (filename != null) {
disposition.append("; filename=");
appendQuotedString(disposition, filename);
}
return create(Headers.of("Content-Disposition", disposition.toString()), body);
}
final Headers headers;
final RequestBody body;
private Part(Headers headers, RequestBody body) {
this.headers = headers;
this.body = body;
}
}
public static final class Builder {
private final ByteString boundary;
private final List<Part> parts = new ArrayList<>();
public Builder() {
this(UUID.randomUUID().toString());
}
public Builder(String boundary) {
this.boundary = ByteString.encodeUtf8(boundary);
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String value) {
return addPart(Part.createFormData(name, value));
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String filename, RequestBody body) {
return addPart(Part.createFormData(name, filename, body));
}
/**
* Add a part to the body.
*/
public Builder addPart(Part part) {
if (part == null) throw new NullPointerException("part == null");
parts.add(part);
return this;
}
/**
* Assemble the specified parts into a request body.
*/
public BugReporterMultipartBody build() {
if (parts.isEmpty()) {
throw new IllegalStateException("Multipart body must have at least one part.");
}
return new BugReporterMultipartBody(boundary, parts);
}
}
}

View file

@ -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,
}

View file

@ -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()
}
}

View file

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

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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
)
},

View file

@ -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
}
)
}
}

View file

@ -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" }

View file

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

View file

@ -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()
}
}

View file

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

View file

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

View file

@ -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
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.core.hardware
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.getSystemService
fun Context.vibrate(durationMillis: Long = 100) {
val vibrator = getSystemService<Vibrator>() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(durationMillis)
}
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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",
)
}

View file

@ -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,6 +38,31 @@ fun ConfirmationDialog(
text = {
Text(content)
},
dismissButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Column {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onCancelClicked()
}) {
Text(cancelText)
}
if (thirdButtonText != null) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onThirdButtonClicked()
}) {
Text(thirdButtonText)
}
}
}
}
},
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
@ -41,7 +71,6 @@ fun ConfirmationDialog(
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDismiss()
onSubmitClicked()
}
) {
@ -49,20 +78,6 @@ fun ConfirmationDialog(
}
}
},
dismissButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDismiss()
}) {
Text(cancelText)
}
}
}
)
}
@ -70,8 +85,8 @@ fun ConfirmationDialog(
@Preview
fun ConfirmationDialogPreview() {
ConfirmationDialog(
isDisplayed = true,
title = "Title",
content = "Content",
thirdButtonText = "Disable"
)
}

View file

@ -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",
)
}

View file

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

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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",
)
}

View file

@ -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")