Add rageskahe module
This commit is contained in:
parent
0644a5822f
commit
3f7a83c519
64 changed files with 3191 additions and 35 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
1
features/preferences/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
29
features/preferences/build.gradle.kts
Normal file
29
features/preferences/build.gradle.kts
Normal 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)
|
||||
}
|
||||
0
features/preferences/consumer-rules.pro
Normal file
0
features/preferences/consumer-rules.pro
Normal file
21
features/preferences/proguard-rules.pro
vendored
Normal file
21
features/preferences/proguard-rules.pro
vendored
Normal 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
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package io.element.android.x.features.preferences
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("io.element.android.x.features.preferences.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
4
features/preferences/src/main/AndroidManifest.xml
Normal file
4
features/preferences/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
1
features/rageshake/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
30
features/rageshake/build.gradle.kts
Normal file
30
features/rageshake/build.gradle.kts
Normal 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)
|
||||
}
|
||||
0
features/rageshake/consumer-rules.pro
Normal file
0
features/rageshake/consumer-rules.pro
Normal file
21
features/rageshake/proguard-rules.pro
vendored
Normal file
21
features/rageshake/proguard-rules.pro
vendored
Normal 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
|
||||
4
features/rageshake/src/main/AndroidManifest.xml
Normal file
4
features/rageshake/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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("")
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package io.element.android.x.features.rageshake.screenshot
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import io.element.android.x.core.bitmap.writeBitmap
|
||||
import io.element.android.x.di.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScreenshotHolder @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val file = File(context.filesDir, "screenshot.png")
|
||||
|
||||
fun writeBitmap(data: Bitmap) {
|
||||
file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85)
|
||||
}
|
||||
|
||||
fun getFile() = file.takeIf { it.exists() && it.length() > 0 }
|
||||
|
||||
fun reset() {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
9
features/rageshake/src/main/res/values/strings.xml
Normal file
9
features/rageshake/src/main/res/values/strings.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package io.element.android.x.core.bool
|
||||
|
||||
fun Boolean?.orTrue() = this ?: true
|
||||
|
||||
fun Boolean?.orFalse() = this ?: false
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package io.element.android.x.designsystem.components.preferences
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal val preferenceMinHeight = 80.dp
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ include(":features:onboarding")
|
|||
include(":features:login")
|
||||
include(":features:roomlist")
|
||||
include(":features:messages")
|
||||
include(":features:rageshake")
|
||||
include(":features:preferences")
|
||||
include(":libraries:designsystem")
|
||||
include(":libraries:di")
|
||||
include(":anvilannotations")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue