Merge pull request #21 from vector-im/feature/bma/rageshake
Add rageskahe module
This commit is contained in:
commit
01470c9aa7
79 changed files with 3499 additions and 89 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -82,3 +82,4 @@ lint/generated/
|
||||||
lint/outputs/
|
lint/outputs/
|
||||||
lint/tmp/
|
lint/tmp/
|
||||||
# lint/reports/
|
# lint/reports/
|
||||||
|
/.idea/deploymentTargetDropDown.xml
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,11 @@ dependencies {
|
||||||
implementation(project(":libraries:core"))
|
implementation(project(":libraries:core"))
|
||||||
implementation(project(":features:onboarding"))
|
implementation(project(":features:onboarding"))
|
||||||
implementation(project(":features:login"))
|
implementation(project(":features:login"))
|
||||||
|
implementation(project(":features:logout"))
|
||||||
implementation(project(":features:roomlist"))
|
implementation(project(":features:roomlist"))
|
||||||
implementation(project(":features:messages"))
|
implementation(project(":features:messages"))
|
||||||
|
implementation(project(":features:rageshake"))
|
||||||
|
implementation(project(":features:preferences"))
|
||||||
implementation(project(":libraries:di"))
|
implementation(project(":libraries:di"))
|
||||||
implementation(project(":anvilannotations"))
|
implementation(project(":anvilannotations"))
|
||||||
anvil(project(":anvilcodegen"))
|
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.DaggerAppComponent
|
||||||
import io.element.android.x.di.SessionComponentsOwner
|
import io.element.android.x.di.SessionComponentsOwner
|
||||||
import io.element.android.x.initializer.CoilInitializer
|
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.MatrixInitializer
|
||||||
import io.element.android.x.initializer.MavericksInitializer
|
import io.element.android.x.initializer.MavericksInitializer
|
||||||
|
import io.element.android.x.initializer.TimberInitializer
|
||||||
|
|
||||||
class ElementXApplication : Application(), DaggerComponentOwner {
|
class ElementXApplication : Application(), DaggerComponentOwner {
|
||||||
|
|
||||||
|
|
@ -25,6 +27,8 @@ class ElementXApplication : Application(), DaggerComponentOwner {
|
||||||
appComponent = DaggerAppComponent.factory().create(applicationContext)
|
appComponent = DaggerAppComponent.factory().create(applicationContext)
|
||||||
sessionComponentsOwner = bindings<AppBindings>().sessionComponentsOwner()
|
sessionComponentsOwner = bindings<AppBindings>().sessionComponentsOwner()
|
||||||
AppInitializer.getInstance(this).apply {
|
AppInitializer.getInstance(this).apply {
|
||||||
|
initializeComponent(CrashInitializer::class.java)
|
||||||
|
initializeComponent(TimberInitializer::class.java)
|
||||||
initializeComponent(MatrixInitializer::class.java)
|
initializeComponent(MatrixInitializer::class.java)
|
||||||
initializeComponent(CoilInitializer::class.java)
|
initializeComponent(CoilInitializer::class.java)
|
||||||
initializeComponent(MavericksInitializer::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.core.compose.OnLifecycleEvent
|
||||||
import io.element.android.x.designsystem.ElementXTheme
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
|
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 kotlinx.coroutines.runBlocking
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -99,6 +102,7 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
|
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
|
||||||
|
var isBugReportVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||||
MainContent(
|
MainContent(
|
||||||
|
|
@ -109,6 +113,22 @@ class MainActivity : ComponentActivity() {
|
||||||
onCloseClicked = { isShowkaseButtonVisible = false },
|
onCloseClicked = { isShowkaseButtonVisible = false },
|
||||||
onClick = { startActivity(Showkase.getBrowserIntent(this@MainActivity)) }
|
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 ->
|
OnLifecycleEvent { _, event ->
|
||||||
Timber.v("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.DestinationsNavigator
|
||||||
import com.ramcosta.composedestinations.navigation.popUpTo
|
import com.ramcosta.composedestinations.navigation.popUpTo
|
||||||
import io.element.android.x.core.di.bindings
|
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.ChangeServerScreenNavigationDestination
|
||||||
import io.element.android.x.destinations.LoginScreenNavigationDestination
|
import io.element.android.x.destinations.LoginScreenNavigationDestination
|
||||||
import io.element.android.x.destinations.MessagesScreenNavigationDestination
|
import io.element.android.x.destinations.MessagesScreenNavigationDestination
|
||||||
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
|
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.destinations.RoomListScreenNavigationDestination
|
||||||
import io.element.android.x.di.AppBindings
|
import io.element.android.x.di.AppBindings
|
||||||
import io.element.android.x.features.login.LoginScreen
|
import io.element.android.x.features.login.LoginScreen
|
||||||
import io.element.android.x.features.login.changeserver.ChangeServerScreen
|
import io.element.android.x.features.login.changeserver.ChangeServerScreen
|
||||||
import io.element.android.x.features.messages.MessagesScreen
|
import io.element.android.x.features.messages.MessagesScreen
|
||||||
import io.element.android.x.features.onboarding.OnBoardingScreen
|
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.features.roomlist.RoomListScreen
|
||||||
import io.element.android.x.matrix.core.RoomId
|
import io.element.android.x.matrix.core.RoomId
|
||||||
|
|
||||||
|
|
@ -67,19 +71,13 @@ fun ChangeServerScreenNavigation(navigator: DestinationsNavigator) {
|
||||||
@Destination
|
@Destination
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
|
fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
|
||||||
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
|
|
||||||
RoomListScreen(
|
RoomListScreen(
|
||||||
onRoomClicked = { roomId: RoomId ->
|
onRoomClicked = { roomId: RoomId ->
|
||||||
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
|
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
|
||||||
},
|
},
|
||||||
onSuccessLogout = {
|
onOpenSettings = {
|
||||||
sessionComponentsOwner.releaseActiveSession()
|
navigator.navigate(PreferencesScreenNavigationDestination())
|
||||||
navigator.navigate(OnBoardingScreenNavigationDestination) {
|
},
|
||||||
popUpTo(RoomListScreenNavigationDestination) {
|
|
||||||
inclusive = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,3 +86,31 @@ fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
|
||||||
fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) {
|
fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) {
|
||||||
MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp)
|
MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Destination
|
||||||
|
@Composable
|
||||||
|
fun BugReportScreenNavigation(navigator: DestinationsNavigator) {
|
||||||
|
BugReportScreen(
|
||||||
|
onDone = navigator::popBackStack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Destination
|
||||||
|
@Composable
|
||||||
|
fun PreferencesScreenNavigation(navigator: DestinationsNavigator) {
|
||||||
|
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
|
||||||
|
PreferencesScreen(
|
||||||
|
onBackPressed = navigator::navigateUp,
|
||||||
|
onOpenRageShake = {
|
||||||
|
navigator.navigate(BugReportScreenNavigationDestination)
|
||||||
|
},
|
||||||
|
onSuccessLogout = {
|
||||||
|
sessionComponentsOwner.releaseActiveSession()
|
||||||
|
navigator.navigate(OnBoardingScreenNavigationDestination) {
|
||||||
|
popUpTo(RoomListScreenNavigationDestination) {
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 android.content.Context
|
||||||
import androidx.startup.Initializer
|
import androidx.startup.Initializer
|
||||||
|
import io.element.android.x.BuildConfig
|
||||||
|
import io.element.android.x.features.rageshake.logs.VectorFileLogger
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class TimberInitializer : Initializer<Unit> {
|
class TimberInitializer : Initializer<Unit> {
|
||||||
|
|
||||||
override fun create(context: Context) {
|
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<*>>> =
|
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||||
listOf(TimberInitializer::class.java)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
features/logout/.gitignore
vendored
Normal file
1
features/logout/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
27
features/logout/build.gradle.kts
Normal file
27
features/logout/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.x.features.logout"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":anvilannotations"))
|
||||||
|
anvil(project(":anvilcodegen"))
|
||||||
|
implementation(project(":libraries:di"))
|
||||||
|
implementation(project(":libraries:core"))
|
||||||
|
implementation(project(":libraries:matrix"))
|
||||||
|
implementation(project(":libraries:designsystem"))
|
||||||
|
implementation(project(":libraries:elementresources"))
|
||||||
|
implementation(libs.mavericks.compose)
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
androidTestImplementation(libs.test.junitext)
|
||||||
|
}
|
||||||
0
features/logout/consumer-rules.pro
Normal file
0
features/logout/consumer-rules.pro
Normal file
21
features/logout/proguard-rules.pro
vendored
Normal file
21
features/logout/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
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package io.element.android.x.features.logout
|
||||||
|
|
||||||
|
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.login.test", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
features/logout/src/main/AndroidManifest.xml
Normal file
4
features/logout/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
package io.element.android.x.features.logout
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Logout
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.airbnb.mvrx.Loading
|
||||||
|
import com.airbnb.mvrx.Success
|
||||||
|
import com.airbnb.mvrx.compose.collectAsState
|
||||||
|
import com.airbnb.mvrx.compose.mavericksViewModel
|
||||||
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
|
import io.element.android.x.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog
|
||||||
|
import io.element.android.x.designsystem.components.preferences.PreferenceCategory
|
||||||
|
import io.element.android.x.designsystem.components.preferences.PreferenceText
|
||||||
|
import io.element.android.x.element.resources.R as ElementR
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LogoutPreference(
|
||||||
|
viewModel: LogoutViewModel = mavericksViewModel(),
|
||||||
|
onSuccessLogout: () -> Unit = { },
|
||||||
|
) {
|
||||||
|
val state: LogoutViewState by viewModel.collectAsState()
|
||||||
|
if (state.logoutAction is Success) {
|
||||||
|
onSuccessLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val openDialog = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LogoutPreferenceContent(
|
||||||
|
onClick = {
|
||||||
|
openDialog.value = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Log out confirmation dialog
|
||||||
|
if (openDialog.value) {
|
||||||
|
ConfirmationDialog(
|
||||||
|
title = stringResource(id = ElementR.string.action_sign_out),
|
||||||
|
content = stringResource(id = ElementR.string.action_sign_out_confirmation_simple),
|
||||||
|
submitText = stringResource(id = ElementR.string.action_sign_out),
|
||||||
|
onCancelClicked = {
|
||||||
|
openDialog.value = false
|
||||||
|
},
|
||||||
|
onSubmitClicked = {
|
||||||
|
openDialog.value = false
|
||||||
|
viewModel.logout()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
openDialog.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.logoutAction is Loading) {
|
||||||
|
ProgressDialog(text = "Login out...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LogoutPreferenceContent(
|
||||||
|
onClick: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
PreferenceCategory(title = stringResource(id = ElementR.string.settings_general_title)) {
|
||||||
|
PreferenceText(
|
||||||
|
title = stringResource(id = ElementR.string.action_sign_out),
|
||||||
|
icon = Icons.Default.Logout,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun LogoutContentPreview() {
|
||||||
|
ElementXTheme(darkTheme = false) {
|
||||||
|
LogoutPreference()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package io.element.android.x.features.logout
|
||||||
|
|
||||||
|
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.SessionScope
|
||||||
|
import io.element.android.x.matrix.MatrixClient
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@ContributesViewModel(SessionScope::class)
|
||||||
|
class LogoutViewModel @AssistedInject constructor(
|
||||||
|
private val client: MatrixClient,
|
||||||
|
@Assisted initialState: LogoutViewState
|
||||||
|
) : MavericksViewModel<LogoutViewState>(initialState) {
|
||||||
|
|
||||||
|
companion object : MavericksViewModelFactory<LogoutViewModel, LogoutViewState> by daggerMavericksViewModelFactory()
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
suspend {
|
||||||
|
client.logout()
|
||||||
|
}.execute {
|
||||||
|
copy(logoutAction = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package io.element.android.x.features.logout
|
||||||
|
|
||||||
|
import com.airbnb.mvrx.Async
|
||||||
|
import com.airbnb.mvrx.MavericksState
|
||||||
|
import com.airbnb.mvrx.Uninitialized
|
||||||
|
|
||||||
|
data class LogoutViewState(
|
||||||
|
val logoutAction: Async<Unit> = Uninitialized,
|
||||||
|
) : MavericksState
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package io.element.android.x.features.logout
|
||||||
|
|
||||||
|
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/preferences/.gitignore
vendored
Normal file
1
features/preferences/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
30
features/preferences/build.gradle.kts
Normal file
30
features/preferences/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.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(":features:logout"))
|
||||||
|
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,48 @@
|
||||||
|
package io.element.android.x.features.preferences
|
||||||
|
|
||||||
|
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.element.resources.R as ElementR
|
||||||
|
import io.element.android.x.features.logout.LogoutPreference
|
||||||
|
import io.element.android.x.features.rageshake.preferences.RageshakePreferences
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferencesScreen(
|
||||||
|
onBackPressed: () -> Unit = {},
|
||||||
|
onOpenRageShake: () -> Unit = {},
|
||||||
|
onSuccessLogout: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
// TODO Hierarchy!
|
||||||
|
// Include pref from other modules
|
||||||
|
PreferencesContent(
|
||||||
|
onBackPressed = onBackPressed,
|
||||||
|
onOpenRageShake = onOpenRageShake,
|
||||||
|
onSuccessLogout = onSuccessLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferencesContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onBackPressed: () -> Unit = {},
|
||||||
|
onOpenRageShake: () -> Unit = {},
|
||||||
|
onSuccessLogout: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
PreferenceScreen(
|
||||||
|
modifier = modifier,
|
||||||
|
onBackPressed = onBackPressed,
|
||||||
|
title = stringResource(id = ElementR.string.settings)
|
||||||
|
) {
|
||||||
|
LogoutPreference(onSuccessLogout = onSuccessLogout)
|
||||||
|
RageshakePreferences(onOpenRageShake = onOpenRageShake)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,232 @@
|
||||||
|
@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.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.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 javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
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 java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
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 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
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,71 @@
|
||||||
|
package io.element.android.x.features.rageshake.preferences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.element.resources.R as ElementR
|
||||||
|
import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewModel
|
||||||
|
import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RageshakePreferences(
|
||||||
|
onOpenRageShake: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
RageshakePreferencesContent(
|
||||||
|
onOpenRageShake = onOpenRageShake,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RageshakePreferencesContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RageshakeDetectionViewModel = mavericksViewModel(),
|
||||||
|
onOpenRageShake: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val state: RageshakeDetectionViewState by viewModel.collectAsState()
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
PreferenceCategory(title = stringResource(id = ElementR.string.send_bug_report)) {
|
||||||
|
PreferenceText(
|
||||||
|
title = stringResource(id = ElementR.string.send_bug_report),
|
||||||
|
icon = Icons.Default.BugReport,
|
||||||
|
onClick = onOpenRageShake
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
PreferenceSlide(
|
||||||
|
title = stringResource(id = ElementR.string.settings_rageshake_detection_threshold),
|
||||||
|
// summary = stringResource(id = ElementR.string.settings_rageshake_detection_threshold_summary),
|
||||||
|
value = state.sensitivity,
|
||||||
|
enabled = state.isEnabled,
|
||||||
|
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 RageshakePreferencePreview() {
|
||||||
|
RageshakePreferences()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
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 javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
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,534 @@
|
||||||
|
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 java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 coroutineScope The coroutine scope
|
||||||
|
* @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:core"))
|
||||||
implementation(project(":libraries:matrix"))
|
implementation(project(":libraries:matrix"))
|
||||||
implementation(project(":libraries:designsystem"))
|
implementation(project(":libraries:designsystem"))
|
||||||
|
implementation(project(":libraries:elementresources"))
|
||||||
implementation(libs.mavericks.compose)
|
implementation(libs.mavericks.compose)
|
||||||
implementation(libs.datetime)
|
implementation(libs.datetime)
|
||||||
implementation(libs.accompanist.placeholder)
|
implementation(libs.accompanist.placeholder)
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,10 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Velocity
|
import androidx.compose.ui.unit.Velocity
|
||||||
import com.airbnb.mvrx.Loading
|
|
||||||
import com.airbnb.mvrx.Success
|
|
||||||
import com.airbnb.mvrx.compose.collectAsState
|
import com.airbnb.mvrx.compose.collectAsState
|
||||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
import com.airbnb.mvrx.compose.mavericksViewModel
|
||||||
import io.element.android.x.core.compose.LogCompositions
|
import io.element.android.x.core.compose.LogCompositions
|
||||||
import io.element.android.x.designsystem.ElementXTheme
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
import io.element.android.x.designsystem.components.ProgressDialog
|
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.x.features.roomlist.components.RoomListTopBar
|
import io.element.android.x.features.roomlist.components.RoomListTopBar
|
||||||
import io.element.android.x.features.roomlist.components.RoomSummaryRow
|
import io.element.android.x.features.roomlist.components.RoomSummaryRow
|
||||||
|
|
@ -42,15 +39,10 @@ import kotlinx.collections.immutable.toImmutableList
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListScreen(
|
fun RoomListScreen(
|
||||||
viewModel: RoomListViewModel = mavericksViewModel(),
|
viewModel: RoomListViewModel = mavericksViewModel(),
|
||||||
onSuccessLogout: () -> Unit = { },
|
onRoomClicked: (RoomId) -> Unit = { },
|
||||||
onRoomClicked: (RoomId) -> Unit = { }
|
onOpenSettings: () -> Unit = { },
|
||||||
) {
|
) {
|
||||||
val logoutAction by viewModel.collectAsState(RoomListViewState::logoutAction)
|
|
||||||
val filter by viewModel.collectAsState(RoomListViewState::filter)
|
val filter by viewModel.collectAsState(RoomListViewState::filter)
|
||||||
if (logoutAction is Success) {
|
|
||||||
onSuccessLogout()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
LogCompositions(tag = "RoomListScreen", msg = "Root")
|
LogCompositions(tag = "RoomListScreen", msg = "Root")
|
||||||
val roomSummaries by viewModel.collectAsState(RoomListViewState::rooms)
|
val roomSummaries by viewModel.collectAsState(RoomListViewState::rooms)
|
||||||
val matrixUser by viewModel.collectAsState(RoomListViewState::user)
|
val matrixUser by viewModel.collectAsState(RoomListViewState::user)
|
||||||
|
|
@ -58,8 +50,7 @@ fun RoomListScreen(
|
||||||
roomSummaries = roomSummaries().orEmpty().toImmutableList(),
|
roomSummaries = roomSummaries().orEmpty().toImmutableList(),
|
||||||
matrixUser = matrixUser(),
|
matrixUser = matrixUser(),
|
||||||
onRoomClicked = onRoomClicked,
|
onRoomClicked = onRoomClicked,
|
||||||
onLogoutClicked = viewModel::logout,
|
onOpenSettings = onOpenSettings,
|
||||||
isLoginOut = logoutAction is Loading,
|
|
||||||
filter = filter,
|
filter = filter,
|
||||||
onFilterChanged = viewModel::filterRoom,
|
onFilterChanged = viewModel::filterRoom,
|
||||||
onScrollOver = viewModel::updateVisibleRange
|
onScrollOver = viewModel::updateVisibleRange
|
||||||
|
|
@ -71,11 +62,10 @@ fun RoomListContent(
|
||||||
roomSummaries: ImmutableList<RoomListRoomSummary>,
|
roomSummaries: ImmutableList<RoomListRoomSummary>,
|
||||||
matrixUser: MatrixUser?,
|
matrixUser: MatrixUser?,
|
||||||
filter: String,
|
filter: String,
|
||||||
isLoginOut: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onRoomClicked: (RoomId) -> Unit = {},
|
onRoomClicked: (RoomId) -> Unit = {},
|
||||||
onFilterChanged: (String) -> Unit = {},
|
onFilterChanged: (String) -> Unit = {},
|
||||||
onLogoutClicked: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
onScrollOver: (IntRange) -> Unit = {},
|
onScrollOver: (IntRange) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
fun onRoomClicked(room: RoomListRoomSummary) {
|
fun onRoomClicked(room: RoomListRoomSummary) {
|
||||||
|
|
@ -112,7 +102,7 @@ fun RoomListContent(
|
||||||
matrixUser = matrixUser,
|
matrixUser = matrixUser,
|
||||||
filter = filter,
|
filter = filter,
|
||||||
onFilterChanged = onFilterChanged,
|
onFilterChanged = onFilterChanged,
|
||||||
onLogoutClicked = onLogoutClicked,
|
onOpenSettings = onOpenSettings,
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -134,9 +124,6 @@ fun RoomListContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (isLoginOut) {
|
|
||||||
ProgressDialog(text = "Login out...")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RoomListRoomSummary.contentType() = isPlaceholder
|
private fun RoomListRoomSummary.contentType() = isPlaceholder
|
||||||
|
|
@ -153,10 +140,8 @@ fun PreviewableRoomListContent() {
|
||||||
roomSummaries = stubbedRoomSummaries(),
|
roomSummaries = stubbedRoomSummaries(),
|
||||||
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
||||||
onRoomClicked = {},
|
onRoomClicked = {},
|
||||||
onLogoutClicked = {},
|
|
||||||
filter = "filter",
|
filter = "filter",
|
||||||
onFilterChanged = {},
|
onFilterChanged = {},
|
||||||
isLoginOut = false,
|
|
||||||
onScrollOver = {}
|
onScrollOver = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -170,10 +155,8 @@ fun PreviewableDarkRoomListContent() {
|
||||||
roomSummaries = stubbedRoomSummaries(),
|
roomSummaries = stubbedRoomSummaries(),
|
||||||
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
||||||
onRoomClicked = {},
|
onRoomClicked = {},
|
||||||
onLogoutClicked = {},
|
|
||||||
filter = "filter",
|
filter = "filter",
|
||||||
onFilterChanged = {},
|
onFilterChanged = {},
|
||||||
isLoginOut = true,
|
|
||||||
onScrollOver = {}
|
onScrollOver = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import io.element.android.x.matrix.MatrixClient
|
||||||
import io.element.android.x.matrix.media.MediaResolver
|
import io.element.android.x.matrix.media.MediaResolver
|
||||||
import io.element.android.x.matrix.room.RoomSummary
|
import io.element.android.x.matrix.room.RoomSummary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
|
@ -33,8 +32,7 @@ private const val extendedRangeSize = 40
|
||||||
class RoomListViewModel @AssistedInject constructor(
|
class RoomListViewModel @AssistedInject constructor(
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
@Assisted initialState: RoomListViewState
|
@Assisted initialState: RoomListViewState
|
||||||
) :
|
) : MavericksViewModel<RoomListViewState>(initialState) {
|
||||||
MavericksViewModel<RoomListViewState>(initialState) {
|
|
||||||
|
|
||||||
companion object : MavericksViewModelFactory<RoomListViewModel, RoomListViewState> by daggerMavericksViewModelFactory()
|
companion object : MavericksViewModelFactory<RoomListViewModel, RoomListViewState> by daggerMavericksViewModelFactory()
|
||||||
|
|
||||||
|
|
@ -44,17 +42,6 @@ class RoomListViewModel @AssistedInject constructor(
|
||||||
handleInit()
|
handleInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logout() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
suspend {
|
|
||||||
delay(2000)
|
|
||||||
client.logout()
|
|
||||||
}.execute {
|
|
||||||
copy(logoutAction = it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterRoom(filter: String) {
|
fun filterRoom(filter: String) {
|
||||||
setState {
|
setState {
|
||||||
copy(
|
copy(
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import androidx.compose.material.ContentAlpha
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -38,7 +38,6 @@ import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.x.core.compose.LogCompositions
|
import io.element.android.x.core.compose.LogCompositions
|
||||||
import io.element.android.x.core.compose.textFieldState
|
import io.element.android.x.core.compose.textFieldState
|
||||||
import io.element.android.x.designsystem.components.avatar.Avatar
|
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.features.roomlist.model.MatrixUser
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -46,7 +45,7 @@ fun RoomListTopBar(
|
||||||
matrixUser: MatrixUser?,
|
matrixUser: MatrixUser?,
|
||||||
filter: String,
|
filter: String,
|
||||||
onFilterChanged: (String) -> Unit,
|
onFilterChanged: (String) -> Unit,
|
||||||
onLogoutClicked: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior
|
scrollBehavior: TopAppBarScrollBehavior
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "RoomListScreen", msg = "TopBar")
|
LogCompositions(tag = "RoomListScreen", msg = "TopBar")
|
||||||
|
|
@ -71,7 +70,7 @@ fun RoomListTopBar(
|
||||||
} else {
|
} else {
|
||||||
DefaultRoomListTopBar(
|
DefaultRoomListTopBar(
|
||||||
matrixUser = matrixUser,
|
matrixUser = matrixUser,
|
||||||
onLogoutClicked = onLogoutClicked,
|
onOpenSettings = onOpenSettings,
|
||||||
onSearchClicked = {
|
onSearchClicked = {
|
||||||
searchWidgetStateIsOpened = true
|
searchWidgetStateIsOpened = true
|
||||||
},
|
},
|
||||||
|
|
@ -160,11 +159,10 @@ fun SearchRoomListTopBar(
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultRoomListTopBar(
|
private fun DefaultRoomListTopBar(
|
||||||
matrixUser: MatrixUser?,
|
matrixUser: MatrixUser?,
|
||||||
onLogoutClicked: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onSearchClicked: () -> Unit,
|
onSearchClicked: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior
|
scrollBehavior: TopAppBarScrollBehavior
|
||||||
) {
|
) {
|
||||||
val openDialog = remember { mutableStateOf(false) }
|
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
|
@ -188,22 +186,11 @@ private fun DefaultRoomListTopBar(
|
||||||
Icon(Icons.Default.Search, contentDescription = "search")
|
Icon(Icons.Default.Search, contentDescription = "search")
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { openDialog.value = true }
|
onClick = onOpenSettings
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Logout, contentDescription = "logout")
|
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,5 @@ data class RoomListViewState(
|
||||||
val rooms: Async<List<RoomListRoomSummary>> = Uninitialized,
|
val rooms: Async<List<RoomListRoomSummary>> = Uninitialized,
|
||||||
val filter: String = "",
|
val filter: String = "",
|
||||||
val canLoadMore: Boolean = false,
|
val canLoadMore: Boolean = false,
|
||||||
val logoutAction: Async<Unit> = Uninitialized,
|
|
||||||
val roomsById: Map<RoomId, RoomListRoomSummary> = emptyMap()
|
val roomsById: Map<RoomId, RoomListRoomSummary> = emptyMap()
|
||||||
) : MavericksState
|
) : MavericksState
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ serialization_json = "1.4.1"
|
||||||
showkase = "1.0.0-beta14"
|
showkase = "1.0.0-beta14"
|
||||||
compose_destinations = "1.7.23-beta"
|
compose_destinations = "1.7.23-beta"
|
||||||
jsoup = "1.15.3"
|
jsoup = "1.15.3"
|
||||||
|
seismic = "1.0.3"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
dagger = "2.43"
|
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_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||||
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
|
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
|
||||||
androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
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_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_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" }
|
||||||
androidx_fragment = {module = "androidx.fragment:fragment-ktx", version.ref = "fragment"}
|
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_pagerindicator = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
|
||||||
accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", 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
|
||||||
test_junit = { module = "junit:junit", version.ref = "test_junit" }
|
test_junit = { module = "junit:junit", version.ref = "test_junit" }
|
||||||
test_runner = { module = "androidx.test:runner", version.ref = "test_runner" }
|
test_runner = { module = "androidx.test:runner", version.ref = "test_runner" }
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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 java.io.File
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// Should not be there, but this is a POC
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.accompanist.systemui)
|
implementation(libs.accompanist.systemui)
|
||||||
|
implementation(project(":libraries:elementresources"))
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package io.element.android.x.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Boolean.toEnabledColor(): Color {
|
||||||
|
return if (this) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
package io.element.android.x.designsystem.components.dialogs
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -9,21 +10,24 @@ import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.x.element.resources.R as ElementR
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConfirmationDialog(
|
fun ConfirmationDialog(
|
||||||
isDisplayed: Boolean,
|
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
submitText: String = "OK",
|
submitText: String = stringResource(id = ElementR.string.ok),
|
||||||
cancelText: String = "Cancel",
|
cancelText: String = stringResource(id = ElementR.string.action_cancel),
|
||||||
|
thirdButtonText: String? = null,
|
||||||
onSubmitClicked: () -> Unit = {},
|
onSubmitClicked: () -> Unit = {},
|
||||||
|
onCancelClicked: () -> Unit = {},
|
||||||
|
onThirdButtonClicked: () -> Unit = {},
|
||||||
onDismiss: () -> Unit = {},
|
onDismiss: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (!isDisplayed) return
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
|
|
@ -33,6 +37,31 @@ fun ConfirmationDialog(
|
||||||
text = {
|
text = {
|
||||||
Text(content)
|
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 = {
|
confirmButton = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(all = 8.dp),
|
modifier = Modifier.padding(all = 8.dp),
|
||||||
|
|
@ -41,7 +70,6 @@ fun ConfirmationDialog(
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = {
|
onClick = {
|
||||||
onDismiss()
|
|
||||||
onSubmitClicked()
|
onSubmitClicked()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
@ -49,20 +77,6 @@ fun ConfirmationDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(all = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
}) {
|
|
||||||
Text(cancelText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,8 +84,8 @@ fun ConfirmationDialog(
|
||||||
@Preview
|
@Preview
|
||||||
fun ConfirmationDialogPreview() {
|
fun ConfirmationDialogPreview() {
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
isDisplayed = true,
|
|
||||||
title = "Title",
|
title = "Title",
|
||||||
content = "Content",
|
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,49 @@
|
||||||
|
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.Divider
|
||||||
|
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()
|
||||||
|
) {
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
thickness = 1.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(top = 8.dp, start = 56.dp),
|
||||||
|
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,76 @@
|
||||||
|
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.Row
|
||||||
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import io.element.android.x.designsystem.components.preferences.components.PreferenceIcon
|
||||||
|
import io.element.android.x.designsystem.toEnabledColor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceSlide(
|
||||||
|
title: String,
|
||||||
|
@FloatRange(0.0, 1.0)
|
||||||
|
value: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
summary: String? = null,
|
||||||
|
steps: Int = 0,
|
||||||
|
onValueChange: (Float) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = preferenceMinHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
PreferenceIcon(icon = icon)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = enabled.toEnabledColor(),
|
||||||
|
text = title
|
||||||
|
)
|
||||||
|
summary?.let {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = enabled.toEnabledColor(),
|
||||||
|
text = summary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
steps = steps,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview(showBackground = false)
|
||||||
|
fun PreferenceSlidePreview() {
|
||||||
|
PreferenceSlide(
|
||||||
|
title = "Slide",
|
||||||
|
summary = "Summary",
|
||||||
|
value = 0.75F
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package io.element.android.x.designsystem.components.preferences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Announcement
|
||||||
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import io.element.android.x.designsystem.components.preferences.components.PreferenceIcon
|
||||||
|
import io.element.android.x.designsystem.toEnabledColor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceSwitch(
|
||||||
|
title: String,
|
||||||
|
isChecked: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
onCheckedChange: (Boolean) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = preferenceMinHeight)
|
||||||
|
.clickable { onCheckedChange(!isChecked) },
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
PreferenceIcon(
|
||||||
|
icon = icon,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = enabled.toEnabledColor(),
|
||||||
|
text = title
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = isChecked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = onCheckedChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview(showBackground = false)
|
||||||
|
fun PreferenceSwitchPreview() {
|
||||||
|
PreferenceSwitch(
|
||||||
|
title = "Switch",
|
||||||
|
icon = Icons.Default.Announcement,
|
||||||
|
isChecked = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package io.element.android.x.designsystem.components.preferences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import io.element.android.x.designsystem.components.preferences.components.PreferenceIcon
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceText(
|
||||||
|
title: String,
|
||||||
|
// TODO subtitle
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
onClick: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = preferenceMinHeight)
|
||||||
|
.clickable { onClick() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
PreferenceIcon(icon = icon)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
text = title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview(showBackground = false)
|
||||||
|
fun PreferenceTextPreview() {
|
||||||
|
PreferenceText(
|
||||||
|
title = "Title",
|
||||||
|
icon = Icons.Default.BugReport,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package io.element.android.x.designsystem.components.preferences.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.x.designsystem.toEnabledColor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceIcon(
|
||||||
|
icon: ImageVector?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = "",
|
||||||
|
tint = enabled.toEnabledColor(),
|
||||||
|
modifier = modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.width(48.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = modifier.width(56.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,11 @@ include(":libraries:textcomposer")
|
||||||
include(":libraries:elementresources")
|
include(":libraries:elementresources")
|
||||||
include(":features:onboarding")
|
include(":features:onboarding")
|
||||||
include(":features:login")
|
include(":features:login")
|
||||||
|
include(":features:logout")
|
||||||
include(":features:roomlist")
|
include(":features:roomlist")
|
||||||
include(":features:messages")
|
include(":features:messages")
|
||||||
|
include(":features:rageshake")
|
||||||
|
include(":features:preferences")
|
||||||
include(":libraries:designsystem")
|
include(":libraries:designsystem")
|
||||||
include(":libraries:di")
|
include(":libraries:di")
|
||||||
include(":anvilannotations")
|
include(":anvilannotations")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue